您的位置:首页 > 其它

epoll 知识总结

2015-06-26 17:33 253 查看

poll/select/epoll 对比

http://www.cnblogs.com/apprentice89/p/3234677.html ---有待继续学习
http://blog.chinaunix.net/uid-20384806-id-1954307.html ---有待继续学习

select和poll即使只有一个描述符就绪,也要遍历整个集合。如果集合中活跃的描述符很少,遍历过程的开销就会变得很大,而如果集合中大部分的描述符都是活跃的,遍历过程的开销又可以忽略。

epoll的实现中每次只遍历活跃的描述符(如果是水平触发,也会遍历先前活跃的描述符),在活跃描述符较少的情况下就会很有优势,在代码的分析过程中可以看到epoll的实现过于复杂并且其实现过程中需要同步处理(锁),如果大部分描述符都是活跃的,epoll的效率可能不如select或poll。(参见epoll 和poll的性能测试 http://jacquesmattheij.com/Poll+vs+Epoll+once+again)

select能够处理的最大fd无法超出FDSETSIZE。

select会复写传入的fd_set 指针,而poll对每个fd返回一个掩码,不更改原来的掩码,从而可以对同一个集合多次调用poll,而无需调整。

select对每个文件描述符最多使用3个bit,而poll采用的pollfd需要使用64个bit,epoll采用的 epoll_event则需要96个bit

如果事件需要循环处理select, poll 每一次的处理都要将全部的数据复制到内核,而epoll的实现中,内核将持久维护加入的描述符,减少了内核和用户复制数据的开销。

所以,epoll的优势:

1 如果事件需要循环处理,epoll只要拷贝文件描述符到kernel一次,而select/poll要拷贝多次。

2 select会复写传入的fd_set 指针,而poll对每个fd返回一个掩码,不更改原来的掩码,从而可以对同一个集合多次调用poll,而无需调整。(select的fd_set既是输入又是输出,poll的输入输出分离)

3 select和poll即使只有一个描述符就绪,也要遍历整个集合,而epoll的文件描述符都是就绪、有意义的,这点在集合中活跃的描述符很少的时候,epoll的优势明显

4 epoll能突破select fd size 的最多限制。

转自 http://www.zhihu.com/question/20122137
2013-10-27更新:由于此文陆陆续续收到赞同,而且其中有些地方并不完全正确,特在本文最后予以订正

我不了解楼主的层次,我必须从很多基础的概念开始构建这个答案,并且可能引申到很多别的问题。

首先我们来定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。
不管是文件,还是套接字,还是管道,我们都可以把他们看作流。
之后我们来讨论I/O的操作,通过read,我们可以从流中读入数据;通过write,我们可以往流写入数据。现在假定一个情形,我们需要从流中读数据,但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),这时候该怎么办?

阻塞。阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。

非阻塞轮询。接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”

很明显一般人不会用第二种做法,不仅显很无脑,浪费话费不说,还占用了快递员大量的时间。
大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。

为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
假设有一个管道,进程A为管道的写入方,B为管道的读出方。

假设一开始内核缓冲区是空的,B作为读出方,被阻塞着。然后首先A往管道写入,这时候内核缓冲区由空的状态变到非空状态,内核就会产生一个事件告诉B该醒来了,这个事件姑且称之为“缓冲区非空”。

但是“缓冲区非空”事件通知B后,B却还没有读出数据;且内核许诺了不能把写入管道中的数据丢掉这个时候,A写入的数据会滞留在内核缓冲区中,如果内核也缓冲区满了,B仍未开始读数据,最终内核缓冲区会被填满,这个时候会产生一个I/O事件,告诉进程A,你该等等(阻塞)了,我们把这个事件定义为“缓冲区满”。

假设后来B终于开始读数据了,于是内核的缓冲区空了出来,这时候内核会告诉A,内核缓冲区有空位了,你可以从长眠中醒来了,继续写数据了,我们把这个事件叫做“缓冲区非满”

