在讲述IO复用模型之前,需先了解Linux内核的wakeup&callback机制,我们先简单回顾下IO复用模型的思路,从上述的IO复用模型图看出,一个进程可以处理N个socket描述符的操作,等待对应的socket为可读的时候就会执行对应的read_process处理逻辑,也就是说这个时候我们站在read_process的角度去考虑,我只需要关注socket是不是可读状态,如果不可读那么我就休眠,如果可读你要通知我,这个时候我再调用recvfrom去读取数据就不会因内核没有准备数据处于等待,这个时候只需要等待内核将数据复制到用户空间的缓冲区中就可以了.那么对于read_process而言,要实现复用该如何设计才能达到上述的效果呢?
复用本质
复用设计原理
在上述的IO复用模型中一个进程要处理N个scoket事件,也会对应着N个read_process,但是这里的read_process都是向内核发起读取操作的处理逻辑,它是属于进程程序中的一段子程序,换言之这里是实现read_process的复用,即N个socket中只要满足有不少于一个socket事件是具备可读状态,read_process都能够被触发执行,联想到Linux内核中的sleep & wakeup机制,read_process的复用是可以实现的,这里的socket描述符可读在Linux内核称为事件,其设计实现的逻辑图如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ln08bszn-1584008069034)(https://raw.githubusercontent.com/xiaokunliu/xiaokunliu.github.io/feature/writing/websites/writing2images/io/io_select_flow.jpg)]
基于上述IO复用模型实现的认知,对于IO复用模型实现的技术select/poll/epoll也应具备上述两个核心的逻辑,即等待逻辑以及唤醒逻辑,对此用伪代码来还原select/poll/epoll的设计原理.
select/poll/epoll的等待逻辑
for(;;){
res = 0;
for(i=0; i<maxfds,i++){
// 检测当前fd是否就绪
if(fd[i].poll()){
// 更新事件状态,让用户进程知道当前socket状态是可读状态
fd_sock.event |= POLLIN;
}
}
if(res | tiemout | expr){
break;
}
schdule();
}
select/poll/epoll的唤醒逻辑
foreach(entry as waiter_queues){
// 唤醒通知并将任务task加入cpu就绪队列中
res = callback();
// 说明当前节点为独占节点,只能唤醒一次,因此需要退出循环
if(res && current == EXCLUSIVE){
break;
}
}
select 函数定义
int select(int maxfd1, // 最大文件描述符个数,传输的时候需要+1
fd_set *readset, // 读描述符集合
fd_set *writeset, // 写描述符集合
fd_set *exceptset, // 异常描述符集合
const struct timeval *timeout); // 超时时间
// timeout的结构
struct timeval {
long tv_sec; // 单位为秒
long tv_usec; // 单位微秒
}
// select函数返回结果
//select() > 0: 表示当前调用select监视到有描述符就绪状态的描述符索引值,意味着可以开始读取/写入/异常处理等操作
//select() = 0: 表示当前调用select发生超时,在最后的一个参数指定
//select() = -1: 表示当前调用select发生异常错误
// 现在很多Unix/Liunx版本使用pselect函数,最新版本(5.6.2)的select已经弃用
// 其定义如下
int pselect(int maxfd1, // 最大文件描述符个数,传输的时候需要+1
fd_set *readset, // 读描述符集合
fd_set *writeset, // 写描述符集合
fd_set *exceptset, // 异常描述符集合
const struct timespec *timeout, // 超时时间
const struct sigset_t *sigmask); // 信号掩码指针
// timeout的结构
struct timespec {
long tv_sec; // 单位为秒
long tv_nsec; // 单位纳秒
}
// 基于POSIX协议
// posix_type.h
#define __FD_SETSIZE 1024 // 最大文件描述符为1024
// 这里只关注socket可读状态,以下主要是休眠逻辑
static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
struct poll_wqueues table;
poll_table *wait;
// ...
// 与上述休眠逻辑初始化等待节点操作类似
poll_initwait(&table);
wait = &table.pt;// 获取创建之后的等待节点
rcu_read_lock();
retval = max_select_fd(n, fds);
rcu_read_unlock();
n = retval;
// ...
// 操作返回值
retval = 0;
for (;;) {
//...
// 监控可读的描述符
inp = fds->in;
for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
bit = 1;
// BITS_PER_LONG若处理器为32bit则BITS_PER_LONG=32,否则BITS_PER_LONG=64;
for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) {
f = fdget(i);
wait_key_set(wait, in, out, bit,
busy_flag);
// 检测当前等待节点是否可读
mask = vfs_poll(f.file, wait);
fdput(f);
// 当前等待节点是否可读
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit;
retval++;
wait->_qproc = NULL;
}
// ...
}
}
// 说明有存在可读节点退出节点遍历
if (retval || timed_out || signal_pending(current))
break;
// ...
// 调度带有超时事件的schedule
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
to, slack))
timed_out = 1;
}
// 移除队列中的等待节点
poll_freewait(&table);
}
// 在poll_initwait -> __pollwait --> pollwake 的方法,主要关注pollwake方法
static int __pollwake(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
struct poll_wqueues *pwq = wait->private;
DECLARE_WAITQUEUE(dummy_wait, pwq->polling_task);
smp_wmb(); // 与sheculde_time_out中的smp_store_mb方法相呼应,一旦触发那个方法,就会调用执行到这里
pwq->triggered = 1;
// 与linux内核中的唤醒机制一样,下面的方法是内核执行的,不过多关心,有兴趣可以看源码core.c下面定义
// 就是polling_task也就是read_process添加到cpu就绪队列中,让cpu进行调度
return default_wake_function(&dummy_wait, mode, sync, key);
}
poll技术与select技术实现逻辑基本一致,重要区别在于poll技术使用链表的方式存储描述符fd,不受数组大小影响,对此,现对poll技术进行分析如下:
poll定义
// poll已经被弃用
int poll(struct pollfd *fds, // fd的文件集合改成自定义结构体,不再是数组的方式,不受限于FD_SIZE
unsigned long nfds, // 最大描述符个数
int timeout); // 超时时间
struct pollfd {
int fd; // fd索引值
short events; // 输入事件
short revents; // 结果输出事件
};
// 当前查看的linux版本(5.6.2)使用ppoll方式,与pselect差不多,其他细节不多关注
int ppoll(struct pollfd *fds, // fd的文件集合改成自定义结构体,不再是数组的方式,不受限于FD_SIZE
unsigned long nfds, // 最大描述符个数
struct timespec timeout, // 超时时间,与pselect一样
const struct sigset_t sigmask, // 信号指针掩码
struct size_t sigsetsize); // 信号大小
poll技术实现的核心代码
// 关于poll与select实现的机制差不多,因此不过多贴代码,只简单列出核心点即可
static int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,
struct timespec64 *end_time)
{
// ...
for (;;) {
// ...
// 从用户空间将fdset拷贝到内核中
if (copy_from_user(walk->entries, ufds + nfds-todo,
sizeof(struct pollfd) * walk->len))
goto out_fds;
// ...
// 和select一样,初始化等待节点的操作
poll_initwait(&table);
// do_poll的处理逻辑与do_select逻辑基本一致,只是这里用链表的方式遍历,do_select用数组的方式
// 链表可以无限增加节点,数组有指定大小,受到FD_SIZE的限制
fdcount = do_poll(head, &table, end_time);
// 从等待队列移除等待节点
poll_freewait(&table);
}
}
小结: poll技术使用链表结构的方式来存储fdset的集合,相比select而言,链表不受限于FD_SIZE的个数限制,但是对于select存在的性能并没有解决,即一个是存在大内存数据拷贝的问题,一个是轮询遍历整个等待队列的每个节点并逐个通过回调函数来实现读取任务的唤醒