异步IO/反应式编程的目的



首先,问题所基于的一些假设:

同步IO:当我需要进行读取IO操作时,我会对文件描述符执行读取系统调用。CPU进入特权模式并执行内核代码,内核代码(通过设备驱动程序)要求设备检索我的数据,并将我的线程置于BLOCKED状态。最后,运行一个调度程序,另一个线程使用我的线程运行的CPU内核。
设备自己处理请求。一旦完成,它就会触发CPU上的中断。中断处理程序执行内核代码,将线程的状态设置为READY(这显然是一个很大的简化)。我的线程现在有机会在内核调度程序运行时进行调度。

异步IO:我执行系统调用,系统调用要求设备检索数据。现在,系统调用不设置线程状态,而是返回一个特殊的标记来指示数据尚未准备好。线程继续执行。

我们通常不直接使用这样的系统调用,而是使用一些包装器函数(由库提供),该函数将回调作为参数。这个库还生成了一个线程,该线程从所有调用(epoll,kqueue…)中选择文件描述符。一旦一些fds可以与这个线程交互,就会在某种工作线程池(运行事件循环/任务循环)上调度适当的回调。

如果上面的一些不正确,我非常乐意被纠正!

现在,进入问题:

1.异步IO是否具有任何性能/资源优势
据我所知,与全上下文切换相比,在线程之间切换相当便宜。如果有足够的工作(将调度另一个线程),CPU仍将被充分利用。

以下是我能想到的:

  • 内存利用率-更少的线程意味着为内核代码中的堆栈和线程相关数据结构分配的内存更少
  • 调度开销——我想内核对线程的调度可能相当复杂

但我认为也有一些事情可能会影响异步IO的性能:

  • 我们总共执行了更多的系统调用(一个用于请求操作,另一个用于等待结果)
  • 需要将回调安排到工人身上
  • 执行回调时跳转到任意位置可能会干扰缓存

2.反应式编程/协同程序(所有代码都作为工作线程上的事件运行)对性能有任何好处吗

3.为什么我们实际上要做反应式编程

在我看来,反应式编程在本应是开发人员工作的抽象(进程和线程)之上构建了一个额外的抽象层,这带来了很多额外的复杂性。有时这似乎是有道理的,例如,如果我们假设我们想要一个单独的UI线程。问题是,从我的角度来看,这种模式基本上是一种同步的替代方法——我们只需启动一个获取UI锁的线程就可以实现同样的功能。

我只是看不出在传统的并发方法中是什么导致了反应式编程框架的创建。

我将非常感谢所有涉及这方面的解释和来源。

设备在自己的上处理请求

实践中并非如此。关于目标设备(以及操作系统和驱动程序实现),请求可以完全卸载,也可以不完全卸载。在实践中,内核线程通常负责完成请求(与IO调度器交互)。该内核线程执行的实际代码及其实际行为与平台无关(HDD、快速NVMe SSD和HPC NIC的行为显然不同)。例如,具有轮询策略的DMA请求可以用于减少低延迟设备的硬件中断使用(因为它通常很慢)。无论如何,这个操作是在操作系统端完成的,除了对CPU使用率和延迟/吞吐量的影响外,用户不应该太在意这一点。重要的是,请求是串行执行的,线程在IO操作期间被取消调度。

我执行系统调用,系统调用要求设备检索数据。现在,系统调用不设置线程状态,而是返回一个特殊的标记来指示数据尚未准备好。线程继续执行。

异步IO的状态在实践中很复杂,其实现也依赖于平台。API可以提供异步IO功能,而底层操作系统不支持它。一种常见的策略是跨进程线程轮询IO请求的状态。这不是一个有效的解决方案,但就实际应用程序而言,它可能比同步IO更好(稍后解释)。事实上,操作系统甚至可以为此提供标准的API,而在其自己的内核中不完全支持异步IO,因此中间层负责隐藏这种差异!除此之外,目标设备对于目标平台也很重要。

