网络 IO 模型的演化过程

一、应用程序接发数据包

在网络通信的过程中,应用程序可能接收数据包,也有可能发送数据包。应用程序接受发送数据包的大致流程如下:

1. 发送数据包

网络 IO 模型的演化过程_第1张图片

  • 调用系统调用 send 方法的时候,用户线程切换到内核态,在内核中根据 fd 找到对应的 Socket 对象,根据这个 Socket 对象构造出 msghdr 结构体对象,同时将用户需要发送的全部数据封装在这个 msghdr 结构体中。
  • 之后调用内核网络协议栈的 inet_sendmsg 方法,进入内核协议栈的处理流程,根据具体的协议调用对应传输层方法(tcp_sendmsg 或者 upd_sendmsg)。以 tcp_sendmsg 为例,将 msghdr 拷贝到 sk_buffer,将新创建的 sk_buffer 添加到 Socket 发送队列的尾部。
  • 如果符合 tcp 协议的发送条件,则会调用 tcp_write_xmit 方法,循环获取发送队列的内容,然后进行拥塞控制和流量控制。
  • 将发送队列中的 sk_buffer 重新拷贝一份设置 TCP 头部后交给网络层(拷贝的目的在于可以超时重传)。
  • 之后填充 IP 头,查找 MAC 地址,封装成帧。将数据放入 RingBuffer 中,调用网卡驱动程序来发送数据。
#include 

struct iovec {                    /* Scatter/gather array items */
   void  *iov_base;              /* Starting address */
   size_t iov_len;               /* Number of bytes to transfer */
};

struct msghdr {
   void         *msg_name;       /* optional address */
   socklen_t     msg_namelen;    /* size of address */
   struct iovec *msg_iov;        /* scatter/gather array */
   size_t        msg_iovlen;     /* # elements in msg_iov */
   void         *msg_control;    /* ancillary data, see below */
   size_t        msg_controllen; /* ancillary data buffer len */
   int           msg_flags;      /* flags on received message */
};

2. 接收数据包

网络 IO 模型的演化过程_第2张图片

  • 当网络包到达网卡的时候,操作系统通过 DMA 的方式将网络包放到环形缓冲区(RingBuffer)。当 RingBuffer 满的时候新来的数据帧将会被丢弃。
  • DMA 操作完成的时候,网卡向 CPU 发送一个硬中断,CPU调用对应的硬中断响应程序,该响应程序会将 RingBuffer 中的内容拷贝到 sk_buffer 中。
  • 当数据拷贝到 sk_buffer 中后会触发软中断,内核线程 ksoftirqd 发现有软中断请求时,会调用网卡驱动注册程序的 poll 函数,该函数将 sk_buffer 中的网络包发送到内核协议栈中的 ip_rcv 函数中。
  • 在 ip_rcv 函数中(网络层),取出数据包的 IP 头,判断该数据包的走向,如果目的 ip 地址与本主机的 ip 地址相同,则取出传输层的协议类型,去掉 ip 头,调用传输层的处理函数(tcp_rcv 或者 upd_rcv)。
  • 如果采用的是 tcp 协议,则会去掉 tcp 头,找到对应 socket 四元组,将数据拷贝到 Socket 的接受缓冲区中。如果没有找到对应的 Socket 四元组,则会发送一个目标不可达的 ICMP 包。
  • 当应用程序调用 read 读取 Socket 缓冲区的数据时,如果没有数据,应用程序就会在系统上阻塞(进入阻塞状态)直到Socket缓冲区有数据,然后 CPU 将 Socket 缓冲区的内容拷贝到用户空间,最后 read 方法返回,应用程序读取数据。

硬中断:由硬件产生,例如磁盘、网卡、键盘、时钟等,每个设备都有自己的硬件请求,基于硬件请求CPU可以将相应的请求发送到对应的硬件驱动程序上。硬中断可以直接中断CPU。
软中断:处理方式非常像硬中断,但是是由当前正在运行的进程产生的。不会直接中断CPU。
ksoftirqd线程:每个CPU都会绑定一个 ksoftirqd 线程专门处理软中断响应。
sk_buffer缓冲区:维护网络帧结构的双向链表,链表中的每个元素都是一个数据帧。

对上面的应用程序接收和发送数据的过程进一步抽象,以读取网卡中的数据为例:
网络 IO 模型的演化过程_第3张图片

