Netty系列(一):入门须知

1,063 阅读17分钟

首页

在开始学习netty之前,需要对一些在学习netty的过程中,需要知道的一些概念有一定的了解,说白了,如果对这些概念不了解的话,那么可能在后续的学习的过程中,会很吃力,事倍功半。

所以,本文的内容,主要就是针对一些关于学习netty的前置知识的学习。

因为我本人也是在学习netty中,因此本篇文章可能写的不是那么详细,因此在我学习的netty的过程中,会一步一步的将这篇文章进行补全。

正文

当前的互联网时代,我们称之为web2.0时代,也就是我们理解的移动互联网时代,互联网+时代,在这个时代里面,互联网技术得到了飞速的发展,逐渐走入了千家万户中,尤其是分布式技术,更是成为了当前开发团队的一个主流话题。

当然,掌握了分布式技术,确实是可以在找工作的过程中,得到更多的机会,但是要注意的是,分布式技术,其中有很多的地方用到了远程服务调用技术,这一部分技术如果深挖,其实很大程度上是和java NIO技术息息相关。

但是如果仅仅这样,还不足以说是让我们去了解NIO技术,去了解netty,或者还没有足够的动力去了解这些技术。但是如果我们愿意去畅享更远的未来,在不久的未来,物联网时代,成千上万的设备,通过网络连接到我们的后台,那么这个时候,在应用层面上面的通信技术,将成为我们的最大痛点。

尤其是,在2022年的这个时间点,我们要看到,移动互联网web2.0时代,已经成为了厮杀竞争极为剧烈的一个红海,而概念炒的非常火爆的web3.0时代,又在技术上和商业模式上,并没有大的突破和落地场景。

这个时候,我们可以看到,如果只能确定物联网是我们未来的发展方向,那么学习netty,将是我们技术人员一个无法避免的事情。

一 . 什么是netty

关于什么是netty,我摘抄了网络上的一段经典的话:

Netty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供异步的、基于事件驱动的网络应用程序框架,用以快速开发高性能、高可靠性的网络 IO 程序,是目前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,知名的 Elasticsearch 、Dubbo 框架内部都采用了 Netty。

可以看出来三个关键点:

1. netty是一个网络应用程序的开源框架,用以开发网络IO程序。
2. netty是一个提供异步通信的。
3. netty是基于事件驱动的。

基于这三个关键点,引申出了大量关于要学习netty需要提前知道的一些前置性知识。

二 . 网络通信是什么?

什么是网络通信呢?

我们这里讲的网络通信,指的是netty所在的场景,也就是根据TCP/UDP协议,或者是http协议等等(实际上netty支持了很多通信协议,如果看过netty的源码就知道,在这里就不具体讲述了)

那么,我们这里讲的网络通信,更多的还是在应用层面的操作。

三 . 网络IO是什么?

在一般的互联网语义中,IO,其实是指读写IO,读写IO分为了两种,一种是网络IO,一种是磁盘IO。

磁盘IO是指代对硬盘中的字节的读取速度,也就是磁盘的读写能力。在这里我们不去细究磁盘IO,我们只去考虑网络IO,因为netty只有网络IO,不支持持久化,也就自然没有磁盘IO的场景。

那么网络IO,自然就是通过网络Intenet进行数据传输的读写操作。

那么,这个传输的读写操作,是怎么实现的呢?

我们就需要去了解一下用户态和内核态了,因为网络IO的数据传输以及读写操作,和这两者有着密不可分的关系。

四 . 用户态和内核态是什么?和网络IO有什么关系?

简单来说,用户态操作的是我们的用户程序,而内核态操作的是系统程序和硬件。

比如说,我们的java服务,就是一个用户程序,那么在这个程序里面执行的操作,就是处于一个用户态,但是我们溯究java程序的底层,发现最终的代码调用都是操作系统的代码,这个时候,就会从用户态转换到了内核态。

因此,简单的理解,用户态就是运行java程序的时候CPU的状态。而内核态,就是运行操作程序(windows和linux)和硬件的CPU的状态。

在我们实际在运行一个java程序的时候,CPU是先处于一个用户态,等到调用到操作系统的程序的时候,CPU就转换为了内核态,然后运行完操作程序的代码后,返回到java程序的时候又转换为了用户态,这两种状态互相的转换变化,组成了我们真实的程序运行过程。

那么这个内核态和用户态和我们的网络IO有什么区别呢?

我们都知道,IO分为了两种,一种是读IO,一种是写IO,具体如下所示:

读IO: 数据从网络传输中过来-->读取到网卡缓冲区-->读取到内核缓冲区-->读取到用户缓冲区

写IO: 数据从用户缓冲区-->写到内核缓冲区-->然后写到网卡缓冲区-->进行网络传输

可以看到,实际的网络IO,都是转到内核态中进行,而我们的驱动,是从用户态开始的。

这大概就是内核态用户态和网络IO之间的联系。

五 . 网络IO的方式有哪些?

网络IO的方式有很多,随着时间的发展,同步阻塞IO,同步非阻塞IO,多路复用IO,异步IO等等,可以看出,互联网世界对IO的性能要求越来越高,也越来越复杂。

接下来,我们从java语言出发,将一个一个的去了解这些IO方式,以及其内部的思路和实现方案。

5.1 同步阻塞IO(BIO)

同步阻塞IO,是java在1.4版本前提供的网络IO模式,通常采用的是有一个独立的Acceptor线程(可以简单理解创建一个ServerSocket实例),通过accept()方法负责监听客户端的连接,它接受到客户端连接请求后,为每一个客户端连接创建一个新的线程(即创建一个socket)来进行链路处理,在处理完成之后,通过输出流返回应答给到客户端,并且销毁线程(销毁socket)。

拿读IO来举例,当java应用程序发起read操作的时候,也就是调用底层的recvfrom系统调用(这里举例的是UDP协议,在UDP协议中,java应用程序是调用操作系统的recvfrom函数,来接收客户端的网络数据的。),用户态将会进入到等待状态,等待数据的就绪。这是java程序的第一个阻塞状态

然后等到数据就绪了,要从内核态的缓冲区拷贝数据到用户态缓冲区的时候,这个时候,java应用程序也阻塞住了。这是java程序的第二个阻塞状态

由此可以得知,BIO的问题主要在于:

第一点,每一个客户端请求过来,都会生成一个线程,那么,当客户端的请求很多的时候,服务端的线程个数和客户端的访问数呈1:1的正比关系。而我们都知道,线程在JVM中是非常宝贵的资源,而且实际的内核态线程的数量,其实是和CPU的内核息息相关的(可以单纯的理解为1:1的关系),那么如果线程的数量膨胀之后,JVM线程虽然很多,但是实际上是在内核态的几个数量有限的真实线程中做来回切换的,且JVM自己能承担的线程的数量也是有限的,如果用户线程数量持续膨胀,那么早晚会导致堆栈溢出,服务宕机。

第二点,在实际的数据传输中,BIO是通过字节流为单位,进行传输的,这个会导致读写的效率非常低下,从内核态复制到用户态,每一次的复制都是字节流的形式,那么会导致耗费的时间太长。

在jdk1.4之前,java因为不支持NIO模式,导致当年的开发者,为了规避JVM里面的线程太多而宕机的问题,不得已使用了伪异步的方式。

什么是伪异步的方式呢?如下所示:

其实这种方式,在我们的实际开发中也经常用到,比如说我们有一个文件上传的服务,并发情况下有许许多多的文件上传,那么我们可以自己做一个线程池,文件传输到服务器后,然后将这个客户端的socket封装成一个Task,投递到线程池中去执行,而服务器则直接返回一个既定的fileId(即文件id,也可以是获取文件的url路径)给到前端,不再等待结果。当线程池中的线程将文件上传成功之后,通过websocket的形式推送告知给前端。

这种方案当然也是有瓶颈的,比如说线程池中的消息队列的数量是有限的,线程池中的线程的数量也是有限的。但是正因为线程池中的消息队列和线程的数量有限,导致它的资源也是有限的,可控的,这样不管客户端的请求有多少,也可以规避JVM的线程被打满,导致服务宕机。

伪异步方案只是给BIO方案的一个简单优化,它对BIO的等待阶段和读取阶段,不能从根本上解决这两个阶段的通信线程阻塞问题,且IO读写的方式也是字节流,这种方式导致的读写缓慢的情况也没有解决。

5.2 非阻塞IO(NIO)

由于同步阻塞IO带来的性能问题,所以java在jdk1.4推出了新的IO模式,New IO,简称为NIO。这种IO由于其不阻塞的性能,所以我们一般称其为非阻塞IO,但是业内也有人称之为异步阻塞IO,这点我是不大同意的,我更愿意称其为同步阻塞IO,接下来我们将进行分析,来说明我为什么这么觉得的原因。

在java NIO中,主要我们要讲解三个概念:Buffer,Channel,Selector。这里面的每一个概念,都针对性的解决了我们在讲述BIO时BIO的一些缺点。

在java NIO中,数据总是从 通道Channel 读取到 缓冲区Buffer 中,或者从缓冲区Buffer中写入到通道Channel里面。也就是说,所有的数据都是用缓冲区处理的。

缓冲区Buffer实际上就是一个数组,一个字节数组,当我们向缓冲区Buffer中写入数据的时候,Buffer会记录写入了多少数据,一旦要读取数据的时候,需要通过flip()方法将Buffer从写模式切换到读模式,在读模式下,可以读取之前写入到Buffer中的所有数据。一旦读取完了数据,就需要清空缓冲区,让它可以重复利用再次被写入数据。有两种方法可以清空缓冲区,clear()方法和compact()方法,clear()方法会清空整个缓冲区,而compact()方法只会清空已经读过的数据。

然后就是Channel,Channel就是通道的意思,我们的数据,是从Channel通道的一侧的缓冲区Buffer中,传递到另一侧的实体中(比如说文件,socket等等)。要知道的是,Channel是可以双向读写数据的,和流只能在一个方向流动是不一样的。

我们从之前的讲解中知道,BIO是基于流操作数据的,那么流有一个特点,就是流只能读一遍,并且我们必须要时刻去等待着流是否读取完成,不然一旦不关心了有可能导致流的数据丢失。这就是一个典型的阻塞状态。

于是NIO中出现了Buffer和Channel,Channel是双向读写通道,那么也就是说,数据从内核态缓冲区传输到用户态缓冲区的时候,是可以异步的。

当然,这个时候,就不得不讲述到我们的 多路复用器Selector

Selector的作用就是监控我们的Channel通道,一个Selector会监控多个Channel,当发现了某一个Channel上发生了读操作或者写操作,那么这个Channel就处于一个就绪状态,会被Selector发现,然后进行下一步的IO操作。要注意的是,Selector获取到Channel处于就绪状态的时候,但是到底是什么样的就绪状态,是通过从Channel中获取到的SelectorKey知道的。

SelectorKey的值如下所示:

可读(SelectionKey.OP_READ):一个数据可读的通道,可以说是“读就绪”(OP_READ)。

可写(SelectionKey.OP_WRITE):一个等待写数据的通道可以说是“写就绪”(OP_WRITE)。

连接(SelectionKey.OP_CONNECT):某个 SocketChannel 通道可以连接到一个服务器,则处于“连接就绪”状态(OP_CONNECT)。

接收(SelectionKey.OP_ACCEPT):一个ServerSocketChannel 服务器通道准备好接收新进入的连接,则处于“接收就绪”(OP_ACCEPT)状态。

这里可以看到,由于有着Selector去轮询Channel的状态,因此Channel的数据传输,其实是可以异步操作的,这里解决了外部的设备将数据拷贝到内存中的一个阻塞过程。也就是说,CPU发送了一个读的命令给到外部设备,然后CPU就直接不管了,去执行其他的命令去了,这个线程就不阻塞了。我们的Selector线程去负责判断外部设备的数据是否传输到了Channel。

当Channel处于一个OP_READ的状态的时候,就意味着数据从外部设备已经传输到了内部内存中,这个时候,我们可以通过Buffer去从Channel中读取数据,这个时候的阻塞其实还是存在的,但是因为是使用缓冲区,通过数据块的形式传输数据,速度也要快上很多,相对于BIO的等待数据传输的阻塞状态来说,性能可以得到显著提升。

另外要注意的一点,JDK使用了操作系统底层的IO方式epoll实现了Selector,那么就意味着它并没有最大连接句柄的限制。

1.什么是epoll,select,poll?

这个是操作系统底层的几种IO方式,Unix和Windows等操作系统实现了这几种IO方式。

2.为什么使用epoll方式实现Selector?

首先,select这种方式,本质上是通过设置或检查存放fd标志位的数据结构进行下一步处理,那么这就意味单个进程可以监视的fd数量是有限制的(因为被管理的 socket fd 需要从用户空间拷贝到内核空间,为了控制拷贝的大小而做了限制,即每个 select 能拷贝的 fds 集合大小只有1024。),这也限制了select能监控的socket的数量,另外,当socket的数量太多的时候(socket调用entry中callBack方法唤醒select,但是select不知道是哪个socket有数据进来),select需要轮询所有的socket,那么就性能会变得很差。

而poll和select很相像,但是poll没有数量的限制(暂时不知道为什么select有数量限制而poll没有,笔者猜测是因为select的实现时间较早,是在1994实现的,应该是历史遗留原因),但是因为也是去轮询所有的socket,导致socket的数量越大,那么性能就会越差。

epoll模型修改主动轮询为被动通知,当有事件发生时,被动接收通知(socket回调callBack方法的时候,会告诉是epoll是哪个socket唤醒了epoll)。所以epoll模型注册套接字后,主程序可做其他事情,当事件发生时,接收到通知后再去处理。

这大概就是为什么NIO是使用操作系统的epoll这种IO方式实现了Selector。

3.什么是fd标志位(文件描述符)?

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念,毕竟Linux下皆为文件。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

那么讲到这里,我要说一下为什么我认为NIO是一个同步非阻塞IO,而不是异步非阻塞IO。

我们都知道,阻塞和非阻塞,讲的是线程的状态,而同步或者异步,说的是消息的通知机制。而在NIO中,是通过Selector自己去监控消息事件然后再调用线程去执行IO,而不是消息事件IO执行成功后通知给到客户端。

因此,我觉得NIO就是一个典型的同步非阻塞IO。

5.3 异步IO(AIO)

异步IO是采用“订阅-通知”模式: 即应用程序向操作系统注册IO监听,然后继续做自己的事情。当操作系统发生IO事件,并且准备好数据后,在主动通知应用程序,触发相应的函数。

这里和NIO不一样的地方是,NIO是自己开启了一个线程去轮询注册的事件,然后等到事件就绪后,就启动一个线程去执行IO任务。而AIO不一样的是在于,它是等到系统发生了IO事件后,会主动通知应用程序,触发相应的IO任务执行。

六 . 为什么要使用netty?

  1. JAVA NIO 和 JAVA AIO框架提供了 多路复用IO/异步IO的支持,但是并没有提供上层“信息格式”的良好封装。例如前两者并没有提供针对 Protocol Buffer、JSON这些信息格式的封装,但是Netty框架提供了这些数据格式封装(基于责任链模式的编码和解码功能)

  2. 要编写一个可靠的、易维护的、高性能的(注意它们的排序)NIO/AIO 服务器应用。除了框架本身要兼容实现各类操作系统的实现外。更重要的是它应该还要处理很多上层特有服务,例如: 客户端的权限、还有上面提到的信息格式封装、简单的数据读取。这些Netty框架都提供了响应的支持。

  3. java NIO依然有着许多的bug,而netty解决了这些bug,提供了更为稳健的NIO系统给到我们。

总结

netty的学习之旅才刚刚开始,本篇文章,会持续的进行更新。