netty--从bind方法流程分析netty的实现原理

nettyServer的标准启动代码

netty官方源码中的示例 DiscardServer 中nettyServer的标准启动姿势如下

netty--从bind方法流程分析netty的实现原理_第1张图片
netty server启动过程

  1. 初始化ServerBootstrap,这里面保存着所有nettyServer运行过程中需要的各种信息,相当于整个nettyServer的环境
  2. 绑定操作,将形如 127.0.0.1:8888 这样的地址端口绑定到nettyServer上,即打开相应端口的socket连接,并且把接收连接后的响应事件与bootstrap关联(这一步之后服务就可以开始接收连接请求了)
  3. 等待关闭操作,如果读者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的一种封装


netty--从bind方法流程分析netty的实现原理_第2张图片
netty套娃

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。

netty--从bind方法流程分析netty的实现原理_第3张图片
epoll

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)本质上是initAndRegisterchannel.bind这两个方法,这两个方法的执行是通过向NioEventLoop提交任务来执行的。所以我们需要先分析NioEventLoop的实现。

ServerBootstrap的模板代码中会设置bossGroup和workerGroup,分别是两个NioEventLoopGroup类型,里面包含若干个NioEventLoop,是任务的核心。


netty--从bind方法流程分析netty的实现原理_第4张图片
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方法。

netty--从bind方法流程分析netty的实现原理_第5张图片
eventloop

当调用到ServerBootstrap#ServerBootstrapAcceptor的方法时,ServerBootstrap会为这个accept事件执行child eventloop的register方法,这个方法又会执行上面提到的NioEventLoop#run方法,这样就又触发了child eventloop的while轮回。
综上,我们可以得到结论:bossGroup与childGroup是通过boss eventloop的accept事件触发启动child eventloop的自转的。

netty--从bind方法流程分析netty的实现原理_第6张图片
boss触发worker

inbound与outbound

netty中有一对概念,inboundHandler与outboundHandler,如下图所示,分别用于处理read和write流程,同样是在第一张图中ServerBootstrap初始化的时候设置到bootstrap的。这些handler最终兜兜转转会设置到NioServerSocketChannel#pipeline中,在channel收到读/写事件时从不同方向顺序执行

netty--从bind方法流程分析netty的实现原理_第7张图片
inbound & outbound

inbound与outbound的总结如下

inbound/outbound 典型用法 需要关注的类 需要关注的方法
inbound LengthFieldBasedFrameDecoder - 收到半包请求之后黏包 ChannelInboundHandler channelRead()
outbound LengthFieldPrepender - 为要发送的请求增加头部信息标识消息长度 ChannelOutboundHandler write/writeAndFlush()
netty--从bind方法流程分析netty的实现原理_第8张图片
workerloop

netty中的方法调用都是向eventloop提交任务

在了解了NioEventLoop的大致原理之后,我们可以回头来看bootstrap.bind()方法的两个核心操作,上面提到bind方法时,为了简化逻辑,我们对其执行逻辑进行了最大规模的概括。而实际上读者可以自行下载源码看到,它们的本质都是向eventloop中提交了一个任务到taskQueue,并触发了NioEventLoop#run方法的执行。

netty--从bind方法流程分析netty的实现原理_第9张图片
AbstractChannel#AbstractChannel#register()方法

通过追踪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的数量,更像一个成熟运转的企业了。
  • 为什么大家都在推崇用netty?netty、nio封装了这么多逻辑为什么就能够比传统bio强?

    • netty/nio的模式可以概括为,一个线程有规划的依次处理多个任务,免去了传统BIO线程切换的代价;另一方面,传统BIO一个连接就分配一个线程处理的模式,并发量上来之后线程数根本不够用啊。
  • 零拷贝技术是什么?

    • 零拷贝技术其实就是一个操作系统的系统调用,在linux中是sendfile(),在java中被封装为了FileChannel.transferTo(),就是把传统 read() and write() ==> sendfile(),这一步系统调用直接将数据从内核缓冲区 ==> socket缓冲区省略了内核缓冲区==>用户缓冲区,用户缓冲区==>socket缓冲区
    netty--从bind方法流程分析netty的实现原理_第10张图片
    没有零拷贝技术的时候--read and write

    netty--从bind方法流程分析netty的实现原理_第11张图片
    有零拷贝技术的时候--sendfile()

综上,nio是java对操作系统epoll等多路复用系统调用的封装,而netty则是在修复nio bug的同时、支持了更加丰富定制化的扩展(工头和打工人职责分离、pipeline责任链)。

refercences

  1. ^ netty源码
  2. ^ 如果这篇文章说不清epoll的本质,那就过来掐死我吧!
  3. ^ 彻底理解 IO 多路复用实现机制
  4. ^《Netty权威指南(第2版)》 - 李林锋
  5. ^ Netty中的异步串行无锁化
  6. ^ 零拷贝(Zero-copy)及其应用详解

你可能感兴趣的:(netty--从bind方法流程分析netty的实现原理)