也许事件Y1已经通知了A,但是A也没有数据写入了,而B继续读出数据,知道内核缓冲区空了。这个时候内核就告诉B,你需要阻塞了!,我们把这个时间定为“缓冲区空”。

这四个情形涵盖了四个I/O事件,缓冲区满,缓冲区空,缓冲区非空,缓冲区非满(注都是说的内核缓冲区,且这四个术语都是我生造的,仅为解释其原理而造)。这四个I/O事件是进行阻塞同步的根本。(如果不能理解“同步”是什么概念,请学习操作系统的锁,信号量,条件变量等任务同步方面的相关知识)。

然后我们来说说阻塞I/O的缺点。但是阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。
于是再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):
while true {
for i in stream[]; {
if i has data
read until unavailable
}
}
我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。这里要补充一点,阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。

为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。代码长这样:
while true {
select(streams[])
for i in streams[] {
if i has data
read until unavailable
}
}
于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。
但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,每一次无差别轮询时间就越长。再次
说了这么多,终于能好好解释epoll了
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(k),k为产生I/O事件的流的个数,也有认为O(1)的[更新 1])
在讨论epoll的实现细节之前,先把epoll的相关操作列出[更新 2]:

epoll_create 创建一个epoll对象,一般epollfd = epoll_create()

epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件
比如
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//有缓冲区内有数据时epoll_wait返回
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//缓冲区可写入时epoll_wait返回

epoll_wait(epollfd,...)等待直到注册的事件发生

(注:当对一个非阻塞流的读写发生缓冲区满或缓冲区空,write/read会返回-1,并设置errno=EAGAIN。而epoll只关心缓冲区非满和缓冲区非空事件)。
一个epoll模式的代码大概的样子是:
while true {
active_stream[] = epoll_wait(epollfd)
for i in active_stream[] {
read or write till unavailable
}
}
限于篇幅,我只说这么多,以揭示原理性的东西,至于epoll的使用细节,请参考man和google,实现细节,请参阅linux kernel source。
======================================
[更新1]: 原文为O(1),但实际上O(k)更为准确
[更新2]: 原文所列第二点说法让人产生EPOLLIN/EPOLLOUT等同于“缓冲区非空”和“缓冲区非满”的事件,但并非如此,详细可以Google关于epoll的边缘触发和水平触发。

首先说一下 ET 和 LT。ET 和 LT 这两个术语实际上并不仅仅使用在 epoll 上(甚至被用于其他领域),它们的含义为:
LT:在特定状态下触发
ET:在状态变化时触发

网络 IO 管理中,告知逻辑层文件描述符可读和可写时,可以是在文件描述符处于可读和可写状态时(LT),也可以是文件描述符变为可读和可写状态时(ET)。

以可读为例,我们可以理解,在 ET 下,当出现数据可读,那么会通知逻辑层一次,如果逻辑层这次通知时没有完全读取完数据,那么不会再得到底层通知。相比下,LT 下,逻辑层会一直得到底层通知,直到文件描述符状态为不可读。

由于上面的情况,在 ET 下,文件描述符就不能是阻塞的,因为逻辑层需要在一次通知时不断读取数据,直到所有数据读取完成,读取操作是不能被阻塞的。(换句话说,如果socket/文件描述符是阻塞的,那么在没有数据的时候就会阻塞,而不是返回EAGAIN,那么程序就会一直阻塞到下一次数据了,然后又读完,然后又阻塞,变成了死循环。。)

select,poll,epoll LT(可以认为是快速的 poll)都是 LT 的,所以文件描述符可以为阻塞的(当然也可以为非阻塞的)

epoll ET 文件描述符必须是非阻塞的

