一、背景
上一篇介绍了Linux五种网络I/O,包括阻塞、非阻塞、同步异步相关的东西,还讲了JAVA 的BIO与NIO。本文介绍NIO的selector的三种实现方式。
上篇回顾:
Netty系列--网络I/O(同步、异步、阻塞、非阻塞)详解--上篇
二、select、poll、epoll介绍
从上一篇我们了解到,在NIO中是一个核心组件是选择器,就是下图的Selector,接下来就是详细聊聊Selector涉及到的知识。
本质上来说,Selector实现的就是I/O多路复用,所以我们这一块分两个部分,一是Linux内核实现I/O多路复用的几种方式,二是Reactor设计模式。先讲讲I/O多路复用。
在讲select、poll、epoll之前,要先了解Linux内核2.6+,文件描述符fd 和wakeup callback机制。
文件描述符fd
文件描述符是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。
wakeup callback机制
内核通过socket睡眠队列来管理所有等待socket的某个事件的进程(Process),同时通过wakeup机制来异步唤醒整个睡眠队列上等待事件的Process,通知Process相关事件发生。
简单说就是,每个socket维护了一个队列,比如socket可读的时候,内核就会唤醒队列里的各个Process,并且执行每个Process的callback函数。
涉及两大逻辑,下方用伪代码展示
睡眠逻辑伪代码
define sleep_list;
define wait_entry;
wait_entry.task= current_task;
wait_entry.callback = func1;
# 判断监控的socket是否有关心的事件发生
if (something_not_ready); then
# 插入阻塞队列
add_entry_to_list(wait_entry, sleep_list);
go on:
# 用schedule函数进行Process的状态转换,shcedule函数是Linux的调度Process的函数,这里指的是Process进入sleep直到超时或者事件发生
schedule();
# 这里指的是Process进入sleep直到超时或者事件发生
if (something_not_ready); then
goto go_on;
endif
# 关心的事件发生后,将当前Process的wait_entry节点从socket的sleep_list中删除
del_entry_from_list(wait_entry, sleep_list);
endif
...
唤醒逻辑伪代码
# socket有事件发生
something_ready;
# 顺序遍历其睡眠队列sleep_list
for_each(sleep_list) as wait_entry; do
# 依次调用每个wait_entry节点(对应各个Process)的callback函数
wait_entry.callback(...);
# wait_entry节点是排他的停止
if(wait_entry.exclusion); then
break;
endif
done
这里有个很重要的流程就是callback,它能做的事真的不止select/poll/epoll,Linux的AIO也是它来做的,注册了callback,你几乎可以让一个阻塞路径在被唤醒的时候做任何事情。一般而言,一个callback里面都是以下的逻辑:
common_callback_func(...)
{
# 自定义逻辑
do_something_private;
# 公共逻辑
wakeup_common;
}
# 将该wait_entry的task加入到CPU的就绪task队列,然后让CPU去调度它
在大多数情况下,要高效处理网络数据,一个Process一般会批量处理多个socket,哪个来了数据就去读那个,这就意味着要公平对待所有这些socket,你不可能阻塞在任何socket的“数据读”上,也就是说你不能在阻塞模式下针对任何socket调用recv/recvfrom,这就是多路复用socket的实质性需求。
现在问题来了,假设有一万个socket被一个Process处理,怎么完成多路复用逻辑呢?
SELECT
结合上面wakeup callback机制分析:
因为我们不知道什么时候,哪个socket会有“读事件”发生。所以Process需要同时插入到我们管理的这好多个socket的sleep_list上等待任意一个socket可读事件发生而被唤醒。
当Process被唤醒的时候,其callback里面应该有个逻辑去检查具体哪些socket可读了。
然后把这些事件反馈给用户程序,用户程序就知道,该处理这些socket了,可以从这些socket里读取数据。
select的设计就非常简单,为每一个socket引入一个poll逻辑,对于“数据可读”的判断如下:
define POLL_IN;
poll() {
# ...
# 当receive queue不为空的时候,我们就给这个socket的sk_event添加一个POLL_IN事件
when (receive_queue is not empty) {
# POLL_IN 表示当前这个socket可读
sk_event = POLL_IN;
}
# ...
}
Process遍历到这个socket,发现其sk_event包含POLL_IN的时候,就可以对这个socket进行读取数据操作。
当用户Process调用select的时候,select会将需要监控的read_fds集合拷贝到内核空间(因为内核才能通知说某个socket可读),然后遍历自己监控的socket,挨个调用socket的poll逻辑以便检查该socket是否有可读事件。
for_each_socket as sk; do
event.evt = sk.poll(...);
event.sk = sk;
put_event_to_user;
done;
简单总结一下select有两个问题:
1、被监控的read_fds集合需要从用户空间拷贝到内核空间。为了减少数据拷贝带来的性能损坏,内核对被监控的dfs集合大小做了限制,默认为1024,并且这个是通过宏控制的,大小不可改变,只能通过重新编译内核等复杂方式改变。
2、被监控的read_fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次并且调用socket的poll函数收集可读事件。
这里其实可以细分为3个:
1、被监控的fds突破1024限制
2、fds不用从用户空间拷贝到内核空间
3、当socket多的时候,我不希望遍历整个socket集合,而且能精准获取到可读的socket
POLL
三个问题,poll解决了第一个,突破了1024限制。poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的fds集合限制远大于select的1024。
EPOLL
epoll真正解决了上述问题,结合上面回调机制
既然一个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_wait_list)上,而不是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之前,我们希望我们的MyProcess可以管理四个socket。
四个socket都没有事件,这时候MyProcess进入single_epoll_wait_list并且sleep。
有一个socket(大红色)收到了数据,触发其wait_entry_sk,把这个socket加入到ready_list里。
MyProcess被唤醒(从single_epoll_wait_list出来了表示被唤醒),来处理ready_list中的所有socket:遍历epoll的ready_list,挨个调用每个socket的poll逻辑收集发生的事件,对于监控可读事件而已,ready_list上的每个socket都是有数据可读的,这里的遍历必要的。
最后我们再聊聊EPOLL的两种模式:EL(边缘触发)、TL(水平触发)
EPOLL的唤醒逻辑:
协议数据包到达网卡并被排入socket的接收队列。
睡眠在socket的睡眠队列wait_entry被唤醒,wait_entry_sk的回调函数epoll_callback_sk被执行。
epoll_callback_sk将当前socket插入epoll的ready_list中。
唤醒睡眠在epoll的单独睡眠队列single_epoll_wait_list的wait_entry,wait_entry_proc被唤醒执行回调函数epoll_callback_proc。
遍历epoll的ready_list,挨个调用每个socket的poll逻辑收集发生的事件。
将每个socket收集到的事件,通过epoll_wait传入的events数组回传并唤醒相应的Process。
大的逻辑其实没有变化,我们之前没有提过wait_entry_proc和epoll_callback_proc,这里简单说一下:
wait_entry_proc:睡眠实体,将当前Process关联给wait_entry_proc,并设置回调函数为epoll_callback_proc。我们之前说睡眠在single_epoll_wait_list中的是Process,其实是包了一层的wait_entry_proc。
epoll_callback_proc:回调函数,主要逻辑是遍历epoll的ready_list,挨个调用每个socket的poll逻辑收集发生的事件,然后将每个socket收集到的事件,通过epoll_wait传入的events数组回传并唤醒相应的Process。
那么两种触发模式的本质,就在唤醒逻辑里我们说的第5步。其实真实的情况应该是这样的:
ET:遍历epoll的ready_list,将socket从ready_list中移除,然后调用该socket的poll逻辑收集发生的事件。
LT:遍历epoll的ready_list,将socket从ready_list中移除,然后调用该socket的poll逻辑收集发生的事件。
如果该socket的poll函数返回了关心的事件(对于可读事件来说,就是POLL_IN事件),那么该socket被重新加入到epoll的ready_list中!
对于可读事件而言,在ET模式下,如果某个socket有新的数据到达,那么该socket就会被排入epoll的ready_list,从而epoll_wait就一定能收到可读事件的通知(调用socket的poll逻辑一定能收集到可读事件)。于是,我们通常理解的缓冲区状态变化时触发的理解是不准确的,准确的理解应该是是否有新的数据达到缓冲区。
而在LT模式下,某个socket被探测到有数据可读,那么该socket会被重新加入到ready_list,那么在该socket的数据被全部取走前,下次调用epoll_wait就一定能够收到该socket的可读事件,从而epoll_wait就能返回。
三、最后
这里我们可以说原来epoll才是真正的高性能,但是epoll的性能一定比select/poll高吗?
答案是否定的,epoll引入了新的数据结构,带来了复杂性,如果并发低,比如低于1024,并且每个socket都处于一直活跃的状态,那么性能反而不如select/poll,所以要结合具体场景来分析。
最后看一张性能对比图:
写到这里感觉剩下内容还是较多,NIO一篇写不完,决定再写个下篇,内容Reactor与Proactor模式,以及Netty里面如何使用的。
参考:
https://blog.csdn.net/dog250
http://b.shiwuliang.com/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3SELECT%E3%80%81POLL%E5%92%8CEPOLL%20.html
http://www.jasongj.com/java/nio_reactor/
另外文中有错误的地方,请帮忙指正,多谢!
系列文章:
Netty系列--网络I/O(同步、异步、阻塞、非阻塞)详解--上篇