netty篇 之基本概念和核心组件(一)

2 篇文章 0 订阅
1 篇文章 0 订阅

基本概念

背景

此篇为了向大数据致敬。在大数据如火如荼的今天,用户不需要太过了解底层的原理,也能很好地运行大数据项目。然而作为一个有最求的有思想的人,那么对于系统的深入了解,不仅在知识面上扩展了,同时也会做到某位大神说的 :“口中有粮,心中不慌”的境界。

在大数据框架中有不少的框架使用了netty通信,比如spark,flink,zookeeper,dou以及死灰复燃的dubbo框架都有netty的身影,有兴趣的读者可以参考一下spark——livy架构解析。当然也有框架不使用netty,比如kafka等等链接(为了做到纯无dependence and a little performance,but 现在在为一些底层bug上不得不为技术负债而买单)。当然和netty相关的架构还有akka架构。

netty不仅在大数据生态中有较为广泛的市场,在其他通信领域中也有渗透,游戏领域貌似也有,具体没去调研过。。。。可以说netty工具还是很好上手,学习成本也低。

本文就是梳理一下netty中的一些基本的底层概念。从网络通信的上层应用模型出发,从BIO到NIO->AIO的演进。然而这些都是更底层的操作系统中的io子系统密不可分。所以会有大篇幅讲解底层io子系统。同时为了熟悉nio中的常用几个比较重要的组件selector/channel/bytebuffer来熟悉nio的基本套路。进而走入netty的世界,在netty中一切都是对jdk中nio的上层封装,并同样介绍netty中的核心组件channel/pipeline/handler,使得重心逐步往netty中走。不过限于篇幅,将在下篇讲解netty的hanlder体系以及bytebuf的概念,并通过一个协议来结尾。

希望大家能够多多指点文中的不足。话不多说,正式进入io世界。

IO篇

由《操作系统概述》介绍,计算机主要有的两个job,一个是I/O操作,另一个就是process(cpu计算)。cpu除了执行相关的计算指令(±*/)之外,还有一些操作比如转移指令(mov mov[bwl])会涉及内存与寄存器之间的数据交互操作。同样的涉及到网络设备的IO数据流的传输,一般会通过拥有中央处理单元(central process unit 类似于CPU的功能)的IO设备在相关网络下相互进行数据信号的交互。

在《深入理解计算机系统》中是这样定义IO的:它是在主存与外设之间复制数据的一个过程。

操作系统在IO系统所扮演的角色就是管理(状态)和控制(行为)IO设备直接的通信行为。

如下是以一个经典的PC bus 架构讲述一下IO操作会涉及到的相关计算机组成部分。这样在我们编程的时候,会做到心中有数。
在这里插入图片描述

核心是bus(总线),不同bus之间通过接口(电路层面的话可以把接口理解成controller)进行连接,io设备往往通过controller或者接口与bus进行连接。

其中controller是单独的一块电路元件,通常拥有独立的处理器和存储(一般比较小,但是图形控制器中的memeory就比较大了,用于存储屏幕的所有像素),用来控制相关device,比如高速缓存,buffer缓存,预取,坏簇映射。controller可以单独抽离出来放在主板上,也可以存在于device中。

在IO子系统中,cpu不与外设进行直接交互,而是直接与controller打交道,而controller直接与之所管理的设备进行交互的.

cpu与controller之间的交互方式
  1. I/O port 方式,cpu主要使用 in / out 这样的IO指令直接与各个controller进行数据的传输。大致的流程是,cpu的io指令触发bus line,然后通过bus line找到一个合适的device或controller,然后往目标中的寄存器中传输一个字节或者一个字大小的数据。

  2. MM I/O(memeory-mapped)方式,通过映射各个controller中的寄存器地址到内存空间中,cpu通过普通的mov等标准数据传输指令,就如同与内存直接交互一样,通过统一的操作io的接口。用户使用也方便,可以联想到门面模式。

perform IO

参考资料

完成一次IO操作的底层的四种方式,也就是cpu与相关 io controller直接的交互

  1. polling 轮询

轮询方式操作IO,是早期的操作系统管理IO设备的方式,在内核中的表现是计算机系统通过定时查看所有相关IO设备(用来读取忙位),每次查询都会有io操作,并确定是否有设备处于请求处理状态。
显然这种polling方式是非常低效的。奈何cpu执行得太快,但是却要遍历所有设备总归是不高效的。

  1. Interrupt 中断处理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IMjSxKlb-1596446307376)(DA9C15B1D013453CB5B92A9B5ADC68FE)]

中断处理是操作系统中一大非常重要的功能,可以说没有中断就没有现代操作系统。中断功能的出现也为操作系统扩展功能起到非常重要的作用。比如为了提高程序执行,操作系统可以自定义虚拟内存缺页处理的算法。还有一些异常处理中断。中断处理往往需要配合一张中断向量表来是实现程序的中断功能。

在现代操作系统中,CPU在执行完一条指令之后,都会通过一条中断线(IRL)来判断中断控制器中是否存在中断事件发生,并触发相应的中断操作。

io中断的本质是保证在一个计算机系统中,两种工作单元(计算,io)能够同时处理,可以给当前IO操作的进程一个非常自由的选择,不仅可以等待io完成的,也可以处理其他事情。

  1. DMA 直接内存copy

直接内存copy,直接内存copy的关键元器件叫做内存DMA控制器。它是一种异步的操作。进程把执行io操作的任务提交给DMA控制器,之后进程以非堵塞方式可以执行异步操作,DMA控制器与相关的io设备控制器进行交互从而控制着主存与io设备内存之间的数据copy任务,直到copy完成直接以中断方式请求执行任务。在copy任务期间,主存是独占状态,可能会有一定的延时。

  1. 通过通道channel(大型机)。

一般很少使用,这里的channel也是controller中的一种,可以替换DMA中的controlller。性能会更加强大。

IO中的四大基本概念的理解

参考资料

在理解IO模型之前, 同步、异步、堵塞和非堵塞这四个概念的区分是非常重要。

当我们讨论堵塞与非堵塞的时候,是站在进程或则线程是否被堵塞而言的。

在现代操作系统的多任务体系下,进程的重要核心数据结构叫做PCB。PCB内部有一个重要的变量叫做状态字段,这个标记着当前进程是running,interruptible,uninterruptible,stopped,zombile这五种状态,只有进程处于running状态,系统调度系统才会从就绪队列中取出进程,并执行进程。

在进程处于interuptible/uniterruptible/stopped状态下,进程是不会继续执行的,站在用户的角度来看,进程处于堵塞的状态,只有通过对应的信号分别把3个状态转换为running状态,才有可能被系统调用并执行后续的指令。

当我们讨论异步和同步的时候,是站在用户态进程系统调用所产生的内核态进程是否都在为完成一次完整的任务或操作付出全部的精力而言的。

站在完成一次完整的IO操作的角度,当用户态进程不管使用堵塞式等待内核态返回io完成状态,还是以非堵塞式轮询的方式以检测内核态进程完成io操作,都是在完成一次完整io操作,用户态进程并不做其他事情,此时就是同步操作。如果在用户态进程使用非堵塞式的过程中,并去完成其他任务的时候,那么此时是一个异步的过程,也就是程序流程被分支处理了,往往此时通过中断处理来通知用户态进程响应并去处理io完之后的操作。

IO 模型演进

演进路线图 BIO -> NIO -> AIO

既然是模型,那么针对同一个模型可以有多个不同实现。

