从Netty到EPollSelectorImpl学习Java NIO

终于可以在写了几篇鸡汤文后,来篇技术文章了,:),题图是Trustin Lee,Mina/Netty都是他搞的,对Java程序员尤其是写通讯类的都产生了巨大影响,向他致敬!

在上周查一个内存OOM的问题之前,我一直觉得自己对Java NIO应该还是比较懂的,君不见N年前我曾经写过一篇《NFS-RPC框架优化过程(从37K到168K)》(尴尬的发现,上次导blog记录的时候竟然丢了一些文章,于是这文章link就不是自己的blog了),从那优化经历来说理论上对Netty的原理应该已经是比较清楚了才对,结果在查那个内存OOM的问题的时候,发现自己还是too young too navie,看到的现象是EPollSelectorImpl里的fdToKey有一大堆数据,就傻眼了,完全不知道这是为什么,当时就在公众号上发了个文本信息咨询大家,有不少同学给了我回复,另外滴滴打车的架构师欧阳康给了我一篇文章来说明EPollSelectorImpl这个部分的原理(强烈推荐,比我写的这篇会更深入到os层),本文就是综合了大家给我的点拨,来写写从Netty到EPollSelectorImpl的相关逻辑。

带着问题去看代码会比较的快和容易,我这次带着这几个问题:
1. EPollSelector里的fdToKey里的一大堆数据是怎么出现的;
2. Netty以及Java的EPoll部分代码是如何让N多连接的处理做到高效的,当然这主要是因为epoll,不过Java部分的相关实现也是重要的。

由于公众号贴代码不太方便,在这我就不贴大段的代码了,只摘取一些非常关键的代),代码部分我看的是Server部分,毕竟Server尤其是长连接类型的,通常会需要处理大量的连接,而且会主要是贴近我所关注的两个问题的相关代码。

Netty在初始化Server过程中主要做的事:
1. 启动用于处理连接事件的线程,线程数默认为1;
2. 创建一个EPollSelectorImpl对象;

在bind时主要做的事:
1. 开启端口监听;
2. 注册OP_ACCEPT事件;

处理连接的线程通过Selector来触发动作:

int selected = select(selector);

这个会对应到EPollSelectorImpl.doSelect,最关键的几行代码:

pollWrapper.poll(timeout);
int numKeysUpdated = updateSelectedKeys(); // 更新有事件变化的selectedKeys,selectedKeys是个Set结构

当numKeysUpdated>0时,就开始处理其中发生了事件的Channel,对连接事件而言,就是去完成连接的建立,连接建立的具体动作交给NioWorker来实现,每个NioWorker在初始化时会创建一个EPollSelectorImpl实例,意味着每个NioWorker线程会管理很多的连接,当建连完成后,注册OP_READ事件,注册的这个过程会调用到EPollSelectorImpl的下面的方法:

protected void implRegister(SelectionKeyImpl ski) {
SelChImpl ch = ski.channel;
fdToKey.put(Integer.valueOf(ch.getFDVal()), ski);
pollWrapper.add(ch);
keys.add(ski);
}

从这段代码就明白了EPollSelectorImpl的fdToKey的数据是在连接建立后产生的。

那什么时候会从fdToKey里删掉数据呢,既然放数据是建连接的时候,那猜测删除就是关连接的时候,翻看关连接的代码,最终会调到EPollSelectorImpl的下面的方法:

protected void implDereg(SelectionKeyImpl ski) throws IOException {
assert (ski.getIndex() >= 0);
SelChImpl ch = ski.channel;
int fd = ch.getFDVal();
fdToKey.remove(new Integer(fd));
pollWrapper.release(ch);
ski.setIndex(-1);
keys.remove(ski);
selectedKeys.remove(ski);
deregister((AbstractSelectionKey)ski);
SelectableChannel selch = ski.channel();
if (!selch.isOpen() && !selch.isRegistered())
((SelChImpl)selch).kill();
}

从上面代码可以看到,在这个过程中会从fdToKey中删除相应的数据。

