近期很长的一段时间,都在学习总结,什么是高并发,怎么样实现高并发,接触了解的内容包括Nginx工作原理,Tomcat 的三种运营模式,Pistache并发机制,CGI/Fast-CGI,nodejs及其底层的异步IO库linuv,Linux的事件轮询机制Epoll,以及Go语言的GMP调度模型。自认为形成一套自圆其说的逻辑,所以记录总结一下。
首先谈一下并发,在OS的课程上对于并发和并行有严格的区分:
- 并发指的是多个任务同一个core上一起执行,通过调度算法,给用户以所有的任务正在同时的进行“错觉”,但是本质上,任务是串行的
- 并行指的是多个任务在多个core上同时执行
而我们这里讨论的并发可以认为是上述两种情况的融合,即所谓高并发是指,如何在有限核的服务器上尽可能多的处理请求或任务(任务数>>核数),同时还要追求低延时(平均延时、尾部延时)、高资源利用率(主要考虑CPU利用率)等性能指标。
这里先给出结论吧,要想实现最极致的并发,核心在于满足以下三点:
-
执行任务的线程等于核数
线程数等于核数,旨在减少线程切换的开销,显然这个开销并不是特大,因此超过核数引入的开销其实并不大,但是引入过多的线程(如启动上万线程)开销就无法忽视了,其次线程切换是需要进行用户态/内核态的转化的,而且CPU的调度算法是O(n)的。 -
CPU的利用率为100%
CPU的利用率为100%,这是为了不让CPU空闲,我们都知道,一个请求的执行一般可以划分为两种情况:执行计算、执行IO,其中后者是不会使用CPU的。到这里我们知道,一般来说前两点是矛盾的,如果线程等于核数,那么CPU利用率一定不会高,因为CPU时间被IO任务占据。 -
任务完全公平的使用CPU
关于这点,我关注的比较少,其实,实现前两点就是高并发了,能做到公平性的也只有Go语言了,不过由于用户级线程是无法被内核感知的,其公平性也是无法达到CFS算法的水准。我们给的公平下一个定义的话:假设当前有N个优先级相同的任务在同一个core上运行,那么在一个调度周期T时间内,每个任务的得到的执行时间是(T/N),那么可以认为是公平的。
显然要想严格实现上述三点是不可能的,对于第三点,关于什么是完全的公平,本身也是一个需要讨论的话题。我们要做的只是向上面三点靠拢。
下面我将从BIO、NIO、Nodejs、Go,四个方面,以递进的方式,阐述,我对于实现高并的的理解。
一、BIO
对于一个web服务器而言,是基于TCP + HTTP协议的。TCP与UDP不同之处在于,在使用TCP进行数据传送之前,必须要在客户端和服务端之间建立TCP连接,然后再进行数据传输,过程及涉及的系统调用如下图所示:
HTTP请求的底层流程 因此在服务器端,有两种阻塞的情况:- accept()
如果任何客户端没有进行connect()操作,那么执行accept()将会阻塞 - read()
如果客户端没有通过write()发送数据(即HTTP请求,HTTP请求就是遵循了HTTP协议的消息),那么执行read()将会阻塞
BIO的模式是这样的:
- 首先,由一个主线程来监听server-fd(listen-fd)
- 执行accept()阻塞等待新连接,当新连接到达时创建client-fd
- 从线程池中选择一个工作线程,讲client-fd交给线程处理,然后执行循环执行2、3
- 工作线程执行read(),阻塞等待HTTP请求的到达,然后解析、处理、封装HTTP请求,发送响应
- 如果是长连接(一般都是长连接),那么将循环执行4,知道断开连接,释放线程
BIO模式要求,每一个线程处理一个连接,这样同时处理的连接数将取决于线程池的大小,那我们假设启动一个合理大小的线程池,使之不会过多的消耗系统资源(如,是当前核数的2倍):
要点 | 表现 | 解释 |
---|---|---|
线程数目 | 尚可 | 因为我们假设启动一个不大的线程池 |
CPU利用率 | 很低 | 因为每个线程会在阻塞等待http请求的到达,在次期间,CPU无法做任何事情,且长连接会让情况更糟,因为这意味着CPU长时间的阻塞 |
公平性 | 很差 | 因为处理的连接数目受限与线程池的大小,新的连接只能等待前面的连接处理完毕,长连接模式显然让这个问题更加严重 |
二、NIO
BIO的主要症结在于阻塞等待新连接和阻塞等待请求,而基于IO复用机制的epoll是解决网络IO阻塞的灵丹妙药,基于epoll,每个线程可以处理多个连接:
- 主线程维护一个epoll,用于监听listen-fd
- 当新连接到达时,调用accept()创建一个client-fd,然后将client-fd放到主线程的epoll中进行监听
- 当epoll监听的client-fd有先请求到达时,会返回产生事件的client-fd
- 主线程将HTTP请求交给工作线程处理,然后继续执行epoll的监听
- 工作线程执行解析、处理、封装HTTP请求,发送响应,处理完毕后,等待主线程的任务派发
注,以上只是一个NIO的一种简单模式
与NIO显著不同的一点在于,工作线程对应于一个HTTP请求,而非一个连接,这样我们每个线程都在执行HTTP请求,而不会像BIO那样阻塞等待请求的到达。也就是说我们使用epoll实现了网络IO的异步。
但是显然,同时处理http请求的数目依然收到线程池大小的限制,我们再次假设启动一个合理大小的线程池:
要点 | 表现 | 解释 |
---|---|---|
线程数目 | 尚可 | 因为我们假设启动一个不大的线程池 |
CPU利用率 | 有提高,但还不够 | 提高是因为实现了网络IO的异步,不需要阻塞等待HTTP请求,在长连接的情况下改进很大;不够是因为,用户的处理函数中进行的IO操作依然会阻塞线程,如需要访问数据库或本地的文件等。 |
公平性 | 有提高,但同样不够 | 有提高是因为新连接可以马上被处理,且任何一个连接的HTTP请求都遵循FIFO的原则;不够的原因依然是FIFO,只能一个个的处理HTTP请求 |
三、Nodejs
NIO的主要问题在于,仅仅将处理TCP连接和HTTP请求的部分实现了异步,但用户处理函数中的IO操作,依然会阻塞线程。
于是nodejs,使用libuv封装了所有的IO操作(当然了主要就是网络IO和文件IO),实现了完全的异步IO。
其中网络IO是基于epoll的,而文件IO是基于线程池的。
这里需要注意一点,关于网络IO和文件IO可以看此图:
真正的IO操作,是由DMA完成的,由网卡或磁盘到socket_Buff或pageCache的这一部分,这一部分CPU是不会参与的,但是将数据再从socket_Buff或pageCache读到用户空间,是CPU操作。
当epoll感知到可读时,数据已经达了socket_Buff了,因此epoll实现异步网络IO可谓绝配;但显然文件IO是无法使用epoll的,而Linux的AIO操作又不成熟,因此只能使用多线程来实现异步了,Go也是这么做的。
而使用线程实现异步文件IO结束的标志是数据已经读取到用户空间(即read(2)操作返回),因此整个过程其实是包含了CPU操作的,当然了epoll机制实现的异步网络IO虽然不包括CPU的copy过程,但是也要内核执行中断处理程序、调用协议栈处理packet。但是IO的时间肯定是占大头,因此我们可以认为执行异步IO的线程,几乎不参与CPU调度的竞争,这是我们满足1. 执行任务的线程等于核数
的一种变通方式!!!
要点 | 表现 | 解释 |
---|---|---|
线程数目 | 接近完美 | 虽然我们启动实现异步文件IO的线程,但是他几乎不会参与线程的CPU调度 |
CPU利用率 | 完美 | 一切IO都是异步的,CPU将专注与计算任务 |
公平性 | 又有提高,但是依然不够 | 因为事件循环机制,每个HTTP任务都被以回调函数的形式拆分,谁的异步IO完成,谁得到执行;但是毕竟是单线程的模式,一旦某个请求的计算任务很长,那么也会指导处理完才能处理别的 |
四、Go
现在只剩下公平性的问题了。
其实上面的优化,总结一下就是:异步I/O。
Go语言使用了另一种方法:协程或者叫纤程或者叫用户级线程。
每个任务都以协程的方式,在线程上执行,也就是PMG模型!这里就不展开PMG的运行原理了。
Go在执行到IO这样阻塞的任务时,同Nodejs一样,对于网络IO,使用epoll机制,用一个线程运行net-poller来等待;对于文件IO,则也是启动新的线程(M)来处理,当然了,不光是IO操作,但凡要执行长时间的系统调用,Go都会启动一个线程来执行等待,然后切换执行其他的协程任务。
此外Go语言还是实现了,基于抢占的协程调度,具体的方法是当协程要调用一个函数时,在函数的入口处,由编译器插入检查代码,如果已经运行了足够的时间,那就切换到其他线程,显然这种方法和CFS调度相比还是弱。
所以我认为,Go在线程数目和CPU利用率上与nodejs差不多,在公平性上比Nodejs又进了一步,但是依然不是完美的!
后记
以上所有,都是一些不够成熟的见解,在描述上也很欠缺,以后随着我的理解深入,一定会进一步的更新!
网友评论