BIO即 blocking io是一种堵塞的io模型,由于堵塞并不能很好地支持并发特性,同时会出现忙等待的现象,资源利用率不充分,为了解决以上问题,提出了nio模型,NIO 即一种new io,也有人以non-blocking来称呼这个模型,因为在nio中的处理读写请求的时候是通过poll轮询方式不断查看网络适配器(controller)是否有新的请求的到来,然而请求建立连接的操作是堵塞的,虽然此时是以非堵塞方式实现了进程调度,但是很明显这是一种同步操作,此时的线程从程序流的角度来看是一种同步操作。之后提出了一种真正的异步AIO的模型,这个是真正的异步操作。

目前市面上以BIO 和 NIO为主要的IO 模型。

BIO 模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zKZGY9B0-1596446307379)(F158A151AA484784800A3BAD30E55192)]

在client端与Server端建立链接之后,客户端与服务端之间的交互主要有三大步骤

  1. 客户端正在写操作,把数据写入流中
  2. 客户端正在发送操作,通过网路通道传递信息到Server端
  3. 服务端正在处理操作,Server端接收到完整信息之后处理消息

BIO模型的最大特点是vip式服务.

注意:在这里,站在操作系统的角度,线程和进程都可以被调度,都是一堆指令的集合,唯一的区别是进程调度需要经历进程上下文所占资源的保存与恢复操作,包括共享资源等等资源,而线程之间的调度的上下文切换比较方便,只保存与恢复局部变量资源。在这里,线程和之前讲的进程是同一回事。

BIO 优缺点

优点支持对文件锁操作,保证数据的一致性,实现简单,不容易出错。

缺点:速度慢性能差主要体现在
当用户在client端建立连接之后的后续传输数据操作中,在三大步骤中的第一步和第二步如果,存在严重的延迟那么相应的线程也会依旧在等待中的,严重出现占坑现象,这在计算机中是不会被看好的。

分析BIO在tomcat上的模型图

通过Tomcat8.xb版本中的相关配置信息,可以指定[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KkTGfTsG-1596446307386)(BD39686CEE6A4262948296E9DBAF7F2A)]

在tomcat中通过acceptor统一处理所有的请求连接,并从服务器线程池中获取线程专门为一个客户请求提供vip服务。很显然,线程在客户从建立连接到读写操作的过程中是一直存在的。

为了解决不必要的等待线程的操作,提出了NIO模型。

NIO 模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gZBBeEzD-1596446307389)(85BD4F22C0644221BCB86735638A4D27)]

NIO是同步的非堵塞模型,是对BIO模型的升级和改造,主要目的是减少用户空间中线程空等待的性能问题。NIO解耦了BIO中数据传递的前端处理(第一步和第二步)和后端处理(第三步骤)过程,单独抽离一个IO线程专门处理后续用户数据到达的事件。只有在用户数据到达之后,服务器才会创建线程准备为用户处理请求。

优点 可以支持较大的并发度,而不影响性能。

缺点
很显然,当服务器的处理请求花费较长时间的时候,仍然会触发较多的线程处理客户请求。在这样的场景下与bio并没有性能上的优势。

NIO在Tomcat中的模型图

通过Tomcat8.xb版本中的相关配置信息,可以指定

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-raP4cuLB-1596446307399)(4BD1E73DCCDF41A7BB7235FC9FBB1BB7)]

NIO中channel与buffer配套结合

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zRUiyib1-1596446307400)(FC0AF9A83E68485BA34242A23116703B)]

channel与buffer 在NIO模型中一起充当了io controller的角色。在cpu与io设备通信的过程中,cpu是直接与各个controller进行交互通信的。同样的道理,在nio世界中,用户通过channel和buffer跟io设备进行交互。

channel分类
A channel represents an open connection to an entity such as a hardware
 * device, a file, a network socket, or a program component that is capable of
 * performing one or more distinct I/O operations, for example reading or
 * writing.
 public interface Channel extends Closeable {
 }

根据源码解释,channel就是一个connection,用于连接一些硬件设备,文件,网络socket文件以及拥有的读或写操作。一般来说connection是双向的,即可读可写。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-APdtiqla-1596446307402)(A473168E54C94ADEBF5A03683C7F6072)]

上图所示channel可大致分三大类

复杂点类图就看看源码的继承图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E8scFpcH-1596446307404)(0964E69BAE3E4591AAC5E58DCE19C8DB)]

FileChannel实例

一个File类型的Channel就是对一个文件的连接,并通过这个连接能够检测文件的变化。

举一个对文件设置不可共享读的例子

@Test
public void testNoShareFileChannel1() throws IOException, InterruptedException {
    // 不可共享读
    FileChannel open = FileChannel.open(Paths.get("data/file"),ExtendedOpenOption.NOSHARE_READ);
    TimeUnit.SECONDS.sleep(100);
}
@Test
public void testNoShareFileChannel2() throws IOException, InterruptedException {

    FileChannel open = FileChannel.open(Paths.get("data/file"),ExtendedOpenOption.NOSHARE_READ);
    TimeUnit.SECONDS.sleep(100);
}

具体的文件打开参数可以根据如下三个枚举类设置

StandardOpenOption(标准打开选项) -> ExtendedOpenOption(扩展选项) -> LinkOption(链接选项)

运行如上方法,会引发如下的错误。

在这里插入图片描述

利用管道复制文件

public class TestChannel {
    @Test
    /**
     *
     * 直接内存copy copy
     * @throws IOException
     */
    @Test
    public void testFileCopyChannel2() throws IOException {

        FileChannel source = new FileInputStream("data/file").getChannel();
        FileChannel target = new FileOutputStream("data/output2").getChannel();

        source.transferTo(0, source.size(), target);
        source.close();
        target.close();
    }

}

在上面的复制过程效率是极高的。原因是channel具体实现了零copy机制。接下来介绍一下零copy技术模型

FileChannel下的零copy技术模型

其核心思想是为了在主存数据搬移到io设备的过程中,避免用户态内存的参与。它的重要前提是不对数据进行重新计算,而只是单纯的搬移数据。在NIO包中借助buffer的直接内存特性实现了零copy技术模型。

先了解buffer中的直接内存模型。

NIO中的buffer的直接内存

据《JVM高级特性与最佳实践(第3版)》 介绍,在java中直接内存不受jvm管理,在jdk1.4版本之后出现的nio接口的引入的channel和buffer的读取IO的方式中,nio可以直接通过native函数库在核空间直接分配堆外内存,然后通过在java堆中的DirectByteBuffer对象引用堆外内存的地址,并通过这个引用间件地实现与IO设备之间的数据交换,避免了java堆内内存与核堆外内存之间的copy操作,提高了执行效率。

在java应用层上,实质上通过Channel进行数据的传递。代码同上面介绍的。

    /**
     *
     * 直接内存copy copy
     * @throws IOException
     */
    @Test
    public void testFileCopyChannel2() throws IOException {

        FileChannel source = new FileInputStream("data/file").getChannel();
        FileChannel target = new FileOutputStream("data/output2").getChannel();

        source.transferTo(0, source.size(), target);
        source.close();
        target.close();
    }

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4ltnfwDG-1596446307412)(3E65F7BF0AA340FC8F3BAEABAD7D5176)]

核心是transferTo方法,这个方法是jdk自己实现的方法,其内部调用了native方法transferTo0。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MpPaEMYL-1596446307414)(5E29105CB2BE498DA416089BA1F10594)]

