服务器并发需求
对于像Nginx这样的网络服务器,每秒可能会接收到几或几十万的网络请求,并且还有数以十万的最近几秒没有收发任何报文的不活跃连接。服务器需要同时处理这些连接事件,并且需要维持高效率的使用CPU等资源。
并发编程的两种实现模型
1)线程模型(或进程模型)
一个线程处理一个连接的全部生命周期。
优点:模型足够简单,可以实现复杂的业务场景,线程个数可以远大于CPU个数
缺点:
- 1)线程个数不是无限增大的。
- 2)调度策略比较低效
线程的调度是由内核调度算法决定的,调度策略并不会考虑某个线程处理的IO情况,它统一由时间片来决定。这样可能调度起来的线程IO资源并没有准备好,又得继续睡眠。这样来回唤醒、睡眠线程,在线程总数特别多时,它的低效就被放大了。
2)多路复用模型
对于一个Network IO来说,通常涉及到两个系统对象,一个是调用这个IO的进程(或者线程),另一个是系统内核,在处理连接上的消息时,大概可以分两个阶段(下面还会提到):
- 第一阶段:等待数据(消息)准备好【内核中执行】
- 第二阶段:将数据从内核拷贝到进程中(消息)处理【内核向用户进程拷贝】
高并发编程方法是将这两个阶段分开处理。这样要求套接字必须是非阻塞的。
那么第一个阶段,“等待消息准备好”的实现方式有两种:
- 1)线程主动查询
- 2)让一个线程为所有连接等待(多路复用)
多路复用就是处理等待消息准备好事件的,但可以处理多个连接。它本身也会“等待”,但由于一个线程处理多个连接或者所有的连接。这样当线程被唤醒时,一定会有连接准备好,所有它是有效率的。
多路复用的实现方式
1)select & poll
2)epoll
多路复用的核心是一个线程处理所有连接的“等待消息准备好”,这一点epoll和select都是这么实现的。
当数以十万并发连接存在时,每秒可能只有几十或几百个活跃的连接,同时其余数十万连接在这一秒是非活跃的,当需要找出活跃的连接时,调用select返回所有的连接,从中找出几百个活跃连接,在高并发的服务器下,这种低效就会被放大。所以,在处理并发上万个连接时,select就力不从心了。
epoll的实现添加了epoll_wait方法只返回活跃的连接,这样就没有上面select的问题,在高并发下依然很高效。
多路复用是实现高并发服务器的一种有效方式,那么实现多路复用需要系统内核提供相应的支持,下面看下几种常用的IO模型。
四种IO模型
1)Blocking IO
默认情况下,所有的Socket都是blocking的,当用户进程发起recvfrom系统调用时,内核就开始上面IO的第一个阶段:准备数据。对于网络io来说,通常一开始数据还没有到达,这时内核就要等待足够的数据到达,而用户进程会被阻塞。当内核等到的数据准备好了,就会执行IO的第二个阶段:拷贝数据到用户进程。然后内核返回结果,用户进程才重新运行起来。2)non-blocking IO
通常需要应用程序设置Socket为non-blocking,当用户进程发起Read请求时,如果内核数据还没有准备好,会返回一个error。对用户进程来说,发起读请求后,不需要等待,马上就会得到一个结果。当结果是error时,就表示内核还没有准备好数据,于是需要用户进程再次发起Read请求。一旦内核中数据准备好了,用户进程再次发起Read请求时,内核就会将数据拷贝到用户进程,然后返回。
这里用户进程是需要轮询内核数据是否准备好的。
3)IO多路复用
select、poll、epoll都属于IO多路复用的实现。通常每个socket都设置为non-blocking。以select为例,当用户进程调用select后,用户进程会被block(是block在select系统调用上,不是block在socket IO上),同时,内核会监视所有select负责的socket,当任何一个socket上数据准备好了,select就会返回。这时用户进程再调用read操作,就会将数据从内核拷贝到用户进程。4)Asynchronous IO
Linux内核暂时还没有提供支持。用户发起read操作后,立刻可以去做其他事。对内核来说,当接收到一个异步read操作后,首先会立刻返回,不会block用户进程。然后,内核会等待数据准备好,并将数据拷贝到用户进程,都完成后,内核会给用户进程发一个signal,通知用户进程read操作完成了。
可见,在整个IO操作过程中,用户进程不需要去检查IO操作的状态,也不需要主动去内核空间拷贝准备好了的数据。也就是说将整个IO操作都交给内核,操作完成后内核通知用户进程。
同步IO & 异步IO
Blocking IO、non-blocking IO、IO多路复用属于同步IO,Asynchronous IO属于异步IO,其核心区别是IO操作的两个阶段是不是被阻塞了。
同步IO中IO操作的第一个阶段:数据准备,可以阻塞也可以非阻塞,但第二个阶段:数据拷贝到用户进程,是肯定阻塞的。
异步IO中IO操作的第一个阶段和第二个阶段都是非阻塞的。
其实最主要的区别就是第二个阶段是否是非阻塞的。
有了IO多路复用,有了epoll,我们已经可以使服务器并发几十万连接的同时,维持比较高的TPS。然而这只是一种模型,如何在工程上实现这种模型,Reactor就是解决这种软件工程问题的一种途径,它可以在软件工程层面,将事件驱动框架分离出具体业务,将不同类型请求之间用面向对象的思想分离。下面看看Reactor的几个关键参与者
Reactor模式的5个关键的参与者
描述符(handle):有操作系统提供,用于识别事件。如Socket、文件描述符等
同步事件分离器(demultiplexer):是一个函数,用来等待一个或多个事件发生。调用者会被阻塞,直到分离器分离的描述符集上有事件发生。常用的分离器有Linux的Select函数、poll、epoll、kqueue等。I/O框架库异步将各种I/O复用系统调用封装成统一的接口,称为事件多路分离器。调用者会被阻塞,直到分离器分离的描述符集上游事件发生。
事件处理器接口(event handler):是由一个或多个模板函数组成的接口。这些模板函数描述了和应用程序相关的某个事件的操作。
具体的事件处理器:实现了应用程序的某个服务。每个具体的事件处理器总和一个描述符相关。它使用描述符来识别事件,识别应用成所提供的服务。
Reactor管理器(Reactor):定义了一些接口,用于应用程序控制事件的调度,以及应用程序注册、删除事件处理器和相关的描述符。是事件处理器的调度核心。使用同步事件分离器来等待事件发生,一旦事件发生,Reactor先分离每个事件,然后调度事件处理器,最后调用相关的模板函数来处理这个事件。
参考文献
高性能网络编程5–IO复用与并发编程
IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
高性能网络编程6–reactor反应堆与定时器管理