异步IO的一种(旧的)方法是将非阻塞IO与selectpoll等轮询函数相结合。在这种情况下,应用程序可以启动多个请求,然后等待它们。它甚至可以在等待目标请求完成之前进行一些有用的计算。这比一次只做一个请求要好得多,尤其是对于高延迟IO请求,如等待接收从东京到巴黎的网络消息(由于光速的原因,持续时间至少为32ms,但在实践中可能>100ms)。也就是说,这种方法有几个问题:

  • 很难很好地将延迟与计算重叠(因为许多未知因素,如延迟时间、计算速度、计算量)
  • 它的扩展性很差,因为每个请求都是在请求准备好时扫描的(更不用说描述符的数量通常是有限的,而且它使用的操作系统资源比它应该使用的要多得多)
  • 由于轮询循环,它降低了应用程序的可维护性。在许多情况下,这种轮询循环被放在一个单独的线程(甚至线程池)中,代价是更高的延迟(由于额外的上下文切换和缓存未命中)。这种策略实际上可以由异步IO库实现

为了解决这些问题,可以使用基于事件的异步IO函数。一个很好的例子是epoll(更具体地说是边缘触发接口)。它旨在解决许多等待请求的无用扫描,只关注准备好的请求。因此,它的规模更好(O(n)时间VSepollO(1))。不需要任何主动探测循环,而是需要一个基于事件的代码来做类似的事情。用户端高级软件库可以隐藏此部分。事实上,软件库对于编写可移植代码也很关键,因为操作系统有不同的异步接口。例如,epoll仅适用于Linux,kqueue适用于BSD,Windows也使用另一种方法(有关更多信息,请参阅此处)。此外,需要记住的一点是,epoll_wait是一个阻塞调用,因此尽管可能有多个请求挂起,但仍有一个最终的同步等待操作。从用户的角度来看,将它放在线程中进行此操作通常会降低性能(主要是延迟)。

在POSIX系统上,有专门为异步操作(基于回调)设计的AIO API。也就是说,AIO的标准Linux实现在内部使用线程来模拟异步IO,因为内核直到最近才有任何完全异步兼容的接口来实现这一点。最后,这并不比自己使用线程来处理异步IO请求好多少。事实上,AIO可能会更慢,因为它执行更多的内核调用。幸运的是,Linux最近为异步IO引入了一个新的内核接口:io_uring。这个新接口是Linux上最好的。它并不意味着要直接使用(因为它是非常低级的)。有关AIO和io_uring之间区别的更多信息,请阅读本文。请注意,io_uring是非常新的,所以AFAIK还没有被许多高级库使用。

最后,来自高级库的异步调用可能会导致多个系统调用或上下文切换。使用时,完成线程也会对CPU使用率、操作延迟、缓存未命中等产生强烈影响。这就是为什么异步IO在实践中的性能并不总是那么好的原因,更不用说异步IO通常需要以非常不同的方式实现目标应用程序。

异步IO有任何性能/资源优势吗?

这取决于用例,但异步IO可以显著提高各种应用程序的性能。实际上,所有能够同时启动多个请求的应用程序都可以从异步IO中受益,尤其是当目标请求持续一段时间时(HDD请求、网络请求等)。如果你使用的是高延迟设备,这是关键,你可以忘记所有其他可以忽略的开销(例如,HDD的寻道时间持续大约十几毫秒,而上下文切换通常持续几微秒,这至少少了2个数量级)对于低延迟设备,情况更为复杂,因为许多开销可能无法忽略:最好是在您的特定平台上尝试

至于所提供的可能影响性能的点,它们取决于所使用的底层接口,也可能取决于设备(以及平台)。例如,没有任何东西强制实现在不同的线程上调用回调。回调导致的缓存未命中可能是您在执行成本高得多的系统调用后遇到的最小问题,更不用说现代CPU现在有相当大的缓存了。因此,除非您要调用一组非常大的回调或非常大的回叫代码,否则您不应该看到由于这一点而对性能产生统计上的显著影响。有了io_uring这样的接口,系统调用的数量就不再是问题了。事实上,AFAIK,io_uring可能会比所有其他接口表现得更好。例如,您可以创建一个IO操作链,避免用户应用程序和内核之间的一些回调和乒乓球。此外,io_uring_enter可以等待IO请求,并同时提交新的IO请求。

反应式编程/协同程序(所有代码都作为工作线程上的事件运行)对性能有任何好处吗?