LT 表示「有东西等着我就告诉你」,这时候你就算怕阻塞一次没读完,转一圈回我这儿来还能继续。你要是不断用 non-blocking 去读到啥都不剩呢也挺好,省我事了。
ET 表示「有东西新来了我吼一声你听见听不见听见了取不取是你的事情」,这样你要是一次没取完,下次来新货之前这些东西就烂在这里了,所以你必须重复去试着读——然后如果你的 socket 不是 non-blocking 的,恭喜……
http://www.cppblog.com/feixuwu/archive/2010/07/10/119995.html
最近有朋友在面试的时候被问了select 和epoll效率差的原因,和一般人一样,大部分都会回答select是轮询、epoll是触发式的,所以效率高。这个答案听上去很完美,大致也说出了二者的主要区别。
今天闲来无事,翻看了下内核代码,结合内核代码和大家分享下我的观点。

一、连接数

我本人也曾经在项目中用过select和epoll,对于select,感触最深的是linux下select最大数目限制(windows 下似乎没有限制),每个进程的select最多能处理FD_SETSIZE个FD(文件句柄),
如果要处理超过1024个句柄,只能采用多进程了。
常见的使用slect的多进程模型是这样的: 一个进程专门accept,成功后将fd通过unix socket传递给子进程处理,父进程可以根据子进程负载分派。曾经用过1个父进程+4个子进程 承载了超过4000个的负载。
这种模型在我们当时的业务运行的非常好。epoll在连接数方面没有限制,当然可能需要用户调用API重现设置进程的资源限制。

二、IO差别

1、select的实现

这段可以结合linux内核代码描述了,我使用的是2.6.28,其他2.6的代码应该差不多吧。
先看看select:
select系统调用的代码在fs/Select.c下,
asmlinkage long sys_select(int n, fd_set __user *inp, fd_set __user *outp,
fd_set __user *exp, struct timeval __user *tvp)
{
struct timespec end_time, *to = NULL;
struct timeval tv;
int ret;

if (tvp) {
if (copy_from_user(&tv, tvp, sizeof(tv)))
return -EFAULT;

to = &end_time;
if (poll_select_set_timeout(to,
tv.tv_sec + (tv.tv_usec / USEC_PER_SEC),
(tv.tv_usec % USEC_PER_SEC) * NSEC_PER_USEC))
return -EINVAL;
}

ret = core_sys_select(n, inp, outp, exp, to);
ret = poll_select_copy_remaining(&end_time, tvp, 1, ret);

return ret;
}
前面是从用户控件拷贝各个fd_set到内核空间,接下来的具体工作在core_sys_select中,
core_sys_select->do_select,真正的核心内容在do_select里:
int do_select(int n, fd_set_bits *fds, struct timespec *end_time)
{
ktime_t expire, *to = NULL;
struct poll_wqueues table;
poll_table *wait;
int retval, i, timed_out = 0;
unsigned long slack = 0;

rcu_read_lock();
retval = max_select_fd(n, fds);
rcu_read_unlock();

if (retval < 0)
return retval;
n = retval;

poll_initwait(&table);
wait = &table.pt;
if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
wait = NULL;
timed_out = 1;
}

if (end_time && !timed_out)
slack = estimate_accuracy(end_time);

retval = 0;
for (;;) {
unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;

set_current_state(TASK_INTERRUPTIBLE);

inp = fds->in; outp = fds->out; exp = fds->ex;
rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;

for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
unsigned long in, out, ex, all_bits, bit = 1, mask, j;
unsigned long res_in = 0, res_out = 0, res_ex = 0;
const struct file_operations *f_op = NULL;
struct file *file = NULL;

in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) {
i += __NFDBITS;
continue;
}