翻代码后,基本明白了fdToKey这个部分,在Netty的实现下,默认会创建一个NioServerBoss的线程,cpu * 2的NioWorker的线程,每个线程都会创建一个EPollSelectorImpl,例如如果CPU为4核,那么总共会创建9个EPollSelectorImpl,每建立一个连接,就会在其中一个NioWorker的EPollSelectorImpl的fdToKey中放入SelectionKeyImpl,当连接断开时,就会相应的从fdToKey中删除数据,所以对于长连接server的场景而言,fdToKey里有很多的数据是正常的。

——————————-

第一个问题解决后,继续看第二个问题,怎么用比较少的资源高效的处理那么多连接的各种事件。

根据上面翻看代码的记录,可以看到在Netty默认的情况下,采用的是1个线程来处理连接事件,cpu * 2个NioWorker线程来处理读写事件。

连接动作因为很轻量,因此1个线程处理通常足够了,当然,客户端在设计重连的时候就得有避让机制,否则所有机器同一时间点重连,那就悲催了。

在分布式应用中,网络的读写会非常频繁,因此读写事件的高效处理就非常重要了,在Netty之上怎么做到高效也很重要,具体可以看看我之前写的那篇优化的文章,这里就只讲Netty之下的部分了,NioWorker线程通过
int selected = select(selector);
来看是否有需要处理的,selected>0就说明有需要处理,EPollSelectorImpl.doSelect中可以看到一堆的连接都放在了pollWrapper中,如果有读写的事件要处理,这里会有,这块的具体实现还得往下继续翻代码,这块没再继续翻了,在pollWrapper之后,就会updateSelectedKeys();,这里会把有相应事件发生的SelectionKeyImpl放到SelectedKeys里,在netty这层就会拿着这个selectedKeys进行遍历,一个一个处理,这里用多个线程去处理没意义的原因是:从网卡上读写的动作是串行的,所以再多线程也没意义。

所以基本可以看到,网络读写的高效主要还是ePoll本身做到,因为到了上层其实每次要处理的已经是有相应事件发生的连接,netty部分通过较少的几个线程来有效的处理了读写事件,之所以读写事件不能像连接事件一样用一个线程去处理,是因为读的处理过程其实是比较复杂的,从网卡cp出数据后,还得去看数据是否完整(对业务请求而言),把数据封装扔给业务线程等等,另外也正因为netty是要用NioWorker线程处理很多连接的事件,因此在高并发场景中保持NioWorker整个处理过程的快速,简单是非常重要的。

——————————

带着这两个问题比以前更往下的翻了一些代码后,确实比以前更了解Java NIO了,但其实还是说不到深入和精通,因为要更细其实还得往下翻代码,到OS层,所以我一如既往的觉得,其实Java程序员要做到精通,是比C程序员难不少的,因为技术栈更长,而如果不是从上往下全部打通技术栈的话,在查问题的时候很容易出现查到某层就卡壳,我就属于这种,所以我从来不认为自己精通Java的某部分。

最近碰到的另外一个问题也是由于自己技术栈不够完整造成排查进展一直缓慢,这问题现在还没结果,碰到的现象就是已经触发了netty避免ePoll bug的workaround,日志里出现:
Selector.select() returned prematurely 512 times in a row; rebuilding selector.
这个日志的意思是Selector.select里没有数据,但被连续唤醒了512次,这样的情况很容易导致一个cpu core 100%,netty认为这种情况出现时ePoll bug造成的,在这种情况下会采取一个workaround方法,就是rebuilding selector,这个操作会造成连接重建,对高并发场景来说,这个会造成超时等现象,所以影响还挺大的。
由于这个问题已经要查到os层,我完全无能为力,找了公司的一个超级高手帮忙查,目前的进展是看到有一个domain socket被close了,但epoll_wait的时候还是会选出这个fd,但目前还不知道为什么会出现这现象,所以暂时这问题还是存在着,有同学有想法的也欢迎给些建议。

=============================
欢迎关注微信公众号:hellojavacases

公众号上发布的消息都存放在http://hellojava.info上。

Java NIO之EPollSelectorImpl详解

这是滴滴的架构师欧阳康同学写的,非常赞,从EPollSelectorImpl到OS层面实现的详细解释,可以让大家对Java NIO的实现有更完整的理解,强烈推荐。

