引言

《跟闪电侠学Netty》 并不是个人接触的第一本Netty书籍,但个人更推荐读者把它作为作为第一本Netty入门的书籍。

《Netty In Action》 不同,这本书直接从Netty入门程序代码开始引入Netty框架,前半部分教你如何用Netty搭建简易的通讯系统,整体难度比较低,后半部分直接从服务端源码、客户端源码、ChannelPipeline开始介绍,和前半部分割裂较为严重。

相较于入门的程序,源码分析毫无疑问是比较有干货的部分,但是和前面入门程序相比有点学完了99乘法表就让你去做微积分的卷子一样,如果Netty使用生疏源码部分讲解肯定是十分难懂的,所以更建议只看前半截。

个人比较推荐这本书吃透Netty编写的简单通讯“项目”之后,直接去看《Netty In Action》做一个更为系统的深入和基础巩固。等《Netty In Action》看明白之后,再回过头来看《跟闪电侠学Netty》的源码分析部分。

抛开源码分析部分,这本书是“我奶奶都能学会”的优秀入门书籍,用代码实战加讲解方式学起来轻松印象深刻。

开篇入门部分先不引入项目,这里先对于过去JDK的网络IO模型作为引子介绍为什么我们需要用Netty,学习Netty带来的好处等。

思维导图

《跟闪电侠学Netty》阅读笔记 - 实战入门篇.png

Netty 依赖版本(4.1.6.Final)

本书使用的Netty版本为 4.1.6,为了避免后面阅读源码的时候产生误解,建议以此版本为基准。

    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.6.Final</version>
    </dependency>

JDK 原生编程模型

到目前为止,JDK一共实现了三种网络IO编程模型:BIO、NIO和AIO。三种模型不仅产生的间隔时间跨度大,并且由三组完全不同编程风格的开发人员设计API,不同编程模型和设计思路之间的切换十分复杂,开发者的学习成本也比较大。

针对这些问题,我们直接了解Netty如何统一这些模型以及如何降低并发编程的开发难度,这里先对过去的JDK网络IO编程模型做一个了解。

洗衣机案例理解阻塞非阻塞,同步异步概念

在了解JDK的网络IO模型之前,必须先了解的阻塞非阻塞同步异步的概念。

同步和异步指的是任务之间是否需要等待其它任务完成或者等待某个事件的发生。如果一个任务必须等待另一个任务完成才能继续执行,那么这两个任务就是同步的;如果一个任务可以直接继续执行而无需等待另一个任务的完成,那么这两个任务就是异步的。

阻塞和非阻塞指的是任务在等待结果时是否会一直占用CPU资源。如果一个任务在等待结果时会一直占用CPU资源,那么这个任务就是阻塞的;如果一个任务在等待结果时不会占用CPU资源,那么这个任务就是非阻塞的。

这里给一个生活中洗衣服的例子帮助完全没有了解过这些概念的读者加深印象,这个例子来源于某个网课,个人觉得十分贴切和易懂就拿过来用了。

同步阻塞

理解:

洗衣服丢到洗衣机,全程看着洗衣机洗完,洗好之后晾衣服。

类比 :

  • 请求接口
  • 等待接口返回结果,中间不能做其他事情。
  • 拿到结果处理数据

分析:
同步:全程看着洗衣机洗完。
阻塞:等待洗衣机洗好衣服之后跑过去晾衣服。

同步非阻塞

理解:

把衣服丢到洗衣机洗,然后回客厅做其他事情,定时看看洗衣机是不是洗完了,洗好后再去晾衣服。(等待期间你可以做其他事情,比如用电脑刷剧看视频)。

这种模式类似日常生活洗衣机洗衣服。

类比:

  • 请求接口。
  • 等待期间切换到其他任务,但是需要定期观察接口是否有回送数据。
  • 拿到结果处理数据。

分析:

和阻塞方式的最大区别是不需要一直盯着洗衣机,期间可以抽空干其他的事情。

同步:等待洗衣机洗完这个事情没有本质变化,洗好衣服之后还是要跑过去晾衣服。
非阻塞:拿到衣服之前可以干别的事情,只不过需要每次隔一段时间查看能不能拿到洗好的衣服。

异步阻塞

理解:

把衣服丢到洗衣机洗,然后看着洗衣机洗完,洗好后再去晾衣服(没这个情况,几乎没这个说法,可以忽略)。

类比:

  • 请求接口,不需要关心结果。
  • 客户端可以抽空干其他事情,但是非得等待接口返回结果
  • 拿到服务端的处理结果

分析:

难以描述,几乎不存在这种说法。

异步非阻塞

理解:

把衣服丢到洗衣机洗,然后回客厅做其他事情,洗衣机洗好后会自动去晾衣服,晾完成后放个音乐告诉你洗好衣服并晾好了

类比 :

  • 请求接口,此时客户端可以继续执行代码。
  • 服务端准备并且处理数据,在处理完成之后在合适的时间通知客户端
  • 客户端收到服务端处理完成的结果。

分析:
异步:洗衣机自己不仅把衣服洗好了还帮我们把衣服晾好了。
非阻塞:拿到“衣服”结果之前可以干别的事情。

注意异步非阻塞情况下,“我们”对待洗衣服这件事情的“态度”完全变了。

BIO 编程模型

BIO叫做阻塞IO模型,在阻塞IO模型中两个任务之间需要等待响应结果,应用进程需要等待内核把整个数据准备好之后才能开始进行处理。

BIO是入门网络编程的第一个程序,从JDK1.0开始便存在了,存在于java.net包当中。下面的程序也是入门Tomcat源码的基础程序。

image.png

Java实现代码

在BIO的实现代码中,服务端通过accept一直阻塞等待直到有客户端连接。首先是服务端代码。

public static void main(String[] args) throws IOException {  
    ServerSocket serverSocket = new ServerSocket(8000);  
  
    // 接受连接  
    new Thread(() -> {  
        while (true) {  
            // 1. 阻塞获取连接  
            try {  
                Socket socket = serverSocket.accept();  
  
                // 2. 为每一个新连接使用一个新线程  
                new Thread(() -> {  
                    try {  
                        int len;  
                        byte[] data = new byte[1024];  
                        InputStream inputStream = socket.getInputStream();  
                        // 字节流读取数据  
                        while ((-1 != (len = inputStream.read()))) {  
                            System.err.println(new String(data, 0, len));  
                        }  
                    } catch (IOException ioException) {  
                        ioException.printStackTrace();  
                    }  
                }).start();  
            } catch (IOException e) {  
                e.printStackTrace();  
  
            }  
        }  
    }).start();  
}

较为核心的部分是serverSocket.accept()这一串代码,会导致服务端阻塞等待客户端的连接请求,即使没有连接也会一直阻塞。

服务端启动之后会监听8000端口,等待客户端连接,此时需要一直占用CPU资源,获取到客户端连接之将会开辟一个新的线程单独为客户端提供服务。

image.png

然后是客户端代码。

public static void main(String[] args) {  
    new Thread(()->{  
        try {  
            Socket socket = new Socket("127.0.0.1", 8000);  
            while (true){  
                socket.getOutputStream().write((new Date() + ":"+ "hellow world").getBytes(StandardCharsets.ISO_8859_1));  
                try {  
                    Thread.sleep(2000);  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        } catch (IOException ioException) {  
            ioException.printStackTrace();  
        }  
    }).start();  
}

客户端的核心代码如下,通过建立Socket和服务端建立连接。

Socket socket = new Socket("127.0.0.1", 8000);
Connected to the target VM, address: '127.0.0.1:5540', transport: 'socket'

客户端启动之后会间隔两秒发送数据给服务端,服务端收到请求之后打印客户端传递的内容。

image.png

Connected to the target VM, address: '127.0.0.1:5548', transport: 'socket'
Disconnected from the target VM, address: '127.0.0.1:5548', transport: 'socket'

Process finished with exit code 130

优缺点分析

传统的IO模型有如下优缺点:

  • 优点

    • 实现简单 。
    • 客户端较少情况下运行良好。
  • 缺点

    • 每次连接都需要一个单独的线程。
    • 单机单核心线程上下文切换代价巨大 。
    • 数据读写只能以字节流为单位。
    • while(true) 死循环非常浪费CPU资源 。
    • API 晦涩难懂,对于编程人员需要考虑非常多的内容。

结论:

在传统的IO模型中,每个连接创建成功之后都需要一个线程来维护,每个线程包含一个while死循环,那么1w个连接对应1w个线程,继而1w个while死循环。

单机是不可能完成同时支撑1W个线程的,但是在客户端连接数量较少的时候,这种方式效率很高并且实现非常简单。

NIO 编程模型

NIO 编程模型是 JDK1.4 出现的全新API,它实现的是同步非阻塞IO编程模型。以下面的模型为例,第二阶段依然需要等待结果之后主动处理数据,主要的区别在第一阶段(红线部分)轮询的时候可以干别的事情,只需多次调用检查是否有数据可以开始读取。

image.png

Java 实现代码

NIO编程模型中,新来一个连接不再创建一个新的线程,而是可以把这条连接直接绑定到某个指定线程

概念上理解NIO并不难,但是要写出JDK的NIO编程模板代码却不容易。

public static void main(String[] args) throws IOException {  
    Selector serverSelector = Selector.open();
        Selector clientSelector = Selector.open();

        new Thread(() -> {
            try {
                // 对应IO编程中服务端启动
                ServerSocketChannel listenerChannel = ServerSocketChannel.open();
                listenerChannel.socket().bind(new InetSocketAddress(8000));
                listenerChannel.configureBlocking(false);
                listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);

                while (true) {
                    // 监测是否有新的连接,这里的1指的是阻塞的时间为1ms
                    if (serverSelector.select(1) > 0) {
                        Set<SelectionKey> set = serverSelector.selectedKeys();
                        Iterator<SelectionKey> keyIterator = set.iterator();

                        while (keyIterator.hasNext()) {
                            SelectionKey key = keyIterator.next();

                            if (key.isAcceptable()) {
                                try {
                                    // (1) 每来一个新连接,不需要创建一个线程,而是直接注册到clientSelector
                                    SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
                                    clientChannel.configureBlocking(false);
                                    clientChannel.register(clientSelector, SelectionKey.OP_READ);
                                } finally {
                                    keyIterator.remove();
                                }
                            }

                        }
                    }
                }
            } catch (IOException ignored) {
            }

        }).start();


        new Thread(() -> {
            try {
                while (true) {
                    // (2) 批量轮询是否有哪些连接有数据可读,这里的1指的是阻塞的时间为1ms
                    if (clientSelector.select(1) > 0) {
                        Set<SelectionKey> set = clientSelector.selectedKeys();
                        Iterator<SelectionKey> keyIterator = set.iterator();

                        while (keyIterator.hasNext()) {
                            SelectionKey key = keyIterator.next();

                            if (key.isReadable()) {
                                try {
                                    SocketChannel clientChannel = (SocketChannel) key.channel();
                                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                                    // (3) 读取数据以块为单位批量读取
                                    clientChannel.read(byteBuffer);
                                    byteBuffer.flip();
                                    System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer)
                                            .toString());
                                } finally {
                                    keyIterator.remove();
                                    key.interestOps(SelectionKey.OP_READ);
                                }
                            }

                        }
                    }
                }
            } catch (IOException ignored) {
            }
        }).start();
}

上面的代码不需要过多纠结,NIO的代码模板确实非常复杂,我们可以把上面的两个线程看作是两个传送带,第一条传送带只负责接收外部的连接请求,收到请求数据之后直接丢给第二条传送带处理。第二条传送带收到任务之后进行解析和处理,最后把结果返回即可。

书中并没有给NIO的客户端案例,但是有意思的是Netty的客户端启动连接代码可以完美衔接JDK的NIO Server服务端,从这一点上可以发现Netty的NIO编程模型实际上就是对于JDK NIO模型的改良和优化。

PS:后续篇章的源码阅读可以看到Netty和JDK的API的关系密不可分。
public static void main(String[] args) throws InterruptedException {  
    Bootstrap bootstrap = new Bootstrap();  
    NioEventLoopGroup eventExecutors = new NioEventLoopGroup();  
    // 引导器引导启动  
    bootstrap.group(eventExecutors)  
            .channel(NioSocketChannel.class)  
            .handler(new ChannelInitializer<Channel>() {  
                @Override  
                protected void initChannel(Channel channel) throws Exception {  
                    channel.pipeline().addLast(new StringEncoder());  
                }  
            });  
  
    // 建立通道  
    Channel channel = bootstrap.connect("127.0.0.1", 8000).channel();  
  
    while (true){  
        channel.writeAndFlush(new Date() + " Hello world");  
        Thread.sleep(2000);  
    }  
}

Netty无论是客户端启动还是服务端启动都会打印一堆日志,下面是客户端启动日志。

14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.cacheTrimIntervalMillis: 0
14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.useCacheForAllThreads: false
14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.maxCachedByteBuffersPerChunk: 1023
14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.allocator.type: pooled
14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.threadLocalDirectBufferSize: 0
14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.maxThreadLocalCharBufferSize: 16384
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.maxCapacityPerThread: 4096
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.ratio: 8
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.chunkSize: 32
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.blocking: false
14:42:24.060 [nioEventLoopGroup-2-1] DEBUG io.netty.buffer.AbstractByteBuf - -Dio.netty.buffer.checkAccessible: true
14:42:24.060 [nioEventLoopGroup-2-1] DEBUG io.netty.buffer.AbstractByteBuf - -Dio.netty.buffer.checkBounds: true
14:42:24.060 [nioEventLoopGroup-2-1] DEBUG i.n.util.ResourceLeakDetectorFactory - Loaded default ResourceLeakDetector: io.netty.util.ResourceLeakDetector@310af49
Disconnected from the target VM, address: '127.0.0.1:13875', transport: 'socket'

Process finished with exit code 130

客户端连接之后会间隔2S向服务端推送当前时间。

Connected to the target VM, address: '127.0.0.1:13714', transport: 'socket'
Tue Apr 11 14:42:24 CST 2023 Hello world
Tue Apr 11 14:42:26 CST 2023 Hello world
Tue Apr 11 14:42:28 CST 2023 Hello world

JDK的NIO针对BIO的改良点

NIO模型工作上有了“分工”的细节,即两个Selector,一个负责接受新连接,另一个负责处理连接传递的数据。

对比BIO模型一个连接就分配一个线程的策略,NIO模型的策略是让所有的连接注册过程变为由一个Selector完成,Selector会定期轮询检查哪个客户端连接可以接入,如果可以接入就注册到当前的Selector,后续遇到数据读取只需要轮询一个Selector就行了。

线程资源受限问题通过Selector将每个客户端的while(true) 转为只有一个 while(true) 死循环得以解决,它的“副作线程用”是线程的减少直接带来了切换效率的提升。不仅如此NIO还提供了面向Buffer的缓存 ByteBuffer,提高读写效率,移动指针任意读写。

JDK的NIO编程模型缺点

看起来无非就是代码复杂了一点,其实NIO模型看起来也“还不错”?

NO!NO!NO!JDK的NIO实际上还有很多其他问题:

    1. API复杂难用,需要理解非常多的底层概念 。(尤其是臭名昭著的 ByteBuffer)
    1. JDK没有线程模型,用户需要自己设计底层NIO模型。
    1. 自定义协议也要拆包 。
    1. JDK的NIO是由于Epoll实现的,底层存在空轮询的BUG
    1. 自行实现NIO模型会存在很多问题。
    1. 编程人员的编程水平层次不齐,个人定制的NIO模型难以通用,替换性也很差。
基于以上种种问题,Netty 统统都有解决方案。

简单介绍AIO

JDK的AIO不是很成熟,AIO底层依然因为Epoll的遗留问题存在臭名昭著的空轮询BUG,这里并不推荐读者使用JDK的AIO进行编程。

image.png

Java AIO 的核心在于两个关键类:AsynchronousSocketChannelAsynchronousServerSocketChannel

AsynchronousSocketChannel 实现异步套接字通信,可以让我们在不同的客户端连接之间切换,而无需创建新的线程或线程池。

AsynchronousServerSocketChannel 则用于异步地监听客户端的连接请求。

Java 实现代码

这里用ChatGPT生成了一段JDK的AIO代码,为了更好理解顺带让它把注释一块生成了。

public class AIOServer {  
  
  
    public static void main(String[] args) throws IOException {  
        // 创建一个 ExecutorService,用于处理异步操作的线程池  
        ExecutorService executor = Executors.newFixedThreadPool(10);  
        // 创建一个 AsynchronousChannelGroup,将线程池与该 Channel 组关联  
        AsynchronousChannelGroup channelGroup = AsynchronousChannelGroup.withThreadPool(executor);  
  
        // 创建 AsynchronousServerSocketChannel,并绑定到指定地址和端口  
        final AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open(channelGroup);  
        InetSocketAddress address = new InetSocketAddress("localhost", 12345);  
        serverSocketChannel.bind(address);  
  
        System.out.println("Server started on port " + address.getPort());  
  
        // 调用 accept 方法接收客户端连接,同时传入一个 CompletionHandler 处理连接结果  
        serverSocketChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {  
            // 当连接成功时会调用 completed 方法,传入客户端的 SocketChannel 实例作为参数  
            @Override  
            public void completed(AsynchronousSocketChannel clientSocketChannel, Object attachment) {  
                // 继续接受下一个客户端连接,并处理当前客户端的请求  
                serverSocketChannel.accept(null, this);  
                handleClient(clientSocketChannel);  
            }  
  
            // 当连接失败时会调用 failed 方法,传入异常信息作为参数  
            @Override  
            public void failed(Throwable exc, Object attachment) {  
                System.out.println("Error accepting connection: " + exc.getMessage());  
            }  
        });  
  
        // 在主线程中等待,防止程序退出  
        while (true) {  
            try {  
                Thread.sleep(Long.MAX_VALUE);  
            } catch (InterruptedException e) {  
                break;  
            }  
        }  
    }  
  
    private static void handleClient(AsynchronousSocketChannel clientSocketChannel) {  
        ByteBuffer buffer = ByteBuffer.allocate(1024);  
        // 读取客户端发送的数据,同时传入一个 CompletionHandler 处理读取结果  
        clientSocketChannel.read(buffer, null, new CompletionHandler<Integer, Object>() {  
            // 当读取成功时会调用 completed 方法,传入读取到的字节数和附件对象(此处不需要)  
            @Override  
            public void completed(Integer bytesRead, Object attachment) {  
                if (bytesRead > 0) {  
                    // 将 Buffer 翻转,以便进行读取操作  
                    buffer.flip();  
                    byte[] data = new byte[bytesRead];  
                    buffer.get(data, 0, bytesRead);  
                    String message = new String(data);  
                    System.out.println("Received message: " + message);  
                    // 向客户端发送数据  
                    clientSocketChannel.write(ByteBuffer.wrap(("Hello, " + message).getBytes()));  
                    buffer.clear();  
                    // 继续读取下一批数据,并传入当前的 CompletionHandler 以处理读取结果  
                    clientSocketChannel.read(buffer, null, this);  
                } else {  
                    try {  
                        // 当客户端关闭连接时,关闭该 SocketChannel                        clientSocketChannel.close();  
                    } catch (IOException e) {  
                        System.out.println("Error closing client socket channel: " + e.getMessage());  
                    }  
                }  
            }  
  
            // 当读取失败时会调用 failed 方法,传入异常信息和附件对象(此处不需要)  
            @Override  
            public void failed(Throwable exc, Object attachment) {  
                System.out.println("Error reading from client socket channel: " + exc.getMessage());  
            }  
        });  
    }  
  
  
}

AIO 编程模型优缺点

优点

并发性高、CPU利用率高、线程利用率高 。

缺点

不适合轻量级数据传输,因为进程之间频繁的通信在追错、管理,资源消耗上不是很可观。

适用场景

对并发有需求的重量级数据传输。

从上面的代码也可以看出,AIO的API和NIO又是截然不同的写法,为了不继续增加学习成本,这里点到为止,不再深入AIO编程模型的部分了,让我们继续回到Netty,了解Netty的编程模型。

使用Netty 带来的好处

  • Netty不需要了解过多概念
  • 底层IO模型随意切换
  • 自带粘包拆包的问题处理
  • 解决了空轮询问题
  • 自带协议栈,支持通用协议切换
  • 社区活跃,各种问题都有解决方案
  • RPC、消息中间件实践,健壮性极强

网络IO通信框架过程

一个网络IO通信框架从客户端发出请求到接受到结果,基本包含了下面这8个操作:

    1. 解析指令
    1. 构建指令对象
    1. 编码
    1. 等待响应
    1. 解码
    1. 翻译指令对象
    1. 解析指令
    1. 执行

下面来看看Netty的编程模型。

Netty 启动模板代码(重要)

经过上面一长串的铺垫,现在来到整体Netty的代码部分:

服务端

首先是服务端代码:

 public static void main(String[] args) {
        ServerBootstrap serverBootstrap = new ServerBootstrap();

        NioEventLoopGroup boos = new NioEventLoopGroup();
        NioEventLoopGroup worker = new NioEventLoopGroup();
        serverBootstrap
                .group(boos, worker)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    protected void initChannel(NioSocketChannel ch) {
                        ch.pipeline().addLast(new StringDecoder());
                        ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
                            @Override
                            protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                System.out.println(msg);
                            }
                        });
                    }
                })
                .bind(8000);
    }

初学Netty的时候可能没有NIO的经验,所以我们简单做个类比:

NioEventLoopGroup boos = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();

可以直接看作

Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();

其中boss负责处理连接,worker负责读取请求和处理数据。两者的工作模式也是类似的,boss就像是老板负责“接单”,worker 打工仔负责接收单子的内容然后开始打工干活。

客户端

客户端的启动代码如下。


public static void main(String[] args) throws InterruptedException {  
    Bootstrap bootstrap = new Bootstrap();  
    NioEventLoopGroup eventExecutors = new NioEventLoopGroup();  
    // 引导器引导启动  
    bootstrap.group(eventExecutors)  
            .channel(NioSocketChannel.class)  
            .handler(new ChannelInitializer<Channel>() {  
                @Override  
                protected void initChannel(Channel channel) throws Exception {  
                    channel.pipeline().addLast(new StringEncoder());  
                }  
            });  
  
    // 建立通道  
    Channel channel = bootstrap.connect("127.0.0.1", 8000).channel();  
  
    while (true){  
        channel.writeAndFlush(new Date() + " Hello world");  
        Thread.sleep(2000);  
    }  
  
}

客户端的代码中的NioEventLoopGroup实际对应了main函数单独开启的线程。上面的代码可以完美的替代调JDK的NIO、AIO、BIO 的API,学习成本大大降低,Netty为使用者做了大量的“准备”工作,提供了很多"开箱即用"的功能,非常方便。

Netty的服务端和客户端的入门程序代码是分析源码的开始,这部分代码需要有较深的印象。

问题

摘录部分Netty入门级别的八股。

Linux网络编程中的五种I/O模型

关键点:

不同的角度理解IO模型的概念会有变化。注意本部分站在用户程序和内核的网络IO交互的角度理解的。

权威:

  • RFC标准
  • 书籍 《UNIX Network Programming》(中文名《UNIX网络编程-卷一》)第六章。

下面部分总结自:《UNIX Network Programming》(中文名《UNIX网络编程-卷一》)

1)阻塞式I/O

注意原书中阻塞式I/O给出的例子是UDP而不是TCP的例子。recvfrom 函数可以看作是系统调用,在阻塞I/O模型中,recvfrom 的系统调用要等待内核把数据从内核态拷贝到用户的缓冲池或者发生错误的时候(比如信号中断)才进行返回。recvfrom 收到数据之后再执行数据处理。

image.png

2)非阻塞式I/O

recvfrom 的系统调用会在设置非阻塞的时候,会要求内核在无数据的时候返回错误,所以前面三次都是错误调用,在第四次调用之后此时recvfrom轮询到数据,于是开始正常的等待内核把数据复制到用户进程缓存。

此处轮询的定义为:对于描述符进行recvfrom循环调用,会增加CPU的开销。注意非阻塞的轮询不一定要比阻塞等待要强,有时候甚至会有无意义的开销反而不如阻塞。

image.png

3)I/O复用(select,poll,epoll...)

I/O多路复用是阻塞在select,epoll这样的系统调用,没有阻塞在真正的I/O系统调用如recvfrom。进程受阻于select,等待可能多个套接口中的任一个变为可读

IO多路复用最大的区别是使用两个系统调用(select和recvfrom)。Blocking IO(BIO)只调用了一个系统调用(recvfrom)。

select/epoll 核心是可以同时处理多个 connection,但是这并不一定提升效率,连接数不高的话性能不一定比多线程+阻塞IO好。但是连接数比较庞大之后会有显著的差距。

多路复用模型中,每一个socket都需要设置为non-blocking,否则是无法进行elect的。

listenerChannel.configureBlocking(false);这个设置的意义就在于此。

image.png

4)信号驱动式I/O(SIGIO)

信号驱动的优势是等待数据报到之前进程不被阻塞,主循环可以继续执行,等待信号到来即可,注意这里有可能是数据已经准备好被处理,或者数据复制完成可以准备读取。

信号驱动IO 也是同步模型,虽然可以通过信号的方式减少交互,但是系统调用过程当中依然需要进行等待,内核也依然是通知何时开启一个IO操作,和前面介绍的IO模型对比发现优势并不明显。

image.png

5)异步I/O(POSIX的aio_系列函数)

核心: Future-Listener机制

  • IO操作分为两步

    • 发起IO请求,等待数据准备(Waiting for the data to be ready)
    • 实际的IO操作,将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

前四种IO模型都是同步IO操作,主要的区别在于第一阶段处理方式,而他们的第二阶段是一样的:在数据从内核复制到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用或者select() 函数。 异步I/O模型内在这两个阶段都要(自行)处理。

阻塞IO和非阻塞IO区别在于第一步,发起IO请求是否会被阻塞,如果阻塞直到完成那么就是传统的阻塞IO,如果不阻塞,那么就是非阻塞IO。

同步IO和异步IO的区别就在于第二个步骤是否阻塞,如果实际的IO读写阻塞请求进程,那么就是同步IO,因此阻塞IO、非阻塞IO、IO复用、信号驱动IO都是同步IO,如果不阻塞,而是操作系统帮你做完IO操作再将结果返回给你,那么就是异步IO

异步IO模型非常像是我们日常点外卖,我们时不时看看配送进度就是在“轮询”,当外卖员把外卖送到指定位置打电话通知我们去拿即可。

交互几个核心点

再次强调是针对用户程序和内核的网络IO交互角度理解的。

  • 阻塞非阻塞说的是线程的状态(重要)
  • 同步和异步说的是消息的通知机制(重要)
     - 同步需要主动读写数据,异步是不需要主动读写数据
     - 同步IO和异步IO是针对用户应用程序和内核的交互

为什么Netty使用NIO而不是AIO?

  1. Netty不看重Windows上的使用,在Linux系统上,AIO的底层实现仍使用EPOLL,没有很好实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优化
  2. Netty整体架构是reactor模型, 而AIO是proactor模型, 混合在一起会非常混乱,把AIO也改造成reactor模型看起来是把epoll绕个弯又绕回来
  3. AIO还有个缺点是接收数据需要预先分配缓存, 而不是NIO那种需要接收时才需要分配缓存, 所以对连接数量非常大但流量小的情况, 内存浪费很多
  4. Linux上AIO不够成熟,处理回调结果速度跟不到处理需求,比如外卖员太少,顾客太多,供不应求,造成处理速度有瓶颈(待验证)。

结论

Netty整体架构是reactor模型,采用epoll机制,所以往深的说,还是IO多路复用模式,所以也可说netty是同步非阻塞模型(看的层次不一样),只不过实际是异步IO。

Netty 应用场景了解么?

  • 作为 RPC 框架的网络通信工具。分布式系统之间的服务器通信可以使用Netty完成,虽然是Java编写的框架,但是性能非常接近 C 和C++ 执行效率。
  • 作为 RPC 框架的网络通信工具,Netty本身就是
  • 消息队列:比如大名鼎鼎的RocketMq底层完全依赖Netty,编程人员不需要很强的并发编程功底也可以快速上手和维护代码。
  • 实现一个即时通讯系统:正好和本书应用场景重合了。

介绍Netty

简短介绍

Netty是一个高性能异步NIO编程模型的网络编程框架。它提供了简单易用的API,可以快速地开发各种网络应用程序,如客户端、服务器和协议实现等。同时,Netty还具有良好的可扩展性和灵活性,支持多种传输协议和编解码器。

稍微复杂一点

Netty是由JBOSS提供的一个java开源框架, 是业界最流行的NIO框架,整合了多种协议( 包括FTP、SMTP、HTTP等各种二进制文本协议)的实现经验,精心设计的框架,在多个大型商业项目中得到充分验证。 1)API使用简单 2)成熟、稳定 3)社区活跃 有很多种NIO框架 如mina 4)经过大规模的验证(互联网、大数据、网络游戏、电信通信行业)。

总结

  • 开篇简单介绍了JDK的BIO、NIO和AIO,三者不仅出现时间跨度大,三个团队编写,和JDK的IO编程一样晦涩难懂和不好用,开发人员需要花大量事件学习底层细节。
  • 用洗衣机的例子,理解网络编程模型的重要概念:同步、非同步、阻塞、非阻塞。从入门的角度来看,同步和异步可以认为是否是由客户端主动获取数据,而阻塞和非阻塞则是客户端是否需要拿到结果进行处理,两者是相辅相成的。
  • Netty 编程模型统一了JDK的编程模型,降低了学习成本,同时效率比原生JDK更高,并且解决了NIO 中的空轮询问题。
  • Netty 底层实际上和JDK的网络编程模型密切相关,从案例代码可以看到Netty的客户端API代码可以直接往NIO的Server发送数据。
  • 补充书中没有介绍的AIO编程模型,用ChatGPT 生成的代码简单易懂。
  • 最后补充有关Netty的问题。

写在最后

开篇部分补充了书中没介绍的一些网络编程模型的基本概念,以及在最后关联了些相关书籍的知识点和,最后顺带归纳了一些八股问题,当然最为重要的部分是熟悉Netty的入门程序代码。

开篇入门篇到此就结束了,如果内容描述有误,欢迎评论或者私信留言。

参考

Netty 书籍推荐

  • 《Netty权威指南》
  • 《Netty进阶之路》

Xander
195 声望50 粉丝