使用协程,任何东西都不会在单独的系统线程中运行。这是一个常见的误解。推论是一个可以暂停的函数。暂停基于延续机制:包括代码指针在内的寄存器临时存储在内存中(暂停),以便稍后可以恢复(重新启动)。这样的操作发生在同一个线程中。推论通常也有自己的堆栈。花冠与纤维相似。

类似于协程(continuation)的机制用于在编程语言中实现异步函数(有些人可能会认为它们实际上是协程)。例如,C#(以及许多其他语言)中的async/await就是这样做的。在这种情况下,异步函数可以启动IO请求,并在等待时暂停,这样另一个异步函数就可以启动其他IO请求,直到没有异步函数可以运行为止。然后,语言运行时可以等待IO请求完成,以便重新启动等待读取请求的目标异步函数。这样的机制使异步编程更加容易。它并不是为了让事情变得更快(尽管使用了异步IO)。事实上,协程/延续有轻微的开销,因此它可能比使用低级异步API慢,但开销通常比IO请求延迟的开销小得多,甚至通常比上下文切换(甚至系统调用)的开销小。

我对反应式编程不是很熟悉,但AFAIK旨在简化具有大量依赖操作的程序的实现,并进行增量更新。对我来说,这对异步编程来说似乎很正交。一个好的实现可以从异步操作中受益,但这不是这种方法的主要目标。该方法的好处是只更新需要以声明方式更新的内容,不再更新。增量更新对性能至关重要,因为重新计算整个数据集可能比目标应用程序的一小部分成本高得多。在GUI中尤其如此。

需要记住的一点是,异步编程可以通过并发来提高性能,但这只有在目标异步操作的延迟可以减轻的情况下才有用。使计算绑定的代码并发在性能方面是无用的(实际上,由于并发开销,甚至肯定是有害的),因为不存在延迟问题(假设您没有以CPU指令的粒度操作)。

为什么我们实际上要进行反应式编程?

如上所述,执行复杂数据流的增量更新是一个很好的解决方案。并非所有应用程序都能从中受益。

编程模型就像工具:开发人员需要选择最好的模型来解决给定应用程序的特定问题。否则,这将是一场灾难。不幸的是,对于人们来说,使用不太适合他们需求的编程模型并不罕见。这有很多原因(历史、心理、技术等),但这是一个过于宽泛的话题,这个答案已经相当大了。

请注意,使用线程执行异步操作通常不是一个好主意。这是实现异步编程的一种方法,但不是一种有效的方法(尤其是在没有线程池的情况下)。它经常引入比解决更多的问题。例如,您可能需要使用锁(或任何同步机制)来保护变量,以避免竞争条件;关心不能在单独的线程上执行的(低级)操作;考虑TLS的开销,由于缓存未命中、内核间通信、可能的上下文切换和NUMA效应(更不用说目标内核可能处于休眠状态、以较低频率运行等)而导致的开销


相关文章:

  • 异步与多线程-有区别吗

异步IO有任何性能/资源优势吗?

在某种程度上。正如您所指出的,异步代码通常比同步代码慢。在设置回调结构等方面会有更多的开销。

然而,异步代码更具伸缩性,正是因为它不会不必要地阻塞线程。在运行真实世界ish代码的web服务器上进行的实验表明,当从同步代码切换到异步代码时,可伸缩性显著提高。

总之,异步代码与性能无关,而是与可伸缩性有关。

为什么我们实际上要进行反应式编程?

反应式编程完全不同。异步代码仍然是基于拉的;即,您的应用程序请求一些i/O操作,然后该操作在一段时间后完成。反应式代码是基于推送的;一个更自然的例子是可以随时推送命令的侦听套接字或WebSocket连接。

使用反应式代码,代码定义了它对传入事件的反应方式。代码的结构更多的是声明性的,而不是命令性的。反应式框架有一种方法来声明如何对事件做出反应;订阅";到那些事件,然后";取消订阅";完成后从事件中删除。

可以将异步代码构造为响应式(I/O请求是"订阅",只有一个事件是该请求的完成,然后是取消订阅)。但是,通常并不是所有异步代码都这样做;只有当已经有大量代码使用声明性反应模式样式,并且该代码希望在保持相同样式的同时执行异步代码时,这才是正常的。

任何异步代码都可以以响应式风格编写,但由于复杂性/可维护性的原因,通常不会这样做。反应式代码往往更难理解和维护。

最新更新