IO基础:阻塞IO与非阻塞IO

IO 操作是程序里最磨人的那类活儿。你发一个读请求,数据还没到,线程就傻等在那儿——这就是阻塞。说白了,阻塞就是“我等你,你不来我就不走”。非阻塞呢?我发完请求,立马干别的事去了,过一会儿再回来看看数据到了没。

这两个概念是理解多线程预加载的基石。我在早年做网络代理时,就因为没搞清楚阻塞模型,把并发压到了个位数。今天咱们把这层窗户纸捅破。

阻塞IO:线程的“原地罚站”

阻塞IO是最传统的模式。你调用 read() 从socket读数据,如果内核缓冲区里没东西,线程就挂起,直到数据准备好。这期间线程什么都不干,就等着。

来看一段伪代码:

// 阻塞IO示例
int fd = open("/data/file.bin", O_RDONLY);
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf)); // 这里线程阻塞,直到数据拷贝到buf
// 继续处理 buf
process(buf, n);

你想想看,如果这个文件很大,或者网络延迟高,线程就在 read() 那条线上“罚站”。我家里的老项目,一个线程处理一个连接,几千个连接就得开几千个线程——系统直接崩了。

核心特点

  • 简单直观:写起来和单线程顺序流程一样
  • 线程/进程资源占用高:每个IO都需要一个独立的执行单元
  • CPU利用率低:大量时间花在等待上

但阻塞IO也有它的用武之地。比如写一个小工具只处理一个文件,或者低并发的命令行程序,阻塞模式反而代码最干净。

非阻塞IO:线程的“多线程打杂”

非阻塞模式就不一样了。你把套接字或文件描述符设为 O_NONBLOCK,然后调用 read()。如果数据没准备好,函数直接返回一个错误码(比如 EWOULDBLOCK),线程立刻可以去干别的。

代码大概长这样:

// 非阻塞IO示例
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

char buf[1024];
ssize_t n;
while (1) {
    n = read(fd, buf, sizeof(buf));
    if (n >= 0) break;          // 读到数据
    if (errno != EWOULDBLOCK) {
        perror("read error");
        break;
    }
    // 数据没准备好,去做其他任务
    do_other_work();
}

咦?这看起来得像一个 while 循环不断轮询,是不是更笨重了?嗯,确实。

避坑指南

我曾经在一个高并发网关里直接用了这种“纯轮询”非阻塞IO,结果CPU跑满了,延迟还高。为什么?因为轮询本身就是一种忙等,消耗CPU。非阻塞IO很少单独使用,它通常要和IO多路复用(select/poll/epoll)配合,或者结合异步IO。

非阻塞IO的真正优势在于:一个线程可以同时管理几百个连接。你不需要为每个连接开一个线程,只需要一套事件循环,哪个连接的数据准备好了,就去处理哪个。这直接支撑了今天的高性能网络框架。

一张表看出区别

维度 阻塞IO 非阻塞IO
线程行为 等待时挂起 立即返回,可做其他
资源占用 线程/连接,资源重 单线程可管理多路
编码复杂度 低,顺序逻辑 中,需处理重试/轮询
适用场景 低并发、单次任务 高并发、可同时处理
典型错误 线程池太小导致饥饿 轮询死循环、忘设非阻塞

个人经验

我个人的习惯是:如果预期并发连接数超过100,就不要碰纯阻塞模型。用非阻塞+epoll,一个4核机器轻轻松松抗住几千连接。但如果是单次数据迁移、后台日志落盘,阻塞IO一把锁倒是更稳当。

两种模型的底层逻辑

阻塞和非阻塞的本质区别,在于系统调用是否把调用线程的控制权交给内核的IO等待队列。阻塞时,线程被移出调度队列;非阻塞时,线程继续运行,只是需要用户态自己来检查状态。

画个图来理解整个流程:

阻塞IO vs 非阻塞IO 流程对比 阻塞模式 用户线程调用 read() 数据未就绪,线程挂起 内核准备数据(等待IO) 数据就绪,线程唤醒 内核拷贝数据到用户buf 非阻塞模式 用户线程调用 read() 数据未就绪,返回 EWOULDBLOCK 线程做其他工作 继续轮询 数据就绪,线程拷贝数据 处理数据 + 继续事件循环 阻塞模式:线程等待时间 = 内核IO时间;非阻塞模式:线程等待时间 ≈ 0,但需主动检查

避坑指南(续)

我曾经接手一个旧服务,代码里用了非阻塞 + 每100微秒轮询一次。CPU 飙到 90%,一问原来开发者忘了加 sleep 或 usleep 来让出CPU。非阻塞不代表无脑循环,一定要配合某种事件通知机制(select/poll/epoll/kqueue),否则就是伪非阻塞,CPU吃光。

那什么场景该选哪个?

我给个朴素判断法:

  • 连接数少(< 50),且每个连接都是“长任务” —— 阻塞+线程池足够,代码简单
  • 连接数多(> 100),且大部分时间连接空闲 —— 非阻塞 + IO多路复用
  • 混合场景 —— 比如主线程用非阻塞做事件分发,工作线程用阻塞处理业务逻辑,这也是很常见的架构

阻塞IO和非阻塞IO没有谁好。我见过一些人用epoll处理一个文件读写,结果代码绕了一大圈还容易出bug。反过来,也有人用阻塞线程池处理百万长连接,结果线程切换开销直接打垮机器。

选型的关键就两条:并发数 × 平均等待时间。乘积大,非阻塞收益就高;乘积小,阻塞更省事。