Network IO Models

Network IO Models

I/O Overview

I/O 即输入输出(Input/Output),核心目标是实现数据的交换与控制。从本质上来说,IO 操作主要涉及两个阶段:

I/O Stages

前置知识 Kernel/User Space

Kernel Space(内核空间)和 User Space(用户空间)是操作系统中两个重要的内存区域。

  • 内核空间是操作系统核心代码运行的区域,具有更高的权限,可以直接访问硬件资源
  • 用户空间则是应用程序运行的区域,权限较低,不能直接访问硬件资源。通常需要通过系统调用执行特权操作
WIKITEXT
 ┌─────────────────────────────────────────────────────────┐    User Space  0x0000000000000000 - 0x00007FFFFFFFFFFF      ┌─────────────┐ ┌─────────────┐ ┌─────────────┐            Stack Buffer│  Heap Buffer   mmap Buffer            └─────────────┘ └─────────────┘ └─────────────┘         └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐  Kernel Space  0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF      ┌─────────────┐ ┌─────────────┐ ┌─────────────┐           │Socket Buffer│  Page Cache    DMA Buffer             └─────────────┘ └─────────────┘ └─────────────┘         └─────────────────────────────────────────────────────────┘
  • 数据准备阶段:等待数据到达并存入 kernel buffer
    • 对于网络 I/O,通常是等待网络数据包到达网卡,经过内核协议栈处理并放入 socket 接收缓冲区时,这一阶段才算完成
  • 数据复制阶段:将数据从 kernel buffer 复制到 user buffer,在这一阶段,数据从内核态进入用户态,供应用程序进一步处理

一次 I/O 操作各阶段的流程可以参考下图:

View Mermaid diagram code
sequenceDiagram
    box User Space
        participant APP as Web Application
        participant FWK as Web Framework/System Libraries
    end
    participant SYS as 🔶 System Call Interface
    box Kernel Space
        participant KER as Kernel
    end
    participant HW as Physical Hardware

    APP->>FWK: 使用Web框架/库函提供的接口,进行IO操作
    FWK->>SYS: 发起系统调用

    Note over SYS: Context Switch (用户态→内核态)

    SYS->>KER: 内核处理逻辑

    KER->>HW: 硬件操作
    Note over KER: 内核等待I/O设备数据就绪

    HW-->>KER: 数据就绪, 存储到内核缓冲区(Kernel Buffer)

    Note over KER: 数据由内核缓冲区复制到用户缓冲区(User Buffer)

    KER-->>SYS: 系统调用返回

    Note over SYS: Context Switch (内核态→用户态)

    SYS-->>FWK: 返回I/O结果

    FWK-->>APP: 返回业务结果

I/O Models

基于两个阶段的处理方式差异,可以将 I/O 操作分为以下几种模型,前 4 种模型都属于同步 I/O 模型,最后一种是异步 I/O 模型。

IO 模型数据准备阶段数据复制阶段
阻塞 IO阻塞等待同步复制
非阻塞 IO非阻塞轮询同步复制
IO 多路复用阻塞等待多个源同步复制
信号驱动 IO异步通知同步复制
异步 IO异步等待异步复制
iomodel_comparison.webp

Blocking I/O

阻塞 IO 是最基本的 IO 类型。使用这种模型进行数据接收的时候,在数据没有到之前程序会一直等待。从 OS 角度来看,如果数据未准备好,调用线程/进程会进入阻塞状态(blocked)。

blocking_io
Hint

States Of Process & Thread: ProcessvsThread & ProcessvsThread

Socket API Reference: Ch7-SocketProgramming

单线程阻塞

对于阻塞 I/O 模型来说,下面是最简单的一个网络服务器实现:单线程处理所有连接。

C
#include <sys/socket.h>#include <netinet/in.h>#include <stdio.h>#include <unistd.h>void single_thread_server() {    int server_fd = socket(AF_INET, SOCK_STREAM, 0);    struct sockaddr_in address;    address.sin_family = AF_INET;    address.sin_addr.s_addr = INADDR_ANY;    address.sin_port = htons(8080);    bind(server_fd, (struct sockaddr*)&address, sizeof(address));    listen(server_fd, 3);    while (1) {        int client_fd = accept(server_fd, NULL, NULL); // 阻塞等待连接        char buffer[1024];        while (1) {            int bytes = recv(client_fd, buffer, 1024, 0); // 阻塞读取            if (bytes <= 0) break;            send(client_fd, buffer, bytes, 0); // 回显数据            close(client_fd);  // 只有当前客户端断开连接后,才能处理下一个客户端        }    }}

