1. Socket 基础回顾:阻塞与非阻塞 I/O 模型、同步与异步的本质区别

做高并发 Socket 编程,很多人一上来就啃 epoll、kqueue、io_uring。但我个人的经验是——先把基础打牢。阻塞、非阻塞、同步、异步,这四个词如果没吃透,后面写出来的代码大概率是「看起来对,跑起来崩」。

这一章,我们就来彻底掰扯清楚这些概念。嗯,我会结合自己踩过的坑来讲。

1.1 阻塞 I/O:最朴素,也最危险

阻塞 I/O 是什么?说白了就是:你调用 recv(),如果没有数据,线程就挂在那等。数据不来,线程不走。

我刚开始写网络程序时,觉得这很自然——等数据嘛,天经地义。直到有一次线上服务出了问题:一个连接卡住了,整个进程都动不了。为什么?因为那时候用的是阻塞 I/O,一个线程只能处理一个连接。

阻塞 I/O 的核心特征:

  • 调用方(线程)在数据就绪前被挂起
  • 内核负责把线程放到等待队列
  • 数据到达后,内核唤醒线程,拷贝数据
  • 线程在等待期间不消耗 CPU,但占用线程资源

来看一段典型的阻塞 I/O 代码:

// 阻塞 I/O 示例
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, ...);

char buf[1024];
// 这里会阻塞,直到有数据可读
ssize_t n = recv(sockfd, buf, sizeof(buf), 0);
if (n > 0) {
    // 处理数据
}

这段代码看起来简单,但问题很大。如果对方不发数据,这个线程就永远卡在 recv() 上。你想想看,如果同时有 1000 个连接,你就得开 1000 个线程。线程多了,上下文切换的开销能把 CPU 吃光。

我曾经踩过的坑: 用阻塞 I/O + 每连接一线程的模式,处理 500 个并发连接时,系统负载直接飙到 80%。后来换成非阻塞 + 事件驱动,同样硬件扛到了 5000 连接。线程不是免费的,每个线程默认栈空间 8MB,500 个线程就是 4GB 虚拟内存。

1.2 非阻塞 I/O:不等待,但你要学会「重试」

非阻塞 I/O 就聪明多了。你调用 recv(),如果没有数据,它立刻返回一个错误码 EAGAIN 或 EWOULDBLOCK。线程不会被挂起,可以去做别的事。

但这里有个关键点:非阻塞 I/O 需要你主动去「轮询」。你得不断调用 recv() 检查数据是否到达。这就是所谓的「忙等待」——CPU 空转,浪费资源。

我个人习惯把非阻塞 I/O 比作「自助餐打菜」:你端着盘子去窗口看,有菜就打,没菜就走开,过一会儿再来看看。但如果你每秒钟来看 1000 次,厨师会被你烦死,你自己也累。

// 非阻塞 I/O 示例(设置 O_NONBLOCK)
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

char buf[1024];
while (1) {
    ssize_t n = recv(sockfd, buf, sizeof(buf), 0);
    if (n > 0) {
        // 处理数据
        break;
    } else if (n == -1 && errno == EAGAIN) {
        // 没数据,做点别的事,过会儿再试
        do_something_else();
        continue;
    } else {
        // 出错或连接关闭
        break;
    }
}

纯非阻塞 I/O 的问题是:你没法知道数据什么时候来。轮询太频繁浪费 CPU,轮询太少延迟又高。所以实际工程中,我们很少单独用非阻塞 I/O,而是配合 I/O 多路复用(select/poll/epoll)一起用。

1.3 同步 vs 异步:到底谁在干活?

这两个概念被很多人搞混。我见过面试时信誓旦旦说「异步就是非阻塞」的候选人——嗯,这是错的。

区分同步和异步,关键看「数据拷贝」这个动作是谁做的:

