上一课我们写了个 TCP echo 服务器,但它有个致命毛病:一次只能服务一个客户端。第一个客户端连进来不说话,服务器就永远卡在 read 上,第二个人怎么等都进不来。这一课,我们解决这个问题——让一个进程同时伺候成千上万个客户端。核心武器,就是网络 I/O 模型:阻塞、非阻塞、多路复用、异步。

从"一个接线员"说起

想象你是客服接线员,负责接电话。有两种工作方式:

  • 死等式:接起一个电话,就只守着这一个,直到对方挂断,你才接下一个。其他来电全在排队——这就是阻塞 I/O
  • 前台式:你雇一个前台,帮你盯着所有电话线,哪条线响了就告诉你,你只处理响的那条——这就是多路复用

上一课的服务器,就是"死等式接线员"。这一课,我们教它雇个前台。


23.1 阻塞 I/O 的痛点

一句话理解:上节课的服务器一次只服务一个客户端——acceptread 都会卡住,第二个客户端只能排队。

回顾上一课服务器的流程:

  1. accept 卡住——等第一个客户端来
  2. 来了之后 read 卡住——等这个客户端发数据

问题就出在第二步:第一个客户端连进来后不说话,服务器就永远卡在 read,既不能去 accept 新的连接,也服务不了别人。

一句话:阻塞 I/O 让一个进程一次只能"专注"于一个连接,其余全靠排队。这在高并发场景下是灾难。

23.2 四种 I/O 模型

先总览,把四种模型摆在桌面上:

模型怎么做特点
阻塞 I/O一直等数据简单,一次只能一个
非阻塞 I/O没数据立刻返回,自己轮询不卡但 CPU 空转
多路复用select/poll/epoll 同时盯多个 fd一个进程管很多连接
异步 I/O发起后内核干完再通知最彻底,编程复杂
一句话理解:阻塞是"死等",非阻塞是"反复敲门",多路复用是"请个前台帮你盯所有房间",异步是"事情办完我给你打电话"。
  • 阻塞read 没数据就睡着,简单但低效;
  • 非阻塞read 没数据立刻返回,你自己反复去试——不卡住,但 CPU 空转、费劲;
  • 多路复用:把一堆 fd 交给内核,内核告诉你"哪些就绪了",你只处理就绪的——一个进程管很多连接
  • 异步:发起 I/O 后彻底不管,内核干完了通知你——最彻底,但编程最复杂。

这一课重点讲多路复用,因为它是现代高性能服务器的基石。


23.3 select:多路复用的起点

一句话理解:把一堆 fd 交给内核,内核告诉你"哪些可读/可写了",你再只处理就绪的。核心就三个词:FD_ZERO/FD_SET(设置集合)、select(等待)、FD_ISSET(判断谁就绪)。

select 是 Unix 里最早的多路复用接口,用法固定三步:

  1. FD_ZERO + FD_SET:把你要监听的 fd,一个个放进一个"集合"里;
  2. select:把集合交给内核,阻塞等待,直到"有 fd 就绪"才返回;
  3. FD_ISSET:遍历检查,看具体是哪个 fd就绪了,然后处理它。
核心思想:一次等一堆,而不是一个一个死等。 这就是"雇前台"的实现。

缺点也很明显:

  • fd 数量有上限(默认 1024);
  • 每次调用都要重新拷贝整个 fd 集合进内核;
  • 内核用线性扫描检查谁就绪——连接越多越慢。

23.4 poll / epoll:一步步变强

select 有天花板,于是有了更强的两代:

poll

数组代替 select 的位图,去掉数量上限。但本质上仍是线性扫描,连接多了照样慢。

epoll(Linux 的杀手锏):

epoll_ctl 注册一次,内核用红黑树 + 就绪链表管理,事件驱动,海量连接也快——Linux 高并发服务器的标配(Nginx、Redis 都用)。

三者的演进,一句话概括:

select 是"每次都拿名单从头点名",epoll 是"谁举手就通知我"。
  • select:每次重新传名单、从头扫一遍;
  • poll:名单变成无限长,但还是要从头扫;
  • epoll:登记一次,谁举手(就绪)直接通知,不用扫

连接越多,epoll 的优势越碾压。


23.5 异步 I/O 与事件循环

最后,把这个思想上升到"异步"的高度:

一句话理解:Python 的 asyncio、Node.js 都是"事件循环 + 非阻塞"的异步模型,底层思想与 epoll 一脉相承——一个线程驱动成千上万个并发任务。

你现在用的很多技术,本质都是同一套:

  • Python 的 asyncio:一个事件循环(event loop),不断"收集就绪的事件 → 分发处理";
  • Node.js:同样的事件循环模型;
  • Redis / Nginx:底层就是 epoll。
一句话:"事件循环 + 非阻塞"是现代高并发的通用范式,epoll 是它在 Linux 上的底层引擎。理解它,你就理解了 asyncio、Node.js 为什么能"单线程扛万连"。

