序言:
本文讨论的问题都是基于JDK8的版本
在使用java的NIO实现一个server之前,本人也有接触过c/c++/php的server实现,在“单进程单线程”下,本身是串行的,也无需讨论“惊群问题”,在“多进程单线程” 模式下(以php为例),一般是在主进程中创建好server socket、绑定好端口等操作,然后fork出多个worker进程(每个worker进程都会accept连接),父子进程间因fork有“写时复制机制”的存在,可以共用同一个端口下的server socket,当有客户端连接上来时,多个worker进程使用select/poll/epoll系统调用,只有一个进程会被唤醒并成功accept连接,所以也不用操心“惊群问题”,那么本文要讨论的就是“单进程多线程”模式下的“惊群问题”,而java NIO实现的server就是“单进程多线程”的场景。
对于多线程下的“惊群问题”,常规的解决方案就是:每个线程都创建一个server socket,各自select/poll自己独立的server socket。因为端口默认是不能重复bind的,所以需要设置socket options:SO_REUSEPORT,这样相当于在操作系统内核层面做了“负载均衡”,当一个连接到来时,只唤醒一个thread去accept。
思路1 :
在使用NIO开发http server时,因本人写过其他语言的server,习惯性的就会采用“one loop per thead”的架构,即:cpu核数个thread,每个thead都独立创建一个server socket,bind同一个port,并设置socket options:SO_REUSEPORT。当我打算这么做时,却发现jdk8下无法“复用端口”。上代码:
看到这里,笔者就不打算使用one loop per thread了,此路不通。
在c++的多线程模型中,一般是多个线程都有自己的EventLoop,每个线程都要处理(accept/read/write), 而java中的做法一般是:accept交个独立的一个线程处理,读写事件再分给其他线程(accept线程的selector只关注OP_ACCEPT, 读写线程的selector关注OP_READ/OP_WRITE), 这样的做法,至少TCP的backlog应该调优,否则当大量新连接接到来时,只有一个核accpet,accept队列很容易堆积满,只不过对于大部分的应用场景,accpet事件都不太可能成为瓶颈,加上读写可以多核处理,CPU也能充分利用起来。
思路2:
上面发现无法端口复用,但是笔者又希望能用多线程去处理accept事件,在没有内核级特殊处理的情况下,应该是会“惊群的”,实际的结果也确实如此。笔者用了两种做法去尝试:
1、主线程创建一个server socket、一个selector,多个子线程共用同一个server socket、selector。当server启动时,客户端curl访问一次。 上代码:
通过上图“圈出来的代码”可以做个“断言”,如果没有惊群现象,那么应该只会打印一次“accpet success”,因为当有新的连接过来时,只有一个线程会被accept事件唤醒。但是实际结果如下:
所有的线程都被唤醒了,但是只有一个线程成功accpet。“惊群发生了”。
2、主线程创建一个server socket、,多个子线程各自创建一个selector并共用同一个server socket。 这种情况也是会发生“惊群问题”。但是略有区别,上截图:
通过和1、对比,会发现截图的控制台输出少了一些内容:empty loop loopWorker:X,输出这些内容的核心代码位置:
通过对比发现,当每个线程都拥有独立的selector时,一旦被select唤醒,每个线程的selectedKeys中都拥有这个新上来的连接,而当多个线程共用一个selector时,此时存在“竞态条件” 所以在select方法上有锁争用,输出empty loop则表示没获得select方法上的锁,直到所有线程都在select上成功返回时(失败返回即:锁未成功获取),都进入accpet,但最终只有一个线程accpet sucess。跟踪select方法源码可以发现内部有锁争用的:
总结:在jdk8以前老老实实的用 “一个selector线程 + worker线程池” 的模式
终极解决方案:
在jdk9之后,开始支持TCP端口复用了,即:SO_REUSEPORT可以被支持了。只可惜这个功能在macos下jdk9版本存在bug!!!:虽然没有惊群现象,但是每次被唤醒的都是同一个thread,等同于退化成单线程的版本了。
不过好在linux下没这个问题。
下面贴出在macos下 的测试结果:
可以发现这个bug挺有意思,永远都是最后一个bind端口的线程会被成功唤醒accept连接, 笔者使用apache ab压测2w次,截图只有一部分,但是2w次唤醒的都是loopWorker:3
然后再贴出同样的代码在linux下的结果:
可以看出,没有 “惊群现象”,且accpet事件被内核“负载均衡”到各个线程,完美!!!
而且ab压测QPS还挺高:
在同一台linux上,性能和c++ boost.asio实现的http server基本无差异了。