底层的实现是通过FD文件描述符,使用内核态中的buffer进行数据的传输。

在这里插入图片描述

UDP服务端的channel实现

同FileChannel一样,作为单向不可靠的快速交互协议的udp,在用户建立连接的时候,也会有一个channel用来连接到upd协议所生成的socket文件

        DatagramChannel udp = DatagramChannel.open();
        udp.bind(new InetSocketAddress(8888));

        ByteBuffer allocate = ByteBuffer.allocate(100);

        while (true){
            // 清空数据
            allocate.clear();
            udp.receive(allocate);

            allocate.flip();

            Charset charset = Charset.forName("UTF-8");
            CharsetDecoder decoder = charset.newDecoder();
            CharBuffer charBuffer = decoder.decode(allocate);
//            CharBuffer  charBuffer = decoder.decode(allocate.asReadOnlyBuffer());
            System.out.println(charBuffer);
        }

使用nc方式进行通信

nc -vu localhost 8888

通过channel的validOps,可以发现udp channel is read|write。

ServerSocketChannel TCP双向通道的实现

[占坑],但是与UDP是实现是一致的。但是ServerSocketChannel会生出多个SocketChannel实例,每个SocketChannel对应一个跟client对应的通道

buffer缓冲区

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U7gvhYLo-1596446307420)(7B886218333449BDA24E64D0E67D5293)]

如上为buffer的继承结构。Buffer的作用非常形象,它往往是与channel配套使用,按照语义上来理解,channel只是一个链接或者操作文件的一个媒介,并不存储数据,而buffer可以作为存储的点。

接下来了解buffer拥有什么特性,并有什么接口可以操作buffer

buffer特性与 api使用

首先没有object类型的Buffer,只有基本类型的buffer(Boolean类型在jvm中被看作int来对待的),因此如果想存储对象,需要有序列化成二进制串,这样就可以使用ByteBuffer存储。

备注: 缓冲区,对于缓冲区(buffer)和缓存(cache)的主要区别是,缓冲区的概念一般是一次性,主要是做数据快速中转方面的,起到协调不同设备之间执行速度差异提升整体性能的作用,而缓存cache更倾向于多次使用,所以会有缓存淘汰算法的出现。

而buffer中的数据结构也是非常有意思的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dz6n5fNA-1596446307422)(F5BE59A5823A4B00990FAEA42285ADE5)]

在buffer中主要有limit.position.capacity.mark标记位。对这四个位正确的语义认知是必不可少的.

在nio中对buffer对象的理解非常重要。还记得小时候兴致冲冲地拿着步步高录音的场景吗?是的,没错,buffer 对象就是我们的步步高,通过如下7个步骤

1. flip(轻按结束单次录音键) position = 0;mark = -1; limit = position;
2. reset(从上次的保存点重新录音) position = mark;
3. clear(重新录音) position = 0;mark = -1; limit = capacity;
4. rewind(结束录音并回放录音) position = 0; mark = -1
5. put(录一个音)
6. read(听一个音) 
7. remaining(还有多少可读 ) limit - position
等等操作可以非常形象地对应到步步高录音的场景。
1. capacity 代表录音磁带的用存储大小。
2. limit标志的录音在磁带上的长度,可能没有用完整个磁带空间
3. position 就是用来记录当前的录音所在的位置
4. mark一般是用来在录音的过程中为了重新录音先埋个点
类似于闯关游戏中过了一关打算保存,避免死了以后重头开始刷。所以常常是和reset配套使用。

NIO多路复用selector模型介绍

Selector源码分析
BIO由于处于空等待而造成系统资源的浪费.NIO提出以selector为中心,监听channel组,获取相关socket的监听事件.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OrIizCOa-1596446307424)(7078FA85B86040A9BACCD25F60AF6113)]

selector的核心组件共三样。

  1. channel 可选择的channel
  2. selector选择器(java层)
  3. selectionKey (一个包含selector和channal关系的节点)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-igXMUnjF-1596446307426)(CA916B8943574815B25BCFFDFA008E57)]

创建一个Selector

Selector selector = Selector.open();

使用静态方法获取一个selector,为什么用静态方法?不仅仅在jvm空间中只存储一份这么简单。
其实selector的具体实现依赖于平台,在selector使用open的方式,会调用本地的具体实现类。

  1. 在windows中的具体实现类WindowsSelectorImpl
protected Set<SelectionKey> selectedKeys = new HashSet();
protected HashSet<SelectionKey> keys = new HashSet();
private Set<SelectionKey> publicKeys;
private Set<SelectionKey> publicSelectedKeys;
  1. 在linux平台具体实现类为EpollSelectorImpl

channel 注册通道

channel可以以堵塞方式运行,也可以以非堵塞方式运行.
默认的channel是以堵塞方式运行的,因此要借助selector来监听channel的时候,必须要设置
channel为非堵塞的。

如果忘了也没事,反正会报错,多试几次就记住了。不过道理上来讲,应该把channel设置成nonblocking状态才是合理的,因为借助于NIO的非堵塞的红利,把堵塞行为转移给selector才是正经事,channel本身就不需要再以堵塞的方式,而以事件驱动更为合理。

在nio中并不是所有类型channel都那么幸运被select选择,只有selectable类型的channel才能被注册到选择器中,我们可以看到,只有更网络io设备有关的的channel才能被selector使用

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DfgrbP0T-1596446307428)(64EE7308C35C42B191B5C6C1103E6213)]

那么FileChannel是否可被selector使用?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vIOtyAkf-1596446307431)(9312B3301A4A4A9BA7F0DEB5B2FD5B8D)]

答案是NO!,他并不继承于SelectableChannel。

SelectionKey

当选择器已经被创建 ,通道已经被注册到选择器中,接下来就是关于如何使用selector来处理业务流程,其中最重要的角色,就是SelectionKey。SelectionKey我把它理解为图节点的边,如下图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HDHmzM3f-1596446307432)(11EED3510747412B99ACE5C564BDD785)]

通过这个边我们既能找到指定的管道channel,也能找到指定选择器selector

在业务处理过程中,选择器通过图中边(selectionKey)的多种集合状态(这里的key是SelectionKey),来触发指定的事件,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6mEDoCIE-1596446307433)(4F8345D0CCBC48D39D2BD4FF8DDB00CD)]

三种selectionKey集合状态

这三种状态是由Selector所维护的,主要有三种集合状态。

  1. chancelKeys

class AbstractSelector

Set<SelectionKey> cancelKeys

cancelKeys 是用来存放被取消的key,当用户通过关闭channel链接的时候,会触发跟channel指定的选择器中的cancel方法

代码验证

class AbstractSelectableChannel{
    protected final void implCloseChannel() throws IOException {
        implCloseSelectableChannel();
        synchronized (keyLock) {
            int count = (keys == null) ? 0 : keys.length;
            for (int i = 0; i < count; i++) {
                SelectionKey k = keys[i];
                if (k != null)
                    k.cancel();
            }
        }
    }
}
class AbstractSelectionKey extends SelectionKey{
    public final void cancel() {
        // Synchronizing "this" to prevent this key from getting canceled
        // multiple times by different threads, which might cause race
        // condition between selector's select() and channel's close().
        synchronized (this) {
            if (valid) {
                valid = false;
                ((AbstractSelector)selector()).cancel(this);
            }
        }
    }
}
class AbstractSelector extends Selector{
    void cancel(SelectionKey k) {                       // package-private
        synchronized (cancelledKeys) {
            cancelledKeys.add(k);
        }
    }
}