其可以分为两个主要的阶段:

  1. 数据准备阶段:网卡中的数据经过 DMA、硬中断、软中断、网络协议栈处理最终到达 Socket 缓冲区(ksoftirqd 线程处理)。
  2. 数据拷贝阶段:将内核 Socket 缓冲区中的数据拷贝到应用层,应用程序才能读取数据。

3. 理解阻塞与非阻塞、同步与异步

有了上面应用程序读取网卡数据抽象出来的两个过程,可以进一步理解阻塞和非阻塞的含义。

  1. 阻塞和非阻塞
    阻塞:
    用户线程发生系统调用会去读取 Socket 缓冲区的内容,如果此时 Socket 缓冲区没有数据,则会阻塞用户线程,直到 Socket 缓冲区有数据之后唤醒用户线程,用户线程负责将 Socket 缓冲区的内容拷贝到用户空间中,这个过程用户线程也是阻塞的。因此阻塞发生在第一、第二阶段。
    非阻塞:
    与阻塞相比,在第一阶段,如果此时 Socket 缓冲区没有数据,则会直接返回。如果 Socket 缓冲区有数据,则用户线程负责将 Socket 缓冲区的内容拷贝到用户空间。因此非阻塞的特点是在第一阶段不会等待,但是第二阶段仍然会等待。

  2. 同步和异步
    同步:
    在数据拷贝阶段,是由用户线程负责将 Socket 缓冲区的内容拷贝到用户空间。
    异步:
    在数据拷贝阶段,是由内核来负责将Socket缓冲区的内容拷贝到用户空间,接着通知用户线程IO操作已经完成。

异步需要操作系统内核的支持,做的比较好的是Windows系统,Linux系统还不是很完善。

因此可以这么理解成阻塞与非阻塞主要关注的是数据准备阶段,而同步与异步主要关注的是数据拷贝阶段。

是不是可以理解为什么没有异步阻塞这种模型了呢?
阻塞的意思意味着用户线程要去等待 Socket 缓冲区的数据,而异步的意思意味着数据从用户态拷贝到内核态不是由用户线程执行的,既然数据的拷贝不是由用户线程进行的,那么用户线程的等待就没有意义了。

二、IO模型

在《UNIX网络编程》中一共介绍了5种IO模型:阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO。其演变过程的原则是:如何用尽可能少的线程去处理更多的连接。

1. 阻塞IO

由上面可知,阻塞IO模型会在数据准备阶段和数据拷贝阶段用户线程都会进行等待。

  • 阻塞读:
    用户线程发生 read 系统调用,查看 Socket 缓冲区是否有数据到来,如果有则用户线程拷贝将 Socket 缓冲区的数据拷贝到用户空间。如果没有则阻塞线程(等待事件,进入阻塞状态),直到Socket 缓冲区有数据了(事件发生),则唤醒线程进入就绪状态,等待 CPU 的调度将数据拷贝回去用户空间。
  • 阻塞写:
    当 Socket 缓冲区能够容纳下要发送数据时,用户线程会将全部的发送数据写入 Socket 缓冲区中;Socket 缓冲区不够的时候用户线程会进入阻塞状态,直到 Socket 缓冲区有足够的空间能够容纳下全部发送数据时,内核唤醒用户线程,继续发送数据。
    在阻塞模型下:每个请求都需要被一个独立的线程处理,一个线程在同一时刻只能与一个连接绑定。同时网络连接并不是总是有事件发生的,因此也就会导致有大量的线程处于阻塞状态了。
    适用场景:连接少、并发度低的内部管理系统。

2. 非阻塞IO

先来看看非阻塞IO的读写过程:

  • 非阻塞读:
    用户线程判断 Socket 缓冲区是否有内容,没有内容则直接返回不等待。有数据则将数据拷贝会用户空间,这个过程是用户线程自己操作的,需要等待。
  • 非阻塞写:
    用户线程直接将数据写入 Socket 缓冲区中,如果缓冲区已满了则直接返回;当 Socket 缓冲区有空间的时候将数据拷贝到 Socket 缓冲区中。
    根据这非阻塞IO的特点,也就可以在线程数量这块做出优化了:
    一个或者少数几个线程(假设称为轮询线程)遍历每个缓冲区是否有数据到达,如果没有数据则继续遍历下一个,如果有数据则将读写数据的业务处理交给业务线程或业务线程池进行处理,轮询线程继续轮询。下面是 Java 中使用非阻塞IO的编程方式:
public static void main(String[] args) throws Exception {
        // 保留所有的Socket连接
        LinkedList clients = new LinkedList<>();
        ServerSocketChannel ss = ServerSocketChannel.open();
        ss.bind(new InetSocketAddress(9090));
        ss.configureBlocking(false); //设置为非阻塞IO模式
        while (true) {
            SocketChannel client = ss.accept();   //没有连接来则不会阻塞
            if (client == null) {
                System.out.println("null.....");
            } else {
                // 有连接了则将连接添加进集合里面
                client.configureBlocking(false);
                int port = client.socket().getPort();
                System.out.println("client...port: " + port);
                clients.add(client);
            }
            for (SocketChannel c : clients) {  
                 // 遍历每个Socket连接,处理具体的读写事件
                }
            }
        }
    }

由于是非阻塞的,在上面的轮询各个 Socket 连接可以交由一个线程来做,处理具体的IO事件和业务的读写逻辑的时候可以交由线程池去处理。这样就可以使用少量的线程来处理较多的连接了。
非阻塞IO与阻塞IO相比已经在线程的数量的优化迈出巨大的一步了。但是在高并发的场景下仍然有个致命的问题就是:每次轮询判断是否有数据时都要发生一次系统调用,进行上下文的切换,随着并发量的增加,这个开销也是非常巨大的。
应用场景:C10K以下的场景。

3. IO多路复用

再强调一遍,IO模型的核心是应用如何使用少量的线程来处理大量的连接。
上面的非阻塞IO提供了解决阻塞IO需要创建大量线程的问题,那么在高并发场景下存在大量系统调用的问题又该如何解决呢?于是就演变出IO多路复用这个IO模型。

  • 多路:指的是要处理的众多连接。
  • 复用:可以理解为多次系统调用复用为一次系统调用,实现了复用就解决了非阻塞IO模型下存在的大量系统调用而导致上下文切换开销的问题。

那么如何去实现复用?
在非阻塞IO中需要在用户空间中轮询是否有数据,这样的每次轮询就会发生一次系统调用,那么换个方式,能不能将这个轮询的过程交由内核空间来完成,用户空间只需要一次调用,就能够获取所有连接的事件,这样的话只需要一次系统调用(这个系统调用就是多路复用器)就能找到所有有状态的连接了。

Linux提供了三种多路复用器:

Select

Select多路复用器的工作方式如下:
网络 IO 模型的演化过程_第4张图片

  1. 调用 select 方法,用户代码的执行流程会阻塞在 select 系统调用上,用户线程从用户态切换到内核态。
  2. 用户线程将需要监听的 Socket 对应的文件描述符fd数组通过 select 系统调用传递给内核,这个fd数组的索引表示进程内对应的文件描述符,值表示文件描述符的状态。
  3. 用户线程在内核空间开始轮询文件描述符数组。
  4. 修改有读写状态的文件描述符,设置为1。
  5. 内核将修改后的fd数组拷贝会用户空间,此时用户代码的执行流程从 select 中恢复,阻塞解除。
  6. 用户线程在用户空间遍历fd数组,找出值为1的fd进行对应的处理。

这里的fd数组其实是一个Bitmap结构,该结构最多支持1024个位(由内核参数的 FD_SETSIZE 控制),因此 select 只支持最多处理 1024 个 Socket 连接。每一位对应的索引就是一个文件描述符fd。
在 Linux 中 Socket 也是一个文件,在 PCB 控制块(task_struct 结构体)中有一个属性files_struct *file s的结构体属性,它最终指向了一个保存进程中所有打开文件的打开文件表数组,该数组的元素是一个封装了文件信息的 file 结构体,打开文件表数组中的下标也就是常说的文件描述符fd。
注意:由于内核返回的文件描述符数组会修改到原来的状态,因此用户线程在每个处理完之后需要重新设置文件描述符的状态。

性能开销:

  1. 一次系统调用,两次上下文的切换,这是不可避免的。
  2. 两次 fd 数组的全量拷贝(但是其实最多只能有 128 个字节大小的bitmap)。
  3. 两次 fd 数组的全量遍历,一次在内核中遍历是否有事件,用于设置状态,另外一次在用户空间中遍历哪些有事件,用于处理状态。
    注意:select系统调用不是线程安全的,因为进程fd数组是共享的。

poll

poll 的工作原理和 select 没有太大的区别,主要是在文件描述符数组的结构和文件描述符大小的限制上。其将 Bitmap 换成一个没有固定长度的链表,链表中的元素如下:

struct pollfd {
    int fd;        // 文件描述符
    short events;    // 需要监听的事件
    short revents;    // 实际发生的事件,由内核修改
}

性能开销方面和 select 是一样的。

Select 和 Poll 两者的问题:

  1. 每次新增、删除要监听的 Socket 时,查看 Socket 连接的事件时,都需要进行全量的拷贝。
  2. 遍历的开销会随着文件描述符数量的增大而增大。
    性能瓶颈产生的原因:
  3. 内核空间不会保存要监听的 Socket 集合,所以需要全量拷贝。
  4. 内核不会通知具体IO就绪的 Socket,所以需要全量遍历并对IO就绪的 Socket 打上标记。

Epoll

Epoll 提供了解决 select 和 poll 性能瓶颈的方案,Epoll 会在内核中保存要监听的 Socket 集合和在适当的时候通知具体就绪的IO事件。
Epoll 这个多路复用器主要包括三个系统调用:

epoll_create

内核提供的一个创建 epoll 对象的系统调用,在用户进程调用 epoll_create 时,内核会创建一个eventpoll 的结构体,并且创建相对应的 file 对象与之相关联(也就是说 epoll 对象也是个文件,“一切皆文件”),同时将这个文件放入进程的打开文件表中。eventpoll 的定义如下:

struct eventpoll {
    // 等待队列,阻塞在epoll上的线程会放在这里
    wait_queue_head_t wq;
    // 就绪队列,IO就绪的Socket连接会放在这里
    struct list_head rdllist;
    // 红黑树,用来监听所有的socket连接
    struct rb_root rbr;
    // 关联的文件对象
    struct file *file;
}
  1. wait_queue_head_t wq:epoll 中的等待队列,存放阻塞在 epoll 上的用户线程,在IO就绪的时候epoll 通过这个队列,将阻塞中的线程唤醒。
  2. list_head rdllist:存放IO就绪的 Socket 连接,阻塞在 epoll 上的线程被唤醒时,可以直接读取这个队列获取有事件发生的Socket,不用再次遍历整个集合(避免全量遍历)。
  3. rb_root rbr:epoll内部使用一颗红黑手来管理大量的Socket连接,红黑树在查找、插入、删除等方面的综合性能比较优。
    select 用数组来管理,poll 用链表来管理。
epoll_ctl

当创建出来 epoll 的对象 eventpoll 后,可以利用 epoll_ctl 向 eventpoll 对象中添加要管理的 Socket连接。这个过程如下:

  1. 首先会在内核中创建一个表示 Socket 连接的数据结构 epitem,这个 epitem 就是红黑树的一个节点。
strict epitem {
    // 指向所属的epoll对象
    struct eventpoll *ep;
    // 注册感兴趣的事件,也就是用户空间的epoll_event
    struct epoll_event event;
    // 指向epoll中的就绪队列
    struct list_head rdllink;
    // 指向epoll中的红黑树节点
    struct rb_node rbn;
    // 指向epitem所表示的Socket文件
    struct epoll_filefd ffd;
}
  1. 内核在创建完 epitem 结构后,需要在 Socket 中的等待队列上创建等待项 wait_queue_t,并且注册 epoll 的回调函数 ep_poll_callback。这个回调函数是 epoll 同步IO事件通知机制的核心所在,也是区别于 select、poll 采用轮询方式性能差异所在。ep_poll_callback 会找到 epitem,将IO就绪的 epitem 放入到 epoll 的就绪队列(eventpoll->rdllit)中。(通过一个 epoll_entry 的结构关联 Socket 等待队列上的 wait_queue_t 和 epitem)
  2. 在 Socket 等待队列创建好等待项,注册回调函数并关联好 epitem 后,就可以将 epitem 插入红黑树中了。
    epoll 红黑树优化点之一:插入删除 Socket 连接不需要像 select、poll 那样全量复制。
    epoll_wait
    epoll_wait 用于同步阻塞线程,获取IO就绪的 Socket。
  3. 用户程序调用 epoll_wait 后,进入内核首先会找到 epoll 中的就绪队列 eventpoll->rdllit 是否有就绪的 epitem。如果有的话将 epitm 中封装的 socket 信息封装到 epoll_event 返回。
  4. 如果就绪队列中没有IO就绪的 epitem,则会创建 Socket 等待队列上的等待项,将用户线程的fd关联到 wait_queue_t->private 上,并注册回调函数 default_wake_function。最后将等待项添加到epoll 中的等待队列(eventpoll->wq)中。用户线程让出CPU,进入阻塞状态。
    当 epoll 中有事件发生时的工作流程:
  5. 当数据包通过软中断经过内核协议栈到达 Socket 的接受缓冲区的时候,内核会调用数据就绪回调函数,在 Socket 的等待队列中找到等待项(wait_queue_t),该等待项注册的回调函数为ep_poll_callback。
  6. 在回调函数 ep_poll_callback 中找到关联的 epitem 对象(通过 epoll_entity 结构体),并将它放到 epoll 的就绪队列(eventpoll->rdllist)中。
  7. 接着查看 eventpoll 中的等待队列(eventpoll->wait_queue_head_t)中是否有等待项,如果有的话唤醒对应的线程,将 Socket 信息封装到 epoll_event 中返回。
  8. 用户线程拿到 epoll_event 获取IO就绪的 socket,就可以针对该 socket 发起系统调用读取数据了。
    网络 IO 模型的演化过程_第5张图片
