2.4 NIO和异步
晴朗天空上的第一朵“乌云”终于被我们驱散了,但还有另外一朵“乌云”在悠悠然地飘着,它仿佛正眯着眼俯视着我们,幸灾乐祸地等待着发生什么事情。于是我们小心翼翼地查看了在线监控系统。不看不知道,一看吓一跳。我们注意到,虽然并发处理的连接数增加了,但是请求的平均响应时间依然很高,数据采集服务器的吞吐能力还是很低,这可与我们的预想相差甚远!于是,我们进一步使用JVisualVM(参见3.4.2节)这个“神器”连接到运行着的数据采集服务器,希望能够找到造成程序性能依旧低下的“元凶”。在对JVM的运行时状态进行采样后,我们立刻发现原来是doExtractCleanTransform()函数的执行耗时占用了整个请求处理用时的90%以上,处理时延明显过高!现在我们就来仔细分析下doExtractCleanTransform()可能耗时的原因。
2.4.1 CPU密集型任务
CPU密集型(CPU-intensive)任务也叫CPU受限型(CPU-bound)任务,是指处理过程中主要依靠CPU(这里不考虑协处理器,如GPU、FPGA或其他各种定制型处理器)运算来完成的任务。这种任务的执行速度会受限于CPU本身的处理能力。当用单核执行CPU密集型任务时,如果此时用top命令查看系统状态,则会发现CPU负载接近100%。
图2-6所示的冯·诺依曼结构是最常见的计算机系统结构,在冯·诺依曼结构的计算机系统中,CPU密集型任务主要发生在CPU和内存之间。所以,针对CPU密集型任务的优化,主要是提高CPU和内存的使用效率。具体实施起来,CPU密集型任务优化的方向有两个:一是优化算法本身,二是将CPU的多核充分利用起来。算法优化包括降低算法复杂度、优化内存使用率、使用GPU或FPGA等协处理器、针对JVM和CPU的执行机制做特定的编程优化等。除了优化算法本身以外,充分利用CPU的多核也是提升CPU密集型任务性能的有效方法,表现在代码编写上,就是利用多线程或多进程执行CPU密集型任务。但需要注意的是,CPU密集型任务中的线程或进程的数量应该与CPU的核数相当,否则过多的线程上下文切换反倒会减少有效计算时间,降低程序性能。通常而言,当任务是CPU密集型任务时,比较合适的线程数应该介于CPU核数至两倍的CPU核数之间。
图2-6 冯·诺依曼结构的计算机系统
2.4.2 I/O密集型任务
I/O密集型(I/O-intensive)任务也叫I/O受限型(I/O-bound)任务,是指在处理过程中有很多I/O操作的任务,这种任务的执行速度会受限于I/O的吞吐能力。通常情况下,我们在编写服务端程序时,涉及的I/O操作主要有磁盘I/O操作和网络I/O操作。常见的磁盘I/O一般发生在诸如日志输出、数据持久化等与本地文件读写相关的地方。常见的网络I/O则主要发生在诸如消息中间件收发消息、调用外部服务、访问数据库等涉及远程服务交互的地方。相比CPU密集型任务而言,I/O密集型任务是大多数Java开发者面临更多、更现实的问题。因为大部分业务系统和基础框架都涉及文件读写、数据库访问、远程方法调用等与I/O相关的操作。
在讨论I/O时,我们需要明白一个很重要的事实。引起I/O耗时的原因,既可能确实是因为硬件资源有限,I/O的数据量已经达到了磁盘或者网络吞吐能力的上限,但也可能是因为I/O调用的远程服务本身时延比较大。在做整体系统优化时,我们需要仔细区分究竟是哪种原因引起了I/O的高时延。在编写代码时,我们却不需要做这种区分,只需要认定I/O具有较大时延即可。
对于计算逻辑简单、计算量不大的I/O密集型任务,提高程序性能最方便、最有效的方法是增加线程数。让大量的线程同时触发更多的I/O请求,可以将I/O资源充分利用起来。为什么这时可以简单、粗暴地使用大量线程呢?这是因为,操作系统调度线程时占用的是CPU资源。如果计算逻辑本身比较简单,对CPU资源要求不高,那么将更多的CPU资源留给操作系统做线程调度也未尝不可。对于I/O密集型任务,比较合适的线程数可以设置在10倍的CPU核数到百倍的CPU核数之间。当然,由于相比CPU密集型任务而言,I/O密集型任务的场景会更多、更复杂,所以最合适的线程数还是需要通过实际的一系列压力测试来最终确定。比如笔者在工作中就曾经遇到过在8核16GB内存的云主机上,需要将线程数量调整到1400才达到最佳性能(QPS和latency都满足要求且比较稳定)的情况,而且测试过程中还发现更多或更少的线程数都会降低程序性能。当时笔者获得这个测试结果后,着实觉得有些出乎意料,因为测试前确实没有想到会需要这么多线程,也没想到Linux操作系统在支持千级别的线程调度时,也并非像之前所想的那么不堪。这里举出这个实际开发和测试的例子,也是为了让读者了解压力测试对程序优化的重要性。
2.4.3 I/O和CPU都密集型任务
当I/O和CPU都比较密集时,问题就复杂了很多。而且不幸的是,这又是我们在平时软件开发时最常遇见的情况。以微服务系统为例,每个微服务模块都不是孤立存在的,除了自己特有的计算逻辑需要由CPU计算完成以外,在实现业务功能过程中,还需要时不时访问其他微服务模块提供的REST或RPC服务,或者时不时需要访问数据库或消息中间件等。因此,在这类程序执行的过程中,会频繁地在CPU计算和等待I/O完成这两种状态之间切换,图2-7正描述了这种情况的程序流程图。
图2-7 CPU和I/O都密集型任务
下面我们来分析在I/O和CPU都比较密集时,该如何提升程序的性能。前边提到,使用大量线程可以提高I/O利用率。这是因为当进程执行到涉及I/O操作或sleep之类的函数时,会引发系统调用。进程执行系统调用操作,会从用户态进入内核态,之后在其准备从内核态返回用户态时,操作系统会提供一次进程调度的机会。对于正在执行I/O操作的进程,操作系统很有可能将其调度出去。这是因为触发I/O请求的进程通常需要等待I/O操作完成,操作系统就让其晾在一旁等着,先调度其他进程。当I/O请求数据准备好的时候,进程再次获得被调度执行的机会,然后继续之前的执行流程。
图2-8 进程进行I/O操作时触发进程调度
图2-8描述了进程在进行I/O操作时触发进程调度的过程,具体如下。
步骤1:进程A调用read,进入内核态。 步骤2.1:处于内核态的进程A触发DMA后继续执行,DMA开始从磁盘读数据到内存。 步骤2.2:处于内核态的进程A在准备返回用户态前,会触发一次进程调度,结果调度器选择了进程B,于是返回用户态时,CPU执行的不是进程A,而是进程B。 步骤3.1:当DMA完成数据传送时,给CPU发出中断信号,从而让正在运行的进程B停止,并陷入内核态。 步骤3.2:进程B因为DMA中断而陷入内核态。 步骤4: CPU在处理完DMA中断后准备返回进程B的用户态时,再次触发一次进程调度,这一次被选中的是进程A,进程A返回用户态继续运行。 步骤5: 进程A在处理完read返回的数据后,调用write函数将结果写入磁盘,此时再次进入内核态。 之后,步骤6.1~步骤8的过程就与2.1~步骤8的过程类似了。
从上面线程执行I/O系统调用的过程可以看出,当线程执行I/O操作时,线程本身并不会因等待I/O返回而阻塞,而是由操作系统将其暂时调度出去,让其他线程使用CPU。因此,当大量线程进行I/O请求时,这些I/O请求都会被触发,使I/O任务被安排得满满的,从而尽可能充分地利用了I/O资源。操作系统采取这种调度策略的主要考虑是能更加充分地使用CPU资源,同时如果I/O请求较多,则I/O资源也会被充分利用,所以操作系统这样做是非常合理的。只不过,如果线程过多,则操作系统将频繁地进行线程调度和上下文切换,耗费过多的CPU时间,而执行有效计算的时间变少,造成另一种形式的CPU资源浪费。
所以,针对I/O和CPU都密集型任务的优化思路是尽可能地让CPU不把时间浪费在等待I/O完成上,同时尽可能地减少操作系统进行上下文切换的耗时。在本章接下来的3节中,我们将讨论3种实现这种优化思路的方法。
2.4.4 纤程
前面提到,使用更多的线程可以让CPU尽可能地不把时间浪费在等待I/O完成上,但过多的线程又会引起更频繁的上下文切换。那有没有一种类似于线程,在碰到I/O调用时不会阻塞,能够让出CPU执行其他计算,等I/O数据准备好了再继续执行,同时还不占用过多CPU在线程调度和上下文切换的办法呢?
有!这就是纤程(fiber),也叫作协程(coroutine)。图2-9是纤程的工作原理,纤程是一种用户态的线程,其调度逻辑在用户态实现,从而避免了过多地进出内核态进行进程调度和上下文切换。事实上,纤程才是最理想的线程!那纤程是怎样实现的呢?就像线程一样,关键是要在执行过程中,能够在恰当的时刻和恰当的地方被中断,然后调度出去,CPU让给其他线程使用。先来考虑I/O,前面说到进程执行I/O操作时,一定会不可避免地提供给操作系统一次调度它的机会,但问题的关键不是避免I/O操作,而是避免过多的线程调度和上下文切换。我们可以将I/O操作委托给少量固定线程,使用其他少量线程负责I/O状态检查和纤程调度,再用适量线程执行纤程,这样就可以大量创建纤程,而且只需要少量线程即可。
图2-9 纤程的工作原理
回想下之前Tomcat NIO连接器的实现机制,是不是这种纤程的实现机制和Tomcat NIO连接器的工作机制有异曲同工之妙?事实上正是如此,理论上讲,纤程才是将异步执行过程封装得最好的方案。因为它封装了所有异步复杂性,在提供同步API便利性的同时,还拥有非阻塞I/O的一切优势!
更进一步讲,最理想的纤程应该完全像线程那样,连CPU的执行都不阻塞。也就是说,纤程在执行非I/O操作的时候,也能够随时被调度出去,让CPU执行其他纤程。这样做是可能的,但需要CPU中断支持,或者通过特殊手段在程序的特定地方安插调度点。线程的调度在内核态完成,可以直接得到CPU中断支持。但位于用户态的纤程要得到中断支持相对会更加烦琐,需要进出内核态,这就再次需要频繁进出内核,严重降低了性能。所以,通常而言,用户态的纤程只会做到I/O执行非阻塞,CPU执行依旧阻塞。当然,有些纤程的实现方案(如Python中的绿色线程)提供了主动让出CPU给其他程序片段执行的方法。这种在程序逻辑中主动让出CPU调度其他程序片段执行的方案,虽然只是由开发人员在编写代码时自行控制的,但也算是对实现CPU非阻塞执行的尝试了。
既然纤程有这么多好处,提供同步API的同时拥有非阻塞I/O的性能,可以大量创建而不用增加操作系统调度开销,这样不管多么复杂的逻辑只需要放在纤程里,然后起个几十万甚至上百万个纤程,不就可以轻松做到高并发、高性能了?一切都很美好是不是?可是为什么到现在为止,我们大多数Java开发人员还没有用上纤程呢?或者说,为什么至少在Java的世界里,时至今日纤程还没有大行其道呢?这是一个比较尴尬的现状。从前面的分析中我们知道,实现纤程的关键在于进程执行I/O操作时拦截住CPU的执行流程。那怎样拦截呢?这就用到我们常说的AOP(Aspec Oriente Programming,面向切面编程)技术了。在纤程上对所有与I/O操作相关的函数进行AOP拦截,给调度器提供调度纤程的机会。在JVM平台上,可以在3个层面进行拦截。
❑ 修改JVM源码,在JVM层面拦截所有I/O操作API。
❑ 修改JDK源码,在JDK层面拦截所有I/O操作API。
❑ 采用动态字节码技术,动态拦截所有I/O操作API。
其中,对于第三种方案,已有开源实现Quasar,读者如果感兴趣可以自行研究,在此不展开叙述。但是笔者认为,Quasar虽然确实实现了I/O拦截,实现了纤程,但是对代码的侵入性还是太强,如果读者要在生产环境使用,那么要做好严格的测试才行。
2.4.5 Actor
在纤程之上,有一种称为Actor的著名设计模式。Actor模式是指用Actor来表示一个个的活动实体,这些活动实体之间以消息的方式进行通信和交互。Actor模式非常适用的一种场景是游戏开发。例如,DotA(Defens of the Ancients)游戏中的每个小兵就可以用一个个的Actor表示。如果要小兵去攻击防御塔,就给这个小兵Actor发送一条消息,让其移动到塔下,再发送一条消息,让其攻击塔。当然Actor设计模式本身不只是为了游戏开发而诞生,它是一种通用、应对大规模并发场景的系统设计方案。最有名的Actor系统非Erlang莫属,而Erlang系统正是构建在纤程之上。再如Quasar也有自己的Actor系统。
并非所有的Actor系统都构建在纤程之上,如JVM平台的Actor系统实现Akka。由于Akka不是构建在纤程上,它在I/O阻塞时也只能依靠线程调度出去,所以Akka使用的线程也不宜过多。虽然在Akka里面能够创建上万甚至上百万个Actor,但这些Actor被分配在少数线程里面执行。如果Akka Actor的I/O操作较多,则势必分配在同一个线程中的Actor会出现排队和等待现象。排在后面的Actor只能等前面的Actor完成I/O操作和计算后才能被执行,这极大地影响了程序的性能。虽然Akka采用ForkJoinPool的work-stealing工作机制,可以让一个线程从其他线程的Actor队列获取任务执行,对Actor的阻塞问题有一定缓解,但这并没有从本质上解决问题。究其原因,正是因为Akka使用的是线程而非纤程。线程过多造成性能下降,限制了Akka系统不能像基于纤程的Actor系统那样给每个Actor分配一个纤程,而只能是多个Actor共用同一个线程。不过如果Actor较少,每个Actor都能分配到一个线程的话,那么使用线程和使用纤程的差别就不是非常明显了。
图2-10 Actor系统
必须强调的是,如果Actor是基于线程构建的,那么在存在大量Actor时,Actor的代码逻辑就不宜做过多I/O,甚至是sleep操作。当然大多数情况下,I/O操作是难以避免的。为了减少I/O对其他Actor的影响,应尽量将涉及I/O操作的Actor与其他非I/O操作的Actor隔离开。给涉及I/O操作的Actor分配专门的线程,不让这些Actor和其他非I/O操作的Actor分配到相同的线程。
2.4.6 NIO配合异步编程
除了纤程外,还有没有方法能够同时保证CPU资源和I/O资源都能高效使用呢?当然有。前面说到纤程是封装得最好的非阻塞I/O方案。所以,如果不用纤程,那就直接使用非阻塞I/O,再结合异步编程,可以充分发挥出CPU和I/O的能力。
何为异步呢?举一个生活中的例子。当我们做饭时,在把米和水放到电饭锅并按下电源开关后,我们不会傻乎乎地站在一旁等着米饭煮熟,而是会利用这段时间去做一些其他事情,如洗菜、炒菜。当米饭煮熟后,电饭锅会发出嘟嘟的声音——通知我们米饭已经煮好,我们这才会去开锅盛饭。同时,这个时候我们的菜肴也差不多做好了。在这个例子中,我们没有等待电饭锅煮饭,而是让其在饭熟后提醒我们,这种做事方式就是“异步”的。反过来,如果我们一直等到米饭煮熟后再做菜,这就是“同步”的做事方式。如果对应到程序中,我们的角色就相当于CPU,电饭锅煮饭的过程就相当于一次耗时的I/O操作,而炒菜的过程就相当于在执行一段算法。很显然,异步的方式能更加有效地使用CPU资源。
针对异步编程,在Jav.8之前,ExecutorService类提供了初步的异步编程支持。在ExecutorService的接口方法中,execute()用于异步执行任务且无须等待执行返回,属于完全异步方案。submit()则用于异步执行任务但同步等待执行结果,属于半异步半同步方案。图2-11演示了submit()和execute()的执行原理。submit()从单个线程中一次性提交多个任务,每个任务分别被一个线程执行,从而实现了任务的多核并行执行。execute()则将任务分成多个步骤后,依次提交给负责每个步骤的线程执行,将执行的过程流水线化。从图2-11中可以很直观地发现,流水线化后的CPU被使用得更加充分,因为代表任务执行的线段更加密集。
来自Google的第三方Java库Guava提供了更好的异步编程方案。特别是其中的SettableFuture类,在Future基础上提供了回调机制,初步实现了方便、好用的链式异步编程API。
受到诸多优秀异步编程方案的启发和刺激后,在Jav.8中JVM平台迎来了全新的异步编程方法,即CompletableFuture类。可以说,CompletableFuture类汇集了各种异步编程的场景和需求,是异步编程API的集大成者,而且还在继续完善中。强烈建议读者仔细阅读CompletableFuture类的API文档,这会对理解和编写高性能程序有极大帮助。本书后面的章节也会讲解并运用到CompletableFuture类。
除了这些偏底层的异步编程方案外,还有很多更高级和抽象的异步编程框架,如Akka、Vert.x、Reactive、Netty等。这些框架大多基于事件或消息驱动,抛开各种不同的表现形式,从某种意义上来讲,异步和事件(或消息)驱动这两个概念是等同的。
图2-11 submit()和execute()的执行原理