一、前言:从“等待”到“询问”的进化

在上一篇文章中,我们探讨了阻塞 IO(BIO) 的局限性——一个线程只能服务一个连接,导致在高并发下资源耗尽。那么,有没有一种方式,能让一个线程同时处理多个连接,而又不被任何一个连接的慢速操作所“卡住”呢?

答案就是非阻塞 IO(Non-blocking I/O, NIO)

非阻塞 IO 是对阻塞 IO 的一次重要改进,它改变了进程与内核交互的方式。虽然 Redis 并未直接使用原始的非阻塞 IO 模型(因为它有更优的解决方案),但理解 NIO 是通往 Redis 核心网络模型——IO 多路复用——的关键桥梁。

💡 核心价值
非阻塞 IO 的核心思想是“不等待”,它让线程可以主动去“询问”数据是否就绪,从而避免了在单个连接上无谓的阻塞。然而,单纯的“轮询”又带来了新的 CPU 开销问题,这正是 IO 多路复用要解决的

本文将带你:

  • 彻底搞懂非阻塞 IO 的工作原理
  • 剖析其“轮询”模式带来的新问题
  • 理解 Redis 如何将其与 IO 多路复用结合,发挥最大效能

二、什么是非阻塞 IO?一个忙碌的餐厅经理

继续用餐厅的比喻来理解。

现在,餐厅里有一位非常勤快但不懂变通的经理(用户进程)。

  1. 他走到 1 号桌客人(socket 1)面前,问:“您的牛排好了吗?”(发起 read() 调用)。
  2. 厨房(内核)回答:“还没好。”
  3. 经理不会傻等!他立刻转身,马上走到 2 号桌客人(socket 2)面前,问同样的问题。
  4. 如果 2 号桌也没好,他就去问 3 号桌、4 号桌……如此循环往复。
  5. 直到某次他问到 N 号桌时,厨房说:“好了!” 他才把牛排端给客人,然后继续他的“巡逻”。

在这个场景中,经理(线程)永远不会停下来等待,他一直在主动“询问”(轮询)。这就是非阻塞 IO 的本质。

技术定义

在 Linux 中,可以通过 fcntl(fd, F_SETFL, O_NONBLOCK) 将一个文件描述符(如 socket)设置为非阻塞模式

  • 当对该 fd 执行 read() 时:
    • 如果数据已准备好,内核会正常拷贝数据并返回读取的字节数。
    • 如果数据未准备好,内核不会阻塞进程,而是立即返回一个错误 EAGAIN 或 EWOULDBLOCK
  • 进程收到这个错误后,就知道“现在没数据”,可以去做别的事,或者稍后再来尝试。

✅ 关键特性同步 + 非阻塞。进程不会在 IO 调用上挂起,但它必须自己负责不断地检查 IO 是否就绪。


三、非阻塞 IO 的工作流程

让我们看看代码层面是如何工作的:

// 1. 将 socket 设置为非阻塞模式
int flags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);

while (1) {
    // 2. 尝试读取数据
    ssize_t n = read(client_fd, buffer, sizeof(buffer));
    
    if (n > 0) {
        // 成功读取到数据,处理它
        process(buffer, n);
    } else if (n == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 3. 数据未就绪!这是非阻塞IO的核心
            // 此时,线程不能在这里等待,必须做点别的
            // ... 例如,去检查其他 socket ...
        } else {
            // 其他错误,如连接断开
            handle_error();
        }
    }
}

⚠️ 核心挑战:第 3 步。当 read 返回 EAGAIN 时,线程接下来该做什么?如果它只是在一个 tight loop 里不断重试同一个 read,那和阻塞没区别,甚至更糟(因为占满了 CPU)。所以,它必须去轮询其他所有需要监听的 socket


四、非阻塞 IO 的致命缺陷:CPU 空转

非阻塞 IO 解决了“线程被单个连接阻塞”的问题,但它引入了一个新的、同样严重的问题:CPU 空转(Busy Waiting)

问题场景

假设服务器有 10,000 个客户端连接,但其中 9,999 个都处于空闲状态,只有 1 个连接偶尔有数据。

  • 采用非阻塞 IO 的服务器,其主线程必须在一个循环里,依次对这 10,000 个 socket 调用 read
  • 对于那 9,999 个空闲连接,每次 read 都会立刻返回 EAGAIN
  • 结果就是,CPU 的绝大部分时间都花在了执行这 9,999 次无意义的系统调用上,而真正处理业务逻辑的时间微乎其微。

结论:单纯的非阻塞 IO 模型,虽然解决了线程阻塞问题,却以极高的 CPU 占用率为代价,在高并发、低活跃度的场景下效率极低。


五、Redis 的智慧:非阻塞 IO + IO 多路复用

Redis 并没有止步于非阻塞 IO,而是将它与更强大的 IO 多路复用(I/O Multiplexing) 技术结合起来,创造出了近乎完美的解决方案。

5.1 为什么需要两者结合?

  • IO 多路复用(如 epoll 的作用是:告诉你哪些 socket 已经准备好了(可读/可写)。
  • 非阻塞 IO 的作用是:当你去处理这些“已就绪”的 socket 时,确保你的 read/write 调用不会意外地再次阻塞

5.2 Redis 的工作流程(精简版)

  1. 初始化:Redis 启动时,会将所有客户端 socket 都设置为非阻塞模式
  2. 注册监听:将这些 socket 注册到 epoll 实例中,并声明关心 EPOLLIN(可读)事件。
  3. 主循环
    while (1) {
        // (1) 阻塞在这里,直到有 socket 就绪
        int num_ready = epoll_wait(epfd, events, max_events, timeout);
        
        // (2) epoll_wait 返回后,events 数组里包含了所有就绪的 socket
        for (int i = 0; i < num_ready; i++) {
            int fd = events[i].data.fd;
            
            // (3) 因为 socket 是非阻塞的,这里的 read 不会阻塞
            // 即使一次没读完(TCP是流式协议),下次 epoll 会再次通知
            handle_client_request(fd); // 内部调用 read/write
        }
    }
  4. 关键点
    • epoll_wait 替我们完成了高效的“轮询”工作,并且是由内核在硬件中断驱动下完成的,效率极高。
    • 当我们处理 epoll_wait 返回的就绪 socket 时,由于它们已经是非阻塞的,read 调用可以安全地一次性读取所有可用数据,而不用担心被卡住。

✅ 优势

  • 零无效轮询:线程只在 epoll_wait 上阻塞,不会浪费 CPU 去检查未就绪的 socket。
  • 绝对不阻塞:即使某个连接的数据量很大,分多次才能读完,由于 socket 是非阻塞的,每次 read 都能立即返回,保证了主线程的流畅性。

5.3 总结关系

组件 角色
IO 多路复用 (epoll) 侦察兵:高效地监控成千上万个连接,并精准报告哪些连接有事发生。
非阻塞 IO 执行者:确保在处理侦察兵报告的“有事”连接时,动作干净利落,绝不拖泥带水。

二者缺一不可,共同构成了 Redis 单线程高并发的基石。


六、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