本文简述JDK1.7的NIO在linux平台上的实现,对java NIO的一些核心概念如Selector,Channel,Buffer等,不会做过多解释,这些请参考JDK的文档。JDK 1.7 NIO Selector在linux平台上的实现类是sun.nio.ch.EPollSelectorImpl,这个类通过linux下的epoll系列系统调用实现NIO,因此在介绍这个类的实现之前,先介绍一下linux的epoll。epoll是poll/select系统调用的一个改进版本,能以更高的性能实现IO事件的检测和分发(主要归功于epoll的事件回调机制,下文详述),主要包含以下3个系统调用:

#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

上述函数中,epoll_create函数负责创建一个检测IO事件的epoll实例,size参数用于“暗示”操作系统事件队列的长度,在linux-2.6.32内核中,此参数被忽略。epoll_ctl函数用于管理文件描述符的事件集,使用此函数可以注册、修改、删除一个或多个事件。epoll_wait负责检测事件,这三个函数的详细描,请参阅epoll的man文档。
Java类sun.nio.ch.EPollSelectorImpl主要的功能都委托给sun.nio.ch. EPollArrayWrapper实现 (下文所引java代码反编译自linux版jdk_1.7.0_17/lib/rt.jar):

package sun.nio.ch;
class EPollArrayWrapper{
private native int epollCreate();
private native void epollCtl(int paramInt1, int paramInt2, int paramInt3, int paramInt4);
private native int epollWait(long paramLong1, int paramInt1, long paramLong2, int paramInt2) throws IOException;
}

EPollArrayWrapper的三个native方法的实现代码可参阅openjdk7/jdk/src/solaris/native/sun/nio/ch/ EPollArrayWrapper.c,可看到这三个native方法正是对上述epoll系列系统调用的包装。(其他jdk的实现代码会有所不同,但归根结底都是对epoll系列系统调用的包装)。
EPollSelectorImpl. implRegister方法(Selector.register方法的具体实现),通过调用epoll_ctl向epoll实例中注册事件:

protected void implRegister(SelectionKeyImpl paramSelectionKeyImpl) {
if (this.closed)
throw new ClosedSelectorException();
SelChImpl localSelChImpl = paramSelectionKeyImpl.channel;
this.fdToKey.put(Integer.valueOf(localSelChImpl.getFDVal()), paramSelectionKeyImpl);
this.pollWrapper.add(localSelChImpl);
this.keys.add(paramSelectionKeyImpl);
}

上述方法中,除了向epoll实例注册事件外,还将注册的文件描述符(fd)与SelectionKey的对应关系添加到fdToKey中,这个map维护了文件描述符与SelectionKey的映射。每当向Selector中注册一个Channel时,向此map中添加一条记录,而当Channel.close、SelectionKey.cancel方法调用时,则从fdToKey中移除与Channel的fd相关联的SelectionKey,具体代码在EPollSelectorImpl.implDereg方法中:

protected void implDereg(SelectionKeyImpl paramSelectionKeyImpl) throws IOException {
assert (paramSelectionKeyImpl.getIndex() >= 0);
SelChImpl localSelChImpl = paramSelectionKeyImpl.channel;
int i = localSelChImpl.getFDVal();
this.fdToKey.remove(Integer.valueOf(i));
this.pollWrapper.release(localSelChImpl);
paramSelectionKeyImpl.setIndex(-1);
this.keys.remove(paramSelectionKeyImpl);
this.selectedKeys.remove(paramSelectionKeyImpl);
deregister(paramSelectionKeyImpl);
SelectableChannel localSelectableChannel = paramSelectionKeyImpl.channel();
if ((!localSelectableChannel.isOpen()) && (!localSelectableChannel.isRegistered()))
((SelChImpl)localSelectableChannel).kill();
}

EPollSelectorImpl. doSelect(Selector.select方法的实现),则通过调用epoll_wait实现事件检测:

