异步编程——协同式多任务

翻译自Asynchronous programming. Cooperative multitasking,阅读需要6分钟

这是关于异步编程的系列文章的第一篇文章。整个系列试图回答一个简单的问题:“什么是异步?”。

起初,当我刚开始深入研究这个问题时,我以为我知道它是什么。但是事实是,我甚至没找到异步是什么的线索。所以,让我们找出来!

全系列:

在上一篇文章中,我们讨论了如何确保多个请求的并发处理,以及可以使用线程或进程实现它。但是还有一个选择——协同式多任务。

这个选择是最困难的。在这里,我们不得不说,操作系统当然很赞,它有调度程序/计划程序,它可以处理进程,线程,组织它们之间的切换,处理锁等,但它仍然不知道应用程序是如何工作的,而这些东西作为开发者的我们是知道的。我们知道我们的应用会有短暂的时刻在CPU上执行某些计算操作,但大多数时候都是在等待网络I / O,并且我们更清楚何时应该处理各个请求之间的切换。

从操作系统的角度来看,协同式多任务只是一个执行线程,但在其中,应用程序在处理各个请求/命令之间切换。就之前的网络示例而言,只要一些数据到达,就会读取数据,解析请求,将数据发送到数据库,这是一个阻塞操作,而不是等待来自数据库的响应,它可以开始处理另一个请求。它被称为“协作”,因为所有任务/命令必须协作以使整个调度方案起作用。它们彼此交错,但是在一个被称为协作调度程序的控制线程中,将其角色减少到启动进程并让它们自动将控制权返回给它。

这比线程的多任务处理更简单,因为程序员总是知道哪一个任务在执行,而另一个任务不在执行。虽然在单处理器系统中,线程应用程序也将以交错模式执行,但使用线程的程序员仍应考虑此方法的缺陷,以免应用程序在移动到多处理器系统时工作不正常。但是,即使在多处理器系统上,单线程异步系统也总是以交错方式执行。

编写这样的程序的困难在于,这种切换,维护上下文的过程,以及将每个任务组织为一系列间歇性执行的较小步骤的重任,都落在了开发人员身上。另一方面,我们获得了效率,因为没有不必要的切换,例如,没有在切换线程和切换进程时需要切换处理器上下文的问题。

有两种方法可以实现协同式多任务 – 回调绿色线程

回调

由于所有阻塞操作都会导致操作将在未来的某个时间发生,并且我们的执行线程应该在准备就绪时返回结果。因此,为了获得结果,我们必须注册回调——当请求/操作成功时,它将调用一个回调,或者如果它不成功,它将调用另一个回调。回调是一个明确的选项——当开发人员不知道应该在何时调用某个函数的时候,他就应该以这样的方式编写程序。

这是最常用的选项,因为它是显式的,并且得到了大多数现代语言的支持。

利弊:

  • 与多线程程序不同,没有多线程程序的问题;
  • 线程/协同程序对程序员来说是不可见的;
  • 回调会吞下异常;
  • 回调后的回调会使人困惑,并且难以调试。

绿色线程

第二个选项是隐含的——当开发人员以这样的方式编写程序时,似乎没有协同式多任务。我们像之前一样做了一个阻塞操作,我们希望结果就像这里只有一个进程或线程一样。但是有一个黑魔法“在幕后”——框架或编程语言使阻塞操作无阻塞并将控制转移到其他的执行线程,但不是在操作系统线程的意义上,而是在逻辑线程(用户级别线程)。它们由“普通”用户级进程调度,而不是由内核调度。此选项称为绿色线程

利弊:

  • 控制是在应用程序级别而不是OS;
  • 使用他们感觉就像是线程;
  • 除CPU上下文切换之外,普通基于多线程编程的所有问题都有。

反应器模式

在协同式多任务中,总有一个处理内核负责所有I / O处理。在设计模式里被称为反应器。反应器接口说:“给我一堆你的接口和你的回调,当这个接口准备好进行I / O时,我会调用你的回调函数。”

反应器提供了第二个接口,称为定时器 – “在X毫秒内给我回调,这是我需要调用的回调方法。” 这个东西在协同式多任务里到处都是,无论是明确的还是隐含的。

“在幕后的”反应堆非常简单。它有一个按响应时间排序的计时器列表。它获取了提供给它的接口列表,并将它们发送到轮询准备机制中。可用性轮询机制总是有一个参数——它说明了如果没有网络活动他将阻塞多长时间。阻塞时间表示最近的计时器的响应时间。因此,要么存在某种网络活动,一些套接字将为I / O做好准备,要么我们等待下一个定时器触发,解锁并将控制转移到一个或另一个回调,基本上是一个逻辑流程的执行。

最好的方法