边缘触发和水平触发

边缘触发和水平触发是 Epoll 中的两个触发模式,从上面的执行流程来看,边缘触发和水平触发最关键的区别在于当 socket 接收缓冲区还有数据可读时,epoll_wait 是否会清空 rdllist。
边缘触发:
epoll_wait 获取到IO就绪的 Socket 后,不管 Socket 上是否还有数据可读,都会直接清空 rdllist。因此使用边缘触发模式需要一次性尽可能的将 socket 上的数据读取完毕,否则用户程序无法再次获得这个 socket 中的数据,直到该 socket 下次有数据到达被重新放入 rdllist。因此边缘触发只会从epoll_wait 中苏醒一次。
水平触发:
epoll_wait 获取到IO就绪的 Socket 后不会清空 rdllist,假如 Socket 中的数据只读取了一部分没有读取完毕,再次调用 epoll_wait(用户线程调用)会检查这些 Socket 中的接受缓冲区是否还有数据可读,如果有数据可读,则会将这些 socket 重新放回 rdllist 中。下次再调用 epoll_wait 时仍然可以获得这些没有读取完数据的 Socket。
总之:边缘触发模式的 epoll_wait 会清空就绪队列,水平触发模式的 epoll_wait 不会清空rdllist。

  • Netty 中的 EpollSocketChannel 默认的是边缘触发模式。
  • JDK 的 NIO 默认的是水平触发模式。

总结epoll的优化点

  1. 内核中通过红黑树维护海量连接,在调用 epoll_wait 时不需要返回全部监听的 Socket 集合,内核只需要将就绪队列中的 Socket 集合返回即可。
  2. epoll 通过同步IO事件的机制将IO就绪的 Socket 放入就绪队列中。不用去遍历所有所有 Socket集合。

应用场景:各大主流网络框架用到的网络IO模型,可以解决C10K、C100K甚至是C1000K的问题。

4. 信号驱动IO

用户线程通过系统调用 sigaction 函数发起一个IO请求,在对应的 Socket 注册一个信号回调,不阻塞用户线程,用户线程继续工作。当数据就绪的时候(放到Socket缓冲区),就会为该线程产生一个SIGIO信号,通过信号回调通知线程进行相关的IO操作。
信号驱动仍然是同步IO,因为在数据拷贝阶段仍然是需要用户线程自己拷贝的。
TCP很少会去使用信号驱动IO,因为其信号事件比较多;UDP只有一种信号事件,可以采用信号驱动IO。

5. 异步IO(AIO)

如果信号驱动IO是通知用户线程数据已经到达了可以去拷贝了,那么异步IO则会将数据拷贝到用户空间。
异步IO的系统调用需要内核的支持,目前只有 Window 中的 IOCP 实现了非常成熟的异步IO机制,Linux 系统对异步IO机制的实现不够成熟,但是在5.1版本后引入了 io_uring 异步IO库,改善了一些性能。

三、IO线程模型

上面的IO模型是从内核空间的角度进行的,重点在关注怎么高效的去处理IO事件;从用户空间角度的话就可以引出IO线程模型,这些不同的IO线程模型都是在讨论如何在多线程中分配工作,谁负责接收连接、谁负责响应读写、谁负责计算、谁负责发送和接收等等。Reactor模型就对这些分工做出了具体的划分。
常见的IO线程模型有 Reactor 和 Proactor。

Reactor

