深入理解Linux网络(六):IO 复用 epoll 内部实现
前言
IO复用解决的真正问题是什么?
:每次⼀个进程专⻔为了等⼀个 socket 上的数据就得被从 CPU 上拿下来。然后再换上另⼀个进程。等到数据 ready 了,睡眠的进程⼜会被唤醒。总共两次进程上下⽂切换开销,根据之前的测试来看,每⼀次切换⼤约是 3-5 us(微秒)左右。
如果是⽹络 IO 密集型的应⽤的话,CPU 就不停地做进程切换这种⽆⽤功。
也就是频繁进程上下文切换的开销巨大。
一、accept 创建新 socket
当 accept 之后,进程会创建⼀个新的 socket 出来,专⻔⽤于和对应的客户端通信,然后把它放到当前进程的打开⽂件列表中。
其中⼀条连接的 socket 内核对象更为具体⼀点的结构图如下:
接下来我们来看⼀下接收连接时 socket 内核对象的创建源码。accept 的系统调⽤代码位于源⽂件 net/socket.c 下。
//file: net/socket.c
SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,
int __user *, upeer_addrlen, int, flags)
{
struct socket *sock, *newsock;
//根据 fd 查找到监听的 socket
sock = sockfd_lookup_light(fd, &err, &fput_needed);
//1.1 申请并初始化新的 socket
newsock = sock_alloc();
newsock->type = sock->type;
newsock->ops = sock->ops;
//1.2 申请新的 file 对象,并设置到新 socket 上
newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);
......
//1.3 接收连接
err = sock->ops->accept(sock, newsock, sock->file->f_flags);
//1.4 添加新⽂件到当前进程的打开⽂件列表
fd_install(newfd, newfile);
1、初始化 struct socket 对象
在上述的源码中,⾸先是调⽤ sock_alloc 申请⼀个 struct socket 对象出来。然后接着把 listen 状态的 socket 对象上的协议操作函数集合 ops 赋值给新的 socket。(对于所有的 AF_INET 协议族下的 socket 来说,它们的 ops ⽅法都是⼀样的,所以这⾥可以直接复制过来)
//file: net/ipv4/af_inet.c
const struct proto_ops inet_stream_ops = {
...
.accept = inet_accept,
.listen = inet_listen,
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
...
}
2、为新 socket 对象申请 file
在 accept ⽅法⾥会调⽤ sock_alloc_file 来申请内存并初始化。 然后将新 file 对象设置到 sock->file 上。
struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname)
{
struct file *file;
file = alloc_file(&path, FMODE_READ | FMODE_WRITE, &socket_file_ops);
......
sock->file = file;
}
sock_alloc_file ⼜会接着调⽤到 alloc_file。注意在 alloc_file ⽅法中,把 socket_file_ops 函数集合⼀并赋到了新 file->f_op ⾥了。
//file: fs/file_table.c
struct file *alloc_file(struct path *path, fmode_t mode, const struct file_operations *fop)
{
struct file *file;
file->f_op = fop;
......
}
socket_file_ops 的具体定义如下:
//file: net/socket.c
static const struct file_operations socket_file_ops = {
...
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
.poll = sock_poll,
.release = sock_close,
...
};
这⾥看到,在accept⾥创建的新 socket ⾥的 file->f_op->poll 函数指向的是 sock_poll。
但其实 file 对象内部也有⼀个 socket 指针,指向 socket 对象。
3、接收过程
在 socket 内核对象中除了 file 对象指针以外,有⼀个核⼼成员 sock。
//file: include/linux/net.h
struct socket {
struct file *file;
struct sock *sk;
}
struct sock 数据结构⾮常⼤,是 socket 的核⼼内核对象。发送队列、接收队列、等待队列等核⼼数据结构都位于此。其定义位置⽂件 include/net/sock.h。
在 accept 的源码中:
//file: net/socket.c
SYSCALL_DEFINE4(accept4, ...)
...
//1.3 接收连接
err = sock->ops->accept(sock, newsock, sock->file->f_flags);
}
sock->ops->accept 对应的⽅法是 inet_accept,它执⾏的时候会从握⼿队列⾥直接获取创建好的 sock。 sock 对象的完整创建过程涉及到三次握⼿。
struct sock 初始化过程中⽤到的⼀个函数:
void sock_init_data(struct socket *sock, struct sock *sk)
{
sk->sk_wq = NULL;
sk->sk_data_ready = sock_def_readable;
}
在这⾥把 sock 对象的 sk_data_ready 函数指针设置为 sock_def_readable,判断一个套接字是否可读。
添加新⽂件到当前进程的打开⽂件列表
当 file、socket、sock 等关键内核对象创建完毕以后,剩下要做的⼀件事情就是把它挂到当前进程的打开⽂件列表中就⾏了。
//file: fs/file.c
void fd_install(unsigned int fd, struct file *file)
{
__fd_install(current->files, fd, file);
}
void __fd_install(struct files_struct *files, unsigned int fd, struct file *file)
{
...
fdt = files_fdtable(files);
BUG_ON(fdt->fd[fd] != NULL);
rcu_assign_pointer(fdt->fd[fd], file);
}
二、epoll_create 实现
在⽤户进程调⽤ epoll_create 时,内核会创建⼀个 struct eventpoll 的内核对象。并同样把它关联到当前进程的已打开⽂件列表中。
struct eventpoll 对象,相关的成员如下:
epoll_create 的源代码相对⽐较简单:
// file:fs/eventpoll.c
struct eventpoll {
//sys_epoll_wait⽤到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这⾥
struct list_head rdllist;
//每个epoll对象中都有⼀颗红⿊树
struct rb_root rbr;
......
}
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
struct eventpoll *ep = NULL;
//创建⼀个 eventpoll 对象
error = ep_alloc(&ep);
}
- wq:等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的
⽤户进程。 - rbr: ⼀棵红⿊树。为了⽀持对海量连接的⾼效查找、插⼊和删除,eventpoll 内部使⽤
了⼀棵红⿊树。通过这棵树来管理⽤户进程下添加进来的所有 socket 连接。 - rdllist: 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist
链表⾥。这样应⽤进程只需要判断链表就能找出就绪进程,⽽不⽤去遍历整棵树。
初始化 eventpoll :
//file: fs/eventpoll.c
static int ep_alloc(struct eventpoll **pep)
{
struct eventpoll *ep;
//申请 epollevent 内存
ep = kzalloc(sizeof(*ep), GFP_KERNEL);
//初始化等待队列头
init_waitqueue_head(&ep->wq);
//初始化就绪列表
INIT_LIST_HEAD(&ep->rdllist);
//初始化红⿊树指针
ep->rbr = RB_ROOT;
......
}
三、epoll_ctl 添加 socket
理解这⼀步是理解整个 epoll 的关键!
我们只考虑使⽤ EPOLL_CTL_ADD 添加 socket,先忽略删除和更新。
在使⽤ epoll_ctl 注册每⼀个 socket 的时候,内核会做如下三件事情:
- 分配⼀个红⿊树节点对象 epitem
- 添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback
- 将 epitem 插⼊到 epoll 对象的红⿊树⾥
通过 epoll_ctl 添加两个 socket 以后,这些内核数据结构最终在进程中的关系图⼤致如下:
接着将 socket 添加进 epoll 对象里。
// file:fs/eventpoll.c
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd, struct epoll_event __user * event)
{
struct eventpoll *ep;
struct file *file, *tfile;
//根据 epfd 找到 eventpoll 内核对象
file = fget(epfd);
ep = file->private_data;
//根据 socket 句柄号, 找到其 file 内核对象
tfile = fget(fd);
switch (op) {
case EPOLL_CTL_ADD:
if (!epi) {
epds.events |= POLLERR | POLLHUP;
error = ep_insert(ep, &epds, tfile, fd);
} else
error = -EEXIST;
clear_tfile_check_list();
break;
}
在 epoll_ctl 中⾸先根据传⼊ fd 找到 eventpoll、socket相关的内核对象 。
对于 EPOLL_CTL_ADD 操作来说,会然后执⾏到 ep_insert 函数。
所有的注册都是在这个函数中完成的。
//file: fs/eventpoll.c
static int ep_insert(struct eventpoll *ep, struct epoll_event *event, struct file *tfile, int fd)
{
//3.1 分配并初始化 epitem
//分配⼀个epi对象
struct epitem *epi;
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
return -ENOMEM;
//对分配的epi进⾏初始化
//epi->ffd中存了句柄号和struct file对象地址
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
//3.2 设置 socket 等待队列
//定义并初始化 ep_pqueue 对象
struct ep_pqueue epq;
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
//调⽤ ep_ptable_queue_proc 注册回调函数
//实际注⼊的函数为 ep_poll_callback
revents = ep_item_poll(epi, &epq.pt);
......
//3.3 将epi插⼊到 eventpoll 对象中的红⿊树中
ep_rbtree_insert(ep, epi);
......
}
1、分配并初始化 epitem
对于每⼀个 socket,调⽤ epoll_ctl 的时候,都会为之分配⼀个 epitem。
//file: fs/eventpoll.c
struct epitem {
struct rb_node rbn; //红⿊树节点
struct epoll_filefd ffd; //socket⽂件描述符信息
struct eventpoll *ep; //所归属的 eventpoll 对象
struct list_head pwqlist; //等待队列
}
对 epitem 进⾏了⼀些初始化,⾸先在 epi->ep = ep 这⾏代码中将其 ep 指针指向 eventpoll 对象。
另外⽤要添加的 socket 的 file、fd 来填充 epitem->ffd。
其中使⽤到的 ep_set_ffd 函数如下。
static inline void ep_set_ffd(struct epoll_filefd *ffd, struct file *file, int fd)
{
ffd->file = file;
ffd->fd = fd;
}
2、设置 socket 等待队列
epitem 并初始化后,ep_insert 中第⼆件事情是设置 socket 对象上的等待任务队列,并把函数 fs/eventpoll.c ⽂件下的 ep_poll_callback 设置为数据就绪时候的回调函数。
先看 ep_item_poll:
static inline unsigned int ep_item_poll(struct epitem *epi, poll_table *pt)
{
pt->_key = epi->event.events;
return epi->ffd.file->f_op->poll(epi->ffd.file, pt) & epi->event.events;
}
返回处调用到了 socket 下的 file->f_op->poll,这个函数实际上是指向 sock_poll。
/* No kernel lock held - perfect */
static unsigned int sock_poll(struct file *file, poll_table *wait)
{
...
return sock->ops->poll(file, sock, wait);
}
而网络协议层中又把这个socket 中的 poll 操作指向了 TCP 的 tcp_poll。
//file: net/ipv4/tcp.c
unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
struct sock *sk = sock->sk;
sock_poll_wait(file, sk_sleep(sk), wait);
}
在 sock_poll_wait 的第⼆个参数传参前,先调⽤了 sk_sleep 函数。在这个函数⾥它获取了 sock 对象下的等待队列列表头 wait_queue_head_t,待会等待队列项就插⼊这⾥。
这⾥稍微注意下,是 socket 的等待队列,不是 epoll 对象的。来看 sk_sleep 源码:
//file: include/net/sock.h
static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
BUILD_BUG_ON(offsetof(struct socket_wq, wait) != 0);
return &rcu_dereference_raw(sk->sk_wq)->wait;
}
回到 sock_poll_wait:
static inline void sock_poll_wait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p)
{
poll_wait(filp, wait_address, p);
}
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
if (p && p->_qproc && wait_address)
p->_qproc(filp, wait_address, p);
}
qproc 函数指针,在前⾯ init_poll_funcptr 调⽤时被设置成了 ep_ptable_queue_proc 函数。
static int ep_insert(...)
{
...
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
...
}
//file: include/linux/poll.h
static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
{
pt->_qproc = qproc;
pt->_key = ~0UL; /* all events enabled */
}
在 ep_ptable_queue_proc 函数中,新建了⼀个等待队列项,并注册其回调函数为 ep_poll_callback 函数,然后再将这个等待项添加到 socket 的等待队列中。
//file: fs/eventpoll.c
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead, poll_table *pt)
{
struct eppoll_entry *pwq;
f (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
//初始化回调⽅法
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
//将ep_poll_callback放⼊socket的等待队列whead(注意不是epoll的等待队列)
add_wait_queue(whead, &pwq->wait);
}
由于需要在数据就绪的时候唤醒⽤户进程,所以等待对象项的 private (飞哥说这个变量名起的也是醉了) 会设置成当前⽤户进程描述符 current。 ⽽我们今天的 socket 是交给 epoll 来管理的,不需要在⼀个 socket 就绪的时候就唤醒进程,所以这⾥的 q->private 没有啥卵⽤就设置成了 NULL。
//file: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 注册到 wait_queue_t对象上
//有数据到达的时候调⽤ q->func
q->func = func;
}
如上,等待队列项中仅仅只设置了回调函数 q->func 为 ep_poll_callback。软中断将数据收到 socket 的接收队列后,会通过注册的这个 ep_poll_callback 函数来回调,进⽽通知到 epoll 对象。
3、插⼊红⿊树
分配的 epitem 对象会插⼊到红⿊树中,⼀个插⼊ socket 描述符的 epoll ⾥的红⿊树的示意图如下:
这⾥我们再聊聊为啥要⽤红⿊树,很多⼈说是因为效率⾼。其实我觉得这个解释不够全⾯,要说查找效率,树哪能⽐的上 HASHTABLE。我个⼈认为觉得更为合理的⼀个解释是为了让 epoll 在查找效率、插⼊效率、内存开销等等多个⽅⾯⽐较均衡,最后发现最适合这个需求的数据结构是红⿊树。
四、epoll_wait 等待接收
epoll_wait 被调⽤时,会查找 eventpoll->rdllist 链表⾥有无数据。有数据就返回,没有数据就创建⼀个等待队列项,将其添加到 eventpoll 的等待队列上,然后把⾃⼰阻塞掉就完事。
注意:epoll_ctl 添加 socket 时也创建了等待队列项。不同的是这⾥的等待队列项是挂在 epoll 对象上的,⽽前者是挂在 socket 对象上的。
//file: fs/eventpoll.c
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
int, maxevents, int, timeout)
{
...
error = ep_poll(ep, events, maxevents, timeout);
}
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
wait_queue_t wait;
......
fetch_events:
//4.1 判断就绪队列上有没有事件就绪
if (!ep_events_available(ep)) {
//4.2 定义等待事件并关联当前进程
init_waitqueue_entry(&wait, current);
//4.3 把新 waitqueue 添加到 epoll->wq 链表⾥
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
...
//4.4 让出CPU 主动进⼊睡眠状态
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
...
}
1、判断就绪队列上有没有事件就绪
⾸先调⽤ ep_events_available 来判断就绪链表中是否有可处理的事件。
//file: fs/eventpoll.c
static inline int ep_events_available(struct eventpoll *ep)
{
return !list_empty(&ep->rdllist) || ep->ovflist != EP_UNACTIVE_PTR;
}
2、定义等待事件并关联当前进程
假设确实没有就绪的连接,那接着会进⼊ init_waitqueue_entry 中定义等待任务,并把 current (当前进程)添加到 waitqueue 上。
注意:当没有 IO 事件的时候, epoll 也是会阻塞掉当前进程。这个是合理的,因为没有事情可做了占着 CPU 也没啥意义。 epoll 来说,epoll 本身是阻塞的,但⼀般会把 socket 设置成⾮阻塞。
//file: include/linux/wait.h
static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)
{
q->flags = 0;
q->private = p;
q->func = default_wake_function;
}
注意:这⾥的回调函数名称是 default_wake_function,数据到来时还会继续调用这个函数。
3、添加到等待队列
static inline void __add_wait_queue_exclusive(wait_queue_head_t *q,
wait_queue_t *wait)
{
wait->flags |= WQ_FLAG_EXCLUSIVE;
__add_wait_queue(q, wait);
}
在这⾥,把上⼀⼩节定义的等待事件添加到了 epoll 对象的等待队列中。
4、让出CPU 主动进⼊睡眠状态
通过 set_current_state 把当前进程设置为可打断。 调⽤ schedule_hrtimeout_range 让出 CPU,主动进⼊睡眠状态。
//file: kernel/hrtimer.c
int __sched schedule_hrtimeout_range(ktime_t *expires, unsigned long delta, const enum hrtimer_mode mode)
{
return schedule_hrtimeout_range_clock(
expires, delta, mode, CLOCK_MONOTONIC);
}
int __sched schedule_hrtimeout_range_clock(...)
{
schedule();
...
}
在 schedule 中选择下⼀个进程调度:
//file: kernel/sched/core.c
static void __sched __schedule(void)
{
next = pick_next_task(rq);
...
context_switch(rq, prev, next);
}
五、数据到来
内核为每⼀个 socket 上都添加了⼀个等待队列项。 在 epoll_wait 运⾏完的时候,⼜在 event_poll 对象上添加了等待队列元素。
- socket->sock->sk_data_ready 设置的就绪处理函数是 sock_def_readable
- 在 socket 的等待队列项中,其回调函数是 ep_poll_callback。另外其 private 没有⽤
了,指向的是空指针 null。 - 在 eventpoll 的等待队列项中,回调函数是 default_wake_function。其 private 指
向的是等待该事件的⽤户进程。
1、接收数据到任务队列
关于软中断是怎么处理⽹络帧,为了避免篇幅过于臃肿,这⾥不再介绍。感兴趣的可以看⽂章 《图解Linux⽹络包接收过程》。
这里从 tcp 协议栈的处理⼊⼝函数 tcp_v4_rcv 开始说起:
// file: net/ipv4/tcp_ipv4.c
int tcp_v4_rcv(struct sk_buff *skb)
{
......
th = tcp_hdr(skb); //获取tcp header
iph = ip_hdr(skb); //获取ip header
//根据数据包 header 中的 ip、端⼝信息查找到对应的socket
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
......
//socket 未被⽤户锁定
if (!sock_owned_by_user(sk)) {
{
if (!tcp_prequeue(sk, skb))
ret = tcp_v4_do_rcv(sk, skb);
}
......
在 tcp_v4_rcv 中⾸先根据收到的⽹络包的 header ⾥的 source 和 dest 信息来在本机上查询对应的 socket。找到以后,我们直接进⼊接收的主体函数 tcp_v4_do_rcv 来看。
//file: net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
if (sk->sk_state == TCP_ESTABLISHED) {
//执⾏连接状态下的数据处理
if (tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len)) {
rsk = sk;
goto reset;
}
return 0;
}
//其它⾮ ESTABLISH 状态的数据包处理
......
}
我们假设处理的是 ESTABLISH 状态下的包,这样就⼜进⼊ tcp_rcv_established 函数中进⾏处理。
//file: net/ipv4/tcp_input.c
int tcp_rcv_established(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
......
//接收数据到队列中
eaten = tcp_queue_rcv(sk, skb, tcp_header_len, &fragstolen);
//数据 ready,唤醒 socket 上阻塞掉的进程
sk->sk_data_ready(sk, 0);
在 tcp_rcv_established 中通过调⽤ tcp_queue_rcv 函数中完成了将接收数据放到 socket 的接收队列上。
//file: net/ipv4/tcp_input.c
static int __must_check tcp_queue_rcv(struct sock *sk, struct
sk_buff *skb, int hdrlen, bool *fragstolen)
{
//把接收到的数据放到 socket 的接收队列的尾部
if (!eaten) {
__skb_queue_tail(&sk->sk_receive_queue, skb);
skb_set_owner_r(skb, sk);
}
return eaten;
}
2、查找就绪回调函数
调⽤ tcp_queue_rcv 接收完成后,调用 sk_data_ready 唤醒在 socket上等待的⽤户进程。
实际上 accept 函数创建 socket 流程⾥的 sock_init_data 函数就已经把 sk_data_ready 设置成
sock_def_readable 函数了,它是默认的数据就绪处理函数。
当 socket 上数据就绪时候,内核将以 sock_def_readable 这个函数为⼊⼝,找到 epoll_ctl 添加 socket 时在其上设置的回调函数 ep_poll_callback。
//file: net/core/sock.c
static void sock_def_readable(struct sock *sk, int len)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
//这个名字起的不好,并不是有阻塞的进程,
//⽽是判断等待队列不为空
if (wq_has_sleeper(wq))
//执⾏等待队列项上的回调函数
wake_up_interruptible_sync_poll(&wq->wait, POLLIN | POLLPRI | POLLRDNORM | POLLRDBAND);
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
这⾥的函数名其实都有迷惑⼈的地⽅。
- wq_has_sleeper,对于简单的 recvfrom 系统调⽤来说,确实是判断是否有进程阻塞。但是对于 epoll 下的 socket 只是判断等待队列不为空,不⼀定有进程阻塞的。
- wake_up_interruptible_sync_poll,只是会进⼊到 socket 等待队列项上设置的回调函数,并不⼀定有唤醒进程的操作。
重点看 wake_up_interruptible_sync_poll,内核是怎么找到等待队列项⾥注册的回调函数的?
//file: include/linux/wait.h
#define wake_up_interruptible_sync_poll(x, m) \
__wake_up_sync_key((x), TASK_INTERRUPTIBLE, 1, (void *) (m))
//file: kernel/sched/core.c
void __wake_up_sync_key(wait_queue_head_t *q, unsigned int mode, int nr_exclusive, void *key)
{
...
__wake_up_common(q, mode, nr_exclusive, wake_flags, key);
}
接着进⼊ __wake_up_common:
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key) && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
在 __wake_up_common 中,选出等待队列⾥注册某个元素 curr, 回调其 curr->func。之前 ep_insert 调⽤的时候,把这个 func 设置成 ep_poll_callback 了。
3、执⾏ socket 就绪回调函数
实际上软中断触发后就会调用这个回调函数:ep_poll_callback。
//file: fs/eventpoll.c
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
//获取 wait 对应的 epitem
struct epitem *epi = ep_item_from_wait(wait);
//获取 epitem 对应的 eventpoll 结构体
struct eventpoll *ep = epi->ep;
//1. 将当前epitem 添加到 eventpoll 的就绪队列中
list_add_tail(&epi->rdllink, &ep->rdllist);
//2. 查看 eventpoll 的等待队列上是否有在等待
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
在 ep_poll_callback 根据等待任务队列项上的额外的 base 指针可以找到 epitem, 进⽽也可以找到 eventpoll对象。
⾸先它做的第⼀件事就是把⾃⼰的 epitem 添加到 epoll 的就绪队列中。
接着它⼜会查看 eventpoll 对象上的等待队列⾥是否有等待项(epoll_wait 执⾏的时候会设置)。
如果没执⾏软中断的事情就做完了。如果有等待项,那就查找到等待项⾥设置的回调函数。
调⽤ wake_up_locked() -> __wake_up_locked() -> __wake_up_common。
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key) && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
在 __wake_up_common⾥, 调⽤ curr->func。
这⾥的 func 是在 epoll_wait 是传⼊的 default_wake_function 函数。
4、执⾏ epoll 就绪通知
在default_wake_function 中找到等待队列项⾥的进程描述符,然后唤醒之。
//file: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);
}
}
等待队列项 curr->private 指针是在 epoll 对象上等待⽽被阻塞掉的进程。
将epoll_wait进程推⼊可运⾏队列,等待内核重新调度进程。然后epoll_wait对应的这个进程重新运⾏后,就从 schedule 恢复当进程醒来后,继续从 epoll_wait 时暂停的代码继续执⾏。把 rdlist 中就绪的事件返回给⽤户进程。
//file: fs/eventpoll.c
static int ep_poll(struct eventpoll *ep, struct epoll_event
__user *events, int maxevents, long timeout)
{
......
__remove_wait_queue(&ep->wq, &wait);
set_current_state(TASK_RUNNING);
}
check_events:
//返回就绪事件给⽤户进程
ep_send_events(ep, events, maxevents))
}
从⽤户⻆度来看,epoll_wait 只是多等了⼀会⼉⽽已,但执⾏流程还是顺序的。
总结
其中软中断回调的时候回调函数也整理⼀下:
sock_def_readable: sock 对象初始化时设置的
-> ep_poll_callback : epoll_ctl 时添加到 socket 上的
-> default_wake_function: epoll_wait 是设置到 epoll上的
总结下,epoll 相关的函数⾥内核运⾏环境分两部分:
- ⽤户进程内核态。进⾏调⽤ epoll_wait 等函数时会将进程陷⼊内核态来执⾏。这部分代码负责查看接收队列,以及负责把当前进程阻塞掉,让出 CPU。
- 硬软中断上下⽂。在这些组件中,将包从⽹卡接收过来进⾏处理,然后放到 socket 的接收队列。对于 epoll 来说,再找到 socket 关联的 epitem,并把它添加到 epoll 对象的就绪链表中,顺便检查⼀下 epoll 上是否有被阻塞的进程,如果有就唤醒。
然后,再次推荐一下飞哥的《深入理解Linux网络–修炼底层内功》。