但实际上,这些选项都不是理想选择。合并后的版本效果最好,因为通常协同式多任务会带来好处,特别是如果您的连接会挂起很长时间。例如,Web套接字是一种长期连接。如果分配一个进程或一个线程来处理单个Web套接字,显然你会受限于同时在一个后端服务器上可以拥有的连接数。由于连接存在很长时间,因此保持多个同时连接非常重要,而每个连接的工作量却很少。

缺乏协同式多任务使程序只能使用一个处理器核心。当然,您可以在同一台机器上运行应用程序的多个实例(这并不总是方便的并且有其缺点),因此在每个进程内运行多个进程并使用reactor进行协同多任务处理会很不错。

这种组合一方面可以在我们的系统中使用所有可用的处理器内核,另一方面,它可以在每个内核中高效工作,而无需分配大量资源来处理每个单独的连接。

结论

编写使用协同式多任务的应用程序的困难在于,这些切换过程和上下文的维护,放到了可怜的开发人员肩上。另一方面,使用这种方法我们获得了效率,因为没有了不必要的切换,没有了在切换线程和切换进程时的问题。

在下一篇文章中,我们将讨论异步编程本身以及它与同步编程的区别,是旧概念,但会在新的层面用新的术语。

翻译参考:

异步编程——阻塞式I / O和非阻塞式I / O

翻译自 Asynchronous programming. Blocking I/O and non-blocking I/O,阅读需要9分钟

这是关于异步编程的系列文章的第一篇文章。整个系列试图回答一个简单的问题:“什么是异步?”。

起初,当我刚开始深入研究这个问题时,我以为我知道它是什么。但是事实是,我甚至没找到异步是什么的线索。所以,让我们找出来!

全系列:

在这篇文章中,我们将讨论网络,但您可以轻松地将其映射到其他输入/输出(I / O)操作,例如,将Socket(下文将译作套接字)更改为文件描述符。尽管示例将使用Python,不过这并不是专注于任何特定编程语言的讲解(我想说 – 我喜欢Python)。

在客户端 – 服务器应用程序中,当客户端向服务器发出请求时,服务器处理请求并发回响应。为此,客户端和服务器首先需要建立彼此的连接,这就是套接字发挥作用的地方。最后客户端和服务器都必须将自己绑定到一个套接字,服务器开始监听其套接字以便客户端发出请求。

如果你看一下处理器处理的速度和网络连接速度的比例,会发现差异有两个数量级。事实证明,如果我们的应用程序使用I / O,那么CPU在大多数情况下都不会执行任何操作,这种类型的应用程序称为I / O密集型。对于需要高性能的应用程序,这是一个主要的障碍,因为其他活动和其他I / O操作都在等待 ——事实证明这些系统都是低效的。

组织I / O的方式有3个选项:阻塞式,非阻塞式和异步式。最后一个不适用于网络,因此,我们有两个选项 – 阻止式和非阻塞式。

阻止式I / O

使用阻塞I / O,当客户端发出连接到服务器的请求时,处理该连接的套接字将被阻塞,直到有一些数据要读取或数据被完全写入。在操作完成之前,服务器除了等待之外别无他法。由此得出最简单的结论:在单个执行线程中,我们不能提供多个连接。默认情况下,TCP套接字处于阻塞模式。

在UNIX(POSIX)BSD套接字的例子中考虑这个选项(在Windows中都是相同的 – 调用方式不一样,但逻辑是相同的)。

关于Python的简单示例,客户端代码:

和服务器代码:

您会注意到服务器持续打印我们的消息“”,直到所有数据都被发送。在上面的代码中,“Data Received”消息将不会被打印,因为客户端必须发送大量数据,这需要花费时间,并且在此之前套接字会被阻塞。

这里发生了什么?该send()方法尝试将所有数据传输到服务器,而客户端上的写缓冲区将继续获取数据。当缓冲区变空时,内核将再次唤醒进程以获取要传输的下一个数据块。简而言之,您的代码将被阻塞,并且不会让任何其他事务继续进行。

如果要用这种方法来实现并发请求,我们就需要有多个线程,即我们需要为每个客户端连接分配一个新线程。我们一会儿会讨论这个问题。

非阻塞式I / O

还有第二个方案- 非阻塞I / O。从措辞来看,差异是显而易见的 – 不是 – 阻塞式的,从客户端角度来看,任何操作都会立即完成。非阻塞式I / O意味着请求立即排队,函数返回。然后在稍后的某个时刻处理实际的I / O。

回到我们的示例,在客户端进行一些更改:

现在,如果我们运行此代码,您会注意到程序将运行一小段时间,它将打印“数据已发送”并终止。

这里发生了什么?这里客户端没有发送完所有数据。当我们通过使用非阻塞式套接字调用setblocking(0)时,它永远不会等待操作完成。因此,当我们调用该send()方法时,它会将尽可能多的数据放入缓冲区并返回。

