IO复用模型本质
在讲述IO复用模型之前,需先了解Linux内核的wakeup&callback机制,我们先简单回顾下IO复用模型的思路,从上述的IO复用模型图看出,一个进程可以处理N个socket描述符的操作,等待对应的socket为可读的时候就会执行对应的read_process处理逻辑,也就是说这个时候我们站在read_process的角度去考虑,我只需要关注socket是不是可读状态,如果不可读那么我就休眠,如果可读你要通知我,这个时候我再调用recvfrom去读取数据就不会因内核没有准备数据处于等待,这个时候只需要等待内核将数据复制到用户空间的缓冲区中就可以了.那么对于read_process而言,要实现复用该如何设计才能达到上述的效果呢?
复用本质
- 摘录电子通信工程中术语,“在一个通信频道中传递多个信号的技术”, 可简单理解: 为了提升设备使用效率,尽可能使用最少的设备资源传递更多信号的技术
- 回到上述的IO复用模型,也就是说这里复用是实现一个进程处理任务能够接收N个socket并对这N个socket进行操作的技术
复用设计原理
在上述的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)]
- 用户进程向内核发起select函数的调用,并携带socket描述符集合从用户空间复制到内核空间,由内核对socket集合进行可读状态的监控.
- 其次当前内核没有数据可达的时候,将注册的socket集合分别以entry节点的方式添加到链表结构的等待队列中等待数据报可达.
- 这个时候网卡设备接收到网络发起的数据请求数据,内核接收到数据报,这个时候就通过轮询唤醒的方式(内核并不知道是哪个socket可读)逐个进行唤醒通知,直到当前socket描述符有可读状态的时候就退出轮询然后从等待队列移除对应的socket节点entry,并且这个时候内核将会更新fd集合中的描述符的状态,以便于用户进程知道是哪些socket是具备可读性从而方便后续进行数据读取操作
- 同时在轮询唤醒的过程中,如果有对应的socket描述符是可读的,那么此时会将read_process加入到cpu就绪队列中,让cpu能够调度执行read_process任务
- 最后是用户进程调用select函数返回成功,此时用户进程会在socket描述符结合中进行轮询遍历具备可读的socket,此时也就意味着数据此时在内核已经准备就绪,用户进程可以向内核发起数据读取操作,也就是执行上述的read_process任务操作
IO复用模型实现
基于上述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 技术
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; // 单位纳秒
}
- select/pselect技术等待逻辑
// 基于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);
}
- select/pselect技术唤醒逻辑
// 在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);
}
- 基于上述的代码,现总结如下:
- select技术的实现也是基于Linux内核的等待与唤醒机制实现,对于等待与唤醒逻辑主要细节也在上文中讲述,这里不再阐述
- 其次可以通过源码知道,在Linux中基于POSIX协议定义的select技术最大可支持的描述符个数为1024个,虽然现代操作系统支持更多的描述符,但是对于select技术增加描述符的话,需要更改POSIX协议中描述符个数的定义,但是此时需要重新编译内核,对于互联网的高并发连接应用是远远不够的
- 另外一个是用户进程调用select的时候需要将一整个fd集合的大块内存从用户空间拷贝到内核中,期间用户空间与内核空间来回切换开销非常大,再加上调用select的频率本身非常频繁,这样导致高频率调用且大内存数据的拷贝,严重影响性能
- 最后唤醒逻辑的处理,select技术在等待过程如果监控到至少有一个socket事件是可读的时候将会唤醒整个等待队列,告知当前等待队列中有存在就绪事件的socket,但是具体是哪个socket不知道,必须通过轮询的方式逐个遍历进行回调通知,也就是唤醒逻辑轮询节点包含了就绪和等待通知的socket事件,如果每次只有一个socket事件可读,那么每次轮询遍历的事件复杂度是O(n),影响到性能
poll 技术
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存在的性能并没有解决,即一个是存在大内存数据拷贝的问题,一个是轮询遍历整个等待队列的每个节点并逐个通过回调函数来实现读取任务的唤醒