多线程阻塞模型

对此,可以使用多线程提高并发情况下的处理能力。每当有新的客户端连接时,创建一个新线程来处理该连接。

这里提供一个 Java 例程演示该模型的实现。在代码中,主函数创建了一个固定大小的线程池后等待客户端连接。每当有新的客户端连接时,主线程会将该连接交给线程池中的一个线程处理。每个线程会阻塞等待客户端发送数据,并将接收到的数据回显给客户端。

JAVA
import java.io.*;import java.net.*;import java.util.concurrent.*;public class BioServer {    public static void main(String[] args) throws IOException {        ExecutorService executor = Executors.newFixedThreadPool(100); // 固定线程池        ServerSocket serverSocket = new ServerSocket(8088); // 监听 8088 端口        while (!Thread.currentThread().isInterrupted()) {            Socket clientSocket = serverSocket.accept(); // 阻塞等待客户端连接            executor.submit(new ConnectionHandler(clientSocket)); // 将连接交给线程池处理        }        executor.shutdown();        serverSocket.close();    }}class ConnectionHandler implements Runnable {    private final Socket socket;    public ConnectionHandler(Socket socket) {        this.socket = socket;    }    public void run() {        try (            BufferedReader in = new BufferedReader(                new InputStreamReader(socket.getInputStream()));            BufferedWriter out = new BufferedWriter(                new OutputStreamWriter(socket.getOutputStream()));        ) {            String line;            while ((line = in.readLine()) != null) { // 阻塞读取                System.out.println("Received: " + line);                out.write("Echo: " + line);                out.newLine();                out.flush();            }        } catch (IOException e) {            System.err.println("Connection error: " + e.getMessage());        } finally {            try {                socket.close(); // 关闭连接            } catch (IOException ignore) {}        }    }}

这种模型的优点是编程模型简单,易于理解与调试,面对少量连接时使用该模型是比较合适的选择。

其限制也比较明显,即线程是比较昂贵的资源,当面对较多的连接时,可能导致系统资源耗尽。C10K问题就是在这种模型下出现的。

美团技术团队也在Java NIO 浅析中分析了该模型的限制

Caution

现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
不过,这个模型(BIO 模型)最本质的问题在于,严重依赖于线程。但线程是很"贵"的资源,主要表现在:

  1. 线程的创建和销毁成本很高,在 Linux 这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
  2. 线程本身占用较大内存,像 Java 的线程栈,一般至少分配 512K ~ 1M 的空间,如果系统中的线程数过千,恐怕整个 JVM 的内存都会被吃掉一半。
  3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统 load 偏高、CPU sy 使用率特别高(超过 20%以上),导致系统几乎陷入不可用的状态。
  4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或 CPU 核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。

所以,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的 I/O 处理模型。

Non-Blocking I/O

非阻塞 I/O 允许应用程序在发起 I/O 操作后立即返回,而不会因数据尚未准备好而阻塞,这就允许用户进程自由去做别的事,最简单的例子就是轮询(polling),应用程序可以定期检查数据是否准备好。

non_blocking_read

仍以网络 I/O 为例,Windows/Linux 下的套接字默认是阻塞的,应用程序可以通过fcntl/ioctl函数将套接字设置为非阻塞。

C
// 如果 fd 已设置了其他标志位,可以先使用 fcntl 获取当前标志位,然后用位或操作添加 O_NONBLOCK 标志位int safe_set_nonblocking(int fd) {    int flags = fcntl(fd, F_GETFL, 0);    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);}

当调用 Socket 操作函数时,如果操作不能立即完成,系统调用会立即返回一个错误,并将错误码(errno)设置为EWOULDBLOCK,含义为 would block,可以对应下面表格的阻塞触发条件。

类别操作函数阻塞触发条件(TCP/UDP)
读操作read readv recv recvfrom recvmsgTCP:接收缓冲区无足够数据
UDP:接收缓冲区为空(UDP 需整报读取)
写操作write writev send sendto sendmsgTCP:发送缓冲区无足够空间
UDP:可能直接丢弃报文,视实现而定
接受连接accept无新连接到来(监听套接字阻塞)
发起连接connectTCP:三次握手未完成
Warning

部分实现中将错误码定义为 EAGAIN,顾及历史原因,POSIX 标准允许这两种错误码的使用,在大多数系统中,这两个错误代码是等价的,具体可以<sys/errno.h>头文件中查看

connect函数稍特别一点,每次调用函数发起 TCP 连时,TCP 三次握手会阻塞调用进程至少一个 RTT 的时间,如果连接无法立刻完成(大多数情况下不会),errno 被设置为 EINPROGRESS,表示“连接还在进行中”

如下代码是最简单的轮询模型。,这种简单的实现从整体上来看,用户进程还是阻塞在while循环上,发现不能读/写就立刻继续下一轮检查

C
while (1) {    ssize_t n = recv(fd, buf, sizeof(buf), MSG_DONTWAIT);    if (n > 0) {        // 处理接收到的数据    } else if (n == -1 && errno == EWOULDBLOCK) {        // 可以在这里做其他事情, 例如:        // 1. 心跳检查:如果没有数据可读,可以发送心跳包,根据业务逻辑触发重连/资源清理        check_heartbeat();        // 2. 更新Log        update_log();        sleep(1); // 等待一段时间后重试    }}

在 Blocking IO 中为了提高并发能力,使用多线程为每一个建立的 socket 分配一个线程去阻塞 read/write,其缺点前文也已解释,当面对百万的连接数时,大量线程会耗尽系统资源。引入 Non Blocking IO 后,我们可以在一个线程,同时处理多个客户端连接:

C
#define MAX_CLIENTS 100  // 设置最多复用100个客户端连接int server_fd;// 存放客户端连接的文件描述符,初始化为-1,表示该位置没有客户端连接;int client_fd_set[MAX_CLIENTS];void multiplexing_server() {    while (1) {        for (int i = 0; i < MAX_CLIENTS; i++) {            if (client_fd_set[i] == -1) continue; // 如果该位置没有客户端连接,跳过            ssize_t bytes_read = recv(client_fd_set[i], buf, sizeof(buf), MSG_DONTWAIT);            if (bytes_read > 0) {                // 处理接收到的数据                process_data(buf, bytes_read);            } else if (bytes_read == -1 && errno == EWOULDBLOCK) {                // 没有数据可读,可以在这里做其他事情,例如心跳检查、更新日志等            } else if (bytes_read == 0) {                // 客户端断开连接,清理资源                close(client_fd_set[i]);            }        }    }}

现在服务器一般为多核处理器,上述代码中的process_data内为 CPU 密集型操作,可以提交到线程池中处理,以充分利用多核 CPU 的并行处理能力。

I/O Multiplexing

I/O 多路复用(I/O Multiplexing)允许单个线程/进程同时监控多个文件描述符的状态变化。通过这种方式,应用程序可以在一个线程中处理多个连接,而不需要为每个连接创建独立的线程。

上面代码中的 while 循环也实现了类似多路复用的功能,但它在循环内每次调用 recv 都是一次从用户态到内核态的上下文切换,而更合理的方式是将轮询的操作从用户空间移动到内核空间,通过一次系统调用就能监控多个文件描述符的状态变化,从根本上减少了用户态和内核态之间的切换开销。

io multiplexing

以 Linux 为例,内核提供了 selectpollepoll 三个函数(其中 epoll 为 Linux 特有)。首先将一组文件描述符注册到内核中,内核负责监控这些文件描述符的状态变化。当其中某个文件描述符变为可读或可写时,内核会通知应用程序,应用程序可以通过一次系统调用获取所有就绪的文件描述符,从而避免了频繁的上下文切换。详细介绍参考IO多路复用

Signal-Driven I/O

信号驱动的 IO 在进程开始的时候注册一个信号处理的回调函数,进程继续执行。当数据到来时,通知目标进程进行 IO 操作(signal handler)。

signal driven io

Asynchronous I/O

Asynchronous: 两个层面的“异步”

“异步”这个词在技术讨论中常被用于描述两个不同层面的概念,这容易引起混淆:

  1. 操作系统层面的 I/O 模型:例如本文介绍的 AIO。在此模型中,从数据准备数据复制的整个过程都由内核完成,应用只需发起请求,然后等待内核的完成信号即可。
  2. 应用程序层面的编程模型:框架提供的 API 是异步的,但框架所调用的操作系统接口是同步非阻塞。

异步 IO 与前面的信号驱动 IO 相似,其区别在于信号驱动 IO 当数据到来的时候,使用信号通知注册的信号处理函数,而异步 IO 则在数据复制完成的时候才发送信号通知注册的信号处理函数。

iomodel_asyncio

References

作者

GnixAij

发布于

2025-06-24

更新于

2025-08-12

许可协议

评论