网络IO模型

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

在Linux中,对于一次I/O读取的操作,数据并不会直接拷贝到程序的程序缓冲区,通常包括两个不同阶段:

  1. 等待数据准备好,到达内核空间 (Waiting for the data to be ready) ;
  2. 从内核向进程复制数据 (Copying the data from the kernel to the process)

对于一次I/O写入的操作,和上面是类似的,过程相反。

同步阻塞IO(blocking IO)

blocking-io-mode

伪代码如下:

int main()
{
    //...
    read(socket, buffer);   //阻塞在此,等待内核返回数据
    process(buffer);
    //...
}

同步非阻塞IO(nonblocking IO)

non-blocking-io-mode

伪代码如下

int main(){
    //...
    while(read(socket,buffer) != success){
        //轮询等待,会浪费cpu资源
    }
    //...
    process(buffer)
}

IO多路复用(IO multiplexing)

IO 复用”其实复用的不是 IO 连接,而是复用线程。本质是把轮询这个动作放在内核中去做了,程序阻塞在select、poll、epoll上。

io-multiplexing-mode

伪代码如下:

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,单进程-单线程模型。

这种模型下,服务器只能和一个客户端通信,显然是不合理的。 image-20220902160247900

在改进模型之前,先看看服务器单机理论上最大能支持多少个客户端:

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会被唤醒,不会出现惊群问题。

这种模式下(可以简单理解成进程池模式),当子进程在处理业务请求的时候,就无法接受连接,所以并发效率不高。好处是不用频繁创建和销毁进程。

img

阻塞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的写事件来进行发送。

reactor_basic

优点:编程简单,对于业务处理不复杂的后台服务,基本能够满足需求

缺点:会有阻塞服务器,在进行业务处理的时候不能进行其他操作:如建立连接,读取其他套接字上的数据等。适合 IO 密集的应用,不太适合 CPU 密集的应用,因为较难发挥多核的威力,所以一般为了高并发,还是会采用多进程/线程的模式。

非阻塞IO+IO多路复用,单进程-多线程模式

a) Reactor+threadpool

全部的 IO 工作都在一个 reactor 线程完成,而计算任务交给 thread pool。如果计算任务彼此独立,而且 IO 的压力不大,那么这种方案是非常适用的。

reactor_threadpool

b) multiReactors⭐

这种模式下,需要考虑几个问题:

  1. 是否共用一个acceptor

    • 共用的情况需要额外看
      • 情况1:固定一个reactor拥有acceptor,其他reactor借用它的能力
      • 情况2:所有的reactor共同拥有一个acceptor
  2. 是否共用一个epollfd

Q:是否共用一个acceptor

对于acceptor首先需要明确:

  • acceptor只有一个,因为同一个地址+端口只有一个进程能监听,但是可以通过设置SO_REUSEPORT来实现多个acceptor都监听相同的地址和端口。
  • acceptor必须得注册到epoll之中的进行监控的。

共用的情况下,也就是acceptor只有一个,有两种情况:

  1. 情况1:就是一个reactor拥有acceptor,其他reactor借用它的能力,那么可以将Reactor进行了功能拆分,做单一职责。

    • mainReactor拥有acceptor,只处理链接事件,处理完成后通知subReactor等待读写事件

    • subReactor负责处理IO和逻辑计算。

    • image-20240521195302917

  2. 情况2,多个reactor都将acceptor挂在到自己的epoll上,那么当连接事件到来时,所有reactor同时被唤醒去调用acceptor。这种情况会导致资源竞争,以及进程被频繁唤醒导致的cpu资源浪费,可以进行方案上的优化。实现上就是多个reactor先去获取锁,获取到了之后才能挂到自己的epoll上,然后处理连接、读写事件等。(这种方案是nginx的方案,只是线程换成了进程)

不共用的情况下,也就是有多个acceptor,设置SO_REUSEPORT共同监听一个地址+端口,当有连接到来时,由操作系统内核来做负载均衡,只会唤醒某一个Reactor。

image-20240521195220456

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和逻辑计算分开

reactor_hybrid