通过如上代码可以发现,关闭channel链接,会关闭跟channel相关的所有SelectionKeys,而keys会触发跟它相关的选择器selector的cancle方法,并把key放入cancelledKeys集合中。

在此过程中没有真正触发删除,而只是放入删除队列中,这是不是很像简单的jvm的垃圾回收机制呢?

class SelectorImpl extends AbstractSelector

Set<SelectionKey> keys : 代表所有注册到选择器中的key
Set<SelectionKey> selectedKeys: 代表已经准备就绪(网络数据已经到达)或者事件发送改变的或者被点亮之后的key

当选择器中的 selectedKeys(就绪key)存在元素的时候
会通知io线程并处理接下来的业务

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6rZSGlgf-1596446307436)(99870DE3730247A3AE9781CE4FA098EE)]

理清这三个对象(channel,key,selector)的关系至关重要。

如下是经典的业务处理流程

  1. 刷新key

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OGIqKRCX-1596446307438)(675702F6E2624543AEC2A4CE9A25F642)]

默认的selector概念均是来自java层,实际正在执行与用户的交互式通过以java native方式运行的,主要根据这个poll0方式,在上层selector等待刷新key的过程会调用一个poll方法等待被内核层触发的keys

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DkgwfYFP-1596446307443)(D5E123AF04CF4B6E8465A7E00EDC3F0A)]

此时selector.select() 是堵塞状态
当selector中的selectedkeys存在集合的状态下,会触发poll0,并通过系统回调继续执行select()方法的后续操作。

  1. 获取选择键
    根据第一步刷新得到的已经就绪的keys,
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i5rQT150-1596446307449)(50D560781DB842F7ABDB473E28DDCBE1)]

  2. 通过选择键获取管道(channel)或者获取selector进行业务的操作

在业务层通过这些已经就绪的keys集合来选择相应的通道和选择器,来处理相关的业务。
比如获取通道就能打开相应的socket文件

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T2TO3e2c-1596446307451)(73C3D882D38E4C9DB284EFB6DFDBB7D6)]

如果获取节点中的选择器,能够通过选择器的方法注册通道
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KExQY3ZM-1596446307454)(7EC2D3289C4B471B9CBD275F2A4D1375)]

  1. 移除被选择
    这个是最终要的一步,通过这个方法能够

selectionKey在业务处理过程中是非常重要的,利用它能够异步处理信息。在业务处理过程中,牢记这条边的两个方向是非常必要的。

当然selectionKey还有其他的方法业务非常重要
比如这条边是可读,可写,可接受(socket中的accept方法),可建立链接的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8ltEMz5V-1596446307455)(3C88082142984FD2BE619F9F2D029961)]

selectionKey其他功能
1. key能够被感知的操作/事件

在nio中可以动态地为这条边触发感兴趣的操作(interrestOps)。这些操作也是围绕上面说的读/写/接受accept/connect。有趣的是通过 与(|) 操作可以联合这些操作!

请思考这样是否违反了封装原则?
个人认为站在nio使用包的角度来说,是违反了封装原则,毕竟动态修改了对象内部的状态会引发不可预料的结果,不暴露不能很好地支持异步处理。但是站在netty框架的角度,在后期介绍的netty架构中,几乎看不见key的存在,netty框架通过封装了key的不稳定性,暴露了稳定的接口。

事件类型OP_READOP_WRITEOP_CONNECTOP_ACCEPT
值大小1 << 1(1)1 << 2(4)1 << 3(8)1 << 4(16)
5 == SelectionKey.OP_READ | SelectionKey.OP_WRITE 即可读也可写

当在业务处理过程中对selectionKey边设置感兴趣的操作,意味着会更新这条边的状态

原理如下

// interestOps 表示给当前的key动态转换感兴趣的类型,就是给当前的文件句柄
public SelectionKey nioInterestOps(int var1) {
    if ((var1 & ~this.channel().validOps()) != 0) {
        throw new IllegalArgumentException();
    } else {
        this.channel.translateAndSetInterestOps(var1, this);
        this.interestOps = var1;
        return this;
    }
}
// translateAndSetInterestOps 最终会调用native方法,对文件句柄重新设置新的属性
// 相当于改变文件的属性
this.pollWrapper.setInterest(var3.getFDVal(), var2);
2. channel支持的selectionKey类型(感兴趣的事件)
channelOP_READOP_WRITEOP_CONNECTOP_ACCEPT
值大小1 << 1(1)1 << 2(4)1 << 3(8)1 << 4(16)
ServerSocketChannel×××
SctpServerChannel×××
SocketChannel×
SctpChannel×
DatagramChannel××
SctpMultiChannel××
SourceChannel×××
SinkChannel×××

每一个channel支持的事件类型是会影响注册到selector上的选项。当超出限制

Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
...
ssc.register(selector, SelectionKey.OP_READ);
 
 错误信息,非法参数
Exception in thread "main" java.lang.IllegalArgumentException
	at java.nio.channels.spi.AbstractSelectableChannel.register(AbstractSelectableChannel.java:199)
	at java.nio.channels.SelectableChannel.register(SelectableChannel.java:280)
	at coderead.nio.httpprotocal.TestSelectionKeyWriteBug.main(TestSelectionKeyWriteBug.java:23)

2. attach缓存变量

对于这条边,它有一个缓存变量,用来存储中间过程
在IO模型的简单处理过程的编码与解码操作中,如果通过key来传播变量,那么就需要通过缓存变量存储中间结果。

--------- readyKey.isReadable() io 主线程 -------
// 对baos读取数据,保证
Request decode = decode(baos);
Response response = new Response();
readyKey.attach(response);


--------- readyKey.isReadable() worker线程 -------
readyKey.interestOps(SelectionKey.OP_WRITE);


--------- readyKey.isWritable() io 主线程 ---------
Response response = (Response) readyKey.attachment();
// 4. 解码,需要同步信息的话
byte[] encode = encode(response);
readyKey.interestOps(SelectionKey.OP_READ);
sc.write(ByteBuffer.wrap(encode));
readyKey.interestOps(SelectionKey.OP_READ);

往往通过attachment()方法获取这条边的缓存变量

这样就能使得 encode/decode操作分离到io线程中,worker线程只关注业务处理。
这样解耦的一个非常重要的原因是在实践过程中

常见的bug

当在处理selectionKey读状态的过程中,selectionKey所对应的channel没有消费(read)
的情形下,并不会改变内核中的key的就绪状态,即使通过删除迭代器删除了selectedKeys集合,select.select()方法仍然会获取一个就绪key,并在应用层进入死循环。

if(readyKey.isReadable()){
    System.out.println("死循环吗");
    SocketChannel sc = (SocketChannel) readyKey.channel();
    ByteBuffer buffer = ByteBuffer.allocate(63);
    sc.read(buffer);
    // 如果只涉及到write的话也是会死循环的
    sc.write(ByteBuffer.wrap("hello world".getBytes()));
}
SelectionKey.OP_WRITE引发的异常

在实际应用过程中当key的状态一直处于OP_WRITE状态的时候,会不断触发,即OP_WRITE状态对于内核来说是一种就绪状态。

当一直不修改WRITE事件的情况下下,意味着当前key所对用的channel是一个可写状态,但是写的操作是不可预料的,所以为了保证数据流能够正常传输,在语义上 write 等价于 ready,除非用户特别指定了其他事件。

