首先,我们要了解IO复用模型之前,先要了解在Linux内核中socket事件机制在内核底层是基于什么机制实现的,它是如何工作的,其次,当我们对socket事件机制有了一个基本认知之后,那么我们就需要思考到底什么是IO复用,基于socket事件机制的IO复用是怎么实现的,然后我们才来了解IO复用具体的实现技术,透过本质看select/poll/epoll的技术优化,逐渐去理解其中是为了解决什么问题而出现的,最后本文将围绕上述思维导图列出的知识点进行分享,还有就是文章幅度较长且需要思考,需要认真阅读!
在Linux内核中存在着等待队列的数据结构,该数据结构是基于双端链表实现,Linux内核通过将阻塞的进程任务添加到等待队列中,而进程任务被唤醒则是在队列轮询遍历检测是否处于就绪状态,如果是那么会在等待队列中删除等待节点并通过节点上的回调函数进行通知然后加入到cpu就绪队列中等待cpu调度执行.其具体流程主要包含以下两个处理逻辑,即休眠逻辑以及唤醒逻辑.
休眠逻辑
// 其中cmd = schedule(), 即一个调用schedule函数的指针#define ___wait_event(wq_head, condition, state, exclusive, ret, cmd)({__label__ __out;struct wait_queue_entry __wq_entry;long __ret = ret;/* explicit shadow */// 初始化过程(内部代码这里省略,直接说明)// 1. 设置独占标志到当前节点entry// 2. 将当前任务task指向节点的private// 3. 同时为当前entry节点传递一个唤醒的回调函数autoremove_wake_function,一旦唤醒将会自动被删除init_wait_entry(&__wq_entry, exclusive ? WQ_FLAG_EXCLUSIVE : 0);for (;;) {// 防止队列中没有entry产生不断的轮询,主要处理wait_queue与entry节点添加或者删除long __int = prepare_to_wait_event(&wq_head, &__wq_entry, state);// 事件轮询检查是否事件有被唤醒if (condition) break;if (___wait_is_interruptible(state) && __int) {__ret = __int;goto __out;}// 调用schedule()方法cmd;}// 事件被唤醒,将当前的entry从队列中移除finish_wait(&wq_head, &__wq_entry);__out:__ret;})
唤醒逻辑
static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,int nr_exclusive, int wake_flags, void *key,wait_queue_entry_t *bookmark){// 省略其他非核心代码...// 循环遍历整个等待队列list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {unsigned flags = curr->flags;int ret;if (flags & WQ_FLAG_BOOKMARK)continue;//执行回调函数ret = curr->func(curr, mode, wake_flags, key);if (ret < 0)break;// 检查当前节点是否为独占节点,即互斥锁,只能执行一个task,因此需要退出循环if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)break;if (bookmark && (++cnt > WAITQUEUE_WALK_BREAK_CNT) &&(&next->entry != &wq_head->head)) {bookmark->flags = WQ_FLAG_BOOKMARK;list_add_tail(&bookmark->entry, &next->entry);break;}}return nr_exclusive;}struct wait_queue_entry {unsigned intflags;void*private;// 这里的func就是上述休眠的时候在init_wait_entry传递autoremove_wake_functionwait_queue_func_tfunc;struct list_headentry;};// 唤醒函数int autoremove_wake_function(struct wait_queue_entry *wq_entry, unsigned mode, int sync, void *key){// 公用的唤醒函数逻辑// 内部执行try_to_wake_up, 也就是将wq_entry的private(当前进程)添加到cpu的执行队列中,让cpu能够调度task执行int ret = default_wake_function(wq_entry, mode, sync, key);// 其他为当前唤醒函数私有逻辑if (ret)list_del_init(&wq_entry->entry);return ret;}EXPORT_SYMBOL(autoremove_wake_function);
至此,linux内核的休眠与唤醒机制有了上述认知之后,接下来揭开IO复用模型设计的本质就相对会比较容易理解
在讲述IO复用模型之前,我们先简单回顾下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内核称为事件,其设计实现的逻辑图如下所示:
基于上述IO复用模型实现的认知,对于IO复用模型实现的技术select/poll/epoll也应具备上述两个核心的逻辑,即等待逻辑以及唤醒逻辑,对此用伪代码来还原select/poll/epoll的设计原理.注意这里文章不过多关注使用细节,只关注伪代码实现的逻辑思路.
select/poll/epoll的等待逻辑伪代码
for(;;){ res = 0; for(i=0; i
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);// 超时时间// 现在很多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); // 信号掩码指针
select技术等待逻辑
// 基于POSIX协议// posix_type.h#define __FD_SETSIZE1024 // 最大文件描述符为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 (;;) {//...// 监控可读的描述符socketinp = 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;// ...// 调度带有超时事件的scheduleif (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, to, slack))timed_out = 1;}// 移除队列中的等待节点poll_freewait(&table);}
select技术唤醒逻辑
// 在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();// 内存屏障,保证数据可见性pwq->triggered = 1;// 与linux内核中的唤醒机制一样,下面的方法是内核执行的,不过多关心,有兴趣可以看源码core.c下面定义// 就是polling_task也就是read_process添加到cpu就绪队列中,让cpu能够进行调度return default_wake_function(&dummy_wait, mode, sync, key);}
select技术小结
poll技术与select技术实现逻辑基本一致,重要区别在于其使用链表的方式存储描述符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技术小结
poll技术使用链表结构的方式来存储fdset的集合,相比select而言,链表不受限于FD_SIZE的个数限制,但是对于select存在的性能并没有解决,即一个是存在大内存数据拷贝的问题,一个是轮询遍历整个等待队列的每个节点并逐个通过回调函数来实现读取任务的唤醒
为了解决select&poll技术存在的两个性能问题,对于大内存数据拷贝问题,epoll通过epoll_create函数创建epoll空间(相当于一个容器管理),在内核中只存储一份数据来维护N个socket事件的变化,通过epoll_ctl函数来实现对socket事件的增删改操作,并且在内核底层通过利用虚拟内存的管理方式保证用户空间与内核空间对该内存是具备可见性,直接通过指针引用的方式进行操作,避免了大内存数据的拷贝导致的空间切换性能问题,对于轮询等待事件通过epoll_wait的方式来实现对socket事件的监听,将不断轮询等待高频事件wait与低频socket注册事件两个操作分离开,同时会对监听就绪的socket事件添加到就绪队列中,也就保证唤醒轮询的事件都是具备可读的,现对epoll技术分析如下:
epoll技术定义
// 创建保存epoll文件描述符的空间,该空间也称为“epoll例程”int epoll_create(int size); // 使用链表,现在已经弃用int epoll_create(int flag); // 使用红黑树的数据结构// epoll注册/修改/删除 fd的操作long epoll_ctl(int epfd, // 上述epoll空间的fd索引值 int op, // 操作识别,EPOLL_CTL_ADD | EPOLL_CTL_MOD | EPOLL_CTL_DEL int fd, // 注册的fd struct epoll_event *event); // epoll监听事件的变化struct epoll_event {__poll_t events;__u64 data;} EPOLL_PACKED;// epoll等待,与select/poll的逻辑一致epoll_wait(int epfd, // epoll空间 struct epoll_event *events, // epoll监听事件的变化 int maxevents, // epoll可以保存的最大事件数 int timeout); // 超时时间
epoll技术实现细节
// 摘取核心代码int do_epoll_ctl(int epfd, int op, int fd, struct epoll_event *epds, bool nonblock){// ...// 在红黑树中查找存储file对应的epitem,添加的时候会将epitem加到红黑树节点中epi = ep_find(ep, tf.file, fd);// 对于EPOLL_CTL_ADD模式,使用mtx加锁添加到wakeup队列中switch (op) {case EPOLL_CTL_ADD: // fd注册操作// epds->events |= EPOLLERR | EPOLLHUP;// error = ep_insert(ep, epds, tf.file, fd, full_check);break;case EPOLL_CTL_DEL: // // 删除操作:存储epitem容器移除epitem信息break;// 对注册的fd进行修改,但epoll的模式为EPOLLEXCLUSIVE是无法进行操作的case EPOLL_CTL_MOD: // 修改操作,内核监听到事件变化执行修改 //error = ep_modify(ep, epi, epds);break;}// 释放资源逻辑}
// 添加逻辑static int ep_insert(struct eventpoll *ep, const struct epoll_event *event, struct file *tfile, int fd, int full_check){ // ...struct epitem *epi;struct ep_pqueue epq;// 将fd包装在epitem的epollfile中epi->ep = ep;ep_set_ffd(&epi->ffd, tfile, fd);epi->event = *event;epi->nwait = 0;epi->next = EP_UNACTIVE_PTR;// 如果当前监听到事件变化,那么创建wakeup执行的sourceif (epi->event.events & EPOLLWAKEUP) {error = ep_create_wakeup_source(epi);if (error)goto error_create_wakeup_source;} else {RCU_INIT_POINTER(epi->ws, NULL);}// 添加回调函数epq.epi = epi;init_poll_funcptr(&epq.pt, ep_ptable_queue_proc); // 轮询检测epitem中的事件revents = ep_item_poll(epi, &epq.pt, 1); // 将epitem插入到红黑树中ep_rbtree_insert(ep, epi);// 如果有ready_list 则执行唤醒逻辑wakeup,这个是linux内核的唤醒机制,会将read_process添加到就绪队列中让cpu调度执行if (revents && !ep_is_linked(epi)) {list_add_tail(&epi->rdllink, &ep->rdllist);ep_pm_stay_awake(epi);/* Notify waiting tasks that events are available */if (waitqueue_active(&ep->wq))wake_up(&ep->wq);if (waitqueue_active(&ep->poll_wait))pwake++;} // ....// 存在预唤醒,则唤醒轮询等待节点if (pwake) ep_poll_safewake(&ep->poll_wait);return 0;// goto statement code ...}
上述代码中存在注册和唤醒逻辑,即对应处理逻辑都在这两个方法ep_ptable_queue_proc & ep_item_poll,现通过流程图如下所示:
在上述的epoll_ctl技术代码实现的细节中存在着两个逻辑,即socket描述符注册与唤醒逻辑,主要体现在两个核心方法上,即ep_ptable_queue_proc & ep_item_poll对此分析如下:
// epoll_wait -> do_epoll_wait -> ep_poll, 我们关注核心方法ep_pollstatic int ep_poll(struct eventpoll *ep, struct epoll_event __user *events, int maxevents, long timeout){// ...fetch_events: // 检测epoll是否有事件就绪// ...for (;;) {// ...// 检测当前ep空间是否有fd事件就绪eavail = ep_events_available(ep);if (eavail)// 是的跳出循环break;if (signal_pending(current)) {res = -EINTR;break;}// 执行休眠方法 schedule()if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS)) {timed_out = 1;break;}}// ...send_events: // ep有事件就绪,将event_poll转换到用户空间中//...ep_send_events(ep, events, maxevents);}
epoll技术解决的问题
epoll技术的边缘触发与水平触发
水平触发与边缘触发代码实现方式
你好,我是疾风先生,先后从事外企和互联网大厂的java和python工作, 记录并分享个人技术栈,欢迎关注我的公众号,致力于做一个有深度,有广度,有故事的工程师,欢迎成长的路上有你陪伴,关注后回复greek可添加私人微信,欢迎技术互动和交流,谢谢!