什么是IO多路复用?
多路复用也是面试比较常见的,尤其对于后端,因为很多中间件例如Redis、Nginx、Netty 以及jdk的 NIO 实现都用到了多路复用技术,作为实现高性能的重要底层手段是需要掌握的,下面总--分--总梳理一下:
BIO和NIO的弊端
前面的文章详细讲过这两个IO的原理:
当客户端数量越来越多时,上面两种方式的弊端逐渐明显:
- BIO主要问题有:
- 阻塞效率太低
- 多线程或者多进程的方式太耗费系统资源
- NIO主要问题有:
- 遍历大量客户端连接,每一次系统调用只能检测一个文件描述符是否就绪。每一个都要发起系统调用是非常消耗系统资源的
- 默认文件描述符数量1024,需要ulimit修改才能创建更多客户端连接,但是设置很大又会遇到内存瓶颈
上结论
原始 socket 编程方式中的 BIO(同步阻塞Blocking)是进程发起系统调用 read 的时候会阻塞挂起直到数据就绪。所以 BIO 中一般使用多线程处理多个文件描述符,但是多线程开销较大。
因此引入 NIO(同步非阻塞Non-Blocking),进程不会挂起而是直接返回成功或者失败,用户不断轮询直到拿到数据。但是系统调用本身成本就高,这里不仅不断轮询,每次也只能检测一个文件描述符是否就绪,客户端连接很多时,系统调用成本依旧很高。
为了避免BIO阻塞和创建多个进程线程,也避免NIO不断轮询造成的CPU资源消耗。比如单机同时处理10k请求(C10K问题)。提出了IO多路复用,一次系统同步阻塞的系统调用就可以检测所有已建立连接的文件描述符是否就绪,也就是将遍历描述符这个任务从用户态转移到内核态,内部遍历时采用同步非阻塞IO方式查看描述符是否就绪。如果有就绪的描述符直接返回,没有就一直阻塞,或者超过用户设置的阻塞时长直接返回。在连接数较多的情况下,有效减少用户态和内核态切换次数,降低系统开销。
多路复用演进分析
操作系统提供了三个多路复用系统调用用于获取当前就绪的文件描述符,注意这三者都是用来检测就绪事件的,检测到之后还是需要使用之前的read函数去内核拷贝数据。不要以为这三者直接会返回数据。
下面简单介绍下面试可能会问到的知识:
select
int select(int nfds, // 遍历下面三个集合 前 nfds 个描述符fd_set *restrict readfds, // 可以读取 文件描述符集合fd_set *restrict writefds, // 可以写入 文件描述符集合fd_set *restrict errorfds, // 发生错误 文件描述符集合struct timeval *restrict timeout); // 如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。
// 函数会用找到的子集替换参数中的对应集合
// 返回所有就绪描述符的总数。
知识回忆:
socket 与 fd :socket 可以用于进程间通信,包含了地址、类型和通信协议等。
int socket(int domain, int type, int protocol)
返回的int 就是文件描述符fd,进程就是通过fd找到socket来完成远程主机通信。
- 存放用户连接的数据结构:用户空间维护当前建立连接的socket文件描述符集合,用的是固定长度的bitsMap,最大长度是1024。
- 调用原理:系统调用时,select会将用户空间的集合fd_set 拷贝到内核空间,内核遍历集合fd_set检测是否有事件产生,将集合中的元素进行标记。之后再把整个集合再次拷贝回用户空间,同时返回就绪事件的个数。用户空间再次遍历集合找到可读写的 Socket 之后对其处理。
- 缺点:大量拷贝,大量遍历,连接数限制一般1024
poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// fds 是一个 pollfd 结构体类型的数组,
// 调用 poll() 时必须通过 nfds 指出数组 fds 的大小,即文件描述符的数量。
- 存放用户连接的数据结构:用户空间维护当前建立连接的socket文件描述符集合,在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储,只会受到系统的文件描述符的限制。
- 调用原理:系统调用时,和select的一致。
- 缺点:大量拷贝,大量遍历
epoll
// 创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。
int epoll_create(int size);// epoll_ctl 将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。
// 当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);// epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例// fd 表示要监听的目标文件描述符// event 表示要监听的事件(可读、可写、发送错误…)// op 表示要对 fd 执行的操作,有以下几种:// EPOLL_CTL_ADD:为 fd 添加一个监听事件 event// EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)// EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用// 返回值 0 或 -1,表示上述操作成功与否。// 功能相当于 select
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);// epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例// events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请// maxevents 指定 events 的大小// timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回// 返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。
扩展:
创建epoll实例,会占用一个 fd 值,查看 /proc/进程id/fd/。所以在使用完 epoll 后必须调用 close(epfd) ,否则可能导致 fd 被耗尽。当指向同一个 epoll 实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll 实例。
- 存放用户连接的数据结构:用户空间不维护当前建立连接的socket文件描述符集合,而是在内核中使用红黑树作为监听列表。同时内核维护一个记录就绪事件的链表,就不需要扫描整个红黑树了
- 调用原理:epoll 包含三个主要的系统调用函数:
- epoll_create() 负责创建一个epoll实例,包括初始化红黑树等等
- epoll_ctl() 负责将需要监控的文件描述符传递给红黑树
- epoll_wait() 负责将内核中的就绪事件链表拷贝给用户空间
- 整体流程:首先使用 epoll_create 创建对应的epoll实例,之后将需要被监听的 socket 文件描述符通过 epoll_ctl 传递给 epoll,最后不断使用 epoll_wait 询问就绪的链表即可。当网卡有数据就绪时候,会首先将数据拷贝到内核的socket buffer。同时会通过中断的形式将红黑树中有就绪事件的文件描述符追加一份到就绪事件列表中,此时epoll_wait去获取列表就可以拿到对应的就绪连接。之后直接使用read函数读取对应连接的socket buffer即可。
- 优点:使用高效的数据结构存储,同时避免了所有文件描述符的遍历。时间复杂度从 O(n) 降为 O(1)
多路复用的水平触发与边缘触发
两个触发表示的是不同的通知方式:
- 水平触发LT(level-triggered):事件就绪之后,只要read函数没有把内核缓存区的数据全部拿走,调用select或者poll或者epoll_wait就一直会返回当前就绪事件
- 边缘触发ET(edge-triggered):事件就绪之后,不管read函数是否把内核缓存区的数据全部拿走,调用epoll_wait都只会在第一次返回就绪事件,后面将不再返回。
明显,边缘触发ET可以减少系统调用的次数,就减少了上下文切换,效率较高。
但是,select和poll只支持水平触发LT,epoll默认也是水平触发LT但是支持设置边缘触发ET。
为什么多路复用是同步阻塞的?而多路复用内部是同步非阻塞的?
多路复用在获取结果的时候,都是获取到连接的状态之后才会返回的,所以是阻塞的。
但是内部执行每一个连接的状态查询的时候如果也是用阻塞方式,就可能会导致整个系统调用一直卡住,所以查询每一个连接的状态的时候使用非阻塞的方式,没有就绪就直接返回错误,这样整体的调用就不会用太久的时间。
为什么使用多路复用最好将read设置为非阻塞?
因为触发就绪不代表数据一定就绪,可能网卡的带来的数据解析错误之后被丢弃,这个时候用户调用read如果是阻塞的,就会读不到数据阻塞住。所以这里read最好使用非阻塞的方式调用,调用的时候设置非阻塞即可。
三个多路复用对比
select、poll 线程结构存放数据,需要遍历就是O(n)时间复杂度,epoll不用遍历而且查询修改只需要O(logn)的复杂度。
select有连接数的限制、poll没有连接数的限制,但是二者都需要拷贝和遍历大量的文件描述符,连接数多的时候效率依旧低下。epoll 无需拷贝和遍历大量的连接,直接将就绪事件链表复制一份返回,效率很高。同时文件描述符个数也没有限制,因此C10K得以解决。
select 和 poll 只支持水平触发,epoll 还支持边缘触发。
多路复用epoll是万能的吗?
当然不是,否则其他方式就没有存在意义了。
IO 多路复用引入了一些额外的数据结构维护和系统开销,并且数据到来时候会有回调将数据拷贝到就绪链表。性能有降低,但是好处是连接数大的时候,可以有效减少NIO中的系统调用次数,同时减少BIO中的多线程切换的开销。
所以:
如果连接数小但是每一个连接都很活跃的时候推荐使用BIO或者NIO。
如果连接数很大但是每一个连接没有那么活跃的时候推荐使用多路复用。
应用:从java + Linux的视角理解多路复用的应用
Java NIO 中的 selector 就是对操作系统的多路复用的抽象,底层依赖的是各个操作系统的实现。
不管是poll还是epoll,首先要做的是建立socket连接,绑定bind和监听listen,之后再开始用多路复用处理监听相关的逻辑:
// poll使用伪代码
新建socket
服务器设置为非阻塞
绑定 bind 端口和IP
开始listenwhile(1) {if (accept 建立连接) {建立连接维护用户空间的socket集合}if (poll 获取就绪事件)read 读取数据
}
// epoll 使用伪代码
新建socket
服务器设置为非阻塞
绑定 bind 端口和IP
开始listen
epoll_create创建epoll实例while(1) {if (accept 建立连接) {建立连接epoll_ctl 维护socket集合红黑树}if (epoll_wait 获取就绪事件)read 读取数据
}
如果使用poll,在jdk 的native用户空间保存了文件描述符fd集合。
如果使用epoll,源码中直接调用epoll_create等等
思考:多线程和单线程选型维度
多线程就等于高性能吗?显然不是!
多线程技术是为了充分利用CPU资源,适合有IO密集型操作,如果是计算密集型的操作,引入多线程反而会导致大量的上下文切换,导致负优化的产生。
如果设计一个软件,需要在内存中有大量的计算处理,但是基本和磁盘很少有交互,那么就没有必要引入多线程,反而适合使用 多路复用 + 单线程的方式来处理多客户端的场景。Redis 就是这么做的!当前Redis的多线程是用在了其他地方,这里不赘述。
主要学习这种根据是IO密集型还是计算密集型来设计的思路。
总结
面试问到的多路复用知识,基本不会逃出上面的内容,除非你是国产操作系统开发者(bushi)。我是deltaqin,下次打算分享一些硬核非八股的JVM知识。我们下次见。