首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

单线程和多线程语言的异步 I/O 如何工作?

在计算中,输入/输出(I/O、i/o或非正式的io或IO)是信息处理系统(例如计算机)与外界(可能是人类或其他信息处理系统)之间的通信。输入是系统接收到的信号或数据,输出是系统发送的信号或数据。该术语也可以用作动作的一部分;“执行 I/O”是执行输入或输出操作。I/O 设备是人类(或其他系统)用来与计算机通信的硬件。例如,键盘或计算机鼠标是计算机的输入设备,而显示器和打印机是输出设备。计算机之间的通信设备,例如调制解调器和网卡,通常执行输入和输出操作。交互器与系统的任何交互都是输入,系统响应的反应称为输出。将设备指定为输入或输出取决于视角。鼠标和键盘将人类用户输出的物理动作转换为计算机可以理解的输入信号;这些设备的输出是计算机的输入。同样,打印机和显示器将计算机输出的信号作为输入,并将这些信号转换为人类用户可以理解的表示形式。从人类用户的角度来看,阅读或查看这些表示的过程就是接收输出;这种计算机和人类之间的交互在人机交互领域进行研究. 更复杂的是,传统上被认为是输入设备的设备,例如读卡器、键盘,可以接受控制命令以例如选择堆叠器、显示键盘灯,而传统上被认为是输出设备的设备可以提供状态数据,例如、碳粉不足、缺纸、卡纸。

在计算机体系结构中,CPU和主存储器的组合,CPU 可以使用单独的指令直接读取或写入,被认为是计算机的大脑。任何与 CPU/内存组合之间的信息传输,例如通过从磁盘驱动器读取数据,都被视为 I/O。[1] CPU 及其支持电路可以提供用于低级计算机编程的内存映射 I/O,例如在设备驱动程序的实现中,或者可以提供对I/O 通道的访问。I /O 算法是一种旨在利用局部性并在与辅助存储设备(例如磁盘驱动器)交换数据时高效执行的工具。

多线程和多线程同步编程中具有挑战性的问题是什么?

第一个问题是一致性。当您开始在线程之间共享一些数据时,您就会开始遇到一致性问题。例如,如果你想递增一个整数,程序需要:

从内存中读取值

更新值

将值存回内存

那是三个操作,另一个 Thread 可以决定他想同时做同样的事情。在这种情况下,尽管有两个线程增加了值,但它最终可能只增加了一个。最肯定的是,两个线程都在不同的内核上执行,因此您必须确保它们不使用缓存而是实际从内存中读取(耗时)。为了防止这个问题的发生,你把这段代码锁起来,这样一次只有一个线程可以更新它(有些人会指出要增加一个整数,你可以使用比较和交换操作,但我只是展示一个非常简单的例子)。

这解决了一致性问题,但是当然,你在线程之间共享的数据越多,你必须放置的锁就越多……随之而来的是第二个问题……死锁。

假设您有两个共享数据及其附带的锁 L1 和 L2,以及两个线程 T1 和 T2。两个线程都想锁定 L1 和 L2。最好的方法是始终在所有 Threads 的 L2 之前锁定 L1 。但有时代码并不那么简单,你最终锁定了 L2,然后发现你需要锁定 L1 ……这就是发生的事情。

T1 锁定 L1

T2 锁定 L2

T1 试图锁定 L2 但不能,所以它等待

T2 试图锁定 L1 但不能,所以它等待

在这里,两个线程等待对方释放锁。

然后你有活锁,我没有经历过那些所以不能说太多。但这有点像死锁,只是线程不是在等待而是不断地尝试获取锁并且永远无法将它们全部用于实际做事。

最后,由于锁定是资源和时间的浪费(上下文切换),您可以尝试进行乐观锁定。对于我们一开始的整数问题,您可以使用“比较和交换”操作来避免锁定:

从内存中加载值

更新值

使用“比较和交换”将更新后的值存储回内存

如果“比较和交换”失败,则意味着另一个线程刚刚更新了该值,因此您需要返回点 (1) 进行其他尝试“比较和交换”操作是“原子的”,所以你不会有一致性问题。如果“比较和交换”失败了,你就白做了,必须重新开始。你必须确保你没有太多的并发,这可能会导致一些“不幸的”线程饿死(有点像活锁)。你可能想给“演员编程”一个机会。它并不能解决所有问题,但可以简化大部分问题。我们已经能够摆脱程序中几乎所有的锁。

单线程和多线程语言的异步 I/O 如何工作?

多线程没有任何区别。

