select/poll机制

背景

Linux提供多种I/O多路复用机制,可以大大提升服务端服务并发网络请求的能力。本文先从最朴素的实现开始介绍,然后最后再说明I/O多路复用,加深理解。

最朴素的实现

假设不利用多路复用,如果处理一个C10K的并发场景,并发的每个网络请求都需要一个进程、一个TCP链接的话,对服务端消耗会非常大。
image.png

这种朴素实现最主要的问题是:

  • 1个进程对应一个socket fd
  • 2次进程阻塞:
    • 数据准备阶段:等待数据从网卡拷贝到内核空间
    • 数据拷贝阶段:等待内核态数据拷贝到用户态

2个阻塞阶段可以从下图看的更加清晰些:
image.png

多线程模型

采用线程池,一个并发请求关联一个线程,避免了进程创建比较重的开销,不过在C10K场景下线程数仍然创建过多,有限CPU个数下过多的线程会有很大的cpu上下文切换的开销。
image.png

I/O多路复用——select模型

I/O多路复用模型的关键在于解耦处理线程和socket内核对象(一个socket内核对象可以近似理解成一个socket fd或者tcp connection)。

select模型的主要优点

  • 数据准备阶段:数据没有从网卡拷贝到内核态的时,变成了非阻塞
  • 一个进程可以处理多个socket fd

数据准备阶段相比于最朴素的实现采用了非阻塞的方式,方便理解的话可以看下图。数据没有准备好的话,系统调用直接返回EWOULDBLOCK
image.png

select模型的主要缺点(相比epool和poll):

  • 存在fd set从内核态到用户态拷贝的过程
  • 不知道具体哪个fd有就绪,都是在用户态完整轮询所有fd set来查找,时间复杂度O(n)
  • fd基于bitsMap实现,有FD_SETSIZE的大小设置,默认为1024,只能处理1024个socket fd

image.png

TIPS: 关于上图进程等待队列、数据队列的作用说明
服务端和客户端建立了连接 socket 后,服务端的用户进程通过 recv 函数接收数据时,如果数据没有到达,则当前的用户进程的进程描述符和回调函数会封装到一个进程等待项中,加入到 socket 的进程等待队列中;如果连接上有数据到达网卡,由网卡将数据通过 DMA 控制器拷贝到内核内存的 RingBuffer 中,并向 CPU 发出硬中断,然后,CPU 向内核中断进程 ksoftirqd 发出软中断信号,内核中断进程 ksoftirqd 将内核内存的 RingBuffer 中的数据根据数据报文的 IP 和端口号,将其拷贝到对应 socket 的数据接收队列中,然后通过 socket 的进程等待队列中的回调函数,唤醒要处理该数据的用户进程;

I/O多路复用——poll模型

和select唯一差别就是突破了fd set的限制,改用pollfd结构,由于是基于链表的,不像select模型使用的数组有限制,可以动态扩展。

I/O多路复用——epoll模型(mac下是kqueue)

主要优点:

  • 实现上有个就绪队列rdlist,返回给用户态进程处理时只需要返回就绪态的fd即可
  • 有个红黑树实现的socket fd集合用于在内核态跟踪待检测的socket fd。使用红黑树存储一份文件描述符集合,每个文件描述符只需在添加时传入一次,无需用户每次都重新传入整个fd set, 解决了 select/poll 中 整个fd集合拷贝到用户态的开销问题以及用户态内核态总是要遍历fd集合的问题
  • 不采用轮询的方式找到就绪的fd,而是通过异步事件通知的方式告知用户就绪的socket fds

image.png

epoll是同步还是异步的?

当我们讨论这个问题的时候,我们实际上讨论的是epoll系统调用是同步还是异步的系统调用API层面来看是同步的。API是同步返回结果的,不过应用层不等待这个调用结果先去做别的事情也是可以的。所以应用层完全可以基于这个系统调用做个异步的实现,不冲突。讨论问题时一定要搞清楚我们在哪个层面讨论问题,问题的主体是谁,否则常常会得到一些矛盾的结果。

此外我们知道,epoll实现机制中数据拷贝阶段完成内核态数据拷贝到用户态通知用户进程取数据采用的是异步通知的方式。这里的异步通知指的是“通知”是异步的,用户进程不需要等待就绪信号。异步通知讨论的主体是这个“通知”,用户进程阻塞式等待的主体是用户进程,两个概念是不冲突的。千万不要认为有异步通知,epoll就是异步的了,这是两个层面的概念。

