nettyServer的标准启动代码
netty官方源码中的示例 DiscardServer 中nettyServer的标准启动姿势如下
- 初始化ServerBootstrap,这里面保存着所有nettyServer运行过程中需要的各种信息,相当于整个nettyServer的环境
- 绑定操作,将形如 127.0.0.1:8888 这样的地址端口绑定到nettyServer上,即打开相应端口的socket连接,并且把接收连接后的响应事件与bootstrap关联(这一步之后服务就可以开始接收连接请求了)
- 等待关闭操作,如果读者debug一下的话会看到,主线程会一直阻塞在这里
ServerBootstrap#bind()
上面netty server启动三部曲的第一步和第三部本身并没有什么特殊逻辑,第一步就是new了一个ServerBootstrap对象并且设置了各种属性,而第三步就是synchronized + wait等待close的消息通知。
netty server启动的核心在于第二步bind方法,本文不再贴大篇幅源码,感兴趣的读者可以自行下载netty源码。ServerBootstrap#bind()
方法的伪代码如下:
def bind(address):
channel = initAndRegister() # 打开serverSocketChannel,执行serverSocketChannel的register
doBind(channel, address) # 执行serverSocketChannel.bind(address)
可以看到,ServerBootstrap#bind() 执行的核心方法只有两个,initAndRegister() 和 doBind()
initAndRegister
def initAndRegister():
channel = channelFactory.newChannel() # 1. 通过工厂模式实例化出来的NioServerSocketChannel,同时会进行nio相关操作
bossGroup.register(channel) # 2. 本质上是执行了channel.register()方法
initAndRegister方法做的事情可以概括为
- 通过工厂模式实例化出来
NioServerSocketChannel
,还记得上文中为ServerBootstrap设置的channel属性吗,netty为了支持不同类型channel的可扩展性,会通过工厂模式+反射机制创建NioServerSocketChannel的实例。这个 NioServerSocketChannel 是对jdk的nio的ServerSocketChannel
的一种封装。- selectorProvider.openServerSocketChannel(),jdk nio的操作,打开ServerSocketChannel
- ServerSocketChannel.register(selector),nio的操作,在selector上注册了该channel
至此,通过initAndRegister我们
- 初始化了ServerSocketChannel(里面封装着nio的ServerChannel)
- 在selector上注册了该channel
doBind
上文中的channel打开并注册多路复用选择器后,一切都准备好了,channel就可以打开相应的socket端口开始接收请求了,因此doBind做的事情就是给nio的ServerSocketChannel绑定端口
ServerSocektChannel#bind()
netty、nio、与操作系统调用
读者可以从源码中发现,netty的ServerBootstrap的本质,是对java nio的一层封装,而java nio的本质又是对操作系统多路复用API的一种封装
epoll
众所周知,Java是一个跨平台的语言,在不同的操作系统上(windows、mac、linux、Solaris)的JDK封装不了不同的调用实现,以最常见的linux系统为例,linux系统上支持多路复用功能的API是经典的epoll
函数(关于select-->poll-->epoll是如何一步步进化过来的,也是一个经典的发展过程,本文不再赘述)。C语言通过使用epoll函数单线程同时监听处理多个socket套接字的模板代码如下:
int s = socket(AF_INET, SOCK_STREAM, 0); // 建立socket
bind(s, ...); // 为socket绑定ip:port
listen(s, ...); // 开始监听ip:port
int epfd = epoll_create(...); // 创建特殊的fd -- epoll_fd
epoll_ctl(epfd, ...); // 将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(); // 阻塞等待连接事件
for(接收到数据的socket) {
// 处理
}
}
epoll的功能可以概括为:同时监听多个文件/网络事件的变更,当收到变更之后能知道哪些文件/网络产生的变更,并且依次处理。
类似于java中万物皆是Object对象,linux系统中万物皆是文件,每个文件都有一个类似于指针的id,文件描述符,英文File Descriptor,简称fd。
Java nio
JDK中的NIO包是对操作系统多路复用API的一种封装,由于Java语言的跨平台特性,不同OS上的JDK包中关于selector等功能的实现源码是不一样的,经典的java nio多路复用代码模板如下:
ServerSocketChannel channel = SelectorProvider.provider().openServerSocketChannel();
channel.bind(...);
channel.configureBlocking();
Selector selector = SelectorProvider.provider().openSelector();
while(true) {
int readyChannels = selector.select();
Set selectionKeys = selector.selectedKeys();
Iterator keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()) {
// 处理
}
keyIterator.remove();
}
以linux系统下的epoll为例,可以粗略的将Java的若干调用与epoll代码模板中进行类比,有如下表格。
nio | linux系统调用 | description |
---|---|---|
SelectorProvider.provider().openSelector() | epoll_create | 创建selector(epoll fd) |
socketChannel.register | epoll_ctl | epfd上注册socket |
selector.select() | epoll_wait | 多路复用,等待多个socket的事件通知 |
SelectorProvider.openServerSocketChannel() | socket | 建立套接字 |
socketChannel.bind() | bind&listen | 绑定监听端口 |
JDK在若干调用上使用了懒加载等手段,因此实际在JDK native源码的实现中并不完全一一对应,,只是在概念上可以类比着理解,具体不同的nio方法的实际调用,读者可以自行下载JDK源码阅读。
NioEventLoop-netty的动脉
通过上面分析我们知道bootstrap.bind(addr)
本质上是initAndRegister
和channel.bind
这两个方法,这两个方法的执行是通过向NioEventLoop提交任务来执行的。所以我们需要先分析NioEventLoop的实现。
ServerBootstrap的模板代码中会设置bossGroup和workerGroup,分别是两个NioEventLoopGroup类型,里面包含若干个NioEventLoop,是任务的核心。
NioEventLoop#run
核心步骤-单线程处理io事件和任务事件
NioEventLoop的核心方法是NioEventLoop#run()
,netty的一系列操作从源码追过去都会落到这个方法上,我们先分析下这个方法的大致实现
while(1):
selector.select();
processSelectedKeys();
runAllTasks(timeout);
方法的源码很长,核心就是这三步
- 先执行selector.select(),获取准备好的io事件
- processSelectedKeys(),依次处理上述io事件,方法的内部就是switch语句对不同的时间类型进行不同的处理逻辑(读/写/接收连接)
- runAllTasks,执行所有taskQueue队列中的任务和所有定时调度的任务,定时调度任务的超时时间是基于select处理的io事件的耗时动态生成的,默认情况下队列任务的超时时间和io耗时五五开
nio epoll空轮询的处理
- 除了上述三步之外,还有nio经典的epoll空轮询的bug的处理,netty也是在这个while循环中处理的,通过统计while循环执行的频率,当发现频率过高时,就重建selector
nio epoll空轮询bug,java nio有一定概率会出现
selector.select()
方法明明什么io事件都没收到的情况下却没有阻塞,而是立即返回,进而导致这个while循环出现空轮训,表现为CPU打满100%
channel的pipeline(boss触发worker)
上面的processSelectedKeys
步骤中,boss eventloop会处理不同的io事件,通过debug追踪可以看到,boss eventloop在处理read/accept类型的io事件时,会调用pipeline.fireChannelRead()
,通过责任链的方式依次调用责任链上的channelRead
方法。
当调用到ServerBootstrap#ServerBootstrapAcceptor
的方法时,ServerBootstrap会为这个accept事件执行child eventloop的register方法,这个方法又会执行上面提到的NioEventLoop#run方法,这样就又触发了child eventloop的while轮回。
综上,我们可以得到结论:bossGroup与childGroup是通过boss eventloop的accept事件触发启动child eventloop的自转的。
inbound与outbound
netty中有一对概念,inboundHandler与outboundHandler,如下图所示,分别用于处理read和write流程,同样是在第一张图中ServerBootstrap初始化的时候设置到bootstrap的。这些handler最终兜兜转转会设置到NioServerSocketChannel#pipeline
中,在channel收到读/写事件时从不同方向顺序执行
inbound与outbound的总结如下
inbound/outbound | 典型用法 | 需要关注的类 | 需要关注的方法 |
---|---|---|---|
inbound | LengthFieldBasedFrameDecoder - 收到半包请求之后黏包 | ChannelInboundHandler | channelRead() |
outbound | LengthFieldPrepender - 为要发送的请求增加头部信息标识消息长度 | ChannelOutboundHandler | write/writeAndFlush() |
netty中的方法调用都是向eventloop提交任务
在了解了NioEventLoop的大致原理之后,我们可以回头来看bootstrap.bind()方法的两个核心操作,上面提到bind方法时,为了简化逻辑,我们对其执行逻辑进行了最大规模的概括。而实际上读者可以自行下载源码看到,它们的本质都是向eventloop中提交了一个任务到taskQueue,并触发了NioEventLoop#run
方法的执行。
通过追踪bootstrap.bind()方法,可以看到在AbstractChannel#AbstractChannel#register()
方法中,以及在很多地方都有 eventLoop.inEventLoop() 这样的判断,这是netty中实现异步任务串行无锁化的方式。
异步任务串行无锁化:每个EventLoop正如其名字,就是个死循环,串行的执行selector事件和task队列的事件,当有某个方法(如上文的register)被调用时,通过判断当前线程,
- 如果是eventloop自己的线程发起的,说明是正在执行task队列任务,直接执行
- 如果是其它线程发起的,则加入到task任务队列中。就像一个手忙脚乱的程序员,为了能够更流畅的处理手头的任务,往往会将零碎的事情记下来挨个做,而不是立刻有求必应,因为立刻响应的话需要打断手头的工作、处理完之后再回来(切换上下文)实在不是一个聪明的策略。
最后总结
综上所述,我们可以回顾最初的问题,
-
nio是jdk对epoll等多路复用系统调用的封装,那么netty在nio之上到底做了什么?
- 传统的nio模型是一条while循环线程通关,一方面这不利于发挥现在多核CPU的全部能力;另一方面单线程因为任何原因挂掉就会导致nio直接挂掉,稳定性很差。因此netty有了
eventloop
这样的概念动态控制reactor模式的boss和worker的数量,更像一个成熟运转的企业了。
- 传统的nio模型是一条while循环线程通关,一方面这不利于发挥现在多核CPU的全部能力;另一方面单线程因为任何原因挂掉就会导致nio直接挂掉,稳定性很差。因此netty有了
-
为什么大家都在推崇用netty?netty、nio封装了这么多逻辑为什么就能够比传统bio强?
- netty/nio的模式可以概括为,一个线程有规划的依次处理多个任务,免去了传统BIO线程切换的代价;另一方面,传统BIO一个连接就分配一个线程处理的模式,并发量上来之后线程数根本不够用啊。
-
零拷贝技术是什么?
- 零拷贝技术其实就是一个操作系统的系统调用,在linux中是
sendfile()
,在java中被封装为了FileChannel.transferTo()
,就是把传统read() and write() ==> sendfile()
,这一步系统调用直接将数据从内核缓冲区 ==> socket缓冲区
,省略了内核缓冲区==>用户缓冲区,用户缓冲区==>socket缓冲区
- 零拷贝技术其实就是一个操作系统的系统调用,在linux中是
综上,nio是java对操作系统epoll等多路复用系统调用的封装,而netty则是在修复nio bug的同时、支持了更加丰富定制化的扩展(工头和打工人职责分离、pipeline责任链)。
refercences
- ^ netty源码
- ^ 如果这篇文章说不清epoll的本质,那就过来掐死我吧!
- ^ 彻底理解 IO 多路复用实现机制
- ^《Netty权威指南(第2版)》 - 李林锋
- ^ Netty中的异步串行无锁化
- ^ 零拷贝(Zero-copy)及其应用详解