netty开篇--网络编程的这些事儿

引子

官网介绍:
Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients.
异步指的是 IO 模型。
事件驱动指的是线程模型。
为了让大家更好的理解,我们回顾一下操作系统提供的网络模型。

Linux 的 IO 模型

先看一个简单的 web 程序,下面是客户端代码:

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char **argv) {
......
// 创建一个socket
int socketfd = socket(AF_INET, SOCK_STREAM, 0);
......
// 连接服务器
connect(socketfd, &serverAddr, sizeof(serverAddr));
......
// 读取数据
int n = read(socketfd, buffer, sizeof(buffer));
......
}

上面省略了其他代码,可以看出主要的步骤就 3 步:

  1. 创建 socket
  2. 连接服务器
  3. 读写数据
    对于 Linux 来说,网络编程是面向 socket 编程,可以看到调用系统方法 socket 返回的是一个类似文件描述符的整数,操作系统认为打开网络连接和打开磁盘文件是一致的,都会返回一个文件描述符,也都会有一个对应的内核缓冲区,对 socket 来说,也叫做 socket缓冲区
    继续看一个服务端例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    int main(int argc, char **argv) {
    ......
    // 创建一个socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    ......
    // 绑定需要监听的地址
    bind(listenfd, &serverAddr, sizeof(serverAddr));
    // 监听端口号
    listen(listenfd, null);
    ......
    for(;;) {
    // 等待客户端连接
    int socketfd = accept(listenfd, null, null);
    ......
    // 读写数据
    int n = read(socketfd, buffer, sizeof(buffer));
    ......
    }
    }
    服务端稍微麻烦了一些,需要 5 个步骤:
  4. 创建 socket
  5. 绑定监听地址,尤其是端口号,监听的客户端 IP 的范围
  6. 开始监听
  7. 等待客户端连接,有了新连接,则会创建新的 socket,标识唯一的 C-S 连接
  8. 在新的 socket 上读写数据
    有了 web 编程的基本印象,接下来可以介绍 linux 经典的 5 个网络模型了,但其中的信号驱动模型不怎么常用,就不做介绍了。

    我们知道,数据从机器 A 传输到机器 B,中间会经过机器 A 的数据从内存复制到网卡,再经过网络,再到达机器 B 的网卡,再到达机器 B 的内核空间。对于机器 B 来说,可以将数据传输阶段分为两个阶段:
    阶段一:数据传输到机器 B 的内核空间,过程中不需要 cpu 参与
    阶段二:数据从机器 B 的内核空间复制到应用空间

1. 阻塞 IO


对于阻塞 IO 来说,应用程序发起读写系统调用,应用线程两个阶段都是在等待,从应用程序的角度来说就是被阻塞住了。

2. 非阻塞 IO


要使用非阻塞 IO,必须在创建 socket 的时候显示声明为非阻塞的。这时候,应用程序发起读写系统调用,如果第一阶段数据没有准备好,那么会直接返回 error。这样就不会阻塞住了,不过应用程序需要过段时间就去尝试调用一次。
当第一阶段完成时,系统调用就会执行第二阶段,整个过程还是阻塞的。完成第二阶段的数据拷贝才会返回成功。

3. IO 多路复用

IO 多路复用是现在高性能网络的基石。
对于单个连接而言,它的两个阶段都是阻塞的,和阻塞 IO 是一致的。
然而对于整体系统而言,所有连接的第一阶段可以由单一线程来统一管理,而第二阶段才需要特定线程来处理。并且,第一阶段的耗时比第二阶段耗时要高很多。IO 多路复用相比阻塞 IO 能大大提高系统的吞吐量。

第一阶段使用 select 系统调用(或者 poll,epoll),这个对于应用程序来说还是阻塞的,当第一阶段完成才会返回结果。第二阶段调用读写方法,也是阻塞的

4. 异步 IO

上面三种都是阻塞的 IO 模型,只有这一种是完全不阻塞的。不阻塞也很好理解,可以简单认为两个阶段的数据运输工作,都由其他线程完成了,和应用程序的用户线程无关。

遗憾的是,Linux 系统目前对异步 IO 模型支持的不好,本身很受限制,在 Java 平台的异步 IO 都是用 JVM 自己创建的线程池。

JavaNIO

JavaNIO 指的是 Java new IO,主要包含 IO 多路复用和异步 IO。其中有三个核心的组件:
Buffer,Channel,Selector,它们的关系如下:

Java 本身也是利用了操作系统的底层能力,三个组件可以这样理解:

  1. Buffer: socket 缓冲区在用户程序空间的对应区域
  2. Channel:相当于 socket,属于 Selector 管理的基本单位
  3. Selector:类似监听 socket IO 事件的线程,主要负责第一阶段的数据传输
    为了加深大家的印象,可以看看如下的一个服务端案例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    String demo_text = "This is a demo String";
    // 1. 创建selector
    Selector selector = Selector.open();

    // 2. 做好服务端的准备工作,socket的创建,绑定,监听
    ServerSocketChannel serverSocket = ServerSocketChannel.open();
    serverSocket.bind(new InetSocketAddress("localhost", 5454));
    serverSocket.configureBlocking(false);

    // 3. 将服务端socket注册到选择器上,注册的事件是accept事件
    serverSocket.register(selector, SelectionKey.OP_ACCEPT);
    ByteBuffer buffer = ByteBuffer.allocate(256);
    while (true) {
    // 4. selector开始监听,方法是阻塞的,有channel准备好才会返回
    selector.select();
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> iter = selectedKeys.iterator();

    // 5. 遍历准备好的channel
    while (iter.hasNext()) {
    SelectionKey key = iter.next();
    int interestOps = key.interestOps();
    System.out.println(interestOps);

    // accept事件
    if (key.isAcceptable()) {
    SocketChannel client = serverSocket.accept();
    client.configureBlocking(false);
    client.register(selector, SelectionKey.OP_READ);
    }

    // 读事件
    if (key.isReadable()) {
    SocketChannel client = (SocketChannel) key.channel();
    client.read(buffer);
    if (new String(buffer.array()).trim().equals(demo_text)) {
    client.close();
    System.out.println("Not accepting client messages anymore");
    }
    buffer.flip();
    client.write(buffer);
    buffer.clear();
    }
    iter.remove();
    }
    }

netty VS JavaNIO

有了以上的铺垫,可以开始介绍主角 netty 了。我们说 netty 的定位是对 JavaNIO 的封装,相比于 JavaNIO,netty 的优点有下面几个:

  1. NIO 的类库和 API 不好使用,netty 屏蔽了 NIO 的 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等抽象概念
  2. 使用 NIO 用户需要熟悉网络编程的原理,还得了解 Reactor 等高性能网络模式,才能写出高质量 NIO 程序。而 netty 套个简单的模板就能使用了,屏蔽了网络细节
  3. 使用 NIO 需要考虑一些异常情况,比如:客户端断连重连,网络闪断,半包读写,异常码的处理等,而 netty 也屏蔽了这些细节

netty 线程模型

netty 的非阻塞所使用的 IO 模型是 IO 多路复用。而它使用的线程模型是 Reactor 模型
Reactor 模型,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。 服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式,即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某 handle 线程)
Reactor 模型中有 2 个关键组件:

  1. Reactor:负责监听和分发事件,在单独的线程中运行
  2. Handler:执行事件的处理程序,可以有不同的实现方案,新建线程,线程池等

    取决于 Reactor 的数量和 Hanndler 线程数量的不同,Reactor 模型有 3 个变种:
  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程
    netty 采用的就是主从 Reactor 多线程模式,相比于其他,采用这个模式有两个优点:
  1. 主从 Reactor,主 Reactor 只负责处理 accept 事件,建立连接,其他的读写事件由从 Reactor 处理。避免了在高并发场景下,Reactor 处理线程成为性能瓶颈
  2. 多线程能充分利用现代处理器多核的特性

    上图就是 netty 的 Reactor 模型的实际实现,整体上还是能看出主从 Reactor 模型的轮廓的,为了让大家能更清晰的理解,必须开始一段枯燥的名词解释:
  • Channel:网络通信的基本组件,可理解为 Linux 中的 socket
  • Selector:用于监听连接的 Channel 事件
  • NioEventLoop:维护了一个线程和任务队列,支持异步提交执行任务
  • NioEventLoopGroup:可以理解为一个线程池,内部维护了一组线程(NioEventLoop)
  • ChannelHandler:业务处理操作
  • ChannelPipline:多个 ChannelHandler 组合成的处理链路,一个 Channel 包含了一个 ChannelPipeline
    使用 netty 写服务端代码也异常简洁:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    public static void main(String[] args) {
    // 创建mainReactor
    NioEventLoopGroup boosGroup = new NioEventLoopGroup();
    // 创建工作线程组
    NioEventLoopGroup workerGroup = new NioEventLoopGroup();

    final ServerBootstrap serverBootstrap = new ServerBootstrap();
    serverBootstrap
    // 组装NioEventLoopGroup
    .group(boosGroup, workerGroup)
    // 设置channel类型为NIO类型
    .channel(NioServerSocketChannel.class)
    // 设置连接配置参数
    .option(ChannelOption.SO_BACKLOG, 1024)
    .childOption(ChannelOption.SO_KEEPALIVE, true)
    .childOption(ChannelOption.TCP_NODELAY, true)
    // 配置入站、出站事件handler
    .childHandler(new ChannelInitializer<NioSocketChannel>() {
    @Override
    protected void initChannel(NioSocketChannel ch) {
    // 配置入站、出站事件channel
    ch.pipeline().addLast(...);
    ch.pipeline().addLast(...);
    }
    });

    // 绑定端口
    int port = 8080;
    serverBootstrap.bind(port).addListener(future -> {
    if (future.isSuccess()) {
    System.out.println(new Date() + ": 端口[" + port + "]绑定成功!");
    } else {
    System.err.println("端口[" + port + "]绑定失败!");
    }
    });
    }

总结

明白整个网络编程的基本运行原理对平时工作还是有很大帮助的。无论使用什么编程语言底层都是依靠操作系统提供的能力,理解socket编程可以一通百通。接下来会介绍netty的具体技术点,从哪方面切入还没有想好。

参考链接

理解高性能网络模型 - 简书 (jianshu.com)
UNIX网络编程 卷1


netty开篇--网络编程的这些事儿
http://ttoobbyyy.github.io/2023/10/25/netty开篇--网络编程的这些事儿/
作者
jianren.xiao
发布于
2023年10月25日
许可协议