首页 > 代码库 > Linux内核中网络数据包的接收-第二部分 select/poll/epoll

Linux内核中网络数据包的接收-第二部分 select/poll/epoll

和前面文章的第一部分一样,这些文字是为了帮别人或者自己理清思路的。而不是所谓的源代码分析。想分析源代码的,还是直接debug源代码最好,看不论什么文档以及书都是下策。

因此这类帮人理清思路的文章尽可能的记成流水的方式,尽可能的简单明了。

Linux 2.6+内核的wakeup callback机制

Linux内核通过睡眠队列来组织全部等待某个事件的task,而wakeup机制则能够异步唤醒整个睡眠队列上的task,每个睡眠队列上的节点都拥有一个callback,wakeup逻辑在唤醒睡眠队列时,会遍历该队列链表上的每个节点,调用每个节点的callback,假设遍历过程中遇到某个节点是排他节点,则终止遍历。不再继续遍历后面的节点。整体上的逻辑能够用以下的伪代码表示:

睡眠等待

define sleep_list;
define wait_entry;
wait_entry.task= current_task;
wait_entry.callback = func1;
if (something_not_ready); then
    # 进入堵塞路径
    add_entry_to_list(wait_entry, sleep_list);
go on:  
    schedule();
    if (something_not_ready); then
        goto go_on;
    endif
    del_entry_from_list(wait_entry, sleep_list);
endif
...

唤醒机制

something_ready;
for_each(sleep_list) as wait_entry; do
    wait_entry.callback(...);
    if(wait_entry.exclusion); then
        break;
    endif
done

我们仅仅须要狠狠地关注这个callback机制,它能做的事真的不止select/poll/epoll,Linux的AIO也是它来做的,注冊了callback。你差点儿能够让一个堵塞路径在被唤醒的时候做不论什么事情。

一般而言,一个callback里面都是以下的逻辑:

common_callback_func(...)
{
    do_something_private;
    wakeup_common;
}

当中。do_something_private是wait_entry自己的自己定义逻辑,而wakeup_common则是公共逻辑。旨在将该wait_entry的task增加到CPU的就绪task队列,然后让CPU去调度它。


       如今留个思考,假设实现select/poll,应该在wait_entry的callback上做什么文章呢?
       .....

select/poll的逻辑

要知道,在大多数情况下。要高效处理网络数据,一个task通常会批量处理多个socket,哪个来了数据就去读那个,这就意味着要公平对待全部这些socket,你不可能堵塞在不论什么socket的“数据读”上,也就是说你不能在堵塞模式下针对不论什么socket调用recv/recvfrom,这就是多路复用socket的实质性需求。


       假设有N个socket被同一个task处理。怎么完毕多路复用逻辑呢?非常显然。我们要等待“数据可读”这个事件,而不是去等待“实际的数据”!。我们要堵塞在事件上,该事件就是“N个socket中有一个或多个socket上有数据可读”,也就是说,仅仅要这个堵塞解除,就意味着一定有数据可读,意味着接下来调用recv/recvform一定不会堵塞!还有一方面。这个task要同一时候排入全部这些socket的sleep_list上,期待随意一个socket仅仅要有数据可读。都能够唤醒该task。
       那么,select/poll这类多路复用模型的设计就显而易见了。


       select/poll的设计非常easy。为每个socket引入一个poll例程,该历程对于“数据可读”的推断例如以下:

poll()
{
    ...
    if (接收队列不为空) {
        ev |= POLL_IN;
    }
    ...
}

当task调用select/poll的时候。假设没有数据可读。task会堵塞。此时它已经排入了全部N个socket的sleep_list,仅仅要有一个socket来了数据,这个task就会被唤醒,接下来的事情就是
for_each_N_socket as sk; do
    event.evt = sk.poll(...);
    event.sk = sk;
    put_event_to_user;
done;

可见。仅仅要有一个socket有数据可读,整个N个socket就会被遍历一遍调用一遍poll函数。看看有没有数据可读,其实,当堵塞在select/poll的task被唤醒的时候,它根本不知道详细socket有数据可读。它仅仅知道这些socket中至少有一个socket有数据可读。因此它须要遍历一遍。以示求证。遍历完毕后。用户态task能够依据返回的结果集来对有事件发生的socket进行读操作。
       可见。select/poll非常原始,假设有100000个socket(夸张吗?),有一个socket可读,那么系统不得不遍历一遍...因此select仅仅限制了最多能够复用1024个socket,而且在Linux上这是宏控制的。select/poll仅仅是朴素地实现了socket的多路复用,根本不适合大容量网络server的处理场景。其瓶颈在于,不能随着socket的增多而战时扩展性。

epoll对wait_entry callback的利用

既然一个wait_entry的callback能够做随意事,那么是否能让其做的比select/poll场景下的wakeup_common很多其它呢?
       为此,epoll准备了一个链表。叫做ready_list,全部处于ready_list中的socket,都是有事件的,对于数据读而言。都是确实有数据可读的。

