IO多路转接
从系统角度看待数据接收
网卡接收数据
网卡接收数据分为如下几步:
-
网卡收到网线传过来的数据
-
网卡会把接收到的数据写入内存
-
网卡通知操作系统读取网络数据
网卡如何通知操作系统读取网络数据
网卡通知系统读取网络数据是通过中断的方式完成的,当网卡收到数据将其写入内存之后,网卡会给CPU发送一个中断信号,由硬件产生的中断信号优先级较高,CPU应该优先进行处理,CPU会执行中断程序,将网络数据拷贝到接收缓冲区
recv阻塞读取,底层发生了什么?
下面是一段经典的TCP服务端代码:
cpp
int listenSock = socket(AF_INET, SOCK_STREAM, 0);
bind(listenSock,...);
listen(listenSock,...);
int fd=accept(listenSock,...);
recv(fd,...);
当程序运行到socket时,Linux文件系统在底层会创建一个文件对象struct file,通过文件描述符可以找到该文件对象,通过bind和listen可以让文件对象与网卡建立关联,程序运行到accept会从TCP的全连接队列获取连接,通过recv读取对端发送过来的数据。
- 每一个连接在底层都是一个socket对象,本质是一个网络文件,用户通过文件描述符可以访问到socket对象
- 每一个socket对象都有发送缓冲区和接收缓冲区,除此之外,每一个socket对象还有一个等待列表,等待列表是所有需要等待该socket事件就绪的进程集合,一般而言,一个socket对象的等待列表中只有一个进程,但是一个进程可以同时处于多个socket对象的等待列表,也就是IO多路复用
- 网卡将接收到的数据写入内存之后,会向CPU发送中断信号,CPU接收到中断信号之后,执行中断处理程序,中断处理程序主要完成2件工作:将网络数据拷贝到socket对象的接收缓冲区、将阻塞的进程从socket对象的等待列表中移除,并添加到CPU运行队列,此时进程执行recv就可以直接从socket对象的接收缓冲区读取数据
- 进程阻塞就是进程在socket对象的等待列表中等待数据就绪,进程阻塞不占用CPU资源
操作系统如何知道网络数据对应哪一个socket对象?
一个服务端可能有多个客户端与之连接,底层就会存在多个socket对象,网卡接收到数据之后,操作系统是如何得知将数据拷贝到哪一个socket对象的接收缓冲区呢?
实际上,在accpet获取新连接的时候,操作系统知道发起连接的客户端对应的IP和端口
c
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
操作系统在底层维护了socket对象与客户端的映射(即文件描述符fd与客户端的映射),网卡接收到的数据中,存在源IP地址和源端口号,根据客户端IP和端口就可以索引到fd,找到socket对象,从而将数据写入指定socket对象的缓冲区
如何同时监视多个文件描述符?
从原理上来讲,同时监视多个文件描述符就是将一个进程添加到多个socket对象的等待队列中,只要这多个socket对象中有1个或者1个以上有数据就绪,就可以将进程从所有socket对象的等待队列中移除,将其添加到CPU的运行队列。这就是select和poll的基本原理
select
c
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
select原理
调用select函数的进程,会被添加到多个socket对象的等待队列中,只要网卡有数据到来,CPU就会执行中断处理程序,select的中断回调与recv的中断回调不同,select的中断回调完成的功能:
- 将网络数据写入对应socket对象的接收缓冲区
- 将进程从所有socket对象的等待队列中移除
select特点
- select可以监视的文件描述符有上限,一般为1024,即
sizeof(fd_set)*8
- select每一次在调用时都要将fd_set结构从用户拷贝到内核,即用户告诉内核,你需要帮我检测哪些文件描述符上的哪些事件,select返回时需要将数据由内核拷贝到用户,即内核告知用户,哪些文件描述符上的哪些事件已经就绪。这2次拷贝存在较大的开销,出于效率的考量,才规定了select最大监视的文件描述符数量
- fd_set结构不可重用,每一次内核返回时都会对fd_set进行修改,下一次调用select需要重新设置fd_set
- select返回时,用户只知道有几个文件描述符就绪,但是不知道有哪些文件描述符就绪,因此用户只能一个一个的进行遍历,效率较低。需要注意的是,select返回时,内核是知道哪些文件描述符就绪的,但是由于select函数在设计上没有告知用户有哪些文件描述符就绪,只是告诉了用户有几个文件描述符就绪,因此用户需要进行遍历操作
- select可以同时监视多个文件描述符,相比于recv等接口,在一定程度上提高了效率
poll
c
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
poll原理
poll的原理和select类似,也是将一个进程添加到多个socket对象的等待队列,在网卡接收到数据后,CPU执行中断程序将数据写入到接收缓冲区,然后将进程从所有等待队列中移除
poll的特点
- 监视的文件描述符理论上没有上限,由用户提供的fds数组决定
- poll把监听事件与就绪事件进行分离,在接口上使用起来更加方便
- fds是可重用的,在每一次重用fds之前,只需要对revents进行清空即可
- poll在调用时也需要将fds从用户拷贝到内核,在返回时从内核拷贝到用户,存在一定开销
- poll可以同时监视多个文件描述符,一定程度上提高了效率
epoll
c
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
epoll_create
epoll_create接口用于创建一个eventpoll
结构体,eventpoll结构体的主要成员:
c
struct eventpoll{
//...
struct mutex mtx;
spinlock_t lock;//mtx和lock保证访问eventpoll是线程安全的
wait_queue_head_t wq;//wait queue,等待队列
struct list_head rdllist;//ready list,就绪列表,双向链表
struct rd_root_cached rbr;//red balck tree root,红黑树根节点
};
epoll_ctl
epoll_ctl可以向eventpoll中的红黑树添加或删除节点,如果调用epoll_ctl添加文件描述符,那么内核会封装一个epitem
结构体,将epitem中的rbn添加到eventpoll的红黑树中,同时将eventpoll添加到socket对象的等待队列
c
struct epitem{
struct rb_node rbn;//red black tree node,将该结点添加到eventpoll中的红黑树中
struct epoll_filefd ffd;//文件描述符
struct list_head rdlink;//当文件描述符上的事件就绪时,会将rdlink插入到eventpoll的就绪列表中
struct eventpoll* ep;//指向eventpoll
struct epoll_event event;//监视的事件
};
在epoll中,并不是进程直接在socket对象的等待队列中等待数据就绪,而是让eventpoll在多个socket对象的等待队列中进行等待 ,当socket对象上有数据时,触发回调,让eventpoll的rdllist引用已经就绪的socket对象
epoll_wait
调用epoll_wait时,用户请求内核检测eventpoll的rdllist是否为空,若为空,则进程阻塞在eventpoll的wq上,若不为空,则内核将rdllist中的文件描述符拷贝到用户空间,同时epoll_wait返回已经就绪的文件描述符个数。
c
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
内核会将已经就绪的文件描述符信息写入events数组中,用户就不需要对events数组进行完全遍历,只需要依据epoll_wait的返回值取出events数组中头部指定个数的元素即可,例如epoll_wait返回3,用户在遍历时只需要:
c
for(int i=0;i<3;i++){
if(events[i].events&EPOLLIN){
//...
}
if(events[i].events&EPOLLOUT){
//...
}
}
epoll的特点
- epoll将"用户告知内核"与"内核通知用户"在接口上进行了分离,使其使用更加方便
- 使用epoll_ctl添加文件描述符,就是将epitem中的rbn添加到红黑树,时间复杂度是O(logN)
- epoll让eventpoll结构等待多个socket对象是否有数据就绪,让eventpoll的rdllist引用已经就绪的socket对象。eventpoll作为进程与socket对象的中间层,socket的数据接收并不直接影响进程,进程只需要在eventpoll的wq中等待即可,当rdllist成功引用socket对象时,epoll会通过回调的方式唤醒进程,通过rdllist+回调机制,进程索引文件描述符的时间复杂度为O(1)
- epoll可以同时监视多个文件描述符,提高了效率
epoll的工作方式
epoll的工作方式有水平出发 (LT,level triggered)和边缘触发(ET,edge triggered),LT指的是只要底层有数据就绪,epoll_wait就会一直通知上层读取,ET指的是只有数据从无到有或者从有到多,epoll_wait才会通知上层一次,如果用户没有把数据读取干净,那么以后也不会通知了,epoll如果采用ET模式,必须反复读取,并且将文件描述符设置为非阻塞,原因是在进行recv读取时,如果恰好底层没有数据,并且对端没有关闭连接,此时recv就会被阻塞,导致服务无法正常运行。
一般而言,ET模式要比LT模式效率高,因为内核只需要通知一次即可,但是如果采用LT模式每一次底层通知事件就绪时上层都把数据读完,那么效率也是很高的。
如何设置文件描述符为非阻塞?
通过fcntl可以设置一个文件描述符为非阻塞
cpp
bool SetNoBlock(int sock) {
int fl = fcntl(sock, F_GETFL);
if (fl < 0) {
return false;
}
fl |= O_NONBLOCK;
return fcntl(sock, F_SETFL, fl) == 0;
}
如何设置ET模式?
在调用epoll_ctl添加文件描述符时,设置EPOLLET
cpp
struct epoll_event event;
memset(&event, 0, sizeof(event));
event.events = EPOLLIN|EPOLLET;
event.data.fd = accept(...);
SetNoBlock(event.data.fd);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &event)
思考
为什么eventpoll的rdllist使用双向链表,单链表不行吗?
在调用epoll_ctl时,除了向红黑树中添加节点,还有可能删除节点,如果删除的那个文件描述符上有事件就绪,需要将它从rdllist中移除,如果rdllist是单链表,那么删除的时间复杂度就是O(N),如果是双向链表,删除的时间复杂度是O(1)
epoll保存监视的文件描述符为什么使用红黑树?使用哈希表可以吗?
epoll_ctl可以支持的功能有插入、删除和修改,eventpoll就需要使用一种便于插入、删除和寻找的数据结构,红黑树正是满足条件的结构,插入、删除和修改时间复杂度都是O(logN),使用哈希表从理论上而言可行,但是哈希表在最糟糕的情况下可能所有的键都哈希到了同一个桶,那么这时查找起来时间复杂度也退化为O(N),不如红黑树稳定,除此之外,哈希表不支持范围查找
eventpoll中的mtx和lock作用是什么?在什么情况下会有多个执行流访问eventpoll结构?
mtx和lock的作用是保证eventpoll在访问时是线程安全的。存在这样一种场景:用户调用epoll_wait访问eventpoll中的rdllist,恰好此时eventpoll等待的socket对象上有数据就绪,需要将对应epitem中的rdlink添加到eventpoll的rdllist,就出现了2个执行流同时访问rdllist的情况,因此需要通过eventpoll的mtx和lock成员保证rdllist线程安全
为什么select和poll索引就绪文件描述符的时间复杂度是O(N),epoll索引就绪文件描述符的时间复杂度是O(1)?
主要是底层使用的结构和机制不同,对于select和poll,都是由进程直接在socket对象的等待队列中阻塞,返回时用户只知道就绪的文件描述符个数,不知道具体是哪些文件描述符,因此需要进行遍历操作,时间复杂度是O(N);对于epoll,由于其使用了先进的结构eventpoll、rddlist,并且通过回调让rddlist引用已经就绪的socket对象,进程只需要在eventpoll的wq中等待,只需要关系rdllist,无需关心socket对象,返回时根据rdllist就可以直接索引到就绪的文件描述符,无需遍历,因此时间复杂度是O(1)
总结
系统调用 | select | poll | epoll |
---|---|---|---|
事件集合 | 通过fd_set设置关心事件,返回时也是通过fd_set获取就绪事件,fd_set不可重用 | 通过events和revents分别表示关心事件和就绪事件,pollfd结构可重用 | 通过epoll_ctl设置关心事件,通过epoll_wait检测eventpoll的rdllist是否为空,从而获取就绪事件 |
索引就绪文件描述符的时间复杂度 | O(N) | O(N) | O(1) |
最大支持的文件描述符个数 | 1024 | 使用ulimit -a 查看 |
使用ulimit -a 查看 |
工作模式 | LT | LT | LT和ET |
内核实现和工作效率 | 底层一个进程在多个socket对象的等待队列上等待,只要其中一个有事件就绪就返回,需要进行遍历操作,时间复杂度为O(N) | 底层一个进程在多个socket对象的等待队列上等待,只要其中一个有事件就绪就返回,需要进行遍历操作,时间复杂度为O(N) | 通过eventpoll、rdllist、wq、rbr等先进结构,让eventpoll在socket对象的等待队列等待,使用rdllist引用就绪的文件描述符,进程只需要在wq中等待并获取rdllist中就绪的文件描述符,时间复杂度为O(1) |