protected int doSelect(long paramLong)
throws IOException
{
if (this.closed)
throw new ClosedSelectorException();
processDeregisterQueue();
try {
begin();
this.pollWrapper.poll(paramLong);
} finally {
end();
}
processDeregisterQueue();
int i = updateSelectedKeys();
if (this.pollWrapper.interrupted())
{
this.pollWrapper.putEventOps(this.pollWrapper.interruptedIndex(), 0);
synchronized (this.interruptLock) {
this.pollWrapper.clearInterrupted();
IOUtil.drain(this.fd0);
this.interruptTriggered = false;
}
}
return i;
}

此方法的主要流程概括如下:
1. 通过epoll_wait调用(this.pollWrapper.poll)获取已就绪的文件描述符集合
2. 通过fdToKey查找文件描述符对应的SelectionKey,并更新之,更新SelectionKey的具体代码在EPollSelectorImpl .updateSelectedKeys中:

private int updateSelectedKeys()
{
int i = this.pollWrapper.updated;
int j = 0;
for (int k = 0; k < i; k++) { int m = this.pollWrapper.getDescriptor(k); SelectionKeyImpl localSelectionKeyImpl = (SelectionKeyImpl)this.fdToKey.get(Integer.valueOf(m)); if (localSelectionKeyImpl != null) { int n = this.pollWrapper.getEventOps(k); if (this.selectedKeys.contains(localSelectionKeyImpl)) { if (localSelectionKeyImpl.channel.translateAndSetReadyOps(n, localSelectionKeyImpl)) j++; } else { localSelectionKeyImpl.channel.translateAndSetReadyOps(n, localSelectionKeyImpl); if ((localSelectionKeyImpl.nioReadyOps() & localSelectionKeyImpl.nioInterestOps()) != 0) { this.selectedKeys.add(localSelectionKeyImpl); j++; } } } } return j; }

关于fdToKey,有几个问题:
一、为何fdToKey会变得非常大?由上述代码可知,fdToKey变得非常大的可能原因有2个:
1.注册到Selector上的Channel非常多,例如一个长连接服务器可能要同时维持数十万条连接;
2.过期或失效的Channel没有及时关闭,因而对应的记录会一直留在fdToKey中,时间久了就会越积越多;
二、为何fdToKey总是串行读取?fdToKey中记录的读取,是在select方法中进行的,而select方法一般而言总是单线程调用(Selector不是线程安全的)。
三、tcp发包堆积对导致fdToKey变大吗?一般而言不会,因为fdToKey只负责管理注册到Selector上的channel,与数据传输过程无关。当然,如果tcp发包堆积导致IO框架的空闲连接检测机制失效,无法及时检测并关闭空闲的连接,则有可能导致fdToKey变大。

下面聊一聊epoll系统调用的具体实现,它的实现代码在(linux-2.6.32.65)fs/eventpoll.c中(下文所引内核代码,由于较长,所以只贴出主流程,省略了错误处理及一些相对次要的细节如参数检查、并发控制等),先看epoll_create 系统调用的实现:
fs/eventpoll.c

SYSCALL_DEFINE1(epoll_create, int, size)
{
if (size <= 0) return -EINVAL; return sys_epoll_create1(0); }

SYSCALL_DEFINE1是一个宏,用于定义有一个参数的系统调用函数,上述宏展开后即成为:
int sys_epoll_create(int size)
这就是epoll_create系统调用的入口。至于为何要用宏而不是直接声明,主要是因为系统调用的参数个数、传参方式都有严格限制,最多六个参数, SYSCALL_DEFINE2 -SYSCALL_DEFINE6分别用来定义有2-6个参数的系统调用。由上述代码可知,epoll_create函数最终调用sys_epoll_create1实现具体功能,同时也可以看出size参数被忽略了。sys_epoll_create1的主要代码如下(省略了错误处理及一些次要的细节如参数检查等):
fs/eventpoll.c

SYSCALL_DEFINE1(epoll_create1, int, flags)
{
int error, fd;
struct eventpoll *ep = NULL;
struct file *file;
error = ep_alloc(&ep);
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
O_RDWR | (flags & O_CLOEXEC));
fd_install(fd, file);
ep->file = file;
return fd;
}

