netty 学习笔记
netty常用的 IO 模型BIO同步阻塞 IOjava 中的 BIONIO同步非阻塞 IOjava 中的 NIOReactor 模式事件驱动模型Netty 底层实现典型应用组件解码器Channel 通道使用示例常见问题JDK NIO 的空轮询 Bug半包和粘包IO 就是计算机内部与外部进行数据传输的过程比如网络 IO 与磁盘 IO所有 IO 都需要系统调用由操作系统代理执行并经历从IO设备拷贝到内核空间拷到用户空间的环节java 中有管理堆外内存的类是个特例在内核收到调用请求之后会有数据准备、数据就绪、数据拷贝的阶段常用的 IO 模型BIO同步阻塞 IO同步阻塞 IO 模型中应用程序某个线程发起 read 调用后会一直阻塞直到在内核把数据拷贝到用户空间在这个模型中一个线程对应一组操作这组操作中的阻塞操作与非阻塞操作都会让线程阻塞这样是非常浪费效率的java 中的 BIO传统的 IO 编程每一个客户端连接都使用一个线程在这个连接不传输信息的时候也会占用线程此外所有调用都是阻塞的try{ServerSocketserverSocketnewServerSocket(666);SocketsocketserverSocket.accept();OutputStreamoutputStreamsocket.getOutputStream();//.......outputStream.close();socket.close();}catch(IOExceptione){e.printStackTrace();}NIO同步非阻塞 IO上面的代码是一个经典的每连接每线程的模型之所以使用多线程主要原因在于 socket.accept()、socket.read()、socket.write() 三个主要函数都是同步阻塞的当一个连接在处理 IO 的时候系统是阻塞的如果是单线程的话必然就挂死在那里但 CPU 是被释放出来的开启多线程就可以让 CPU 去处理更多的事情现在的多线程一般都使用线程池可以让线程的创建和回收成本相对较低。在活动连接数不是特别高小于单机1000的情况下这种模型是比较不错的可以让每一个连接专注于自己的 I/O 并且编程模型简单也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗可以缓冲一些系统处理不了的连接或请求。不过这个模型最本质的问题在于严重依赖于线程。但线程是很贵的资源主要表现在线程的创建和销毁成本很高并且线程本身占用较大内存像 Java 的线程栈一般至少分配512K1M的空间如果系统中的线程数过千恐怕整个 JVM 的内存都会被吃掉一半有没有一种技术可以降低线程数的同时保证如此高量的连接请求呢有的就是 NIO说一下 NIO 的原理NIO 的主要事件有几个读就绪、写就绪、有新连接到来。主要的参与对象有几个处理器、事件选择器我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器我对这个事件感兴趣。对于写操作就是写不出去的时候对写事件感兴趣对于读操作就是完成连接和系统没有办法承载新读入的数据时对于 accept一般是服务器刚启动的时候而对于 connect一般是 connect 失败需要重连或者直接异步调用 connect 的时候然后用一个死循环选择就绪的事件会执行系统调用还会阻塞的等待新事件的到来。新事件到来的时候会在选择器上注册标记位标示可读、可写或者有连接到来注意select 是阻塞的无论是通过操作系统的通知epoll还是不停的轮询sellectpoll这个函数是阻塞的。所以你可以放心大胆地在一个 while(true) 里面调用这个函数而不用担心 CPU 空转java 中的 NIOjava 中的 NIO、Radis 中的单线程、Netty 框架就是 IO 多路复用传统的 BIO 里面 socket.read()如果 TCP RecvBuffer 里没有数据函数会一直阻塞直到收到数据返回读到的数据。表现为我们需要传入一个 x 参数给 socketsocket 读取 x 后或者链接关闭了才会返回但是对于 NIO如果 TCP RecvBuffer 有数据就把数据从网卡读到内存并且返回给用户反之则直接返回0永远不会阻塞。这个是给 socket 设置了非阻塞的条件让其从套接字中能读多少数据读多少数据这样的话方法永远不会关闭示例代码如下// 像餐厅叫号系统一个服务员管理多个客人谁准备好了服务谁SelectorselectorSelector.open();ServerSocketChannelserverChannelServerSocketChannel.open();serverChannel.configureBlocking(false);// ✅ 非阻塞模式serverChannel.register(selector,SelectionKey.OP_ACCEPT);while(true){selector.select();// 等待就绪的事件SetSelectionKeykeysselector.selectedKeys();for(SelectionKeykey:keys){if(key.isAcceptable()){// 有连接到达acceptConnection(key);}elseif(key.isReadable()){// 有数据可读readData(key);}}}// 优点一个线程管理成千上万个连接java 中的 NIO 通信是 IO 多路复用的一种实现reactor 模式是其一种实现的思想使用选择器缓冲区、通道来实现通道将用户数据拷贝到缓冲区中选择器让程序读取缓冲区中数据java 中一个线程对应一个选择器一个选择器对应多个连接通道选择器选择那个通道是由事件确定的Buffer 是一个内存块底层用数组实现要么是输入状态要么是输出状态每一个通道都要注册到选择器中选择器检查是否有事件发生来进行通道的选择Reactor 模式又叫分发者模式反应器模式通知者模式这个是高性能 IO 的基石Reactor 模式又分为三种子模式单 Reactor 单线程这个模式下处理连接与业务的只有一个线程Reactor 进行阻塞操作的分离并将需要线程处理的业务交给 Handler 进行处理这种模式可以应对较少客户端连接下快速业务的处理比如 Redis 的 IO 多路复用在这个模式下Handler 绝对不能做耗时操作比如查数据库因为只要 Handler 卡住整个线程就卡死了单 Reactor 多线程Reactor 由一个线程控制将业务交给线程池控制的 Handler 处理主从 Reactor主 Reactor 负责建立连接从 Reactor 负责调用 read 把数据从内核接收缓冲区搬到用户态 ByteBuf以及调用 write 把用户态 ByteBuf 搬回内核发送缓冲区Handler 负责处理业务分为三层处理业务较大的提高了效率netty 就是使用了这种模式。无论何时网卡到内核那段仍然由内核网卡负责不是 Reactor 线程亲自搬的// 1. 创建SelectorJava层的SelectorselectorSelector.open();// 2. 注册Channel和感兴趣的事件Java - 操作系统SocketChannelchannelSocketChannel.open();channel.configureBlocking(false);// 必须非阻塞channel.register(selector,SelectionKey.OP_READ);// 注册读事件// 3. 轮询while(true){// select() 是阻塞的会等待直到有事件intreadyCountselector.select();// 这里会阻塞if(readyCount0){SetSelectionKeyreadyKeysselector.selectedKeys();// 处理事件...}}Reactor 模式的思路是基于一个选择器的死循环线程select/poll/epoll。同时基于事件驱动将 IO 封装成不同的事件每个事件配置对应的回调函数事件驱动模型事件驱动模型是一种编程范式其中程序的流程由外部事件如用户输入、网络请求、定时器等驱动。在这种模型中程序通常会注册一系列事件处理器当特定事件发生时相应的处理器会被调用netty 的事件驱动模型工作原理是事件队列中有了待处理的事件后会不断从事件队列中取出事件并分发给相应的事件处理器事件处理器就是处理具体事件的代码逻辑这里事件处理器会不断的等待事件的到来在传统的多线程模型中每个任务都需要一个线程这会导致大量的上下文切换和资源消耗。事件驱动模型通过少量的线程处理多个事件减少了线程的开销同时 netty 使用了零拷贝技术做了优化减少了上下文切换和数据复制的次数Netty 底层实现netty 使用了反应器模式它允许程序在等待 I/O 操作完成时不被阻塞而是继续执行其他任务。这种方式特别适合处理大量并发连接的场景因为它可以显著减少线程的数量和上下文切换的开销netty 的工作原理如下1注册事件应用程序向操作系统注册感兴趣的 I/O 事件如读就绪、写就绪。注册的接口是操作系统提供的2事件轮询操作系统使用选择器Selector来监控多个 I/O 通道Channel并等待其中一个或多个通道准备好进行 I/O 操作3事件通知当某个通道准备好了数据被复制到用户空间中或者系统空间中并通过共享内存方式让用户进程操作。总之当通知事件产生的时候进程可以直接拿到数据而不用等待操作系统会执行通知此时选择器会知道那些通道里面有事件。此时需要应用程序不断轮询选择器4处理事件应用程序处理该事件执行业务逻辑典型应用netty 是高性能基石很多高性能的框架都是基于 netty 做的。需要处理高并发连接如万级 TCP 长连接、低延迟、高吞吐量的网络服务推荐使用典型应用WebSocket 服务器实时推送如股票行情、在线游戏RPC 框架底层Dubbo、gRPC 的通信层自定义协议服务器物联网IoT设备接入如 MQTT、Modbus组件解码器解码器Decoder是 ChannelInboundHandler 的一种负责将原始字节流ByteBuf转换为应用层协议对象如 String、POJO我们解决 TCP 粘包/半包问题或者解析自定义二进制协议时需要使用到这个常见的解码器包含固定长度解码器FixedLengthFrameDecoder每个数据包长度固定分隔符解码器DelimiterBasedFrameDecoder按特殊字符拆分消息长度字段解码器LengthFieldBasedFrameDecoder处理包含长度字段的自定义协议如 DubboChannel 通道Channel 是 Netty 网络通信的核心抽象代表一个开放的连接如 TCP Socket、UDP 或文件 IO封装了底层操作提供以下能力数据读写通过 Channel.write() 和 Channel.read() 实现事件通知如连接建立channelActive、数据到达channelRead、异常捕获exceptionCaught配置参数如 TCP 缓冲区大小、Nagle 算法开关ChannelOption.TCP_NODELAY此外还有 ChannelHandlerContext它是一个处理环节的执行上下文。包含 Handler 引用、前后环节、Channel 引用生命周期是 Handler 添加到 Pipeline → Handler 移除主要用来执行 Handler 逻辑、传递数据使用示例// 负责分发链接 EventLoopGroup bossGroup new NioEventLoopGroup(1); // 建议 1 个线程接受连接 // 负责搬运数据 EventLoopGroup workerGroup new NioEventLoopGroup(); // 默认 CPU*2 线程 ServerBootstrap b new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { // Pipeline 事件传播机制接收到数据后会按序传播 ChannelPipeline p ch.pipeline(); // 拆包 p.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); // 前置处理 p.addLast(new LengthFieldPrepender(4)); // 解码 p.addLast(new StringDecoder()); // 只有在发送数据的时候会切换到 encode p.addLast(new StringEncoder()); // 业务 p.addLast(new MyBusinessHandler()); } }); ChannelFuture f b.bind(8080).sync(); f.channel().closeFuture().sync();Netty 底层其实就是在跑一个死循环for(;循环里干三件事1select() 看看有没有事发生连接、读、写。2如果有读就 read() 从内核搬到用户态顺着流水线往后丢。3如果有写就从流水线往前拿write() 从用户态搬到内核态。周而复始。常见问题JDK NIO 的空轮询 Bug在 Linux 系统下JDK 的 Selector.select() 方法可能会在没有就绪事件时被唤醒返回 0导致 CPU 空转100% 占用。触发条件为某些 Linux 内核版本如 2.6.x的 epoll 实现存在缺陷或者网络连接异常比如连接被对端重置但未关闭Netty 通过 Selector 重建机制和空轮询检测解决该问题核心思路如下1空轮询次数统计在 NioEventLoop 中每次 select() 返回 0 时计数器 selectCnt 递增。如果 selectCnt 超过阈值默认 512判定为空轮询 Bug 触发2重建 Selector很简单就是关闭旧的 Selector创建新的 Selector。将原有 Channel 重新注册到新 Selector半包和粘包TCP 粘包是指发送方多次发送的小数据包被接收方一次性接收从接收缓冲区看后一包数据的头紧接着前一包数据的尾导致应用层不知道这个包从哪里结束了半包则指发送方的一个大数据包被接收方拆分成多次接收为什么会出现这种情况呢TCP 是面向流的协议本身没有消息边界的概念数据像水流一样连续传输这个问题出现的核心原因是 TCP 是字节流服务TCP 不知道消息之间的界限不知道一次性提取多少字节的数据所造成的// 从 TCP 的视角看数据 发送方发送了 3 条消息 Message1: Hello Message2: World Message3: Netty // TCP 看到的只是字节流 字节流 HelloWorldNetty // 接收方可能收到 情况1 HelloWorldNetty // 粘包3条粘成1条 情况2 He lloWorld Netty // 半包粘包 情况3 Hello World Netty // 理想情况但不可靠UDP 不可能出现粘包问题因为它不用窗口接受不是面向字节流的每次只接受一个数据包每个 UDP 段都是一条消息应用程序必须以消息为单位提取数据而 TCP 是套接字传输方式TCP 粘包发生的表面原因有两点1发送方如果有连续几次发送的数据都很少通常 TCP 会根据优化算法把这些数据合成一包后一次发送出去2接收方如果未及时清理缓冲区的数据造成多个包同时接收根本原因还是因为 netty 是面向字节流的传输协议只负责消息的传输对消息的边界不关心解决粘包问题有很多方案以下提出部分方案在 netty 中对应解码器1定长发送 FixedLengthFrameDecoder发送端在发送数据时都以 LEN 为长度进行分包。这样接收方都以固定的 LEN 进行接收如此一来发送和接收就能一一对应了。分包的时候不一定能完整的恰好分成多个完整的 LEN 的包最后一个包一般都会小于 LEN这时候最后一个包可以在不足的部分填充空白字节2头尾部标记 DelimiterBasedFrameDecoder在每个要发送的数据包的尾部设置一个特殊的字节序列此序列带有特殊含义跟字符串的结束符标识”\0”一样的含义用来标示这个数据包的末尾接收方可对接收的数据进行分析通过尾部序列确认数据包的边界头部标记则是定义一个用户报头在报头中注明每次发送的数据包大小。接收方每次接收时先以报头的 size 进行数据读取这必然只能读到一个报头的数据从报头中得到该数据包的数据大小然后再按照此大小进行再次读取3加入消息长度信息 LengthFieldBasedFrameDecoder最常用在收到头标志时里面还可以带上消息长度以此表明在这之后多少 byte 都是属于这个消息的。如果在这之后正好有符合长度的 byte则取走作为一个完整消息给应用层使用