netty源码之 -- I/O 模型

265 阅读9分钟

提到 Java I/O 我们的第一反应是文件读取操作、底层网络通信,等等。这些都是我们在系统中最常遇到的和 I/O 有关的操作。不知道你时候和我一样(在一段时间内)认为,客户端和服务端之间通信是 I/O 操作,而忽略了数据在服务端的内部的处理也涉及到 I/O 操作。下面我们重新认识一下IO模型。

IO 交互模式

在 Linux 操作系统内核中,内置了 5 种不同的 I/O 交互模式,分别是阻塞 I/O、非阻塞 I/O、多路复用 I/O、信号驱动 I/O、异步 I/O。 首先,我们需要区分同步&异步,阻塞&非阻塞。

  • 同步 VS 异步(synchronous/asynchronous)。

    简单来说,同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系。

  • 阻塞 VS 非阻塞(blocking/non-blocking)。

    在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如 ServerSocket 新连接建立完毕,或数据读取、写入操作完成;而非阻塞则是不管 IO 操作是否结束,直接返回,相应操作在后台继续处理。

其次,我们要简单了解一下网络编程。网络编程中有一个重要的概念就是:Socket。 这张图描述了客户端与服务端建立网络连接的过程。 总结一下流程:

  • 服务端(图中右边部分)
  1. 调用 socket 函数,创建一个套接字。我们通常把这个套接字称为主动套接字(Active Socket);

  2. 调用 bind 函数,将主动套接字和当前服务器的 IP 和监听端口进行绑定;

  3. 调用 listen 函数,将主动套接字转换为监听套接字,开始监听客户端的连接。

  4. 调用 accept 函数, 该函数是阻塞函数,也就是说,如果此时一直没有客户端连接请求,那么,服务器端的执行流程会一直阻塞在 accept 函数。一旦有客户端连接请求到达,accept 将不再阻塞,而是处理连接请求,和客户端建立连接,并返回已连接套接字(Connected Socket)。

  • 客户端(图中左边部分)
  1. 客户端需要先初始化 socket;
  2. 再执行 connect 向服务器端的地址和端口发起连接请求。三次握手完成,客户端和服务器端建立连接,就进入了数据传输过程。

在上述图中所有的操作都是通过 socket 来完成的。无论是客户端的 connect,还是服务端的 accept,或者 read/write 操作等,socket 是我们用来建立连接,传输数据的唯一途径。

socket 对象一般包括五种信息:连接使用的协议、本地主机的IP地址、本地进程的协议端口、远端主机的IP地址、远端进程的协议端口。在整个 socket 通信工作流程中,socket 的默认状态是阻塞的。可通过 fcntl()函数设置为非阻塞。后面在使用 java 时,我们通常设置为非阻塞的状态。

当发出一个不能立即完成的套接字调用时,其进程将被阻塞,被系统挂起,进入睡眠状态,一直等待相应的操作响应。从上图中,可以看到在调用 connectacceptread/write 函数时,会发生阻塞。

阻塞 I/O

阻塞IO connect 阻塞:当客户端发起 TCP 连接请求,通过系统调用 connect 函数,TCP 连接的建立需要完成三次握手过程,客户端需要等待服务端发送回来的 ACK 以及 SYN 信号,同样服务端也需要阻塞等待客户端确认连接的 ACK 信号,这就意味着 TCP 的每个 connect 都会阻塞等待,直到确认连接。 accept 阻塞:一个阻塞的 socket 通信的服务端接收外来连接,会调用 accept 函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态。 read、write 阻塞:调用 read 函数当接收缓冲区没有数据时一直阻塞,等待数据到来。调用 write 函数,当发送缓冲区空闲,全部写入发送缓冲区才返回。当好发送缓冲区不空闲,会一直阻塞等待发送缓冲区空闲。

非阻塞 I/O

非阻塞IO 当我们把 socket 设置为了非阻塞状态,我们需要设置一个线程对该操作进行轮询检查,这也是最传统的非阻塞 I/O 模型。

多路复用 I/O

IO多路复用 在基本的 Socket 编程模型中,accept 函数只能在一个监听套接字上监听客户端的连接,recv 函数也只能在一个已连接套接字上,等待客户端发送的请求。

而 IO 多路复用机制,可以让程序通过调用多路复用函数,同时监听多个套接字上的请求。这里既可以包括监听套接字上的连接请求,也可以包括已连接套接字上的读写请求。这样当有一个或多个套接字上有请求时,多路复用函数就会返回。此时,程序就可以处理这些就绪套接字上的请求,比如读取就绪的已连接套接字上的请求内容。

Linux 提供的 IO 多路复用机制主要有三种,分别是 select、poll 和 epoll。

