什么是IO多路复用
使用一个进程来维护多个 Socket 就是 I/O 多路复用技术

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,
种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件
select/poll/epoll 是如何获取网络事件的呢?
在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可
select/poll/epoll 这是三个多路复用接口
select/poll
select 实现多路复用的方式
将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式
当检查到事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里
然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合
一次是在内核态里,一个次是在用户态里
而且还会发生 2 次「拷贝」文件描述符集合
先从用户空间传入内核空间,由内核修改后,再传出到用户空间中
select 使用固定长度的 BitsMap表示文件描述符集合,而且所支持的文件描述符的个数是有限制的
在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 \(1024\),只能监听 0~1023 的文件描述符
poll实现多路复用的方式
poll 不再用 BitsMap 来存储所关注的文件描述符
取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制
当然还会受到系统文件描述符限制
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合
因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 \(O(n)\),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上乘,性能的损耗会呈指数级增长
epoll
先复习下epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}
epoll 通过两个方面,很好解决了 select/poll 的问题
第一点:红黑树保存socket
epoll在内核使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)
而select/poll内核里没有类似epoll红黑树这种保存所有待检测的socket的数据结构
所以select/poll每次操作时都传入整个socket集合给内核
而epoll因为在内核维护了红黑树,可以保存所有待检测的socket,所以只需要传入一个待检测的socket
减少了内核和用户空间大量的数据拷贝和内存分配
第二点:事件驱动机制
epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件
当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中
当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数
不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率
从下图你可以看到 epoll 相关的接口作用:

epoll的方式即使监听的Socket数量越多的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll被称为解决C10K问题的利器
题外话
插个题外话,网上文章不少说,epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。
这是错误的!看过epoll内核源码的都知道,压根就没有使用共享内存这个玩意
你可以从下面这份代码看到,epoll_wait实现的内核代码中调用了__put_user函数,这个函数就是将数据从内核拷贝到用户空间。

边缘触发和水平触发
epoll 支持两种事件触发模式
分别是
边缘触发(edge - triggered,ET)
和
水平触发(level - triggered,LT)
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取
例子
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;
如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;
而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。
因此,我们会循环从文件描述符读写数据
那么如果文件描述符是阻塞的,没有数据可读时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用
程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率高,因为边缘触发可以减少 epoll_wait 的系统调用次数
系统调用也是有一定的开销的,毕竟也存在上下文的切换
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式
IO多路复用搭配非阻塞IO
另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用
在 Linux 下,select () 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。
也有可能在其他情况下,文件描述符被错误地报告为就绪
因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全
简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况
简单说一下Redis的IO多路复用
引⽤知乎上⼀个⾼赞的回答来解释什么是I/O多路复⽤。假设你是⼀个⽼师,让30个学⽣解答⼀道题⽬,然后检查
学⽣做的是否正确,你有下⾯⼏个选择:
第⼀种选择:按顺序逐个检查,先检查A,然后是B,之后是C、D。。。这中间如果有⼀个学⽣卡住,全班都
会被耽误。这种模式就好⽐,你⽤循环挨个处理socket,根本不具有并发能⼒。
第⼆种选择:你创建30个分身,每个分身检查⼀个学⽣的答案是否正确。 这种类似于为每⼀个⽤户创建⼀个
进程或者- 线程处理连接。
第三种选择,你站在讲台上等,谁解答完谁举⼿。这时C、D举⼿,表示他们解答问题完毕,你下去依次检查
C、D的答案,然后继续回到讲台上等。此时E、A⼜举⼿,然后去处理E和A。
(其实我们这样子举手,就相当于我们的io多路复用监听多个客户端,然后你监听到哪个先到达你就去执行哪个)
第⼀种就是阻塞IO模型(顺序执行),第三种就是I/O复⽤模型。
Linux系统有三种⽅式实现IO多路复⽤:select、poll和epoll。
例如epoll⽅式是将⽤户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了
⼤量的⽆⽤操作。此时的socket应该采⽤⾮阻塞模式。
(我们是监听到数据到达才去处理,而不是监听到它正在发信息,我们就阻塞等待它的消息到达,然后处理)
这样,整个过程只在进⾏select、poll、epoll这些调⽤的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或
者线程就被充分利⽤起来,这就是事件驱动,所谓的reactor模式。


为什么Redis已经有IO多路复用了,还有使用多线程IO处理请求
单线程模型是单线程,且这个线程一次监听一个socket
IO多路复用是单线程,且这个线程可以监听多个socket
尽管 IO 多路复用允许 Redis 主线程监听多个 socket,并在有事件发生时才进行处理,
但是对每一个 socket 的命令解析执行,仍然是在一个线程内串行完成的。
这意味着随着客户端连接数量的增长,Redis 会花费更多的时间在网络通信上,而不是执行实际的数据操作。
为了进一步提高性能,尤其是在面对大量连接但每个连接数据交换量不大的场景时,Redis 6.0 引入了多线程负责解析网络请求,从而释放出主线程专注于命令的执行,这样可以更好地利用现代多核处理器的能力,减少网络延迟的影响。
通过这种设计,Redis 能够在保持其原有数据安全性和一致性的基础上,有效提升在高并发场景下的吞吐量,特别是在大量客户端连接并且大部分时间都在等待网络响应的应用环境中。
同时,由于命令执行仍然保留在主线程中,所以避免了多线程安全问题和数据竞争等问题
部分文章内容来源:小林coding

4650

被折叠的 条评论
为什么被折叠?