epoll惊群问题

什么是epoll惊群问题

epoll"惊群"简单地来讲,就是多个进程(线程)阻塞睡眠在某个系统调用上,在等待某个 fd(socket)的事件的到来。当这个 fd(socket)的事件发生的时候,这些睡眠的进程(线程)就会被同时唤醒,无事可做的进程/线程又重新睡眠,导致资源浪费。

惊群一定是坏的吗?

不一定,如果惊起后都能处理请求那实际上是符合预期的,我们说的惊群问题更多指的是惊起后无所事事浪费资源。比如如下一个case,惊群后大部分进程/线程都无所事事浪费在spin上了:
image.png

惊群的解决办法

  • linux 2.6内核以后通过添加了一个 WQ_FLAG_EXCLUSIVE 标记告诉内核唤醒等待进程时进行排他性的唤醒,即唤醒一个进程后即退出唤醒的过程。这样,内核层面accept系统调用执行时已经不会引发惊群问题了。
  • linux 3.9以上内核可以开启SO_REUSEPORT。监听同个端口采用多队列的方式,不会有争抢。每一个 CPU 核创建一个 listen_socket 来监听处理请求,这样就是每个 CPU 一个处理进程、一个 listen_socket、一个 accept 队列,多个进程同时并发处理请求,进程之间不再相互竞争 listen_socket。SO_REUSEPORT 可以做到多个 listen_socket 间的负载均衡。此外SO_REUSEPORT也可以考虑和4.4内核引入的SO_INCOMING_CPU结合做亲缘绑定,避免多网卡的不均衡问题。
  • linux内核4.4及以上可以采用**Lockless TCP listener,**避免listen socket的争用。在4.4之前,一个request是属于一个Listener的,也就是说一个Listener有一个request队列,每构造一个request,都 要操作这个Listner本身,但是4.4内核给出了突破性的方法,就是基于这个request构造一个新的socket!插入到全局的socket哈希 表中,这个socket仅仅记录一个它的Listener的轻引用即可。等到第3个握手包ack到来后,查询socket哈希表,找到的将不再是 Listnener本身,而是syn包到来时构造的那个新socket了,这样传统的下面的逻辑就可以将Listener解放出了。lockless tcp listener本质上是打破了request和listener之间一对一的关系。
  • linux 4.5内核以后提供epoll层面排他性唤醒标记EPOLLEXCLUSIVE解决epoll层面惊群问题

tips: NGINX因为是比较早的项目,主要通过避免同时监听来避免惊群。不让多个进程在同一时间监听接受连接的socket,而是让每个进程轮流监听,这样当有连接过来的时候,就只有一个进程在监听那肯定就没有惊群的问题。具体做法是:利用一把进程间锁,每个进程中都尝试获得这把锁,如果获取成功将监听socket加入wait集合中,并设置超时等待连接到来,没有获得所的进程则将监听socket从wait集合去除。现代内核下,如果不考虑兼容老的CPU可以采用更好的方式

边缘触发和水平触发

epoll默认是边缘触发,水平触发可选;select/poll只提供水平触发。

水平触发(level-trggered): 可多次发送通知。比如可读的时候,可以每次只读一部分。

  • 只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,
  • 当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知

边缘触发(edge-triggered): 通知只在满足条件时发送1次,要求如果可读时需要一次性取完。

  • 当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,
  • 当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知

读到EGAIN(EGAIN说明缓冲区已经空了)为止

1
2
3
4
5
6
7
8
//水平触发,可以只取一部分
ret = read(fd, buf, sizeof(buf));

//边缘触发(代码不完整,仅为简单区别与水平触发方式的代码),必须一次性全部取完
while(true) {
ret = read(fd, buf, sizeof(buf);
if (ret == EAGAIN) break;
}

LT的优缺点:

  • 优点:工程实现时灵活,不用一次性读取完,并发量不大情况下用LT即可。
  • 缺点:因为要一直提供给进程/线程读,fd不能马上释放,导致fd集合膨胀性能变差

ET的优缺点:

  • 优点:性能相比LT好。并发量大的话考虑ET。nginx默认是ET
  • 缺点:要求用户态程序一次性取完可读的所有数据

参考资料