从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上。

netty新建连接并发数很小的case

双12之前的一个case,现在分享下,这个case的现象是当这个应用每秒新建40多个连接的时候,就会经常连接不上,这个应用时是基于netty的,按照我对netty的理解,这不太可能发生,可以来看看这个case解决的那个弯曲,总共折腾了4天,搞的这么弯曲和我有很大关系。

这个应用的背景先简单说一下,是一个提供给手机访问的应用,手机端app和这个应用时采用长连的方式,通讯这层是基于netty。

当时有同学给我反馈这个现象后,就登录到机器上看了看,按照以往经验,通常来说,新建连接并发数支撑的不够大有可能是两原因:
1. 应用在netty建连接的过程中做了耗时的事;
因此我先dump了应用的线程,看到一切正常,boss线程看起来非常空闲;

2. backlog太小;
首先问了下开发代码里有没有设置过backlog,开发告诉我没设置过,于是我翻了下netty的源码确认下默认值,看到backlog的默认值为读取自系统的/proc/sys/net/core/somaxconn,于是查了下系统的这个值,确认系统的这个值已经是调整过的,设置为了2048,然后确认了系统上的tcp_max_syn_backlog是4096,也就是最后work的会是2048(为什么是这样具体可见这篇文章),也就是说应该是够的。

用ss -s观察连接的状况,看到的是synrecv是0,也印证了上面的不是backlog的问题。

到这一步就彻底傻眼了,不知道该用什么方法排查了,于是开始一堆的google,看到的各种说解决新建连接并发低的解决办法,除了调整backlog外,主要是以下两种:
1 关闭tcp_tw_recycle,尝试了(话说这里充分体现了”病急乱投医“的心态,其实我自己都不相信改这参数有用,但想着只是改下参数这种小代价的事,还是试试吧),没任何作用;
2 关闭window scaling,也尝试了,一样没任何作用;

查到这个阶段觉得自己已经无法理解了,于是求助了厂内内核团队对网络这块比较精通的同学,然后又求助了“神”,“神“帮忙看了会后,说主要的问题是现在是每epollWait唤醒一次,只建了一个连接,这导致在大量新建连接请求并发的时候,效率不够高,因此我翻了下代码,发现我本机上看到的代码不是这样的,我本机上看到的netty代码在epollWait唤醒后,是会尝试一直去accept的,但这个应用使用的netty确实不是这样,于是查了下应用的jar包库,发现里面有两个版本的netty(一个是3.2.1.Final,一个是3.6.3.Final),3.2.1确实是每次epollWait后就处理一个,于是通知开发同学把3.2.1去掉,满心期待的认为应该是好了,等开发更新好了后,自己也确认了一次epollWait唤醒后会连续处理很多个建立连接的请求,但悲催的还是没解决问题,具体的netty在这块的改造感兴趣的同学可以看看NioServerSocketPipelineSink这个类(重点看select唤醒后)…

到这步,就彻底郁闷了,话说其实到这步的时候我已经被这个问题困扰了2天多了,于是只好继续google,发现又有提到打开syn cookies的建议,于是尝试了下,竟然真的work了,打开了这个参数后新建连接的并发请求轻松超过100+了。

到此以为已经解决了,但很快开发给我反馈,整个集群开启了这个参数后,连接确实是能建上了,但客户端出现了发了请求后,等不到任何响应的现象,当时还不确定服务器端到底有没有收到请求,于是只好又先关闭了这个参数(话说这个我到现在都不明白为什么这个参数打开后,会出现发请求没响应的现象,求高人解答)。

尽管还是没解决,但毕竟有进展,有的进展就是打开了syn cookies后连接就能建上,而syn cookies只有在backlog满了后才会生效,那也就是说还是backlog满了,从kern的日志也能确认syn cookies确实是work了,到这步就觉得诡异了,明明netty用的默认值就是somaxconn,而每秒新建40多个连接,且boss线程还很空闲的情况下显然不应该出现backlog满的现象,这个时候仔细看了下本机查看的netty代码,才发现我看的是netty 4的代码,而应用用的是netty 3.6.3,悲催,赶紧把netty 3.6.3的代码捞下来看了,才发现在3.6.3里backlog的默认值处理时不一样的,在3.6.3里默认值是50,不是netty写的默认值,是java本身,50那估计真的不一定够,于是就通知开发在代码里先强制设置下backlog为1000。

在开发改代码的过程中,还有一个怀疑点想确认,就是既然是backlog满了,为什么看到的synrecv会是0呢,于是再用netstat -na | grep [port] | grep SYN_RECV -c统计了下,结果发现值基本一直是64,java层面默认设置的是50,linux会将这个值调整为大于这个值的2的n次幂的值,那也就是64,好吧,看到这就彻底可以确定真的是因为backlog太小了造成的(只是话说我不明白为什么ss -s统计出来的synrecv会是0呢,求高人解答)。