动手实验:C 语言 select 多客户端 echo

// lesson23.c —— 用 select 同时服务多个客户端
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(void) {
    int srv = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(8080);
    bind(srv, (struct sockaddr*)&addr, sizeof(addr));
    listen(srv, 5);

    fd_set rfds;
    int clients[10] = {0};   // 最多记录 10 个客户端
    printf("select 服务器监听 8080……\n");

    while (1) {
        FD_ZERO(&rfds);
        FD_SET(srv, &rfds);
        int maxfd = srv;
        for (int i = 0; i < 10; i++)
            if (clients[i] > 0) { FD_SET(clients[i], &rfds); if (clients[i] > maxfd) maxfd = clients[i]; }

        int ready = select(maxfd + 1, &rfds, NULL, NULL, NULL);
        if (ready < 0) { perror("select"); return 1; }

        if (FD_ISSET(srv, &rfds)) {          // 新连接来了
            int c = accept(srv, NULL, NULL);
            for (int i = 0; i < 10; i++) if (clients[i] == 0) { clients[i] = c; break; }
            printf("新客户端加入 fd=%d\n", c);
        }
        for (int i = 0; i < 10; i++) {       // 处理就绪的客户端
            int c = clients[i];
            if (c > 0 && FD_ISSET(c, &rfds)) {
                char buf[256];
                int n = read(c, buf, sizeof(buf) - 1);
                if (n <= 0) { close(c); clients[i] = 0; printf("客户端 fd=%d 离开\n", c); }
                else { buf[n] = '\0'; write(c, buf, n); }
            }
        }
    }
    return 0;
}

Python 高层对照版(同样的多客户端 echo,只用几行):

# lesson23.py —— selectors 多客户端 echo(对照)
import selectors, socket

sel = selectors.DefaultSelector()   # 自动选 epoll/kqueue/select

def accept(sock, mask):
    conn, _ = sock.accept()
    sel.register(conn, selectors.EVENT_READ, echo)

def echo(conn, mask):
    data = conn.recv(1024)
    if data:
        conn.send(data)
    else:
        sel.unregister(conn); conn.close()

srv = socket.socket()
srv.bind(("127.0.0.1", 8080)); srv.listen(5)
sel.register(srv, selectors.EVENT_READ, accept)
while True:
    for key, mask in sel.select():   # 只处理就绪的
        key.data(key.fileobj, mask)

运行:

gcc lesson23.c -o lesson23 && ./lesson23   # 终端 1
# 另开多个终端,同时连过去发消息
python3 -c "import socket;[socket.create_connection(('127.0.0.1',8080)).sendall(b'hi\n') for _ in range(3)]"

开多个客户端同时连,观察:服务器能同时回显所有人——这就是多路复用的效果。

对比上一课:这次没有一个客户端能"卡死"服务器了,因为 select 一次性盯着"监听 socket + 所有客户端 socket",谁就绪处理谁。

深入点:两个进阶话题

① epoll 的两种触发

  • 水平触发(LT):只要这个 fd 还有数据没读完,就一直通知你。默认模式,简单不易出错;
  • 边缘触发(ET):只在状态变化时(比如"没数据→有数据")通知一次。性能更高,但要求你一次把数据读干净,否则可能漏掉。
一句话:LT 省心,ET 高效但要求高。 高性能服务器常配合非阻塞 I/O 用 ET。

② C10K 问题

一台服务器怎么扛 1 万并发连接?答案就是 epoll + 事件驱动 + 非阻塞。这是高并发服务器的起点。

"1 万连接"曾经是个难题——如果用"一连接一进程/线程",1 万个进程/线程的开销是天文数字。而一个线程 + epoll 就能轻松扛下。这就是 epoll 的价值所在。


小结与思考题

这一课,我们解决了"一个服务器只能服务一个人"的问题:

  • 阻塞 I/O:一次专注一个连接,其余排队;
  • 四种 I/O 模型:阻塞(死等)、非阻塞(反复试)、多路复用(前台盯)、异步(办完通知);
  • select:一次等一堆,但有上限、要拷贝、线性扫描;
  • poll/epoll:去掉上限 → 事件驱动,epoll 是海量连接标配;
  • 事件循环:asyncio、Node.js 的底层思想,与 epoll 一脉相承。

留三个问题:

  1. 阻塞 I/O 和多路复用本质区别?
  2. select 相比 epoll 有哪些不足?
  3. Python 的 selectors / asyncio 底层靠什么?
现在我们知道:一个线程 + epoll 就能扛万连。但问题是——真的所有场景都该用这一个线程吗?如果任务很重(比如 CPU 密集计算),单线程会不会成为瓶颈?多进程、多线程、事件驱动,到底怎么选?下一课,我们站在更高处,看高并发服务器的三种模型,以及 Reactor(反应器)这个经典设计模式。

标签: 计算机基础, 操作系统, Linux

添加新评论