网络IO模型
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
在Linux中,对于一次I/O读取的操作,数据并不会直接拷贝到程序的程序缓冲区,通常包括两个不同阶段:
- 等待数据准备好,到达内核空间 (Waiting for the data to be ready) ;
- 从内核向进程复制数据 (Copying the data from the kernel to the process)
对于一次I/O写入的操作,和上面是类似的,过程相反。
同步阻塞IO(blocking IO)
伪代码如下:
int main()
{
//...
read(socket, buffer); //阻塞在此,等待内核返回数据
process(buffer);
//...
}
同步非阻塞IO(nonblocking IO)
伪代码如下
int main(){
//...
while(read(socket,buffer) != success){
//轮询等待,会浪费cpu资源
}
//...
process(buffer)
}
IO多路复用(IO multiplexing)
IO 复用”其实复用的不是 IO 连接,而是复用线程。本质是把轮询这个动作放在内核中去做了,程序阻塞在select、poll、epoll上。
伪代码如下:
int main(){
//...
select(socket);
while(1){
sockets = select(); //数据已经存在内核缓冲区了,需要拷贝到用户态。
for(socket in sockets) {
if(can_read(socket)) {
read(socket, buffer);
process(buffer);
}else if(can_write(socket)){
write(socket, buffer);
process(buffer);
}else{
// ....
}
}
}
//...
}
信号驱动IO(signal driven IO)
不怎么使用
异步IO(IO)
太复杂
服务端网络模式
thread-based
阻塞IO,单进程-单线程模型。
这种模型下,服务器只能和一个客户端通信,显然是不合理的。
在改进模型之前,先看看服务器单机理论上最大能支持多少个客户端:
TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口。服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数。对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。
这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:
- 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
阻塞IO,多进程-单线程模型。
情况1:accept后再fork子进程
父进程accept后,fork子进程,因为子进程会复制父进程的文件描述符,所以子进程可以和客户端通信。
这个是最简单的模式,由于只有一个进程在使用accept进行监听,不涉及多进程争抢的问题,当tcp连接事件到达后也只会唤醒这个监听进程,自然也不存在惊群效应。
这种模式就是来一个请求起一个进程,当并发量上来后,机器是撑不住的(内存cpu等资源不够)。
情况2:fork子进程后再accpet
预先fork,就是进程池(相对于线程池)
这种情况下,因为所有子进程都在监听父进程创建的listenfd,所以当有连接到来时,所有子进程都会被唤醒,但是只有一个进程能获取到事件并进行处理。这就出现了惊群问题。好在2.6版本的内核已经解决了,当有连接事件到来时候,只有一个进程或者线程的accept会被唤醒,不会出现惊群问题。
这种模式下(可以简单理解成进程池模式),当子进程在处理业务请求的时候,就无法接受连接,所以并发效率不高。好处是不用频繁创建和销毁进程。
阻塞IO,单进程-多线程模型。
和**「阻塞IO,多进程-单线程模型」**没有什么本质上的区别,就是换成了线程而已。
-
[对应情况1]主线程accept后,创建一个线程来处理,完事之后线程自己被干掉。
- 好处是当前的请求不会影响后来的请求,但是并发一大还是撑不住。
-
[对应情况2]主线程accept后,将acceptfd放入队列中,通过条件变量等通知线程池中的子线程去队列中获取fd,然后子线程和客户端通信。
- 优点:是不用频繁创建线程
- 缺点:当线程繁忙时,会影响后续请求。【因为是阻塞IO,子线程要阻塞在read上等客户端发送数据来,如果客户端不发数据,就会一直阻塞住】
👆演变👇
线程/进程模型一般都是阻塞IO,如果换成非阻塞IO来看,因为不知道什么时候有连接到来或是有数据到来,所以得while轮询,浪费cpu资源,假设可以知道什么时候有连接到来或者数据可读呢?linux下面epoll/select/poll系统调用正是这个功能,由epoll、select、poll系统调用引出了事件驱动。
envent-driven
基于事件驱动的设计思想,有Reactor模式和Proactor模式,目前一般都采用reactor模式
非阻塞IO+IO多路复用,单进程-单线程模型。
该方案也可以叫做单线程Reactor模式,所有的IO操作和业务逻辑都在主线程中完成。主线程阻塞在select/poll/epoll上,当有事件发生时(新链接、数据读写),链接事件时丢给acceptor类,调用accept()完成tcp三次握手,然后让select/poll/epoll关注acceptfd的读事件,当数据到来的时候丢给业务类去处理,处理完后回复对端,再让select/poll/epoll关注acceptfd的写事件来进行发送。
优点:编程简单,对于业务处理不复杂的后台服务,基本能够满足需求
缺点:会有阻塞服务器,在进行业务处理的时候不能进行其他操作:如建立连接,读取其他套接字上的数据等。适合 IO 密集的应用,不太适合 CPU 密集的应用,因为较难发挥多核的威力,所以一般为了高并发,还是会采用多进程/线程的模式。
非阻塞IO+IO多路复用,单进程-多线程模式
a) Reactor+threadpool
全部的 IO 工作都在一个 reactor 线程完成,而计算任务交给 thread pool。如果计算任务彼此独立,而且 IO 的压力不大,那么这种方案是非常适用的。
b) multiReactors⭐
这种模式下,需要考虑几个问题:
- 是否共用一个acceptor
- 共用的情况需要额外看
- 情况1:固定一个reactor拥有acceptor,其他reactor借用它的能力
- 情况2:所有的reactor共同拥有一个acceptor
- 共用的情况需要额外看
- 是否共用一个epollfd
Q:是否共用一个acceptor
对于acceptor首先需要明确:
- acceptor只有一个,因为同一个地址+端口只有一个进程能监听,但是可以通过设置SO_REUSEPORT来实现多个acceptor都监听相同的地址和端口。
- acceptor必须得注册到epoll之中的进行监控的。
共用的情况下,也就是acceptor只有一个,有两种情况:
-
情况1:就是一个reactor拥有acceptor,其他reactor借用它的能力,那么可以将Reactor进行了功能拆分,做单一职责。
-
mainReactor拥有acceptor,只处理链接事件,处理完成后通知subReactor等待读写事件
-
subReactor负责处理IO和逻辑计算。
-
-
情况2,多个reactor都将acceptor挂在到自己的epoll上,那么当连接事件到来时,所有reactor同时被唤醒去调用acceptor。这种情况会导致资源竞争,以及进程被频繁唤醒导致的cpu资源浪费,可以进行方案上的优化。实现上就是多个reactor先去获取锁,获取到了之后才能挂到自己的epoll上,然后处理连接、读写事件等。(这种方案是nginx的方案,只是线程换成了进程)
不共用的情况下,也就是有多个acceptor,设置SO_REUSEPORT共同监听一个地址+端口,当有连接到来时,由操作系统内核来做负载均衡,只会唤醒某一个Reactor。
Q:是否共用一个epollfd
一般是不考虑共用一个epollfd的:原因:假设共用,那么当有事件发生的时候,所有的epoll_wait都会被唤醒,以读写事件来看:Reactor-A在epoll添加了socket1的读事件,然后调用epoll_wait,此时Reactor-B进程也调用了epoll_wait,由于属于同一个epollfd,当socket1产生事件的时候,Reactor-A和B都会被唤醒,假设Reactor-B获得了事件,但是Reactor-B并不知道socket1是什么东西,所以就没法读写。
c) multiReactors+threadpool
在b的方案下,新增threadpool,将IO和逻辑计算分开
最后打一个小广告,可以看看我写的一个网络库:https://github.com/HLhuanglang/EasyNet