维度 同步 I/O 异步 I/O
数据拷贝 用户线程自己调用 recv()/read() 从内核拷贝数据 内核把数据拷贝到用户缓冲区后,通知用户线程
线程阻塞点 阻塞在数据拷贝阶段 不参与拷贝,不会被阻塞
典型 API read(), write(), recv(), send() Windows IOCP, Linux AIO, io_uring
编程复杂度 低,但线程利用率低 高,但线程利用率高

举个例子你就明白了:

  • 同步阻塞:你去餐厅吃饭,坐下点菜,厨师做菜,你坐在那等,菜端上来你吃。整个过程你都在等。
  • 同步非阻塞:你去餐厅,点完菜,每隔 10 秒去厨房窗口看一眼:「菜好了吗?」没好就回去刷手机。你还是在主动检查。
  • 异步:你点完菜,拿了个震动取餐器,回座位刷手机。菜好了,取餐器震动,你去取。你不需要主动检查,是系统通知你。

看到了吗?同步和异步的区别在于「谁负责通知你」。同步是你主动去问,异步是别人主动告诉你。

一个容易混淆的点: 很多人以为 epoll 是异步 I/O。其实 epoll 是同步 I/O 多路复用——它只是帮你监听多个 socket 的事件,但数据还是要你自己调用 recv() 去读。真正的异步 I/O 是内核直接把数据放到你指定的缓冲区,然后回调你的函数。Linux 原生的 AIO 一直不太好用,直到 io_uring 出现才真正成熟。

1.4 四种组合:一张图说清楚

把阻塞/非阻塞和同步/异步组合起来,就是四种 I/O 模型。我画了一张图帮你理解:

四种 I/O 模型对比 四种 I/O 模型对比 同步阻塞 I/O 线程调用 recv() 后挂起 内核等待数据就绪 数据到达后唤醒线程 线程自己拷贝数据 典型:传统 read()/recv() 同步非阻塞 I/O 线程调用 recv() 立即返回 无数据时返回 EAGAIN 线程轮询检查数据 有数据时自己拷贝 典型:O_NONBLOCK + 轮询 I/O 多路复用(同步) 用 select/poll/epoll 监听 内核通知哪个 socket 就绪 线程遍历就绪列表 调用 recv() 拷贝数据 典型:epoll + 非阻塞 socket 异步 I/O 提交 I/O 请求到内核 线程立即返回,做其他事 内核完成数据拷贝 通过回调/信号通知线程 典型:io_uring, IOCP 注意:I/O 多路复用本质上是同步的,因为它需要用户线程主动调用 recv() 读取数据

1.5 实际工程中的选择

搞清楚了理论,我们来看看实际中怎么选。我个人在项目中总结了一套经验:

  • 连接数少(< 100):阻塞 I/O + 每连接一线程。简单,够用。但要注意线程池管理,别频繁创建销毁。
  • 连接数中等(100 ~ 1000):非阻塞 I/O + epoll(Linux)或 kqueue(macOS/FreeBSD)。这是最常用的模式。
  • 连接数大(> 1000):非阻塞 I/O + epoll + 事件驱动(Reactor 模式)。或者考虑异步 I/O(io_uring)。
  • 极致性能:io_uring。但学习曲线陡,生态还不够成熟。

一个重要的提醒: 不要为了用新技术而用新技术。我见过有人用 io_uring 写一个只处理 50 个连接的服务——完全没必要。epoll 在 1000 连接以下的表现已经足够好。选技术栈,先看业务场景。

1.6 本章小结

阻塞和非阻塞,说的是「调用会不会挂起线程」。同步和异步,说的是「数据拷贝谁来做」。这两个维度正交,组合出四种 I/O 模型。

记住几个关键点:

  • 阻塞 I/O 简单但线程利用率低,不适合高并发
  • 非阻塞 I/O 需要配合多路复用,否则轮询浪费 CPU
  • epoll 是同步 I/O,不是异步 I/O
  • 真正的异步 I/O 在 Linux 上直到 io_uring 才成熟