Java NIO 正是基于这个 IO 交互模型,来支撑业务代码实现针对 IO 进行同步非阻塞的设计,从而降低了原来传统的同步阻塞 IO 交互过程中,线程被频繁阻塞和切换的开销。

信号驱动 IO

信号驱动IO 用户进程发起一个 I/O 请求操作,会通过系统调用 sigaction 函数,给对应的套接字注册一个信号回调,此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号,通过信号回调通知进程进行相关 I/O 操作。 信号驱动式 I/O 相比于前三种 I/O 模式,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳。

异步 I/O

异步IO 当用户进程发起一个 I/O 请求操作,系统会告知内核启动某个操作,并让内核在整个操作完成后通知进程。这个操作包括等待数据就绪和数据从内核复制到用户空间。

在我们日常的开发中,大部分使用的是同步非阻塞 IO 的模式,然后在加 IO 多路复用的机制来满足功能。下面就通过 java 代码的方式来看看同组阻塞IO 与 同步非阻塞IO + 多路复用的实现。

不同模式 IO 实现

同步阻塞

/**
 * @desc: BIO 实现
 */
public class DemoServer extends Thread {
    ServerSocket serverSocket;
    public int getPort() {
        return serverSocket.getLocalPort();
    }

    @Override
    public void run() {
        try {
            serverSocket = new ServerSocket(6666);
            while (true) {
                Socket socket = serverSocket.accept();
                RequestHandler requestHandler = new RequestHandler(socket);
                requestHandler.start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (serverSocket != null) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                ;
            }
        }
    }

    class RequestHandler extends Thread {
        private Socket socket;

        RequestHandler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
                out.println("Hello world!");
                out.flush();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }


    public static void main(String[] args) {
        DemoServer server = new DemoServer();
        server.start();
        try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));
            bufferedReader.lines().forEach(System.out::println);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
  • 利用 Socket 模拟了一个简单的客户端,只进行连接、读取、打印。
  • 当连接建立后,启动一个单独线程负责回复客户端请求。

同步非阻塞 + 多路复用

在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。

  • Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。

  • Channel,类似在 Linux 之类操作系统上看到的文件描述符,是 NIO 中被用来支持批量式 IO 操作的一种抽象。File 或者 Socket,通常被认为是比较高层次的抽象,而 Channel 则是更加操作系统底层的一种抽象,这也使得 NIO 得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过 Socket 获取 Channel,反之亦然。

  • Selector,是 NIO 实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,进而实现了单线程对多 Channel 的高效管理。

public class NioServer extends Thread {
    @Override
    public void run() {
        // 创建一个 Selector,作为类似调度员的角色
        try (Selector selector = Selector.open();
             ServerSocketChannel serverSocket = ServerSocketChannel.open()) {
            // 创建Selector和Channel
            serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
            serverSocket.configureBlocking(false);
            // 注册到Selector,并说明关注点
            serverSocket.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                selector.select();
                // 阻塞等待就绪的Channel,这是关键点之一
                Set<SelectionKey> selectedKeys = selector.selectedKeys();
                Iterator<SelectionKey> iter = selectedKeys.iterator();
                while (iter.hasNext()) {
                    SelectionKey next = iter.next();
                    sayHelloWorld((ServerSocketChannel) next.channel());
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void sayHelloWorld(ServerSocketChannel server) throws IOException {
        try (SocketChannel client = server.accept();) {
            client.write(Charset.defaultCharset().encode("Hello world!"));
        }
    }


    public static void main(String[] args) {

        NioServer server = new NioServer();
        server.start();
        for (int i = 0; i < 10; i++) {

            try (Socket client = new Socket(InetAddress.getLocalHost(), 8888)) {
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));
                bufferedReader.lines().forEach(s -> System.out.println(s));
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }

}
  • 首先,通过 Selector.open() 创建一个 Selector,作为类似调度员的角色。
  • 然后,创建一个 ServerSocketChannel,并且向 Selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求。注意,为什么我们要明确配置非阻塞模式呢?这是因为阻塞模式下,注册操作是不允许的,会抛出 IllegalBlockingModeException 异常。
  • Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。在 sayHelloWorld 方法中,通过 SocketChannel 和 Buffer 进行数据操作,在本例中是发送了一段字符串。

但 Java 的 NIO 接口设计得并不是非常友好,代码中需要关注 Channel 的选择细节,而且还需要不断关注 Buffer 的状态切换过程。因此,基于这套接口的代码实现起来会比较复杂。

基于 Java NIO 设计的 Netty 框架来帮助屏蔽这些细节问题,同时在接口使用上也非常友好,所以目前使用也很广泛。后续我将会对 Netty 展开深入的了解。