美文网首页
netty--从bind方法流程分析netty的实现原理

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

作者: JackpotDC | 来源:发表于2020-11-21 01:28 被阅读0次

    nettyServer的标准启动代码

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

    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套娃

    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。

    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<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> 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,是任务的核心。


    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方法。

    eventloop

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

    boss触发worker

    inbound与outbound

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

    inbound & outbound

    inbound与outbound的总结如下

    inbound/outbound 典型用法 需要关注的类 需要关注的方法
    inbound LengthFieldBasedFrameDecoder - 收到半包请求之后黏包 ChannelInboundHandler channelRead()
    outbound LengthFieldPrepender - 为要发送的请求增加头部信息标识消息长度 ChannelOutboundHandler write/writeAndFlush()
    workerloop

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

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

    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缓冲区
      没有零拷贝技术的时候--read and write
      有零拷贝技术的时候--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的实现原理

          本文链接:https://www.haomeiwen.com/subject/biksiktx.html