上述代码主要是分配一个struct eventpoll实例,并分配与此实例相关联的文件描述符,后续的epoll_ctl,epoll_wait调用通过此文件描述符引用此实例。struct eventpoll的结构如下:
fs/eventpoll.c

struct eventpoll {
spinlock_t lock;
struct mutex mtx;
wait_queue_head_t wq;
wait_queue_head_t poll_wait;
struct list_head rdllist;
struct rb_root rbr;
struct epitem *ovflist;
struct user_struct *user;
struct file *file;
int visited;
struct list_head visited_list_link;
}

上述数据结构的关键部分是:
1. 一个等待队列wq,epoll正是通过此等待队列实现的事件回调
2. 一个就绪列表rdllist,此列表以双链表的形式保存了已就绪的文件描述符
3. 一个红黑树rbr,用于保存已注册过的文件描述符,若重复注册相同的文件描述符,则会返回错误
等待队列是epoll系统调用的核心机制(不只是epoll,linux下事件的通知、回调等机制大都依赖于等待队列),在讲述epoll_ctl,epoll_wait的实现之前,先来看看等待队列。等待队列可以使一组进程/线程在等待某个事件时睡眠,当等待的事件发生时,内核会唤醒睡眠的进程/线程。注意,下文并不区分进程和线程,在linux下,进程和线程在调度这个意义下(调度就是指linux的进程调度,包括进程的切换、睡眠、唤醒等)并无差别。此机制可以类比java.lang.Object类的wait和notify/notifyAll方法,其中wait方法使线程睡眠,notify/notifyAll方法则唤醒睡眠的一个或全部线程。等待队列主要涉及两个数据结构:
include/linux/wait.h

struct __wait_queue_head {
spinlock_t lock;
list_head task_list;
};
struct __wait_queue {
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
void *private;
wait_queue_func_t func;
struct list_head task_list;
};

struct __wait_queue_head是队头结构,task_list 保存了添加到此队列上的元素,struct list_head是标准的linux双链表, 定义如下:
include/linux/list.h

struct list_head {
struct list_head *next, *prev;
};

注意,此结构既可以表示双链表的表头,也可以表示一个链表元素,且next,prev这两个指针可以指向任意数据结构。
struct __wait_queue是等待队列的元素结构,成员func是等待的进程被唤醒时执行的回调函数,其定义如下:
include/linux/wait.h

typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);

struct __wait_queue的成员task_list是一个链表元素用于将此结构放置到struct __wait_queue_head中(这和此结构的task_list成员含义是不同的,此成员的含义为双链表的头),private成员一般指向等待进程的task_struct实例(该结构成员很多,在此就不贴出了,只需要知道linux下每个进程都对应一个task_struct 实例)。
在使用上,等待队列主要涉及以下函数(或者宏):
include/linux/wait.h
__add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
#define wait_event(wq, condition)
#define wake_up_xxx(x,…)
__add_wait_queue用于将一个进程添加到等待队列,wait_event是一个宏,它用于等待一个事件,当事件未发生时使等待的进程休眠,wake_up_xxx是一系列的宏,包括wake_up,wake_up_all,wake_up_locked,wake_up_interruptible等,负责唤醒休眠在某个事件上的一个或一组进程。关于等待队列的具体实现细节,由于牵涉较广(涉及到进程调度、中断处理等),这里不再详述,可以将add_wait_queue,wait_event类比java.lang.Object的wait方法,而wake_up则可以类比java.lang.Object的notify/notifyAll方法。
介绍完等待队列后,就可以进一步研究epoll_ctl的实现了,其代码实现中核心的部分是:
fs/eventpoll.c

SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
if (!tfile->f_op || !tfile->f_op->poll)
goto error_tgt_fput;
switch (op) {
case EPOLL_CTL_ADD:
error=ep_insert(ep, &epds, tfile, fd);
break;
case EPOLL_CTL_DEL:
error=ep_remove(ep, epi);
break;
case EPOLL_CTL_MOD:
error = ep_modify(ep, epi, &epds);
break;
}
return error;
}

