select 和 epoll 是 linux 中典型的多路复用模型,用于绑定监听多个事件源(实现了poll接口的文件描述符?),若无事件则该进程处于当阻塞状态或一定时间后超时,若某个事件源的某个动作触发(数据拷贝完成),多路复用模型将进程加入工作队列,进程根据处理事件。众所周知 epoll 是比 select 更先进的多路复用模型本文不求甚解的解释此事。并不会涉及epoll的具体实现细节(数据结构)。
select 实现
select 调用如下:
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
使用select监控多个链接的代码大致如下:
#define MAXCLINE 5 // 连接队列中的个数
int fd[MAXCLINE]; // 连接的文件描述符队列
int main(void)
{
sock_fd = socket(AF_INET,SOCK_STREAM,0) // 建立主机间通信的 socket 结构体
.....
bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr); // 绑定socket到当前服务器
listen(sock_fd, 5); // 监听 5 个TCP连接
fd_set fdsr; // bitmap类型的文件描述符集合,01100 表示第1、2位有数据到达
int max;
for(i = 0; i < 5; i++)
{
.....
fd[i] = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size); // 跟 5 个客户端依次建立 TCP 连接,并将连接放入 fd 文件描述符队列
}
while(1) // 循环监听连接上的数据是否到达
{
FD_ZERO(&fdsr); // 对 fd_set 即 bitmap 类型进行复位,即全部重置为0
for(i = 0; i < 5; i++)
{
FD_SET(fd[i], &fdsr); // 将要监听的TCP连接对应的文件描述符所在的bitmap的位置置1,比如 0110010110 表示需要监听第 1、2、5、7、8个文件描述符对应的 TCP 连接
}
ret = select(max + 1, &fdsr, NULL, NULL, NULL); // 调用select系统函数进入内核检查哪个连接的数据到达
for(i=0;i<5;i++)
{
if(FD_ISSET(fd[i], &fdsr)) // fd_set中为1的位置表示的连接,意味着有数据到达,可以让用户进程读取
{
ret = recv(fd[i], buf,sizeof(buf), 0);
......
}
}
}
1)调用select 后将本进程加入所有socket的等待队列中;
2)当socket 收到数据,中断控制程序将进程从所有的socket的等待队列中移除,进程加入工作队列。
3)进程遍历自己监控的socket 列表找到有数据的socket 进行处理。
当进程调用 select
时,内核会执行以下操作:
(1) 进程阻塞
-
用户态调用
select
后,进程从用户态陷入内核态。 -
内核检查所有监听的文件描述符(fdsr中设为1的位)的接收缓冲区:
-
如果有文件描述符已就绪(数据到达):
- 内核直接返回,
select
不会阻塞。
- 内核直接返回,
-
如果无文件描述符就绪:
- 内核将当前进程的 task_struct 加入每个监听文件描述符的等待队列(wait queue)。
-
-
进程进入 TASK_INTERRUPTIBLE 状态(可中断睡眠),主动让出 CPU。
(2) 内核唤醒进程
当任意一个被监听的文件描述符满足条件时(例如数据到达):
-
网络协议栈的触发:
- 数据包到达网卡后,内核协议栈处理数据并填充到对应 Socket 的接收缓冲区。
- 内核调用 Socket 的唤醒回调函数(例如
sock_def_readable
)。
-
等待队列的唤醒:
- 内核遍历该 Socket 的等待队列,找到所有监听该文件描述符的进程(例如你的进程)。
- 将进程状态从
TASK_INTERRUPTIBLE
改为TASK_RUNNING
,并重新加入 CPU 调度队列。
-
进程重新运行:
- 进程被调度器选中后,继续执行
select
的内核逻辑。
- 进程被调度器选中后,继续执行
- 内核再次检查文件描述符的就绪状态,更新
fdsr
位图,然后返回到用户态。
(3) 用户态继续执行
select
返回后,用户代码通过FD_ISSET
检查哪些文件描述符有数据到达。- 进程调用
recv
读取数据(此时不会阻塞,因为内核已确认数据就绪)。
select 缺陷
1.性能开销大
1)调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间,select 执行完后,还需要将 fd_set 从内核空间拷贝回用户空间,高并发场景下这样的拷贝会消耗极大资源;(epoll 优化为不拷贝)
2)进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪;(epoll 优化为异步事件通知)
3)select 只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)
2.同时能够监听的文件描述符数量太少。受限于 sizeof(fd_set) 的大小,在编译内核时就确定了且无法更改。一般是 32 位操作系统是 1024,64 位是 2048。(poll、epoll 优化为适应链表方式)
epoll
select 仅一个方法,epoll 有三个方法
int epoll_create(int size); // 创建一个 eventpoll 内核对象
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 将连接到socket对象添加到 eventpoll 对象上,epoll_event是要监听的事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待连接 socket 的数据是否到达
具体使用方法如下:
int main(void)
{
struct epoll_event events[5];
int epfd = epoll_create(10); // 创建一个 epoll 对象
......
for(i = 0; i < 5; i++)
{
static struct epoll_event ev;
.....
ev.data.fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); // 向 epoll 对象中添加要管理的连接
}
while(1)
{
nfds = epoll_wait(epfd, events, 5, 10000); // 等待其管理的连接上的 IO 事件
for(i=0; i<nfds; i++)
{
......
read(events[i].data.fd, buff, MAXBUF)
}
}
先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
改进1 功能分离
select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
改进2 就绪列表
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。当然这个就绪列表是通过红黑树维护的。
epoll实现
1)当epoll_create 会在内核中创建一个eventpoll 对象,其中包含了该进程描述符和回调函数(将进程修改为运行态),
2)epoll_ctl 会将 socket 连接注册到 eventpoll 对象,将epoll的回调函数(与前面的并非同一个,用于根据事件触发 epoll 后续行为)添加到 socket 的进程等待队列中,
3)epoll_wait会将当前进程加入等待队列,socket 的数据接收队列有数据到达时将该socket 对应的 epitem 添加到 eventpoll 对象的就绪队列 rdllist 中, 然后查看 eventpoll 对象的进程等待队列上是否有等待项,通过回调函数 default_wake_func 唤醒这个进程,进行数据的处理;
epoll_create
epoll_create 函数会创建一个 struct eventpoll 的内核对象,类似 socket,把它关联到当前进程的已打开文件列表中。
eventpoll 主要包含三个字段:
struct eventpoll {
wait_queue_head_t wq; // 等待队列链表,存放阻塞的进程
struct list_head rdllist; // 数据就绪的文件描述符都会放到这里
struct rb_root rbr; // 红黑树,管理用户进程下添加进来的所有 socket 连接
......
}
wq:等待队列,如果当前进程没有数据需要处理,会把当前进程描述符和回调函数 default_wake_functon 构造一个等待队列项,放入当前 wq 对待队列,软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
rbr:一棵红黑树,管理用户进程下添加进来的所有 socket 连接。
rdllist:就绪的描述符的链表。当有的连接数据就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。
epoll_ctl
epoll_ctl 函数主要负责把服务端和客户端建立的 socket 连接注册到 eventpoll 对象里,会做三件事:
1)创建一个 epitem 对象,主要包含两个字段,分别存放 socket fd 即连接的文件描述符,和所属的 eventpoll 对象的指针;
2)将一个数据到达时用到的回调函数添加到 socket 的进程等待队列中,这里添加的 socket 的进程等待队列结构中,只有回调函数,没有设置进程描述符,因为在 epoll 中,进程是放在 eventpoll 的等待队列中,等待被 epoll_wait 函数唤醒,而不是放在 socket 的进程等待队列中;
3)将第 1)步创建的 epitem 对象插入红黑树;
epoll_wait
epoll_wait 函数的动作比较简单,检查 eventpoll 对象的就绪的连接 rdllist 上是否有数据到达,如果没有就把当前的进程描述符添加到一个等待队列项里,加入到 eventpoll 的进程等待队列里,然后阻塞当前进程,等待数据到达时通过回调函数被唤醒。
当 eventpoll 监控的连接上有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:
1)socket 的数据接收队列有数据到达,会通过进程等待队列的回调函数 ep_poll_callback 唤醒红黑树中的节点 epitem;
2)ep_poll_callback 函数将有数据到达的 epitem 添加到 eventpoll 对象的就绪队列 rdllist 中;
3)ep_poll_callback 函数检查 eventpoll 对象的进程等待队列上是否有等待项,通过回调函数 default_wake_func 唤醒这个进程,进行数据的处理;
4)当进程醒来后,继续从 epoll_wait 时暂停的代码继续执行,把 rdlist 中就绪的事件返回给用户进程,让用户进程调用 recv 把已经到达内核 socket 等待队列的数据拷贝到用户空间使用。
三种多路复用模型的特点
参考:
https://www.cnblogs.com/looyee/articles/12964911.html