死锁情况

当用户建立tcp链接的时候,在处理accept 的过程中,如果注册新的管道所在的线程selector.select() 所处的io线程 不在同一线程空间的话,
假设accept处理过程太慢,而selector.select()已经在等待的时候,会出现死锁

ServerSocketChannel ssc2 = (ServerSocketChannel) next.channel();
SocketChannel accept = ssc2.accept();
accept.configureBlocking(false);
new Thread(new Runnable() {
    @Override
    public void run() {
        try {
        // 子线程等待1秒,之后注册
            ThreadUtil.sleep(1);
            // 死锁在里面
            accept.register(next.selector(), SelectionKey.OP_READ);
        } catch ( ClosedChannelException e ) {
            e.printStackTrace();
        }
    }
}).start();

此时不管客户端发送什么数据,均无法被服务端接收

因为在selector.select()与 selector.register()共用了一把锁(publicKeys 是锁对象)。
所以会出现死锁的情况。

为了解决这个问题
select.select(100) // 100ms等待
// 也不确定能够在100ms内能够注册成功!!

一般来说,这个注册流程提交给io线程即处理编解码的线程使用,worker线程只处理read 和 write业务请求。

Netty线程模型

netty的底层是Nio的一层封装。netty 的模型相当复杂,这个复杂的模型是由一些简单地模型不断演进的结果。软件工程是一门动态的工程,当外部事件或外部的条件发生了变化,那么场景变了,其实现为了满足特定的场景,不得不重构。

模型的演进经历了如下三个过程

  1. 单reactor单线程模型
  2. 单reactor多线程模型
  3. 多reacror多线程模型

每一种演进何尝不是一种社会进步的表现。

什么是reactor

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HBBSchPm-1596446307459)(AFB7BD991CD74E4DB068512FED21B2D5)]

reactor翻译的意思是反应堆。针对不同的原料组成产生不同的反应。
在网络的事件中。原料指的是不同的事件。事件包括建立连接(connect) 接收连接(accept) 处理读请求(read) 处理写请求(write),分别对应SelectionKey中的四个事件值

对于实现这样监听不同事件触发不同操作的具体实现。我们可以很容易想到生产者消费者模型。生产者生产事件,消费者消费处于队列中的事件。或者消费者不主动消费,而是被动消费,这样的模型实现也叫做dispatch模型。在react 框架中就是以dispatch 方式分发不同响应事件的。

针对不同的事件会进行不同的处理,在reactor模型中抽象出一层handler用来封装异步处理各个事件,特别是对accept事件的处理,我们特别使用acceptor对象来做区分。

在netty 中是主动推送消息的方式来实现reactor模型的,因此也叫做dispatch模型。为什么用推送方式而不是以消费者主动方式呢?这个问题肯定是有答案的,暂时想不到,占个坑先

接下来来了解这三种模型

单reactor单线程模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eWSVdpVE-1596446307461)(8A00DECEC0644AEC937E37E4979E811C)]

这是最简单的一种只有一条路线的模型,可想而知,这在当今高并发的场景中已经被逐渐替代。
业务处理均发生在一条线程中,这样是很容易出现堵塞现象的。

单reactor多线程模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tESbHMys-1596446307463)(88AB99CA8F4B4579BEB177A750F5B582)]

为了避免不必要的堵塞。多线程主要抽离耗时非常少的操作耗时非常多的操作。

﹥耗时少操作 acceptor操作
对应seversocket.accept and select.register(channel)操作。

﹥耗时多操作
Encode decode handler操作是相对比较耗时的

如图所示,为什么编码和解码操作是放在reactor线程(io线程)中的呢?
这里除了耗时少的特性之外,最重要的一点是,它们的操作是通用的。针对任何通信框架来说,建立链接/解码/编码事先都是可以确定的,可以把这一套流程用于任何一套业务通信模型中。而对业务的处理就比较复杂,所以是单独分开的。

多reactor多线程模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xfgn0MPO-1596446307465)(94C401F695C64D8C9E3AD666F0CE6ED8)]

这里的多指的是主reactor(Only one)与子reactor(has many)。是对前一个模型的改进。

netty模型介绍

netty 中最复杂的模型如下所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iMRMRS0t-1596446307467)(A4AFE529EE2B40A284178C0AC189FBB6)]

其实netty可以使用一个reactor,此时就演变成了单reactor多线程模型了。

netty就是这么牛!!

netty核心组件

在nio中bind,write,read,connection等等的行为都是通过管道链接来处理的,最后都会根据select选择器监听相关事件进行异步处理.

在netty中,也是这么处理的.不过netty对nio进行了更进一步的包装.

channel

如下不作特殊说明,channel就表示netty的Channel实现类,javaChannel代表nio中的底层原生channel

以一个netty中的channel作为例子
AbstractChannel的子类AbstractNioChannel 封装 nio中的原生javaChannel,其本质是内部以组合方式包装了原生javaChannel

class AbstractNioChannel extends AbstractChannel{
    private final SelectableChannel ch;
}

不过有趣的是,channel通过并不是通过门面模式代理原生channel的所有接口,它不直接与底层的channel交互,而是通过pipeline方式传递事件到一个taskQueue,并由io线程(NioEventLoop)消费队列中的数据,并交由它的内部类unsafe做真正的门面接口的操作,unsafe底层就是利用javaChannel()产生的channel链接,对“io操作”进行异步处理。

在netty中eventloop不仅扮演着替代选择器的角色,同时也扮演着EventExecutor的角色,即异步处理消息队列中的消息,也扮演者ExecutorService角色提供提供线程执行任务(即当消费者也当生产者),并作事件异步处理,它在每个channel中有且只有一份。查看如下继承图。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dPet5Smf-1596446307469)(54AF407DD1DC4F46ACC591B26E1862C8)]

channel的理解再次强调的是,当一个新的客户端链接到服务器端的时候,就会生成一个socket文件,那么同时底层就会触发一个channel,那么这个新的channel就是一个链接,用于维护服务端和客户端通信的桥梁,一旦客户端或者服务端关闭了,那么系统就会销毁这个channel

channel的理解再次强调的是,当一个新的客户端链接到服务器端的时候,就会生成一个socket文件,那么同时底层就会触发一个channel,那么这个新的channel就是一个链接,用于维护服务端和客户端通信的桥梁,一旦客户端或者服务端关闭了,那么系统就会销毁这个channel

channel继承体系

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V6CiVpsr-1597126687600)(DBC2BC296E2A45D591AD6C4BF4B941EF)]

我们常用的是基于AbstractNioChannel类型的channel。

两个抽象的继承类 AbstractNioByteChannel 和 AbstractNioMessageChannel
它们的区别是一个channel负责 Byte数据的access(读/取),另一个channel负责Message的access。它们具体的实现类如下所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aCYWMt8C-1597126687601)(84BCA6CEB97C4BC184FC9426C7A01278)]

很明显我们常用的NioServerSorcketChannel和NioDatagramChannel是继承MessageChannel的,而NioSocketChannel是继承自ByteChannel的

那么什么是Message?
在netty中的Message分如下两个大类:

  1. ByteBufHolder 一个拥有ByteBuf的holder对象

1.1 new DatagramPacket(...) upd封装的包

1.2 new SctpMessage(...) sctp封装的包

1.3 new UdtMessage(...) udt封装的包

  1. 子channel,比如通过Serversocket生成的socketChannel

