【Vert.x准备篇2】C10K问题与Reactor模式

知乎专栏: 关于Vert.x你需要知道的一切

C10K问题是1999年一个叫Dan Kegel的美国人提出的概念,其中Cconcurrently, 10K指的是1万个网络连接, 结合起来意为如何能够做到并发处理1万个连接。

这里首先要澄清一下,并发(concurrency)和并行(parallel)虽然都是用来描述"同时"干多件事的名词,但他们是有本质区别的。并发指的是CPU通过在不同的线程之间快速切换来营造出一种多个线程同时在执行的假象, 而并行则是真正意义上的多个线程在同时运行。

对于现代操作系统来说,C10K问题的核心在于线程(或进程)无法随着连接数的增加而无休止的创建。对于web应用,我们平时最常用也是最熟悉的线程模型就是"一请求一线程"模型,但它难以解决C10K的原因是单个线程会占用较多的内存资源,内存的有限性就决定了线程的总数是有限制的。即便现在很多企业级服务器都是100G+的内存,线程切换所带来的开销也会随着线程数量的增加而增加,从而进一步限制了有效线程的数量。这样看来解决问题的唯一方法,就是想办法让单个线程能在不发生阻塞的前提下处理多个连接了。于是,在操作系统的支持下,reactor模式诞生。

Reactor模式

Reactor是一种基于事件驱动的设计模式,它可以做到只用少量的线程就能处理大量的I/O操作。简单来说,一个Reactor就是一个事件循环,执行这个循环的线程会阻塞在多路复用器的select()调用中,当感兴趣的I/O事件发生时,操作系统会让select()函数返回,同时告诉你发生了哪些事件,然后当前线程会将事件分发给事件对应的处理器(Handler)来进行处理, 处理完成后再进入下一次循环,以此类推。在这个过程中主要有以下三个核心组件:

  • Reactor

由一条线程执行的无限循环,其任务就是等待操作系统通知I/O ready事件的发生然后将这些事件分派给对应的处理器来处理。

  • Demultiplex

即多路复用器,其作用为让当前线程阻塞在等待事件发生的过程上,然后在事件发生时返回。其实多路复用起初是通讯工程中的术语,本意是让多种不同的信号在同一条物理线路上传输。在这里多路是指可同时监听多个I/O事件,复用是指对这些事件的处理复用同一条线程。前面我们在说Reactor的诞生有一个前提条件,即必须有操作系统的支持。在这里操作系统就扮演着通知应用程序的角色,具体的通知方式在不同的OS有着不同的实现,如epoll(Linux), k-queue(freeBSD), iocp(windows)等。

  • Handler

事件处理器。事件处理器首先要向多路复用器告知自己对哪些事件感兴趣,然后当这些事件发生时自身会被调用。其实Handler就是我们需要实现的业务逻辑,但需要注意的一点是,无论如何都不能阻塞Handler, 因为这会直接block整个事件循环。

现在我们来看一下Reactor是如何做到用少量线程来处理大量I/O的。假定现在已经建立了10个HTTP连接且我们想让服务器同时为这10个client服务而不是像排队一样完成前一个再处理下一个。在一请求一线程的模型下,我们需要启动10条线程来调用receive()方法并block在这个方法调用上, 这样只要有请求数据到来这些线程就会马上进行处理。不过在Reactor模式里,我们可以只使用一条线程先向Demultiplex注册一下我们对这10个连接的"读就绪"事件感兴趣并绑定对应的Handler,之后block在select()调用上; 当事件发生后(如这10个连接的读就绪事件同时发生),我们的主线程从select()中返回,主线程遍历所有的事件并调用对应的事件处理器完成业务逻辑。这样我们仅用一个线程就完成了先前10个线程才能完成的工作。当然,这里是有一些限制条件的,比如Handler中绝对不允许出现阻塞代码。但是业务逻辑难免会有像数据库查询这样的阻塞且耗时的调用, 该怎么办呢?答案是在Handler中只发起DB查询然后立即返回,发起后告知Demultiplex我们对DB查询完成的事件感兴趣, 当DB返回结果时再唤醒Handler进行后续处理。在Vert.x中,注册和等待事件ready的逻辑框架都会为我们代劳,我们只需要专注于编写Handler即可。

世面上各框架在实现Reactor时都会有很多变种,例如在Vert.x中,Reactor会有多个,其负责执行Reactor事件循环的NIO线程也会有多条, 而不是上面最简单的单Reactor模型。

从上面的讨论可以看到,使用Reactor模式并不能降低服务器对於单个请求处理的总耗时,而是能最大限度的减少线程阻塞(提高CPU利用率),从而大大减少了处理10K连接时需要的线程数量,进而提高了服务器的并发处理能力。不过这样也给程序员带来了麻烦,即我们需要为各种会block线程的操作注册各种回调,导致业务逻辑从先前线性的"一本道"变成了被迫分散在各个回调方法中。鱼和熊掌不可兼得,如果你真遇到了高并发问题,那么就只能牺牲一下代码了【Go语言除外😏】。

与Proactor的区别

谈到reactor就不得不提一句proactor。这二者最根本的区别在于,Reactor中监听的是I/O就绪事件,此时数据还在操作系统的内核缓冲区,线程被唤醒后需要主动将数据从内核缓冲区读取到用户进程中; 而Proactor里用户进程监听的是I/O完成事件,即当线程被唤醒时,数据已经从内核转移到进程中了,这个过程是操作系统帮你完成的,你只需要在发起I/O时提供一个buffer, 等I/O完成时buffer已经被填满,而不需要你手动从read()中获取了。

Tomcat为什么"搞不定"高并发

首先,这不是tomcat的锅,而是servlet规范(3.0之前)和业务代码的问题。自Tomcat6开始就已经支持了JDK的NIO, 可以使用少量的线程处理大量的I/O事件,问题在于servlet和你的业务代码是同步的。也就是说,即便tomcat使用了某种黑魔法,仅用了一个线程就能搞定N个连接的创建和读写操作,但当tomcat调用servlet处理业务逻辑时仍然需要从维护的worker线程池中取一个线程来执行,这就又回到一请求一线程的模式了----只有同时启动10K条线程才能真正完成10K个请求的处理,否则后面的请求尽管已经完成了连接的建立和数据的接收,也只能是在一味的等待,等待前面的worker线程干完活才能来处理后面的请求。所以,只要servlet和你的业务代码也异步起来,Tomcat完全可以搞定C10K。只可惜,servlet3.0来的太晚了,异步编程的江山已经被Netty, Vert.x, Akka和Node.js这样的框架(工具)瓜分完毕了。

下一篇我们会介绍Vert.x中的核心组件,以及它是如何实现Reactor模式的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章