在传统的阻塞I/O模型中,一个Socket(套接字)通常只能处理一个TCP连接,即一对一的关系。每个TCP连接都需要分配一个独立的Socket来处理。
然而,使用多路复用技术,可以在单个线程中同时监视多个Socket的状态,以确定哪些Socket有可读或可写事件。通过在单个线程内等待和处理多个连接的事件,高效地处理大量的并发连接,减少资源消耗。
在linux上多路复用技术有select、poll、epoll。其中epoll的性能表现是最优异的,能支持的并发量也最大。linux中和epoll相关的函数有如下三个:
用户进程调用epoll_create时,内核会创建一个eventpoll的内核对象,并把它加入到当前进程已打开的文件列表中。eventpoll内核对象的定义如下:,
struct eventpoll {
//等待队列
wait_queue_head_t wq;
//就绪的描述符链表
struct list_head rdllist;
//每个eventpoll对象中都有一颗红黑树
struct rb_root rbr;
}
这一步是由用户进程调用epoll_ctl完成的。假设现在和客户端的多个连接的socket都创建好了,也创建好了eventpoll对象。epoll_ctl在注册每一个socket的时候,内核都会做如下三件事情:
struct epitem {
......
struct epoll_filefd ffd; //socket句柄
struct eventpoll *ep; //所属的eventpoll
}
通过epoll_ctl添加两个socke以后,这些内核数据结构最终在进程中的关系如下:
epoll_ctl先完成epitem对象的初始化后,接着就会去设置socket的等待队列,把ep_poll_callback设置为socket数据就绪时的回调函数,注册到socket等待队列:
// include/linux/wait.h
static inline void init_waitqueue_func_entry(wait_queue_t *q, wait_queue_func_t func){
q->flags = 0;
q->private = NULL;
//将ep_poll_callback注册到socket等待队列wait_queue_t
q->func = func;
}
前面介绍的阻塞式IO,private设置的是当前用户进程描述符current。而多路复用IO,所有的socket是交由epoll对象管理的,唤醒用户进程是由eventpoll对象完成的,所以这里的private就设置成了NULL。
这一步是由epoll_wait来做的。当epoll_wait被调用时,观察eventpoll->rdllist就绪链表里有没有数据,如果有数据,就返回;如果没有数据,把当前用户进程添加到eventpoll的等待队列,然后把自己阻塞掉。所以说,epoll也是会阻塞的,当实在没什么事可做的时候,占着CPU也没什么意义。需要注意的是,阻塞式IO中,是把当前进程添加到socket的等待队列。
// fs/eventpoll.c
SYSCALL_DEFINE4(epoll_wait, int, epfd, ...){
......
error = ep_poll(ep,events,maxevents,timeout);
}
static int ep_poll(struct eventpoll *ep, ...){
......
// 1、判断就绪链表上有没有数据
if(!ep_events_available(ep)){
// 2、把当前用户进程current添加到eventpoll等待队列wq
init_waitqueue_entry(&wait, current);
__add_wait_queue_exclusive(&ep->wq, &wait);
for(;;){
// 3、设置用户进程状态,并调用schedule_hrtimeout_range让出CPU
set_current_state(TASK_INTERRUPTIBLE);
......
}
}
}
static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p){
q->flags = 0;
// 当前用户进程
q->private = p;
//设置epoll等待队列的回调函数
q->func = default_wake_function;
}
设置了epoll等待队列的回调函数default_wake_function。这里需要注意的是,前面介绍的阻塞式IO中,内核唤醒用户进程的函数也是default_wake_function。
static inline void __add_wait_queue_exclusive(wait_queue_head_t *q, wait_queue_t *wait){
//当前用户进程对应的等待添加到epoll的等待队列
__add_wait_queue(q, wait);
}
前面说过,当网卡数据达到时,内核ksoftirqd线程会将数据放到socket对象的接收队列,接着调用socket的数据就绪回调函数sk_data_ready,这里不在过多介绍。
epoll_ctl已经在socket等待队列里注册了回调函数ep_poll_callback,sk_data_ready会调用它。
// fs/eventpoll.c
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key){
// 1、找到socket对应的epitem
struct epitem *epi = ep_item_from_wait(wait);
// 2、找到epitem对应的eventpoll
struct eventpoll *ep = epi->ep;
// 3、将epitem添加到eventpoll的就绪链表
list_add_tail(&epi->rdllink, &ep->rdllist);
// 4、查看eventpoll的等待队列上是否有等待
if (waitqueue_active(&ep->wq))
wake_up_lock(&ep->wq);
}
wake_up_lock()最终会调用epoll_wait传入的epoll等待队列回调函数default_wake_function来唤醒用户进程。
// kernel/sched/core.c
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags, void *key){
return try_to_wake_up(curr->private, mode, wake_flags);
}
看一下epoll是如何把rdllist中就绪的事件返回给用户进程的:
// fs/eventpoll.c
static int ep_poll(...){
......
__remove_wait_queue(&ep->wq, &wait);
set_current_state(TASK_RUNNING);
//给用户进程返回就绪事件
ep_send_events(ep, events, maxevents);
}
如下是服务器端Java NIO的典型写法:
while(true){
selector.select();//这是一个阻塞方法,直到注册的channel有事件传过来,会放开阻塞。
Set<SelectionKey> selectionKeys = selector.selectedKeys();
......
}
eventpoll,顾名思义就是事件轮询。所谓的事件轮询就是通过不断调用系统调用epoll_wait来等待事件的发生。在事件轮询中,用户代码会反复进行系统调用epoll_wait函数,该函数将会一直阻塞等待,直到返回事件。
当有事件发生时,epoll_wait函数将返回一个正整数,表示收到的事件数量。用户代码可以通过遍历事件列表来逐个处理这些事件,例如读取或写入数据等。
实际上,只要活儿足够多,rdllist一直有数据,epoll_wait根本就不会让用户进程阻塞,用户进程会一直干活。这也是epoll性能高的关键所在。由一个内核eventpoll对象管理着所有的socket连接。IO多路复用,我的理解是复用了一个eventpoll对象。