8.2.3 请求的多阶段异步处理

这里所讲的多阶段异步处理请求与事件驱动架构是密切相关的,换句话说,请求的多阶段异步处理只能基于事件驱动架构实现。什么意思呢?就是把一个请求的处理过程按照事件的触发方式划分为多个阶段,每个阶段都可以由事件收集、分发器来触发。

例如,处理一个获取静态文件的HTTP请求可以分为以下几个阶段(见表8-1)。

8.2.3 请求的多阶段异步处理 - 图1

这个例子中大致分为7个阶段,这些阶段是可以重复发生的,因此,一个下载静态资源请求可能会由于请求数据过大、网速不稳定等因素而被分解为成百上千个表8-1中所列出的阶段。

异步处理和多阶段是相辅相成的,只有把请求分为多个阶段,才有所谓的异步处理。也就是说,当一个事件被分发到事件消费者中进行处理时,事件消费者处理完这个事件只相当于处理完1个请求的某个阶段。什么时候可以处理下一个阶段呢?这只能等待内核的通知,即当下一次事件出现时,epoll等事件分发器将会获取到通知,再继续调用事件消费者处理请求。这样,每个阶段中的事件消费者都不清楚本次完整的操作究竟什么时候会完成,只能异步被动地等待下一次事件的通知。

请求的多阶段异步处理优势在哪里?这种设计配合事件驱动架构,将会极大地提高网络性能,同时使得每个进程都能全力运转,不会或者尽量少地出现进程休眠状况。因为一旦出现进程休眠,必然减少并发处理事件的数目,一定会降低网络性能,同时会增加请求处理时间的平均时延!这时,如果网络性能无法满足业务需求将只能增加进程数目,进程数目过多就会增加操作系统内核的额外操作:进程间切换,可是频繁地进行进程间切换仍会消耗CPU等资源,从而降低网络性能。同时,休眠的进程会使进程占用的内存得不到有效释放,这最终必然导致系统可用内存的下降,从而影响系统能够处理的最大并发连接数。

根据什么原则来划分请求的阶段呢?一般是找到请求处理流程中的阻塞方法(或者造成阻塞的代码段),在阻塞代码段上按照下面4种方式来划分阶段:

(1)将阻塞进程的方法按照相关的触发事件分解为两个阶段

一个本身可能导致进程休眠的方法或系统调用,一般都能够分解为多个更小的方法或者系统调用,这些调用间可以通过事件触发关联起来。大部分情况下,一个阻塞进程的方法调用时可以划分为两个阶段:阻塞方法改为非阻塞方法调用,这个调用非阻塞方法并将进程归还给事件分发器的阶段就是第一阶段;增加新的处理阶段(第二阶段)用于处理非阻塞方法最终返回的结果,这里的结果返回事件就是第二阶段的触发事件。

例如,在使用send调用发送数据给用户时,如果使用阻塞socket句柄,那么send调用在向操作系统内核发出数据包后就必须把当前进程休眠,直到成功发出数据才能“醒来”。这时的send调用发送数据并等待结果。我们需要把send调用分解为两个阶段:发送且不等待结果阶段、send结果返回阶段。因此,可以使用非阻塞socket句柄,这样调用send发送数据后,进程是不会进入休眠的,这就是发送且不等待结果阶段;再把socket句柄加入到事件收集器中就可以等待相应的事件触发下一个阶段,send发送的数据被对方收到后这个事件就会触发send结果返回阶段。这个send调用就是请求的划分阶段点。

(2)将阻塞方法调用按照时间分解为多个阶段的方法调用

注意,系统中的事件收集、分发者并非可以处理任何事件。如果按照前一种方式试图划分某个方法时,那么可能会发现找出的触发事件不能够被事件收集、分发器所处理,这时只能按照执行时间来拆分这个方法了。

例如读取文件的调用(非异步I/O),如果我们读取10MB的文件,这些文件在磁盘中的块未必是连续的,这意味着当这10MB文件内容不在操作系统的缓存中时,可能需要多次驱动硬盘寻址。在寻址过程中,进程多半会休眠或者等待。我们可能会希望像上文所说的那样把读取文件调用分解成两个阶段:发送读取命令且不等待结果阶段、读取结果返回阶段。这样当然很好,可惜的是,如果我们的事件收集、分发者不支持这么做,该怎么办?例如,在Linux上Nginx的事件模块在没打开异步I/O时就不支持这种方法,像ngx_epoll_module模块主要是针对网络事件的,而主机的磁盘事件目前还不支持(必须通过内核异步I/O)。这时,我们可以这样来分解读取文件调用:把10MB均分成1000份,每次只读取10KB。这样,读取10KB的时间就是可控的,意味着这个事件接收器占用进程的时间不会太久,整个系统可以及时地处理其他请求。

那么,在读取0KB~10KB的阶段完成后,怎样进入10KB~20KB阶段呢?这有很多种方式,如读取完10KB文件后,可能需要使用网络来发送它们,这时可以由网络事件来触发。或者,如果没有网络事件,也可以设置一个简单的定时器,在某个时间点后再次调用下一个阶段。

(3)在“无所事事”且必须等待系统的响应,从而导致进程空转时,使用定时器划分阶段

有时阻塞的代码段可能是这样的:进行某个无阻塞的系统调用后,必须通过持续的检查标志位来确定是否继续向下执行,当标志位没有获得满足时就循环地检查下去。这样的代码段本身没有阻塞方法调用,可实际上是阻塞进程的。这时,应该使用定时器来代替循环检查标志,这样定时器事件发生时就会先检查标志,如果标志位不满足,就立刻归还进程控制权,同时继续加入期望的下一个定时器事件。

(4)如果阻塞方法完全无法继续划分,则必须使用独立的进程执行这个阻塞方法

如果某个方法调用时可能导致进程休眠,或者占用进程时间过长,可是又无法将该方法分解为不阻塞的方法,那么这种情况是与事件驱动架构相违背的。通常是由于这个方法的实现者没有开放非阻塞接口所导致,这时必须通过产生新的进程或者指定某个非事件分发者进程来执行阻塞方法,并在阻塞方法执行完毕时向事件收集、分发者进程发送事件通知继续执行。因此,至少要拆分为两个阶段:阻塞方法执行前阶段、阻塞方法执行后阶段,而阻塞方法的执行要使用单独的进程去调度,并在方法返回后发送事件通知。一旦出现上面这种设计,我们必须审视这样的事件消费者是否足够合理,有没有必要用这种违反事件驱动架构的方式来解决阻塞问题。

请求的多阶段异步处理将会提高网络性能、降低请求的时延,在与事件驱动架构配合工作后,可以使得Web服务器同时处理十万甚至百万级别的并发连接,我们在开发Nginx模块时必须遵循这一原则。