2.1 new NioSctpChannel(...)

2.2 new NioSocketChannel(...)

2.3 new NioUdtByteConnectorChannel(...)

2.4 new NioUdtMessageConnectorChannel(...)

很显然,message在ByteBuf上进行了封装,用来表达通过不同的channel生产的第一手source数据。并把这个source放在pipeline中传递,并根据不同的管道handler做相应的处理。

channel与ByteBuf

我这里为了方便记忆,做了如下channel的读取(read)事件的表格,只记录如下比较重要3个channel

AbstracChannelchannel实例doReadMessagesdoReadBytes
AbstractNioMessageChannelNioServerSocketChannelnew NioSocketChannel/
AbstractNioMessageChannelNioDatagramChannelnew DatagramPacket/
AbstractNioByteChannelNioSocketChannel/new ByteBuf

在netty中是如何触发这些message/bytebuf的处理的?

其实它们均通过channel内部的unsafe内部类中的方法,统一转载到相关channel的read/write,接下来我们来了解一下channel的内部类unsafe。

unsafe

根据类关系图,unsafe是Channel的内部类,意味着unsafe可以调用channel中的方法,在我认为unsafe就是充当了channel的角色,只是这个角色并不是同步的,而是以异步的方式来实现channel的功能,在channel中提供的接口方法都是main线程的,而unsafe不一样。

接下来以 register/write/read这些io操作分别讲解unsafe的线程异步特性.

unsafe.register特性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jTjyVkEN-1596446307471)(645DCE061CFA4312966A6999A9E02EAB)]

在注册流程中,会判断当前执行unsafe中的register方法的线程(Thread.currentThread())与eventloop(被注入channel中的io线程)是不是同一个线程

  1. eventLoop.inEventLoop()

如果答案是的话,就直接执行register0() 方法

如果不是,那么就会通过内部的eventLoop(既当爹有当妈的角色),来异步提交处理一个runnable事件。不过最终仍然是调用register0() 方法,此时可以确定,调用register0方法是在eventloop线程中的。

eventLoop.execute(new Runnable() {
    @Override
    public void run() {
        register0(promise);
    }
});

在这样的执行环境下,是能够保证线程同步(register,bind是同步操作)的。

其他安全操作

如下方法用户调用的操作是安全的。

  • *
  • {@link #localAddress()}
  • *
  • {@link #remoteAddress()}
  • *
  • {@link #closeForcibly()}
  • *
  • {@link #register(EventLoop, ChannelPromise)}
  • *
  • {@link #voidPromise()}
  • *

当然其他的bind/read/write io操作均是异步处理的。如何异步?接下来以write和read这个方法分别讲解。

unsafe.write()异步方法特性

以write为例子,通过write方法,我保证能够在同一个线程中等到注册之后才能写数据,完成了同步操作。

查看如下代码


....
channel.eventLoop().submit(new Runnable() {
            @Override
            public void run() {
                // 打印错误日志。 为什么不在pipeline中处理?
//                protected final void safeSetFailure(ChannelPromise promise, Throwable cause) {
                // 这里的!promise.tryFailure(cause),表示在作io操作的时候传递了一个Promise
                // 如果这个promise触发了tryFailure,那么久会发出这个信息
//                    if (!(promise instanceof VoidChannelPromise) && !promise.tryFailure(cause)) {
//                        logger.warn("Failed to mark a promise as failure because it's done already: {}", promise, cause);
//                    }
//                }
                // 方法1
                channel.unsafe().write("1232", new DefaultChannelPromise(channel) {
                    @Override
                    public boolean tryFailure(Throwable cause) {
//                        cause.printStackTrace();
//                        return super.tryFailure(cause);
                        return false;
                    }
                });
//                channel.unsafe().flush();

            }
        });
        ...

应用unsafe的write/flush的操作意味着不走pipeline的处理,此时用户可以自定义管道策略来处理读写操作。

这里不填代码,可以自己调试试一试,看看是否走pipeline

代码流程比较多,但是最终还是没有走pipeline

直接通过unsafe对象进行写操作是不走pipeline的

unsafe.read()异步方法特性

然而unsafe的read操作是走pipeline的,因为默认情况下,在javaChannel读取数据之后,unsafe会通过channel中的管道并作处理。

看如下的代码片段验证思路。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gsdApBw3-1596446307473)(6BE06566C6054CC8BE52DBC401781FDF)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xkLqT7pN-1596446307475)(DA5F57EB84E14B0E87BFB37A53480927)]

在safe中的read方法中,会调用pipeline的fireChannelRead操作,在这里细心地看到,这里只调用了一个pipeline的方法,其实,其本质是一个链式调用栈,在管道内部是通过不断地通过fire操作向不同节点之间传递消息,像极了web中的filter的链式设计模式。

如下我们再了解一下 pipeline

pipeline

在pipeline所提供的接口均会走context内容,并转发到unsafe对象进行异步处理。

Pipeline中文范围为管道,它是AbstractChannel中的一个成员.这样也意味着在netty中每一个继承AbstractChannel的channel实现类其内部,默认都有一个pipeline对象,除非自己自定义pipeline,否则使用DefaultChannelPipeline

pipeline在netty中的角色就是用来插拔用户自定义的handler的,因为在用户自定义handler逻辑的情况下,默认的pipeline会以其自身定义的策略来执行内置的handler的.

那么在什么情况下会触发pipeline中的handler呢?在上节的unsafe.read方法,可以看到pipeline的情况,难道只有read方法才能触发handler的操作吗?其实这个read方法,是netty封装的一个读取io设备信息的操作,对于用户来说是透明的。但是用户在触发pipeline.write方法时候,同样会触发handler操作。

  1. unsafe.read ,底层透明的操作
  2. pipeline.write (channel.write) 用户调用

默认定义的策略是

*                                                 I/O Request
 *                                            via {@link Channel} or
 *                                        {@link ChannelHandlerContext}
 *                                                      |
 *  +---------------------------------------------------+---------------+
 *  |                           ChannelPipeline         |               |
 *  |                                                  \|/              |
 *  |    +---------------------+            +-----------+----------+    |
 *  |    | Inbound Handler  N  |            | Outbound Handler  1  |    |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |              /|\                                  |               |
 *  |               |                                  \|/              |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |    | Inbound Handler N-1 |            | Outbound Handler  2  |    |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |              /|\                                  .               |
 *  |               .                                   .               |
 *  | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
 *  |        [ method call]                       [method call]         |
 *  |               .                                   .               |
 *  |               .                                  \|/              |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |    | Inbound Handler  2  |            | Outbound Handler M-1 |    |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |              /|\                                  |               |
 *  |               |                                  \|/              |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |    | Inbound Handler  1  |            | Outbound Handler  M  |    |
 *  |    +----------+----------+            +-----------+----------+    |
 *  |              /|\                                  |               |
 *  +---------------+-----------------------------------+---------------+
 *                  |                                  \|/
 *  +---------------+-----------------------------------+---------------+
 *  |               |                                   |               |
 *  |       [ Socket.read() ]                    [ Socket.write() ]     |
 *  |                                                                   |
 *  |  Netty Internal I/O Threads (Transport Implementation)            |
 *  +-------------------------------------------------------------------+


read: head-> tail 入站

write: tail -> head 出站

ChannelHandlerContext

在pipeline的实现中,有两个context,分别用来作为占位符

public class DefaultChannelPipeline implements ChannelPipeline {
    final AbstractChannelHandlerContext head;
    final AbstractChannelHandlerContext tail;
    protected DefaultChannelPipeline(Channel channel) {
        this.channel = ObjectUtil.checkNotNull(channel, "channel");

        tail = new TailContext(this);
        head = new HeadContext(this);

        head.next = tail;
        tail.prev = head;
    }
}

头的继承体系HeadContext
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TElQS21f-1596446307478)(37167CE9801247A2B2DE44F1B4ED8C0E)]