什么样的文件描述符可以注册?从那个if判断可以看出,只有文件描述符对应的文件实现了poll方法的才可以,一般而言,字符设备的文件都实现了此方法,网络相关的套接字也实现了此方法,而块设备文件例如ext2/ext3/ext4文件系统文件,都没有实现此方法。实现了poll方法的文件,对应于java NIO的java.nio.channels.SelectableChannel,这也是为何只有 SelectableChannel 才能注册到Selector上的原因。ep_insert,ep_remove,ep_modify分别对应事件的注册、删除、修改,我们以ep_insert为例,看一下事件注册的过程,其关键代码如下:
fs/eventpoll.c

static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = tfile->f_op->poll(tfile, &epq.pt);
ep_rbtree_insert(ep, epi);
if ((revents & event->events) && !ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);;
wake_up_locked(&ep->wq);
}
}

上述代码的主要做的事是:
1. 绑定等待队列的回调函数ep_ptable_queue_proc
2. 调用对应文件的实例的poll方法,此方法的具体实现差别非常大,但绝大多数都会调用wait_event相关方法,在没有事件发生时,使进程睡眠,例如socket对应的实现(代码在net/ipv4/af_inet.c的tcp_poll方法,在此不再详述);
3. 若注册的事件已经发生,则将已就绪的文件描述符插入到eventpoll实例的就绪列表(list_add_tail(&epi->rdllink, &ep->rdllist);),并唤醒睡眠的进程(wake_up_locked(&ep->wq))
第1步绑定的回调函数ep_ptable_queue_proc,会在等待的事件发生时执行,其主要功能是将就绪的文件描述符插入到eventpoll实例的就绪列表(具体是通过ep_ptable_queue_proc绑定的另一个回调函数ep_poll_callback实现的):
fs/eventpoll.c

static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key){
if (!ep_is_linked(&epi->rdllink))
list_add_tail(&epi->rdllink, &ep->rdllist);
}

最后看epoll_wait的实现,有了就绪队列,epoll_wait的实现就比较简单了,只需检查就绪队列是否为空,若为空,则在必要时睡眠或等待:
fs/eventpoll.c

SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
int, maxevents, int, timeout)
{
int error;
struct file *file;
struct eventpoll *ep;
file = fget(epfd);
ep = file->private_data;
error = ep_poll(ep, events, maxevents, timeout);
return error;
}

此函数最终调用ep_poll完成其主要功能:

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
retry:
if (list_empty(&ep->rdllist)) {
init_waitqueue_entry(&wait, current);
wait.flags |= WQ_FLAG_EXCLUSIVE;
__add_wait_queue(&ep->wq, &wait);

for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (!list_empty(&ep->rdllist) || !jtimeout)
break;
if (signal_pending(current)) {
res = -EINTR;
break;
}

spin_unlock_irqrestore(&ep->lock, flags);
jtimeout = schedule_timeout(jtimeout);
spin_lock_irqsave(&ep->lock, flags);
}
__remove_wait_queue(&ep->wq, &wait);

set_current_state(TASK_RUNNING);
}
eavail = !list_empty(&ep->rdllist) || ep->ovflist != EP_UNACTIVE_PTR;

spin_unlock_irqrestore(&ep->lock, flags);
if (!res && eavail &&
!(res = ep_send_events(ep, events, maxevents)) && jtimeout)
goto retry;

return res;
}

上述代码主要是检查就绪队列是否为空,若为空时,则根据超时设置判断是否需要睡眠(__add_wait_queue)或等待(jtimeout = schedule_timeout(jtimeout);)。

综上所述,epoll系统调用通过等待队列,其事件检测(epoll_wait系统调用)的时间复杂度为O(n),其中n是“活跃”的文件描述符总数,所谓的活跃,是指在该文件描述符上有频繁的读写操作,而对比poll或select系统调用(其实现代码在fs/select.c中),其时间复杂度也是O(n),但这个n却是注册的文件描述符的总数。因此,当活跃的文件描述符占总的文件描述符比例较小时,例如,在长连接服务器的场景中,虽然同时可能需要维持数十万条长连接,但其中只有少数的连接是活跃的,使用epoll就比较合适。

=============================
欢迎关注微信公众号:hellojavacases

公众号上发布的消息都存放在http://hellojava.info上。