让我们从一些简单的事情开始——在单线程应用程序中阻塞同步网络 I/O。为此,我们将假装存在直接调用 UNIX 系统调用的 Python 变体,因为……好吧,因为。事实上,您几乎可以运行这段代码,但不完全是——但它可以用来说明正在发生的事情。为什么要联网?因为网络很慢——我们理解它们很慢。

好的。这是 HTTP GET 函数的近似值。当你运行它时会发生什么非常简单 - 代码逐行执行直到它最终返回结果(顺便说一下,有很多标题)。这很容易理解,但问题是对于许多这样的调用,程序只是在等待另一台计算机做出反应并做一些事情。connect()、write() 和 read() 调用都可能“阻塞”,等待响应,操作系统通过挂起进行调用的线程来处理此问题。connect() 每次都需要等待对方响应,协商连接。write() 通常不会阻塞,但如果远程端对数据的响应速度很慢,则可能会阻塞 - 您将在此处填充发送缓冲区,结果是操作系统将阻塞调用,直到确认某些数据为止。如果远程端尚未发送(或完成发送)数据,read() 当然会阻塞。如果我们添加另一个线程,使程序成为多线程,那么我们就会有很多线程——所有线程在被阻塞时都会挂起。我们可以通过更改套接字上的一些设置来更改它以使用非阻塞调用。现在,O/S 不会阻塞——它只会说它会。我们可以使用 select()(或者更典型地,像 poll() 这样的替代方法)来查明我们打开的套接字是否可读或可写。这允许我们只在一个地方挂起线程,并在需要做任何事情时唤醒。

read() 现在将返回到目前为止远程端已发送的尽可能多的数据——我们需要仔细跟踪以查看是否足够。如果远端还没有发送,它会说它会阻塞并立即返回。如果有空间,write() 现在将把我们试图发送到缓冲区的数据丢弃,如果没有,它会说它会阻塞并立即返回。与 read() 一样,无论是否阻塞,这都非常快,但这确实意味着我们有更多的工作要做,管理我们正在等待的套接字以及等待什么。

connect() 要复杂得多。它总是阻塞,但操作系统在后台继续连接,最后告诉我们一旦完成套接字是可写的。最后一个实际上是异步 I/O。在这里,操作系统进行了复杂的操作,并简单地告诉我们它何时完成。

POSIX 异步 I/O 也适用于文件——通过一组完全不同的调用。这些将在操作完成时向线程发出信号。例如,aio_read() 调用总是会立即返回,只有稍后,一旦应用程序收到信号通知或通过 aio_suspend() 挂起,结果才会通过 aio_read() 获得。在非阻塞或实际异步 I/O 中,添加多个线程允许在有数据时进行更多处理,但不会从根本上改变模式。您可能希望在特定线程中处理特定套接字,并且几乎肯定希望在请求操作的线程中处理 aio_return,但您不需要。您可能认为这看起来很复杂。你是对的。您可能也根本没有预料到这一点,并且想知道为什么当您想了解异步编码时我在地球上讨论这个问题。还有另一个“异步”,看起来像这样:

此代码看起来与第一个代码相同。很容易理解。唯一的区别是我添加的“async”和“await”位。“async”关键字(实际上是一个真正的 Python 关键字)将函数转换为“协程”。当被调用时,它将运行到第一个“await”,然后,它不会阻塞 connect() 调用,而是简单地向调用者返回一个句柄:

连接完成后,可以再次恢复(在大多数环境中,这会自动发生,因此程序员只需做很少的事情)。然后它会一直运行到下一个“await”,依此类推。这给出了等同于复杂的非阻塞代码,但它很容易遵循,并且可以构建调用其他协程的协程 - 并运行协程直至完成,我们只需要“等待”它:

事实上,在这个例子中,我假设我已经神奇地将套接字调用变成了协程。大多数语言都有一个问题,那就是我们只能等待来自另一个协程的协程,所以大多数语言都有一些漂亮的引导程序来解决这个问题。当然,我们可以将 aio_read() 和它的朋友包装到协程友好的界面中——使用 C++ 的协程,这实际上非常容易,这意味着您可以非常整洁地等待 aio_read(),而且不会大惊小怪。您的程序现在具有多个可以单独挂起和恢复的执行序列的想法看起来很像多线程,事实上它是 - 它是协作多线程的一种形式。将这种类型的多线程与更传统的类型混合使用绝对没问题(而且可能比没有使用更简单,因为本地状态很方便地保存在协程的堆栈中)。

因此,总结是“异步 I/O”非常复杂,但通过“异步编程”(或协程)变得更简单,多线程并没有真正带来太大区别。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20230209A054KP00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券