for (j = 0; j < __NFDBITS; ++j, ++i, bit <<= 1) {
int fput_needed;
if (i >= n)
break;
if (!(bit & all_bits))
continue;
file = fget_light(i, &fput_needed);
if (file) {
f_op = file->f_op;
mask = DEFAULT_POLLMASK;
if (f_op && f_op->poll)
mask = (*f_op->poll)(file, retval ? NULL : wait);
fput_light(file, fput_needed);
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit;
retval++;
}
if ((mask & POLLOUT_SET) && (out & bit)) {
res_out |= bit;
retval++;
}
if ((mask & POLLEX_SET) && (ex & bit)) {
res_ex |= bit;
retval++;
}
}
}
if (res_in)
*rinp = res_in;
if (res_out)
*routp = res_out;
if (res_ex)
*rexp = res_ex;
cond_resched();
}
wait = NULL;
if (retval || timed_out || signal_pending(current))
break;
if (table.error) {
retval = table.error;
break;
}

/*
* If this is the first loop and we have a timeout
* given, then we convert to ktime_t and set the to
* pointer to the expiry value.
*/
if (end_time && !to) {
expire = timespec_to_ktime(*end_time);
to = &expire;
}

if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
}
__set_current_state(TASK_RUNNING);

poll_freewait(&table);

return retval;
}
上面的代码很多,其实真正关键的代码是这一句:
mask = (*f_op->poll)(file, retval ? NULL : wait);
这个是调用文件系统的 poll函数,不同的文件系统poll函数自然不同,由于我们这里关注的是tcp连接,而socketfs的注册在 net/Socket.c里。
register_filesystem(&sock_fs_type);
socket文件系统的函数也是在net/Socket.c里:
static const struct file_operations socket_file_ops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = compat_sock_ioctl,
#endif
.mmap = sock_mmap,
.open = sock_no_open, /* special open code to disallow open via /proc */
.release = sock_close,
.fasync = sock_fasync,
.sendpage = sock_sendpage,
.splice_write = generic_splice_sendpage,
.splice_read = sock_splice_read,
};
从sock_poll跟随下去,
最后可以到 net/ipv4/tcp.c的
unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
这个是最终的查询函数,
也就是说select 的核心功能是调用tcp文件系统的poll函数,不停的查询,如果没有想要的数据,主动执行一次调度(防止一直占用cpu),直到有一个连接有想要的消息为止。
从这里可以看出select的执行方式基本就是不同的调用poll,直到有需要的消息为止,如果select 处理的socket很多,这其实对整个机器的性能也是一个消耗。

2、epoll的实现

epoll的实现代码在 fs/EventPoll.c下,
由于epoll涉及到几个系统调用,这里不逐个分析了,仅仅分析几个关键点,
第一个关键点在
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
这是在我们调用sys_epoll_ctl 添加一个被管理socket的时候调用的函数,关键的几行如下:
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);

/*
* Attach the item to the poll hooks and get current event bits.
* We can safely use the file* here because its usage count has
* been increased by the caller of this function. Note that after
* this operation completes, the poll callback can start hitting
* the new item.
*/
revents = tfile->f_op->poll(tfile, &epq.pt);
这里也是调用文件系统的poll函数,不过这次初始化了一个结构,这个结构会带有一个poll函数的callback函数:ep_ptable_queue_proc,
在调用poll函数的时候,会执行这个callback,这个callback的功能就是将当前进程添加到 socket的等待进程上。
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
poll_table *pt)
{
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;

if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->whead = whead;
pwq->base = epi;
add_wait_queue(whead, &pwq->wait);
list_add_tail(&pwq->llink, &epi->pwqlist);
epi->nwait++;
} else {
/* We have to signal that an error occurred */
epi->nwait = -1;
}
}
注意到参数 whead 实际上是 sk->sleep,其实就是将当前进程添加到sk的等待队列里,当该socket收到数据或者其他事件触发时,会调用
sock_def_readable 或者sock_def_write_space 通知函数来唤醒等待进程,这2个函数都是在socket创建的时候填充在sk结构里的。
从前面的分析来看,epoll确实是比select聪明的多、轻松的多,不用再苦哈哈的去轮询了。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: