为什么需要 Netty?

Posted by Ink Bai on 2020-05-29, & views

Netty 是一个高性能网络应用框架,应用很普遍,在 Java 领域 Netty 基本上是网络编程的标配了,所以很有必要深入学习一下。

首先思考:如何实现一个网络应用?

了解一个东西之前,首先要考虑为什么需要这样一个东西。那么为什么我们需要 Netty 呢?

现在互联网上的绝大多数应用都是网络应用程序,大多数都是标准的 CS 架构,需要进行频繁的网络通信,在真正介绍 Netty 之前,先让我们想一个极其基础的问题:如何实现一个网络应用?

网络基础

先回忆一下计算机网络的基础。

网络会分为很多层,一般有两种模型,一种分七层,一种分四层。但是,不管分几层,理解内在最重要。

为什么要分层?需要实现一个完整的互联网,整个的内容一定是很庞大的,通过分层可以进行隔离,彼此不影响。每一层都有自己独一无二的功能,并且上层依赖于下层。

这里把互联网分层五层:

越下面的层越接近硬件,越上面的层越接近人,上层依赖于下层,五层的功能简单来讲就是实体层是一堆硬件,链路层确定 MAC 地址,网络层确定 IP,传输层确定端口号,应用层是直接面向用户的应用。这部分详细内容可以看一下阮一峰写的:互联网协议入门(一),我就不班门弄斧了。

理解了计算机网络的多层结构之后,现在我们要写的网络应用在哪一层呢,对了,就在应用层。其他几层的内容完全不需要我们去操心,一般来说操作系统已经替我们做好了,现在我们从 Java 的角度看看如何在应用层上去写一个网络应用。

JDK 如何实现网络应用?

网络分层上层一定是基于下层的,Java 的网络编程是基于其下层:传输层和网络层。

为什么这么讲呢?网络层的功能,是建立「主机」到「主机」的通信。而一台主机上必然有很多应用,传输层的功能就是实现「端口」到「端口」的通信。只要确定了主机和端口号,我们就能够实现程序之间的交流。Linux 系统把主机+端口,叫做「套接字」(socket),socket 就是通信的基石,提供了进程通信的端点,进程之间通信之前,必须各自创建一个端点,否则是没有办法建立联系并相互通信的。一个完整的 socket 有一个本地唯一的 socket 号,这是由操作系统分配的。

在 Linux 世界中,「一切皆文件」,socket 通信和读写文件都被当作是 IO 操作,IO 操作有多种处理方式,如同步阻塞式 IO、同步非阻塞式 IO、异步非阻塞式 AIO 等等。基于此,Java 提供了三种类型的 IO 包:

  • 传统的 java.io 包,基于流模型实现的 BIO
  • 升级的 java.nio 包,同步非阻塞的 NIO
  • 改造的 NIO2,引入了异步非阻塞的 AIO

Java 最基本的网络编程模型是 BIO,即阻塞式 I/O,BIO 中所有的读写操作都会阻塞当前线程。

如果客户端和服务端建立了一个连接,但是客户端一直没有请求过来,那么服务端的 read() 就会一直处于阻塞状态。如果服务端处理这个请求时在等待其他资源时阻塞了,那么此时客户端就会一直处于阻塞状态,无法继续发送请求。所以使用 BIO 模型时一般都会为每个 socket 分配一个独立的线程。

如果并发比较大,我们就需要创建多个线程来处理,为了避免频繁创建、消耗线程,我们可以采用线程池创建线程,但是 socket 和线程的关系的对应关系是不变的。

BIO 这样的线程模型适用于 socket 连接不是很多的场景,但是现在的互联网场景,往往需要服务器支撑成百上千万的连接,而创建上百万个线程显然不现实,BIO 无法满足,我们需要的线程模型应该是这样的,NIO 就粉墨登场了:

NIO 基于多路复用已经极大提升了性能,为什么不用呢,还是因为存在一些问题的:

  • NIO 的类库和 API 繁杂,使用麻烦。你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。 需要具备其他的额外技能做铺垫。例如熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。
  • 可靠性能力缺失,开发工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等。 NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。
  • JDK NIO 的 Bug。例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。

理解:同步&异步、阻塞&非阻塞

首先记住一句话:

同步异步是针对调用者说的,阻塞非阻塞是针对被调用者说的

理解不了先背下来,以后把这句话讲出来再想一想就理解了,下面具体进行讲解。

  • 同步,就是我客户端(c端调用者)调用一个功能,该功能没有结束前,我(c端调用者)死等结果。
  • 异步,就是我(c端调用者)调用一个功能,不需要知道该功能结果,该功能有结果后通知我(c端调用者)即回调通知。
  • 阻塞,就是调用我(s端被调用者,函数),我(s端被调用者,函数)没有接收完数据或者没有得到结果之前,我不会返回。
  • 非阻塞,就是调用我(s端被调用者,函数),我(s端被调用者,函数)立即返回。

同步IO和异步IO的区别就在于:数据访问的时候进程是否阻塞!
阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回!

Netty 是如何设计的?

Reactor 线程模型

Netty 是基于 Reactor 线程模型设计的,Reactor 是反应堆的意思,服务器会处理多路的请求,并将这些请求同步分派给对应的单独的处理线程去处理。Reactor 模式也叫 Dispatcher 模式,即通过 IO 多路复用统一监听事件,收到事件后分发(dispatch),是编写高性能网络服务器的必备技术之一。

Reactor 模型中有两个关键组成:

  • Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的线程处理到来的 IO 事件。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人。
  • Handlers:处理程序执行 IO 事件要完成的实际操作,Reactor 通过调度适当的处理程序来响应 IO 事件,处理程序执行非阻塞操作。

取决于 Reactor 的数量和 Handler 线程数的不同,Reactor 模型有 3 个变种:

  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程

可以这样理解,Reactor 就是一个执行 while (true) { selector.select(); …} 循环的线程,会源源不断地产生新的事件,称作反应堆很贴切。

Netty 线程模型

Netty 主要基于主从 Reactor 多线程模型(如下图)做了一定的修改,其中主从 Reactor 多线程模型有多个 Reactor:

  • MainReactor 负责客户端的连接请求,并将请求转交给 SubReactor
  • SubReactor 负责相应通道的 IO 读写请求
  • 非 IO 请求(具体逻辑处理)的任务则会直接写入队列,等待 worker threads 进行处理

这里引用 Doug Lee 大神的 Reactor 介绍:Scalable IO in Java 里面关于主从 Reactor 多线程模型的图:

总结

本文可以算作是 Netty 的入门篇,通过对 JDK 原生网络编程 API 问题的分析和 Netty 架构的基本了解,可以知道为什么 Netty 的性能这么高而且如此流行,后面会对 Netty 的源码进行一些讲解。