IO模型专题
1. 什么是IO多路复用
IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄。一旦某个文件句柄就绪,就能通知应用程序进行相应的IO操作。没有文件句柄时就会发生阻塞,阻塞该线程,交出cpu。多路指多个文件句柄(网络连接),复用指同一个线程。
一句话解释:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。
2. 常见IO模型
BIO 同步阻塞:
这是最常用的简单的IO模型。单线程下,阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情,无法处理并发。阻塞IO操作只能对单个文件描述符进行操作,详见read或write。
在多线程下,当accept一个请求后,开启线程完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换时CPU上下文切换会带来很大的开销。
NIO 同步非阻塞:
我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN
,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。和阻塞IO一样,非阻塞IO也是通过调用read或write来进行操作的,也只能对单个描述符进行操作。
服务器端当accept一个请求后,加入fds集合,每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu。
与上面同步阻塞BIO不同的是,BIO当accept 一个请求之后会一直等着这个连接就绪、读、写等;而同步非阻塞NIO是接收一个请求后,把这个请求放到一个队列或集合中,然后遍历这个集合,查看有没有就绪的连接,有就绪的就会进行同步的IO操作,没有就绪的连接,继续accept其他请求并加入队列。
setNonblocking(listen_fd)
// 伪代码描述
while(1) {
// accept非阻塞(cpu一直忙轮询)
client_fd = accept(listen_fd)
if (client_fd != null) {
// 有人连接
fds.append(client_fd)
} else {
// 无人连接
}
for (fd in fds) {
// recv非阻塞
setNonblocking(client_fd)
// recv 为非阻塞命令
if (len = recv(fd) && len > 0) {
// 有读写数据
// logic
} else {
//无读写数据
}
}
}
IO多路复用
IO多路复用在Linux下包括了三种,select、poll、epoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。更多细节详见下面的。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的,下面会详细介绍。
多路复用与上面同步非阻塞NIO类似,也是会把一个IO事件的描述符(可以理解为ID)放到一个队列或集合中。但这个轮询判断有没有可用就绪连接的过程交给了内核去做,而不再像NIO那样由程序去做。程序逻辑要做的只是调用select poll epoll
获取就绪连接,没有就绪连接则会一直阻塞,下面详细说。
采用单线程通过select/epoll/poll等系统调用获取fds列表,遍历有事件的fd 进行accept/recv/send,使其能支持更多的并发连接请求。
信号驱动IO
信号驱动IO是利用信号机制,让内核告知应用程序文件描述符的相关事件。这里有一个信号驱动IO相关的例子。
但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:
- TCP连接建立
- 一方断开TCP连接请求
- 断开TCP连接请求完成
- TCP连接半关闭
- 数据到达TCP socket
- 数据已经发送出去(如:写buffer有空余空间)
上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。
异步IO
异步IO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。我们使用 aio_read 来读,aio_write 写。
同步IO vs 异步IO 1.同步IO指的是程序会一直阻塞的IO操作,如等待read、write完成。2.异步IO指的是IO操作不会阻塞当前程序的继续执行 所以根据这个定义,上面阻塞IO当然算是同步的IO,非阻塞IO也是同步IO,因为当文件操作符可用时我们还是需要阻塞的读或写,同理IO多路复用和信号驱动IO也是同步IO,只有异步IO是完全完成了数据的拷贝之后才通知程序进行处理,程序中没有阻塞的数据读写过程。
2. 为什么要有多路复用机制
凡事都是有成本的,线程/进程也一样,有这么几个方面:
- 线程/进程创建成本
- CPU切换不同线程/进程成本 Context Switch
- 多线程的资源竞争
有没有一种可以在单线程/进程中处理多个事件流的方法呢?一种答案就是IO多路复用。
因此IO多路复用解决的本质问题是在用更少的资源完成更多的事。同时单线程DMA的处理网络连接也能避免造成数据的丢失,为数据的不丢失性提供保障。
单线程通过select/epoll/poll等系统调用获取fds列表,遍历有事件的fd 进行accept/recv/send,使其能支持更多的并发连接请求。
fds = [listen_fd]
// 伪代码描述
while(1) {
// 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
// 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
for (fd in select(fds)) {
if (fd == listen_fd) {
client_fd = accept(listen_fd)
fds.append(client_fd)
} elseif (len = recv(fd) && len != -1) {
// logic
}
}
}
3. IO多路复用的实现方式
Linux:
- select
- poll
- epoll
Mac:kqueue
Windows:IOCP
4. select函数接口、使用示例、缺点
接口
#include <sys/select.h>
#include <sys/time.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 数据结构 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// API
int select(
int max_fd,
fd_set *readset, // 读 写 异常三张bitmap
fd_set *writeset,
fd_set *exceptset,
struct timeval *timeout
) // 返回值就绪描述符的数目
FD_ZERO(int fd, fd_set* fds) // 清空集合
FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除
示例
int main() {
/*
* 这里进行一些初始化的设置,
* 包括socket建立,地址的设置等,
*/
fd_set read_fs, write_fs;
struct timeval timeout;
int max = 0; // 用于记录最大的fd,在轮询中时刻更新即可
// 初始化比特位
FD_ZERO(&read_fs);
FD_ZERO(&write_fs);
int nfds = 0; // 记录就绪的事件,可以减少遍历的次数
while (1) {
// 1.阻塞获取 将所有的 fd_set 传入内核 让它帮我们监听是否有事件准备就绪
// 2.每次需要把多有的fd_set从用户态拷贝到内核态 read_fd write_fd...
// 3.nfds 表示内核返回给你有多少个 fd 准备就绪了,并将有数据的fd在bitmap中置位
nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout); // 这里的max+1主要是为了告诉内核最大fd的,以便内核监听
// 1.用户态的进程是不知道到底哪个 fd 准备就绪的,那么它就需要再遍历一遍之前需要遍历所有的 fd
// 2.判断有无读写事件发生 看下哪几个数据准备就绪了,然后进行处理
for (int i = 0; i <= max && nfds; ++i) {
if (i == listenfd) {
--nfds;
// 这里处理accept事件
FD_SET(i, &read_fd);//将客户端socket加入到集合中
}
if (FD_ISSET(i, &read_fd)) {
--nfds;
// 这里处理read事件
}
if (FD_ISSET(i, &write_fd)) {
--nfds;
// 这里处理write事件
}
}
}
}
缺点
- 单个进程所打开的FD是有限制的,通过FD_SETSIZE设置,默认1024
- 每次调用select,都需要把fd_set(文件描述符标记的bitmap)从用户态拷贝到内核态,这个开销在fd很多时会很大。这个fd_set由于会改变,所以不可重用。
- 唤醒的时候由于进程不知道是哪个 fd 已经就绪,需要进行一次遍历,采用轮询的方法,效率较低(高并发时)
5. poll函数接口、使用示例、缺点
poll 跟 select 相似,但对其进行了部分优化,比如单进程能打开的文件描述符数量不再受限制,并可以自定义。相比select,只是从bitmap标记改到了结构体数组的实现方式,没有fd的限制,其它基本一样。仍然有把数组从用户态改到内核态的过程。
接口
#include <poll.h>
// 数据结构
// 没有使用bitmap带来了一定好处
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
代码示例
// 先宏定义长度
#define MAX_POLLFD_LEN 4096
int main() {
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
int nfds = 0;
pollfd fds[MAX_POLLFD_LEN];
memset(fds, 0, sizeof(fds));
fds[0].fd = listenfd;
fds[0].events = POLLRDNORM;
int max = 0; // 队列的实际长度,是一个随时更新的,也可以自定义其他的
int timeout = 0;
int current_size = max;
while (1) {
// 1. 阻塞获取 每次需要把pollfd数组从用户态拷贝到内核态
// 2. 当内核监听到有事件就绪的时候,会把pollfd结构体中的revents置成内核监听事件,并返回就绪数目
nfds = poll(fds, max+1, timeout);
if (fds[0].revents & POLLRDNORM) {
// 这里处理accept事件
connfd = accept(listenfd);
//将新的描述符添加到读描述符集合中
}
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 1; i < max; ++i) {
// 可以修改fds[i].fd 和 fds[i].revents = 0 实现复用
if (fds[i].revents & POLLRDNORM) {
sockfd = fds[i].fd
if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
// 这里处理read事件
if (n == 0) {
close(sockfd);
fds[i].fd = -1;
}
} else {
// 这里处理write事件
}
if (--nfds <= 0) {
break;
}
}
}
}
}
优点
- 相比select 文件描述符的数量已不再受限制
- 可以修改fds[i].fd文件描述符 和 fds[i].revents = 0 实现数组的复用
缺点
- 每次调用poll,都需要把pollfd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 对fd集合扫描时仍然是线性扫描,采用轮询的方法,效率较低(高并发时)
6. epoll函数接口、使用示例、缺点
epoll 的出现相较于 select 晚了几年,它对 select,poll 进行了大幅度的优化。
#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
// 就绪队列
struct list_head rdlist;
};
// API
// 内核中加一个 eventpoll 对象,把所有需要监听的 fd 都放到这个对象中
int epoll_create(int size);
// epoll_ctl 负责把 fd 增加、删除到内核红黑树
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epoll_wait 负责检测红黑树,就绪则放到可读队列,没有可读 fd 则阻塞进程
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
struct epoll_event {
__uint32_t events; // 回调事件
epoll_data_t data; // 存储fd等
};
示例
int main(int argc, char* argv[])
{
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
// 内核中创建eventpoll对象
epfd=epoll_create(256);
// 需要监听的epfd 放到 eventpoll中
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
while(1) {
// 阻塞获取 准备好了的 epfd。
// 内核监测到就绪的事件之后,把时间放到rdlist
nfds = epoll_wait(epfd,events,20,0);
// 这里就直接对收到就绪的rdlist 进行遍历 不需要再遍历所有的 fd
// 是怎么做到的呢,下面继续分析
for(i=0;i<nfds;++i) {
if(events[i].data.fd==listenfd) {
// 这里处理accept事件
connfd = accept(listenfd);
// 接收新连接写到内核对象中
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
} else if (events[i].events&EPOLLIN) {
// 这里处理read事件
read(sockfd, BUF, MAXLINE);
//读完后准备写
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
} else if(events[i].events&EPOLLOUT) {
// 这里处理write事件
write(sockfd, BUF, n);
//写完后准备读
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
}
}
return 0;
}
就绪队列
epoll实际是注册一个eventpoll到内核, 进程A调用epoll_wait
,没有事件eventpoll上面挂起的进程 A等待,此时进程 A 是处于被阻塞状态的。当rdlist
有事件就绪的时候,被唤醒。后序补图
就绪队列就是上图的 rdlist
它是 eventpoll 的一个成员,指的是内核中有哪些数据已经准备就绪。这个是怎么做到的呢,当我们调用 epoll_ctl() 的时候会为每一个 socket 注册一个回调函数,当某个 socket 准备好了就会回调然后加入 rdlist 中的,rdlist 的数据结构是一个双向链表。
epoll 提升了系统的并发,有限的资源提供更多的服务较于 select、poll 优势总结如下
- 内核监视 fd 的时候不再需要每次传入所有的 fd 文件描述符,它只需通过一次 epoll_ctl 即可
- select、poll 模型下进程收到了 sockets 准备就绪的指令执行后,它不知道到底是哪个 fd 就绪了,需要去遍历所有的 fd,而 epoll 维护了一个 rdlist 通过回调的方式将就绪的 fd 插入到 rdlist 链表中,我们可以直接遍历 rdlist 即可,提升效率
最后我们考虑下 epoll 的适用场景,只要同一时间就绪列表不要太长都适合。比如 Nginx 它的处理都是及其快速的,如果它为每一个请求还创建一个线程,这个开销情况下它还如何支持高并发。
缺点
- epoll只能工作在linux下
https://juejin.cn/post/6844903954917097486 https://juejin.cn/post/6844904200141438984
7. epoll 水平触发(LT)与 边缘触发(ET)
水平触发level-triggered,边缘触发edge-triggered。这两个概念来自电路,triggered代表电路激活,也就是有事件通知给程序。
水平触发LT表示只要有IO操作可以进行比如某个文件描述符有数据可读,每次调用epoll_wait都会返回以通知程序可以进行IO操作。
边缘触发ET表示只有在文件描述符状态发生变化时,调用epoll_wait才会返回,如果第一次没有全部读完该文件描述符的数据而且没有新数据写入,再次调用epoll_wait都不会有通知给到程序,因为文件描述符的状态没有变化。
select和poll都是状态持续通知的机制,且不可改变,只要文件描述符中有IO操作可以进行,那么select和poll都会返回以通知程序。而epoll两种通知机制可选。
一个例子
状态变化通知(edge-triggered)模式下的epoll
在epoll状态变化通知机制下,有一些的特殊的地方需要注意。考虑下面这个例子
1. 服务端文件描述符rfd代表要执行read操作的TCP socket,rfd已被注册到一个epoll实例中
2. 客户端向rfd写了2kb数据
3. 服务端调用epoll_wait返回,rfd可执行read操作
4. 服务端从rfd中读取了1kb数据
5. 服务端又调用了一次epoll_wait
在第5步的epoll_wait调用不会返回,而对应的客户端会因为服务端没有返回对应的response而超时重试,原因就是我上面所说的,epoll_wait只会在状态变化时才会通知程序进行处理。第3步epoll_wait会返回,是因为客户端写了数据,导致rfd状态被改变了,第3步的epoll_wait已经消费了这个事件,所以第5步的epoll_wait不会返回。
我们需要配合非阻塞IO来解决上面的问题:
- 对需要监听的文件描述符加上非阻塞IO标识
- 只在read或者write返回EAGAIN或EWOULDBLOCK错误时,才调用epoll_wait等待下次状态改变发生
通过上述方式,我们可以确保每次epoll_wait返回之后,我们的文件描述符中没有读到一半或写到一半的数据。
区别:
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。
- LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作
- ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误
8. select/poll/epoll之间的区别
select | poll | epoll | |
---|---|---|---|
数据结构 | bitmap | 数组 | 红黑树+双链表 |
最大连接数 | 1024 | 无上限 | 无上限 |
fd拷贝 | 每次调用select拷贝 | 每次调用poll拷贝 | fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
工作效率 | 轮询:O(n) | 轮询:O(n) | 回调:O(1) |
超时时间 | 精确到微妙 | 精确到毫秒 | - |
兼容性 | 更广 | 相对较窄 | - |
epoll的文件描述符可以在多个线程/进程共享,所以epoll的使用场景要比select&poll要多。
9. epoll应用
- redis: Linux下 epoll(level-triggered),没有epoll用select
- nginx:Linux下 epoll(edge-triggered),没有epoll用select
10. nginx/redis 所使用的IO模型是什么?
epoll
11. 代码示例
select poll epoll代码示例:https://github.com/caijinlin/learning-pratice/tree/master/linux/io
12. PIO和DMA
PIO
Programming Input/Output Model,在以前磁盘和内存之间的数据传输是通过CPU控制的。如果我们读取磁盘文件到内存中,数据要经过CPU存储转发,这种方式占用大量的CPU时间来读取文 件。
DMA
Direct Memory Access 直接内存访问,它可以不经过CPU直接进行磁盘和内存数据的传输。CPU只要下达指令,让DMA控制器来处理数据传输即可,DMA控制器通过系统总线传输数据,传输完毕再通知CPU,大大减少了CPU的占用时间。
拿 Node Js 来说,它是单线程的,如果它从磁盘读取一个文件,那么其它的无法工作了,但其实读取文件是个很简单的工作,所以可以把 CPU 让出来,直接下单指令,让 DMA 去读取,等 DMA 读好了,会把数据放到内存中,然后中断来通知 CPU 说,你要的数据已经放到某个地址上了。