使用此选项,我们可以同时从一个线程对不同的套接字执行多个I / O操作。但是,由于不知道套接字是否已准备好进行I / O操作,我们必须询问每个套接字,最后就会陷入无限循环。

为了摆脱这种效率低下的循环,需要一种轮询准备机制,我们可以在这里轮询所有套接字的准备情况,并告诉我们哪些套接字已准备好进行新的I / O操作。当任一套接字准备就绪时,我们将执行排队操作,之后我们可以返回阻塞状态,等待再次为下一个I / O操作做好准备的套接字。

轮询准备有几种机制,它们在性能和细节方面有所不同,但通常情况下,细节隐藏在“引擎盖下”并且对我们来说是不可见的。

要搜索的关键字:

消息通知:

  • Level Triggering(state)(译者注:条件触发,状态不改变时)
  • Edge Triggering(state changed)(译者注:边缘触发,状态改变时)

机制:

  • select(), poll()
  • epoll(), kqueue()
  • EAGAIN, EWOULDBLOCK

多任务处理

我们的目标是同时管理好多个客户端,如何该如何确保多个请求的同时处理呢?有几种选择:

独立进程

最简单的也是历史上最初的方法是在单独的进程中处理每个请求。这很好,因为我们可以使用相同的阻塞I / O API。如果进程突然崩溃,它只会影响在这个特定进程中处理的操作,而不会影响其他任何进程。

减少——通信困难。比如,在形式上,进程之间几乎没有任何共同点,我们想要组织的任何非特殊的通信都需要额外的努力来同步访问。此外,在任何给定的时间点,可能有多个进程在等待客户端请求,这只是浪费资源。

让我们来看看这在实践中是如何工作的:通常第一个进程(主进程/守护进程)启动,例如,监听,然后它产生一些进程作为工作进程,每个进程可以在同一个套接字上接受等待传入的连接。一旦传入连接出现,其中一个进程被占用 – 它接收此连接,从开始到结束处理,关闭套接字,并再次准备好完成下一个请求。可能的变化——可以为每个传入连接生成过程,或者它们都是预先启动的,等等。这可能会影响性能特征,但现在对我们来说并不那么重要。

此类系统的示例:

  • Apache的mod_prefork;
  • 针对经常使用PHP的人的FastCGI;
  • 针对在Ruby on Rails上写作的人的Phusion Passenger;
  • PostgreSQL。

线程(OS)

另一种方法是使用操作系统(OS)线程。在一个进程中,我们生成多个线程。也可以使用阻塞式I / O,因为只会阻塞一个线程。操作系统自身管理线程,它能够在处理器之间分散它们。线程比进程轻量级。实质上,这意味着我们可以在同一系统上生成更多线程。我们很难运行一万个进程,但是一万个线程很容易。并不是说它会有效,但是,它们更轻巧。

另一方面,没有隔离,即如果发生某种崩溃,它不仅会崩溃一个特定的线程而且会崩溃整个进程。最大的困难是线程和正在工作的进程里的其他线程之间存在共享。我们共享资源——内存,这意味着需要同步访问。同步访问共享内存的问题——这是最简单的情况,但是,例如,可能存在与数据库的连接,或者与数据库的连接池,这对于处理即将到来的请求的应用程序内的所有线程很常见。要正确处理对资源的同步访问是很难的。

有一些问题:

  1. 首先——在同步过程中可能出现死锁。当进程或线程进入等待状态时发生死锁,因为请求的系统资源由另一个等待进程保持,而另一个等待进程又等待另一个等待进程持有的另一个资源;
  2. 当我们对共享数据具有竞争性访问权限时,会发生不充分的同步。粗略地说,两个线程同时更改数据并破坏它们。这样的程序更难调试,并非所有错误都立即出现。例如,著名的GIL – Global Interpreter Lock – 是制作多线程应用程序的最简单方法之一。当使用GIL时我们说所有的数据结构,我们所有的内存都只受到整个进程的一个锁的保护。这似乎意味着多线程执行是不可能的,因为只能执行一个线程,只有一个锁,有人已经捕获它,所有其他线程都无法工作。是的,这是事实,但请记住,大多数情况下我们不对线程进行任何计算,而是进行网络I / O操作,因此在访问阻塞I / O操作时,GIL会关闭,线程会重置并且实际上会切换到另一个准备执行的线程。因此,从后端的角度来看,使用GIL可能并不是那么糟糕。

总结

阻塞方法同步执行——运行应用程序,它的操作在调用后直接执行。

非阻塞方法异步执行——您运行应用程序并且非阻塞操作立即返回,但实际工作是在稍后执行。

实现多任务处理有几种方法:线程和进程。

在下一篇文章中,我们将讨论合作多任务及其实现。

翻译参考:

  1. 边缘触发和条件触发
  2. 触发中断