在设计高并发高性能服务器时,一项关键的考虑就是I/O。
I/O是一个问题
有的同学可能会有疑问,为什么I/O会成为问题?
假设有一个web server,每分钟有数百万次的请求过来,服务器在处理请求时要访问数据库,同时该服务器也可能要请求其它的服务,一张典型的后端server可能的架构如图所示:
一个用户请求过来后Server可能需要访问数据库,然后再去请求另外几个server后才能得到用户请求的处理结果,然后将response返回给客户端,从这张图中数一数涉及到哪些IO?
其实主要有两种:
- 数据库操作的磁盘IO、文件IO
- 网络IO
现在你已经知道了涉及哪些IO,让我们再来看一张图:
我们可以看到,磁盘IO和网络IO是非常慢的,也就是说我们通常用手机APP、PC浏览器打开一个页面点击一个按钮到完全得到响应,这其中大部分的时间都消耗在了这两种IO上,真正用在处理数据的CPU时间反而不是很多。
这告诉了我们一个道理,那就是高效处理IO对于高并发高性能服务器来说至关重要。
两类经典设计模式
有两类处理网络请求的经典模型:
- 基于线程(进程)的 Thread(Process)-per-connection,也就是每个请求一个线程(进程)
- 基于事件驱动的Reactor模式,也就是反应器模式。
其中第一种模式我们在之前的文章中已经多次讲解过了,这种模式会为每个请求都创建一个线程或者进程:
但这种模式的一个问题就在于当并发数较多时需要创建很多的线程,创建线程过多会有性能问题。
第二种基于事件的模式我们在之前的文章中也讲解过,在这种模式下我们只需要一个线程就能同时处理多个用户请求:
在基于事件的并发编程中有一种叫做Reactor的模式非常流行,Node.js以及Nginx就使用Reactor,在这篇文章中我们详细的讲解一下高性能高并发服务器中的Reactor模式。
当然,在了解Reactor之前我们先来看一下咖啡馆是怎样运作的。
咖啡馆是怎样运作的
假设你有一家咖啡馆,作为老板的你在前台接待喝咖啡的顾客,你的生意不错,来这里喝咖啡的人络绎不绝。
有时,有的人点的东西很简单,比如来一杯咖啡或者牛奶之类,但也有一些顾客会点一些复杂的比如来一份意大利面等,作为前台的你,如果这是停止接待顾客而且制作意大利面的话那么后续到来的所有顾客都要等待。
幸好,作为老板的你还有几位大厨来帮忙,因此你只需要简单的把制作意大利面的命令交代下去就好了,“张三去煮面条,李四去制作酱料,制作好后通知我”。
就这样,即便前台只有你一个人也能快速接待顾客的点餐,其实这背后本质上就是Reactor模式。
Reactor模式
实际上每个你可以把咖啡馆这个例子中每个顾客理解为服务器接收的请求,前台的服务员理解为一个单线程的while循环,这个while循环有一个很形象的名字,event loop,这个event loop要做的事情非常简单,那就是接收用户请求,然后让handler,或者回调函数去处理,这里的handler或者回调函数就好比大厨张三和李四去,handler或者回调函数可以和event loop运行在同一个线程中,也可以和event loop各自运行在各自的线程中。
既然该模式是基于事件驱动,那么都有哪些事件呢?
我们需要关心的典型事件这样几种:
- 网络请求的到来,也就是socket编程中accept到客户端连接
- 文件可读
- 文件可写
看到了吧,这几种event都是和IO相关的,涉及网络和文件。
有的同学可能会问,那么这个event loop是怎么知道有这些event到来呢?
这是涉及到了IO多路复用技术,典型的像Linux中的select、poll、epoll。
通过IO多路复用技术,我们可以一次监控一堆的文件描述符,当这些文件描述符对应的IO事件发生时会收到操作系统的通知,这时我们获取到该event并交给相应的handler或者回调函数来处理。
总结下来,Reactor的核心组成部分就是event loop + IO多路复用 + 回调函数。
单线程 or 多线程
我们在上文提到过,处理event的handler可以和event loop运行在同一个线程中,也可以运行在不同的线程中。
如果是运行在同一个线程中那么我们无需面对复杂的多线程问题,但在当前的多核时代,单线程无法充分利用多核资源,此外如果某个请求比较复杂需要占用的CPU资源较多,那么在单线程下其它所有的用户请求都要等待,基于以上考虑我们可以使用线程池(多线程)技术。
event loop在接收到event后,将event和处理event的handler(回调函数)打包发给线程池,线程池中的线程接收到打包后的任务后调用handler(回调函数)来处理相应的event。
这样我们的组合就成了event loop + IO多路复用 + 回调函数 + 线程池。
把协程也加进来
回调函数的一大缺点在于如果处理用户请求的逻辑比较复杂可能会导致回调地狱,关于回调地狱你可以参考这里,协程这种技术在一定程度上解决了这一问题,让我们可以用同步的方式来进行异步编程,关于协程你可以参考这里和这里。
最终我们的组合就成了event loop + IO多路复用 + 协程 + 线程池。
接下来让我们以Node.js来讲解一下Reactor模式。
Node.js与Reactor模式
我们来看一下Node.js的架构图:
这张架构图已经无比清晰的展示了Reactor模式是如何运行的。
1, 当用户请求到来后需要将其放到一个队列当中,因为event loop是运行在单线程中的。
2,接下来event loop不断检测event queue中是否有event到来,如果队列中有请求,那么根据队列的“先来先服务”原则,event loop取出相应的event,并将其交给线程池。
3,该线程池不断检测是否有task到来,这里的task也就是将event和相应的回调函数打包后形成的。
4,线程接收到task后,线程池中的线程开始工作,比如查询数据库、读取文件等等。
5,当线程处理完一个请求后调用task相应的回调函数,并将该处理结果response发送给event loop。
6,event loop在接收到处理结果后发送给客户端。
怎样,这是不是像极了上文中的咖啡馆以及这里的核反应堆。
这就是Reactor模式。
此外,Node.js中的协程叫做Fiber,都是用来以同步的方式来进行异步编程的,这里就不详细讲解了。
Reactor vs Proactor
Reactor模式中使用的IO都是同步IO,什么是同步IO呢?
就是说调用方在IO完成之前会被阻塞等待,这种IO更具体的就叫做同步阻塞式IO。
但我们知道event loop是运行在一个线程中的,如果在event loop中调用同步阻塞式IO的话,那么整个线程会被暂停运行,由于event loop就像咖啡厅前台,非常关键,如果event loop所在线程被阻塞那么所有的用户请求都必须等待。
因此,在event loop中的IO不能是阻塞式的。
有同步阻塞式IO就有同步非阻塞式IO。
什么是同步非阻塞式IO呢?意思是当我们调用同步非阻塞式IO相关函数时,函数会立刻返回,并告诉我们文件是否可读或者可写,如果可读或者可写的话我们再真正的进行文件读写,这就是同步非阻塞式IO。
Reactor模式都是采用的同步非阻塞式IO。
与同步IO相对应的是异步IO。
在异步IO下我们需要将接收或者写入数据的地址告诉操作系统,操作系统会将数据从进程地址空间写入文件或者将文件内容写到进程地址空间中,操作系统完成IO后会通知我们,这就是异步IO。
执行异步IO同样不会阻塞调用线程。
关于同步以及异步的概念你可以参考这里。
而采用异步IO的事件驱动编程被称为Proactor。
也就是说Reactor和Proactor的区别就在于一个采用同步IO一个采用异步IO。
接下来我们用一个读文件的例子来讲解这两者的差异。
Reactor中的读:
- 告诉event loop,我们对某个文件可读事件感兴趣
- event loop等待该事件
- 事件到来,event loop被唤醒,并调用相应handler
- 该handler开始读取文件,并处理数据,完成后返回到event loop
而Proactor的读是这样的:
- 我们发起一个针对某个文件的异步读取操作,告诉event loop,我们不关心这个文件是否可读,我们只关心这个文件是否读取完成。
- event loop开始等待该事件
- 与此同时,操作系统开始执行真正的文件读取,读取完成后通知event loop读取完成
- event loop被唤醒,此时文件已经读取完毕,调用相应的handler
- handler开始处理数据,完成后返回到event loop。
现在你应该明白Reactor和Proactor的差异了吧。
总结
在这篇文章中我们详细讲解了高性能高并发目前流行的Reactor模式,其实其本质和咖啡馆没什么区别,如果你善于观察和思考的话那么你会发现其实很多技术问题都能在现实生活中找到相似的场景。
希望这篇能对大家理解Reactor模式有帮助。