尾的继承体系TailContext
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1ghCzQmJ-1596446307480)(9069BF2471C141738A85F187F6A513F7)]

可以发现,头的继承实现体系是实现outbound和inbound接口的
尾的继承实现体系仅仅只实现了inbound的。

InboundHandler 和 OutboundHandler的作用

本质上来说,它是作为context的inbound和outbound属性的标记。持有
实现 层面上来看的话

class AbstractChannelHandlerContext

public ChannelHandlerContext fireChannelRead(final Object msg) {
// findContextInbound()
        invokeChannelRead(findContextInbound(), msg);
        return this;
}

private AbstractChannelHandlerContext findContextInbound() {
    AbstractChannelHandlerContext ctx = this;
    // 根据当前的context的next对象查找,一旦找到一个标记为
    // inbound的hander那么就直接退出循环,返回持有inbound handler
    // 的context
    do {
        ctx = ctx.next;
    } while (!ctx.inbound);
    return ctx;
}

<!--private AbstractChannelHandlerContext findContextOutbound() {-->
<!--    AbstractChannelHandlerContext ctx = this;-->
<!--    do {-->
<!--        ctx = ctx.prev;-->
<!--    } while (!ctx.outbound);-->
<!--    return ctx;-->
<!--}-->

总地来说,就是通过context::fireChannelRead触发管道的读事件,同时会根据当前context查找下一个持有inboundHandler的context。
具体实现见

class DefaultChannelHandlerContext

 private static boolean isInbound(ChannelHandler handler) {
        return handler instanceof ChannelInboundHandler;
    }
 private static boolean isOutbound(ChannelHandler handler) {
        return handler instanceof ChannelOutboundHandler;
    }

那么谁真正调用了context::fireChannelRead?

  1. eventLoop中的run方法在不停轮询监听selectionKey.OP_READ事件,并通过processSelectedKeys调用 unsafe方法,前面讲过,unsafe本身就是channel的替代品,它通过异步方式在io当前线程中处理相关的读写消息,同时read方式会走pipeline的,write方式不会走
private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) {
        final NioUnsafe unsafe = ch.unsafe();
        if (!k.isValid()) {
            final EventLoop eventLoop;
            try {
                eventLoop = ch.eventLoop();
            } catch (Throwable ignored) {
                // If the channel implementation throws an exception because there is no event loop, we ignore this
                // because we are only trying to determine if ch is registered to this event loop and thus has authority
                // to close ch.
                return;
            }
            // Only close ch if ch is still registerd to this EventLoop. ch could have deregistered from the event loop
            // and thus the SelectionKey could be cancelled as part of the deregistration process, but the channel is
            // still healthy and should not be closed.
            // See https://github.com/netty/netty/issues/5125
            if (eventLoop != this || eventLoop == null) {
                return;
            }
            // close the channel if the key is not valid anymore
            unsafe.close(unsafe.voidPromise());
            return;
        }

        try {
            int readyOps = k.readyOps();
            // We first need to call finishConnect() before try to trigger a read(...) or write(...) as otherwise
            // the NIO JDK channel implementation may throw a NotYetConnectedException.
            if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
                // remove OP_CONNECT as otherwise Selector.select(..) will always return without blocking
                // See https://github.com/netty/netty/issues/924
                int ops = k.interestOps();
                ops &= ~SelectionKey.OP_CONNECT;
                k.interestOps(ops);

                unsafe.finishConnect();
            }

            // Process OP_WRITE first as we may be able to write some queued buffers and so free memory.
            if ((readyOps & SelectionKey.OP_WRITE) != 0) {
                // Call forceFlush which will also take care of clear the OP_WRITE once there is nothing left to write
                ch.unsafe().forceFlush();
            }

            // Also check for readOps of 0 to workaround possible JDK bug which may otherwise lead
            // to a spin loop
            if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
                unsafe.read();
                if (!ch.isOpen()) {
                    // Connection already closed - no need to handle write.
                    return;
                }
            }
        } catch (CancelledKeyException ignored) {
            unsafe.close(unsafe.voidPromise());
        }
    }
  1. 用户自定义handler并在handler相关的的方法中触发ctx
    可见下方的
doXXX的设计原则

do代码实际操作的地方,其实do才是核心业务逻辑,在doXXXX更接近于底层javaChannel级别的操作.因此当看到doXXX的字眼,第一反映就应该是在做实际的XXX操作.而不是在上层的逻辑代码.

读写消息流流程实例讲解

在netty中,当读到外部的信息

NioDatagramChannel channel = new NioDatagramChannel();
group.register(channel);
channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("read mesage:::::::" + msg);
        // 用户自定义传递消息到后面一个
        ctx.fireChannelRead(msg);
    }
});


channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("read message2:::::::" + msg);
    }
});
//        channel.bind(new InetSocketAddress(8082));
channel.pipeline().fireChannelRead("123123");
System.in.read();

在unsafe.read信息的过程中,我们发现,只调用了一次
pipeline.fireChannelRead,点击查看图。

EventLoop

官方如下所示
EventLoop will handle all the I/O operations for a {@link Channel} once registered.
也就是一旦Channel被注册到selector中,那么后续所有的I/O操作都是在EventLoop中处理的。

那么EventLoop的作用是什么?有什么样的功能呢?

我们先看一下EventLoop的继承体系
在这里插入图片描述

EventLoop是一个接口,在这个接口之下有两个主类,一个是SingleThreadEventLoop(单一线程EventLoop),另一个是EmbeddedEventLoop(嵌入式EventLoop,用于测试)。

其实netty的同步非堵塞的思想就是一位内EventLoop是单一线程的,这里的单一线程意思是一个EventLoop对象其内部有一个线程在做不断地轮询事件(selector.select()),不断触发事件。

那么事件是如何被触发,以及如何被消费的?其实在上图的继承体系中,对于这个我呢提的回答需要查看SingleThreadEventExecutor.

在SingleThreadEventExecutor中,有两个属性,两个方法非常值得注意。

> 1. 两个非常重要的属性
private final Queue<Runnable> taskQueue;
private final Thread thread;

taskQueue,这个Queue是的泛型类型是Runnable,可想而知,这个任务队列是用来存放可执行task的,Thread线程很显然是用来消费taskQueue的后台线程,这个线程只有一个通过在构造方法中传入传入一个threadFactory来构建

