发现自己近一年有些毛病,自己也算是研习了不少的源代码,看了不少的技术书籍,但是自己就是记忆力不行,总是过段时间就会忘记,忘记之后还得从头开始啃源码、啃书籍。而且有些重要技术点也会遗忘,导致再学习的时候发现自己又回到了起点!我总结为,就是自己近一年期间犯懒,没有再写一下博客,技能点不能很好的再回顾!
趁着发现自己的问题,同时自己也在做前后端rpc分离实践,现在将之前研习netty的结果再总结出来,写到博客上!
首先,我们要确定java中的netty用来做什么的?具体的工作模式优势不解释,网上能找一大堆,主要讲它通信这块的rpc,高效稳定的协议栈绕不开tcp/ip协议!
本来是不想记录tcp/ip的,这个实在是没有太多好说的,但是也发现虽然自己明白,有时候却也是会遗漏要点知识,所以也还是记录一下吧!
似乎作为上层程序员只需要了解tcp的握手与挥手情况即可!
首先要了解tcp/ip的头部结构,
16位源端端口 | 16位目标端端口
32位序列号(发送端确认信息)
32位确认序号(服务端确认)
4位偏移量(每个数字表示1个4字节,所以最大表示15个4字节,也就是60字节)----
6位保留位(记不太清了) | 标志位:包括6种报文段 urg、ack、rst、syn、fin、psh
16位校验和 | 16位紧急指针
16位窗口大小(用于tcp缓冲区的控制)
40位填充字段。
我觉得挺容易理解的,作为通信双方必须知道相互的地址,所以要有源地址与目标地址的标记;序列号与确认序列号,这个涉及到数据包分片,比如mtu导致了数据分片,如何分片与如何重组分片;偏移数据表示tcp头部结构能够占用的最大数据长度;保留位大概就是保留用的吧;标记位,6个就是我们常说的同步报文段、确认报文段的标记;窗口大小表示一次传输数据的大小,由于是16位,按照二进制计算也就是65535字节;校验和据说是为了教研数据有效性的,我通过抓tcp的包看到类似这样的
;16位紧急指针,这个是配和urg报文段设置优先紧急数据的。
然后是三次握手,与四次挥手中tcp状态的转移。都是基于上面tcp头部结构进行变化的。
三次握手
1):client将syn标志位置1,序列号seq置x(随机数), 发送到server端,client状态为同步发送状态,表示等待server端的确认;
2):server端收到client端数据包,判断syn=1,了解到为同步报文段。则这时,将要发送数据的tcp头部分别设置syn与ack为1,确认序列号x+1,序列号置y(随机数),发送到client端,server端状态为同步接收状态;
3):client端收到serve数据包之后,判断是否ack报文段为1,确认序列号是否为x+1,
符合则将要发送的数据的tcp头部的ack报文段置1,确认序列号y+1,然后发送;到达server端之后,检查ack报文段是否为1 && 确认序列号是否为y+1,符合则建立连接。
此时client与server都进入了建立成功阶段。
四次挥手
1): client将要发送报文信息的tcp头部的fin报文段置1,序列号置随机值x,发送给server端,client端状态为结束等待状态;
2): server端收到fin结束报文段,将发送报文信息的tcp头部ack置1,确认序号为x+1,server端进入关闭等待阶段;
3):server端再次发送一个报文数据,tcp头部fin置1,此时server端进入了最后确认阶段;
4):client接收到了server的ack报文信息与fin报文信息之后,验证确认序列号x+1,然后再向server端发送一个tcp头部ack=1的报文段,client进入time_wait阶段,server端收到信息验证后进入关闭状态;
最后的时刻client等待2*msl(最大报文传输时间)时间长度,进入close状态。
第二绕不开的一点就是网络的io模型!一般我们应该都知道有几种常谈的io模型,阻塞式、非阻塞、异步io、多路复用、信号驱动的io模型,这里java常用的就是非阻塞式io与多路复用io也就是nio!nio基reactor的模型进行设计的。所以接下来要讲一下nio与reactor模型。
Nio,这个我们需要去看epoll的解释,最好去看epoll的源码,这里我建议看一下深入理解nginx这本书中的epoll的解释,详细描述了代码状况!下面具体谈一下epoll的优秀思想设计。
首先要知道原来模式的弊端,原有linux2.X(某版本)之前的操作系统,采用select/poll的形式这两种形式在进行连接事件的收集的时候,是将所有活跃与不活跃的套接字由用户态的内存向操作系统的内核内存传递,然后由操作系统内核扫描所有套接字,不管是内存还是cpu都造成巨大的浪费。而epoll则不是,它定义了一个文件系统,首先,调用epoll_create创建一个epoll对象;然后,调用epoll_ctl向epoll添加所有的连接套接字;第三,调用epoll_wait收集活跃的连接。这样它就避免了完全的内存复制,只进行收集活跃连接,然后也不用遍历原来所有的连接,使cpu也降温。
Eventpoll是创建epoll时候的数据结构,里面是一种红黑树的数据结构,用于存储epoll_ctl向epoll对象中添加的事件,为什么使用红黑树,因为如果出现重复的套接字可以很快的识别出来。据书上说,被添加到epoll中的时间都会与网卡建立回调关系epoll_callback。Eventpoll中含有一个红黑树的根节点rbr与双向链表rdllist,回调事件会放到rdllist,所以epoll_wait扫描事件连接的时候就只针对这个rdllist的链表进行遍历,然后将事件复制给用户内存,所以也就保证了效率。
第三,Reactor模式(也有人叫做反应器模式)!Netty中的nio线程模型命名了三种模式:Reactor单线程模型、Reactor多线程模型和主从多线程模型!实际上都是根据上边epoll的三阶段进行的划分,所以抽象出这三种阶段才能更好的理解这三种设计的模型,也就是得理解epoll的三种事件!所有这三种模型都是围绕着三种阶段进行的。
单线程模型:就是说把这三个阶段统统放在一个线程上操作。该线程负责,创建事件对象,accept收集活跃socket,分发器分发二进制消息进行读写操作(接收数据、发送数据)。
多线程模型:这个其实主要是将收集活跃的socket与事件分发分成了两步;一个专门的线程用于收集活跃的socket,另一个线程池专门负责数据的读写操作;
主从多线程模型:这个其实是在多线程模型上进一步的加固;用来收集活跃socket的线程改成了线程池,acceptor接收到tcp socket接收验证(这个应该是指的tcp的数据验证)连接后,将该连接的通道交给io处理线程池的一个线程上进行io的读写操作。
实际上就是这么回事,只是将原来串行的操作改成了并行化,同时保证线程的安全性!
(二)
Netty源码分析:
做一套rpc长连接框架,架构上其实没有多么难,只要具体里面的步骤包括即可:
一、全双工的socket连接;
二、心跳检测
三、超时重连、重传
四、白名单
五、编解码
这里还有相当多的技术规范与技术点,比如tcp消息封装定义消息头、消息体;定义反射类与方法用于远程方法执行;主从线程与线程池的设定;编解码序列的定义;粘包、拆包,涉及到tcp报文包的分片;网络的流量、拥塞控制;编解码中的大小端;buffer数据解析等。
其实,我不得不吐槽一下,netty源码分析那本书有些地方并不好,有些地方没有按照思维逻辑去讲解,而是按照执行顺序,这样其实并不符合这么一个项目的构建过程!
还有一点,讲太细致,太深入,如果要全部分析完成,恐怕会非常耗时,其实很多只需要整体把握,如果有时间再慢慢消化!
全双工连接:
对于java来说,原生的java nio,其存在固有的复杂性与bug,难以令人满意!而netty则将用户边界做了封装,降低用户的开发难度,实际上我对于netty这本书的讲解流程并不满意,因为它是按照代码的执行顺序讲解的,实际上并不符合人的思维逻辑;接下来我将会先从书中将的内容顺序梳理,然后再通过思维流程进行梳理一遍。
首先要通过epoll理解buffer与channal!这两者是不同的,channal从物理上将是位于机器的内核空间,处于一个接收网络数据、本地文件数据等一系列前置底层的数据操作!
而buffer则不然,对于java来说,就是为了将这些数据进行结构化,同时在物理上讲这是一种位于用户空间,也就是java进程中为了与对应java基本数据类型等的映射!
Server端:
一、根据netty权威指南,ServerBootstrap是socket服务端的启动辅助类,用途是封装启动参数;
二、使用绑定Reactor线程池,处理多路复用的管道channal (netty书上说使用EventLoopGroup,但是我没有找到对应类的源码,可能读的不够深吧),也就是连接;
三、使用ServerSocketChannel嫁接数据 =》处理活跃连接的管道上,处理活跃连接;
四、ChannalPipeline这一步应该并不归于建立连接!用于处理请求数据的,比如验证信息、编码解码、心跳检测、流量控制等;
五、绑定监听端口,ServerSocketChannal注册到Selector(多路复用器,也就是为了处理epoll中的epoll_wait活跃事件)
六、轮询Selector,找出对应的Channal;
最终建立连接会有一个listen方法传入文件描述符、backlog,具体的实现类方法可以看
DualStackPlainSocketImpl
这个类,会发现一个这样的方法
最终调用native的c函数获取操作系统的描述符,建立连接。关于backlog这个参数
是内核为此套接字排队的最大连接的个数。
然后关于建立连接使用的两个队列:未连接队列、已连接队列,就是涉及到tcp的相关知识了!如果是建立连接的时候客户端发送syn报文段,此时未连接队列创建一个节点,当server端发送syn与ack报文段之后一直到server端收到client端的ack报文段之前一直处于未连接队列;如果server端收到client的第三次握手ack,则将未连接队列的此节点移动到已完成连接队列的队尾。系统默认为5,一般都会进行设置用以支持高并发!
正是因为不同的操作方式,所以有了不同的操作位!SelectionKey,中有四个常量分别为OP_READ、OP_WRITE、OP_ACCEPT、OP_CONNECT,用于表示不同的事件。这样切合了事件机制。这四种常量分别为针对1的位操作,分别为1、4、8、16,对应二进制位为0001、0100、1000、10000;书上指定是说错了的!使用位操作的优势,说是方便网络操作位状态的判断与修改,这我可以理解,因为如果都转成二进制的话,在数据结构上更方便转换,但是我不理解的是为什么不适用0001、0010、0100、1000呢?后续再考量这到底是怎么回事!
基本上整体思路就是这样,具体到内部还有很多相关于java的内容,比如为什么ChannalHanlder使用volatile?Volatile只保证了可见性,没有保证原子性,我也没有找到它需要保证原子性的操作,那么是它不需要保证原子性还是再更深处的地方做了处理?
Client端:
终于到了client端了!关于这块暂时不具体考虑netty的实现,而且不再按照书上说的代码执行顺序讲,可观看TcpClient!
一、建立双工连接,可以使用NioSocketChannal、SocketChannal;
二、根据tcp的三次握手报文段,确认SelectionKey的枚举类型;
如果进入ChannalActive阶段,也就是epoll中epoll_waite活跃的连接,则设置网络操作位为SelectionKey.OP_READ阶段,否则连接到selector(多路复用器)阶段;