等开发改完代码重新发布后,稍微增加了点引流测试了下,轻松支撑每秒200+,客户端建立连接后发请求获取响应也完全ok,问题到此宣告解决。

题外话: 这应用之所以会比较容易出现较多的synrecv,主要是因为手机网络通常是不太稳定的,另外一个原因是这种对外的都很容易带来攻击,而当时刚好这个应用前面的一个用来防syn flood的由于有bug临时关闭了,所以问题暴露的比较明显。

从这个折腾了4天的case的排查过程,大家可以看到其实如果一开始我仔细确认过应用用的netty版本和我本机看的代码是不一致的话,估计很快就会排查出原因就是backlog值太小造成的,所以说折腾了这么多天其实也是自己造成的,这个告诉自己,以后排查问题的时候一定要对出现问题的应用所在的环境更加清楚的确认。

ps: 最后再多啰嗦几句,从这个case还能看到的是netty的版本其实在细节上是一直在改进的,就像这个case里的不同版本的netty在处理连接事件唤醒上,还有backlog的默认值上,所以我一直很强调,对于需要存活很多年的软件而言,选择一个使用范围较广的开源软件是非常重要的,如果自己开发,也许短期能超越,但放到三年、五年这样的范围来看,通常是很难和开源软件去抗衡的(原因是商业公司没多少人会专注在一个领域做三五年的,而开源界这样的人实在是多,说实话,这种case看过太多),所以如果觉得你能做的比开源软件好,还不如去帮助已有的(当然,如果这个领域目前完全没有什么使用面较广的、靠谱的,那自己做一个开源是挺好的),软件的可持续发展能力(除非是一次性软件、做的玩的或就玩个一两年的)是非常非常重要的。

=============================
题图来源于:http://www.prairiemoonbed.com/Time_out_faded.jpg
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

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

YGC频繁、NettyClient误用的两个Case

写写最近碰到的两个case,一个是关于ygc频繁排查的case,另一个是netty client误用造成的cpu sy高的case。

ygc频繁的case
很多人都问过我多久一次的ygc叫频繁,其实由于ygc通常是非常快的(几十ms或更低),所以通常来说ygc很频繁的发生也不会造成太大的影响,例如我们很多生产系统都是4s左右一次ygc。
ygc频繁是比较难查的,原因是ygc频繁,但通常来说都是ygc后就回收掉了,所以内存dump下来是看不出什么的,对于这个case我给的建议是多jmap -histo [pid]几次,原因是希望多看看在ygc的阶段中产生的对象到底是些什么…
业务方多执行了几次后,很幸运的看到了频繁产生的对象是一个自定义的对象,看到这个后就用btrace去跟了,看看到底是哪里在频繁的产生这个对象。
不过这个排查方法其实并没有通用,例如如果看到频繁产生的是char[],那就非常不好跟了….

netty client误用造成cpu sy高
有一个应用出现了cpu sy非常高的状况,sy大概使用到了60%,对于java应用而言sy如此高通常是由于线程太多,或线程主动切换太多造成的,按照这个思路,先统计了下线程数:ps -eLf | grep java -c,看到结果后,我和我的小伙伴们都惊呆了,1.6w个java线程,对大多数java应用而言,这不是太正常的现象,于是jstack dump看看线程到底什么状况。

jstack dump出来的结果显示,大部分的线程都是Netty I/O Worker线程…

对netty熟一些的同学会知道netty创建一个连接后,会将连接交给后端的NioWorker线程,由NioWorker线程来处理具体的I/O读写事件,NioWorker的线程池的线程数默认为cpu * 2,在上面的场景中创建了1w+的NioWorker线程,那只能是更前端让NioWorker产生了更多的线程数。

往上追朔可以看到NioWorker的线程数要创建更多的话,必然是NioClientSocketChannelFactory每次重新创建了,如每次创建连接时,NioClientSocketChannelFactory重新创建,就会直接导致每次创建的这个连接都会扔到一个新的NioWorker线程去处理。

和业务方确认后,可以看到业务方的代码里确实是在每次创建连接时都重新new NioClientSocketChannelFactory,这就能解释通了。

因此这问题的解决方法也很简单,NioClientSocketChannelFactory创建一个就够了,可以直接static创建一个,然后赋值到每次new的BootStrap里。

不过话说,这一定程度上来说确实是个“坑”,mina也是同样如此,server端由于通常只是启动一个端口监听,所以通常不会出现这现象,对于client而言,这貌似是挺容易犯的一个错。

=============================
题图来源于:http://www.caps-entreprise.com.cn/wp-content/uploads/2012/06/%E6%88%90%E5%8A%9F%E6%A1%88%E4%BE%8B.jpg
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

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