> 2.1 第一个比较重要的方法,创建后台线程
protected SingleThreadEventExecutor(
            EventExecutorGroup parent, ThreadFactory threadFactory, boolean addTaskWakesUp, int maxPendingTasks,
            RejectedExecutionHandler rejectedHandler) {
    。。。
    thread = threadFactory.newThread(new Runnable() {
                @Override
                public void run() {
                    boolean success = false;
                    updateLastExecutionTime();
                    try {
                        // 不停在这里循环执行
                        SingleThreadEventExecutor.this.run();
                        success = true;
                    } catch (Throwable t) {
                        logger.warn("Unexpected exception from an event executor: ", t);
                    }finally{
                        // 收尾工作
                        。。。
                    }
    }
    。。。。
}

请大家注意一般默认的情况下SingleThreadEventExecutor.this.run()是在不断执行的。netty通过SingleThreadEventExecutor子类的重写run方法来间接触发对任务的消费。
我们来看一下其中的最常见的子类(见图中)的NioEventLoop,对象的run方法

在linux系统中不仅可以使用select作为io事件驱动模型,也可以使用Epoll(extend select)驱动数据,可以查看EpollEventLoop的run方法(站在内核的角度比较Nio和Epoll的方式来比较性能的,在存在较多idle connections环境下,Epoll效率更高)。站在同步非堵塞的角度来看,各个EventLoop实现类所做的工作都不会有大同小异。

class NioEventLoop


protected void run() {
    for (;;) {
        try {
            switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
                case SelectStrategy.CONTINUE:
                    continue;
                case SelectStrategy.SELECT:
                    select(wakenUp.getAndSet(false));
                    if (wakenUp.get()) {
                        selector.wakeup();
                    }
                default:
                    // fallthrough
            }
            cancelledKeys = 0;
            needsToSelectAgain = false;
            final int ioRatio = this.ioRatio;
            if (ioRatio == 100) {
                try {
                // 处理IO事件
                    processSelectedKeys();
                } finally {
                    // Ensure we always run tasks.
                    runAllTasks();
                }
            } else {
                final long ioStartTime = System.nanoTime();
                try {
                // 处理IO事件
                    processSelectedKeys();
                } finally {
                    // Ensure we always run tasks.
                    final long ioTime = System.nanoTime() - ioStartTime;
                    runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
                }
            }
            // Always handle shutdown even if the loop processing threw an exception.
            try {
                if (isShuttingDown()) {
                    closeAll();
                    if (confirmShutdown()) {
                        return;
                    }
                }
            } catch (Throwable t) {
                handleLoopException(t);
            } 
        }
}

可以看到在nioeventloop中有一个死循环for(;😉,在不断selector.select处理指定的selectionKey(processSelectedKeys()这个名字就是这个意思)。如果队列中还有其他残留事件的话,那么就runAllTasks执行全部的残留任务。

> 2.2 查看第二个非常重要的方法:启动后台线程

通过2.1我们知道,thread后台线程是通过threadFactory创建的,但是这个仅仅只是创建一个线程,并没有真正地执行任务。那么什么时候才会执行任务呢?我们回到SingleThreadEventExecutor事件执行者,

@Override
public void execute(Runnable task) {
    if (task == null) {
        throw new NullPointerException("task");
    }

    boolean inEventLoop = inEventLoop();
    if (inEventLoop) {
        addTask(task);
    } else {
        startThread();
        addTask(task);
        if (isShutdown() && removeTask(task)) {
            reject();
        }
    }

    if (!addTaskWakesUp && wakesUpForTask(task)) {
        wakeup(inEventLoop);
    }
}
private void startThread() {
    if (STATE_UPDATER.get(this) == ST_NOT_STARTED) {
        if (STATE_UPDATER.compareAndSet(this, ST_NOT_STARTED, ST_STARTED)) {
            thread.start();
        }
    }
}

可以看到 在execute中会判断当前任务是否在当前的io线程中,如果在那么在队列中添加任务,如果不在的话,那么就开启后台线程,并添加任务
什么时候才会第一次触发execute呢?
非常重要的一个方法AbstractBootstrap中的bind方法中的initAndRegister方法。

该方法是初始化channel并注册channel到selector中。

private ChannelFuture doBind(final SocketAddress localAddress) {
        final ChannelFuture regFuture = initAndRegister();
        final Channel channel = regFuture.channel();
        if (regFuture.cause() != null) {
            return regFuture;
        }

}


final ChannelFuture initAndRegister() {
    ...
    ChannelFuture regFuture = group().register(channel);
    ...
}

通过eventGroup选中其中一个eventloop用来注册,这里我们一般在boss线程中只配置一个eventloop,

class SingleThreadEventLoop


@Override
public ChannelFuture register(Channel channel) {
    return register(channel, new DefaultChannelPromise(channel, this));
}
@Override
public ChannelFuture register(final Channel channel, final ChannelPromise promise) {
    ...
    channel.unsafe().register(this, promise);
    ...
}

接下来就是通过初始化的channel回调它本身的unsafe对象,并注册channel。注册需要注意一点的是,不仅仅只是把channel本身注册进入selector,同时还要把自身以attachment的方式注册进入selector,这个是保证在异步处理的过程中,当监听到SelectionKey.OP_XXX事件的时候,能够在子线程中获取在main线程创建的channel,并通过该channel的unsafe来触发事件处理。

代码验证如下 channel中的doRegister方法用来真正的nio selector注册,另一个在做EventLoop在做事件监听的时候在处理processKey的过程中获取注册时候的channel对象。

class AbstractChannel


 protected void doRegister() throws Exception {
        boolean selected = false;
        for (;;) {
                selectionKey = javaChannel().register(eventLoop().selector, 0, this);
                return;
        }
class AbstractChannel
...
 public final void register(EventLoop eventLoop, final ChannelPromise promise) {
 ...
    if (eventLoop.inEventLoop()) {
        register0(promise);
    } else {
        eventLoop.execute(new Runnable() {
            @Override
            public void run() {
                register0(promise);
            }
        });
    }
    ...
}


class NioEventLoop

private void processSelectedKeysPlain(Set<SelectionKey> selectedKeys) {
    ....
    final Object a = k.attachment();
    ....
}

private void processSelectedKeysOptimized(SelectionKey[] selectedKeys) {
    ....
    final Object a = k.attachment();
    ....
}

我们知道bootstrap是用户调用的,当前的线程定然是main线程的,所以,第一次会直接触发eventloop的后台线程的启动执行run方法来消费启动的任务,此时是没有任务的,当channel注册成功之后,接下来才是bind操作,bind操作是通过注册future并添加一个监听者来完成最后的端口绑定

回到Bootstrap的注册doBind操作。我们可以看到在regFuture中add了一个LIstener,用来异步监听并真正的doBind0操作。

class AbstractBootstrap

private ChannelFuture doBind(final SocketAddress localAddress) {
    final ChannelFuture regFuture = initAndRegister();
    final Channel channel = regFuture.channel();
    if (regFuture.cause() != null) {
        return regFuture;
    }

    if (regFuture.isDone()) {
        // At this point we know that the registration was complete and successful.
        ChannelPromise promise = channel.newPromise();
        doBind0(regFuture, channel, localAddress, promise);
        return promise;
    } else {
        // Registration future is almost always fulfilled already, but just in case it's not.
        final PendingRegistrationPromise promise = new PendingRegistrationPromise(channel);
        regFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                Throwable cause = future.cause();
                if (cause != null) {
                    // Registration on the EventLoop failed so fail the ChannelPromise directly to not cause an
                    // IllegalStateException once we try to access the EventLoop of the Channel.
                    promise.setFailure(cause);
                } else {
                    // Registration was successful, so set the correct executor to use.
                    // See https://github.com/netty/netty/issues/2586
                    promise.executor = channel.eventLoop();

                    doBind0(regFuture, channel, localAddress, promise);
                }
            }
        });
        return promise;
    }
}

这里也顺带着把netty的启动流程也走了一遍。

读者思考

在netty中是如何读取客户端的?它经历了哪些阶段或类或方法?同样写又如何?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值