利用NIO对IO线程进行不同的分工:

  • 利用IO多路复器进行IO事件的注册和监听。
  • 将监听到的就绪IO事件分发到各个具体的 Handler 进行处理。
    通过IO多路复用技术就可以不断的监听IO事件,不断的分发,就想一个反应堆一样,于是就将这种模型称为 Reactor 模型。具体又可以分为如下的几种:

1. 单Reactor单线程

例如使用 epoll 来进行IO多路复用、监听IO就绪事件。

  • 单Reactor:意味着只有一个epoll对象(eventpoll),用来监听所有 Socket 的连接事件、读写事件等。
  • 单线程:意味着只有一个线程来执行 epoll_wait 获取IO就绪的 Socket,然后对这些就绪的 Socket执行读写操作、业务逻辑操作。
    其实就只有一个线程。

2. 单Reactor多线程

  • 单Reactor:只有一个epoll对象(eventpoll)来监听所有的IO事件,对用有一个主线程来调用 epoll_wait 获取IO就绪的Socket。
  • 多线程:当获取到IO就绪的 Socke t时,通过线程池来处理具体的IO事件及业务逻辑。

3. 主从Reactor多线程

  • 主从Reactor:将原来的单Reactor变为了多Reactor,主Reactor负责监听Socket连接事件,将要监听的读写事件注册到从Reactor中,由从Reactor监听Socket的读写事件。。
  • 多线程:将读写的业务逻辑交由线程池处理。

Proactor

Reactor 模型是同步非阻塞网络模型,数据拷贝阶段仍然需要用户线程自己从内核拷贝到用户空间。而 Proactor 模型是异步的网络模型,数据拷贝阶段都不需要用户线程自己拷贝,而是由操作系统内核拷贝到用户空间之后通知用户线程去读取,因此其要使用异步IO的方式才能够实现。其工作模式如下:
网络 IO 模型的演化过程_第6张图片

【来自《小林coding》】

  1. Proactor Initator 初始化 Handler 和 Proactor,并将这两者注册到操作系统的异步操作处理器中。
  2. 在内核处理请求和完成IO操作。
  3. 通知 Proactor,Proactor 根据不同的事件调用不同的 Handler 进行处理。

Netty中的IO线程模型

Netty支持三种Reactor模型,但是常用的是主从Reactor多线程模型。注意三种Reactor只是一种设计思想,具体实现不一定严格按照其来实现。Netty中的主从Reactor多线程模型如下:
网络 IO 模型的演化过程_第7张图片

  • Reactor在 Netty 中以 group 的形式出现,Netty 中将 Reactor 分为两组,一组是 MainReactorGroup,也就是 EventLoopGroup=BossGroup。另外一组是 SubReactorGroup,也就是 EventLoopGroup=workerGoup。主Reactor负责监听,将产生的 NIOSocketChannel 对象通过负载均衡的方式注册到从Reactor中。(单个Reactor的原因是一般只监听一个端口)。从Reactor一般有多个,默认的从Reactor个数为CPU核心数*2,主要负责Socket上的读写事件。
  • 一个Reactor分配一个IO线程,这个IO线程负责从Reactor中获取IO就绪事件,执行IO调用获取IO数据,执行 Pipeline。同时每个Socket会被绑定到固定的从Reactor中,有固定的IO线程执行IO事件,实现无锁串行化,避免线程安全问题。
  • 当IO请求在业务线程中完成相应的业务逻辑处理后,在业务线程中利用持有的ChannelHandlerContext 引用将响应数据在 Pipeline中反向传播,最终写回给客户端。
    配置各种模型
  1. 配置单Reactor单线程
EventLoopGroup eventGroup = new NioEventLoopGroup(1);  
ServerBootstrap serverBootstrap = new ServerBootstrap();   
serverBootstrap.group(eventGroup);

指定线程数为1。
2. 配置单Reactor多线程

EventLoopGroup eventGroup = new NioEventLoopGroup();  
ServerBootstrap serverBootstrap = new ServerBootstrap();   
serverBootstrap.group(eventGroup);

默认线程数为CPU*2。
3. 配置主从Reactor多线程

EventLoopGroup bossGroup = new NioEventLoopGroup(1);   
EventLoopGroup workerGroup = new NioEventLoopGroup();  
ServerBootstrap serverBootstrap = new ServerBootstrap();   
serverBootstrap.group(bossGroup, workerGroup);

网络 IO 模型的演化过程_第8张图片

【参考】
https://mp.weixin.qq.com/s/zAh1yD5IfwuoYdrZ1tGf5Q

你可能感兴趣的:(网络,tcp/ip,网络协议)