epoll的wait_entry的callback要做的就是,将自己自行增加到这个ready_list中去。等待epoll_wait返回的时候。仅仅须要遍历ready_list就可以。epoll_wait睡眠在一个单独的队列(single_epoll_waitlist)上,而不是socket的睡眠队列上。
       和select/poll不同的是,使用epoll的task不须要同一时候排入全部多路复用socket的睡眠队列,这些socket都拥有自己的队列,task仅仅须要睡眠在自己的单独队列中等待事件就可以,每个socket的wait_entry的callback逻辑为:

epoll_wakecallback(...)
{
    add_this_socket_to_ready_list;
    wakeup_single_epoll_waitlist;
}
为此。epoll须要一个额外的调用,那就是epoll_ctrl ADD。将一个socket增加到epoll table中,它主要提供一个wakeup callback,将这个socket指定给一个epoll entry,同一时候会初始化该wait_entry的callback为epoll_wakecallback。整个epoll_wait以及协议栈的wakeup逻辑例如以下所看到的:
协议栈唤醒socket的睡眠队列
1.数据包排入了socket的接收队列;。
2.唤醒socket的睡眠队列,即调用各个wait_entry的callback;
3.callback将自己这个socket增加ready_list;
4.唤醒epoll_wait睡眠在的单独队列。


自此。epoll_wait继续前行。遍历调用ready_list里面每个socket的poll历程,搜集事件。这个过程是例行的,由于这是不可缺少的,ready_list里面每个socket都有数据可读,做不了无用功,这是和select/poll的本质差别(select/poll中,即便没有数据可读。也要全部遍历一遍)。
       总结一下,epoll逻辑要做以下的例程:

epoll add逻辑

define wait_entry
wait_entry.socket = this_socket;
wait_entry.callback = epoll_wakecallback;
add_entry_to_list(wait_entry, this_socket.sleep_list);


epoll wait逻辑

define single_wait_list
define single_wait_entry
single_wait_entry.callback = wakeup_common;
single_wait_entry.task = current_task;
if (ready_list_is_empty); then
    # 进入堵塞路径
    add_entry_to_list(single_wait_entry, single_wait_list);
go on:  
    schedule();
    if (sready_list_is_empty); then
        goto go_on;
    endif
    del_entry_from_list(single_wait_entry, single_wait_list);
endif
for_each_ready_list as sk; do
    event.evt = sk.poll(...);
    event.sk = sk;
    put_event_to_user;
done;

epoll唤醒的逻辑

add_this_socket_to_ready_list;
wakeup_single_wait_list;

综合以上。能够给出以下的关于epoll的流程图。能够对照本文第一部分的流程图做比較


技术分享


能够看出。epoll和select/poll的本质差别就是,在发生事件的时候,每个epoll item(也就是socket)都拥有自己单独的一个wakeup callback,而对于select/poll而言。仅仅有一个!这就意味着epoll中,一个socket发生事件,能够调用其独立的callback来处理它自身。从宏观上看,epoll的高效在于分离出了两类睡眠等待。一个是epoll本身的睡眠等待。它等待的是“随意一个socket发生事件”,即epoll_wait调用返回的条件,它并不适合直接睡眠在socket的睡眠队列上,假设真要这样,究竟睡谁呢?毕竟那么多socket...因此它仅仅睡自己。一个socket的睡眠队列一定要仅仅和它自己相关。因此还有一类睡眠等待是每个socket自身的,它睡眠在自己的队列上就可以。


epoll的ET和LT

是时候提到ET和LT了,最大的争议在于哪个性能高。而不是究竟怎么用。各种文档上都说ET高效,但其实,根本不是这样,对于实际而言,LT高效的同一时候。更安全。

两者究竟什么差别呢?

概念上的差别

ET:仅仅有状态发生变化的时候,才会通知。比方数据缓冲去从无到有的时候(不可读-可读),假设缓冲区里面有数据,便不会一直通知。
LT:仅仅要缓冲区里面有数据。就会一直通知。
查了非常多资料,得到的答案无非就是相似上述的。然而假设看Linux的实现,反而让人对ET更加迷惑。什么叫状态发生变化呢?比方数据接收缓冲区里面一次性来了10个数据包,对照上述流程图。非常显然会调用10次的wakeup操作,是不是意味着这个socket要被增加ready_list 10次呢?肯定不是这种,第二个数据包到来调用wakeup callback时,发现该socket已经在ready_list了。肯定不会再加了,此时epoll_wait返回,用户读取了1个数据包之后。假设程序有bug。便不再读取了。此时缓冲区里面还有9个数据包。问题来了。此时假设协议栈再排入一个包,究竟是通知还是不通知呢??依照概念理解,不会通知了,由于这不是“状态的变化”,可是其实在Linux上你试一下的话,发现是会通知的,由于仅仅要有包排入socket队列。就会触发wakeup callback,就会将socket放入ready_list中,对于ET而言,在epoll_wait返回前,socket就已经从ready_list中摘除了。因此,假设在ET模式下,你发现程序堵塞在epoll_wait了,并不能下结论说一定是数据包没有收完一个原因导致的。也可能是数据包确实没有收完,但假设此时来一个新的数据包。epoll_wait还是会返回的。尽管这并没有带来缓冲去状态的边沿变化。


       因此。对于缓冲区状态的变化。不能简单理解为有和无这么简单,而是数据包的到来和不到来。
       ET和LT是中断的概念,假设你把数据包的到来。即插入到socket接收队列这件事理解成一个中断事件,所谓的边沿触发不就是这个概念吗?

实现上的差别

在代码实现的逻辑上,ET和LT实现的差别在于LT一旦有事件则会一直加进ready_list。直到下一次的poll将其移出,然后在探測到感兴趣事件后再将其加进ready_list。由poll例程来推断是否有事件,而不是全然依赖wakeup callback,这是真正意义的poll。即不断轮询!也就是说。LT模式是全然轮询的,每次都会去poll一次。直到poll不到感兴趣的事件,才会休息。此时就仅仅有数据包的到来能够又一次依赖wakeup callback将其增加ready_list了。

在实现上。从以下的代码能够看出二者的差异。


epoll_wait
for_each_ready_list_item as entry; do
    remove_from_ready_list(entry);
    event = entry.poll(...);
    if (event) then
        put_user;
        if (LT) then
            # 以下一次poll的结论为结果
            add_entry_to_ready_list(entry);
        endif
    endif
done


性能上的差别

性能的差别主要体如今数据结构的组织以及算法上,对于epoll而言。主要就是链表操作和wakeup callback操作,对于ET而言,是wakeup callback将socket增加到ready_list,而对于LT而言。则除了wakeup callback能够将socket增加到ready_list之外,epoll_wait也能够将其为了下一次的poll增加到ready_list,wakeup callback中反而有更少工作量。但这并非性能差异的根本。性能差异的根本在于链表的遍历。假设有海量的socket採用LT模式,由于每次发生事件后都会再次将其增加ready_list。那么即便是该socket已经没有事件了。还是会用一次poll来确认。这额外的一次对于无事件socket没有意义的遍历在ET上是没有的。可是注意。遍历链表的性能消耗仅仅有在链表超长时才会体现,你认为千儿八百的socket就会体现LT的劣势吗?诚然。ET确实会降低数据可读的通知次数,但这其实并没有带来压倒性的优势。
       LT确实比ET更easy使用,也不easy死锁,还是建议用LT来正常编程。而不是用ET来偶尔炫技。



编程上的差别

epoll的ET在堵塞模式下,无法识别到队列空事件,从而仅仅是堵塞在单独一个socket的Recv而不是全部被监控socket的epoll_wait调用上。尽管不会影响代码的执行,仅仅要该socket有数据到来便好,可是会影响编程逻辑。这意味着解除了多路复用的武装,造成大量socket的饥饿。即便有数据了,也没法读。

当然,对于LT而言。也有相似的问题,可是LT会激进地反馈数据可读,因此事件不会轻易由于你的编程错误而被丢弃。
       对于LT而言,由于它会不断反馈,仅仅要有数据,你想什么时候读就能够什么时候读。它永远有“下一次poll”的机会主动探知是否有数据能够继续读。即便使用堵塞模式,仅仅要不要跨越堵塞边界造成其它socket饥饿。读多少数据均能够,可是对于ET而言,它在通知你的应用程序数据可读后。尽管新的数据到来还是会通知,可是你并不能控制新的数据一定会来以及什么时候来。所以你必须读全然部的数据才干离开,读全然部的时候意味着你必须能够探知数据为空,因此也就是说,你必须採用非堵塞模式,直到返回EAGIN错误。

给出几个ET模式下的tips

1.队列缓冲区的大小包含skb结构体本身的长度,230左右
2.ET模式下。wakeup callback中将socket增加ready_list的次数 >= 收到数据包的个数,因此
多个数据报足够快到达可能仅仅会触发一次epoll wakeup callback的成功回调,此时仅仅会将socket增加进ready_list一次
        =>造成队列满
                =>兴许的大报文加不进去
        =>瓶塞效应
        =>能够填补缓冲区剩余hole的小报文能够触发ET模式的epoll_wait返回。假设最小长度就是1,那么能够发送0长度的包引诱epoll_wait返回
            =>可是由于skb结构体的大小是固有大小,以上的引诱不能保证会成功。


3.epoll惊群,能够參考ngx的经验
4.epoll也可借鉴NAPI关中断的方案,直到Recv例程返回EAGIN或者错误发生,epoll的wakeup callback不再被调用。这意味着仅仅要缓冲区不为空。就算来了新的数据包也不会通知了。
a.仅仅要socket的epoll wakeup callback被调用,禁掉兴许的通知;
b.Recv例程在返回EAGIN或者错误的时候,開始兴许的通知。

Linux内核中网络数据包的接收-第二部分 select/poll/epoll