基于 C++ 手写 Muduo 高性能网络库

大纲

前言

本文将基于 C++ 开发一个类似 Muduo 的高性能网络库,项目代码大部分是从 Muduo 移值过来,同时去掉 Boost 依赖,并使用 C++ 11 进行代码重构,重点是学习 Muduo 的底层设计思想。

学习目的

  • 1、理解阻塞、非阻塞、同步、异步
  • 2、理解 Unix/Linux 上的五种 I/O 模型
  • 3、epoll 的原理以及优势
  • 4、深刻理解 Reactor 模型
  • 5、从开源 C++ 网络库 Muduo,学习优秀的代码设计
  • 6、掌握基于事件驱动和事件回调的 epoll + 线程池的面向对象编程
  • 7、通过深入理解 Muduo 源码,加深对于相关项目的深刻理解
  • 8、改造 Muduo,不依赖 Boost,使用 C++ 11 进行代码重构

知识储备

在开发高性能的网路库之前,要求先掌握以下前置知识:

  • 1、TCP 协议和 UDP 协议
  • 2、Linux 的 TCP 网络编程和 UDP 网络编程
  • 3、I/O 多路复用编程,包括 select、poll、epoll 库的使用
  • 4、Linux 的多线程编程(pthread)、进程和线程模型
  • 5、C++ 20 标准新加入的协程支持

推荐阅读的书籍

《UNIX 环境高级编程》、《Linux 高性能服务器编程》、《 Linux 多线程服务端编程 - 使用 Muduo C++ 网络库》《鸟哥的 Linux 私房菜》

开发工具

软件版本说明
C++ 标准11C++ 标准的版本
G++(GCC)12.2.0建议使用 9 版本的 G++(GCC) 编译器
CMake3.25.1C/C++ 项目构建工具
LinuxDebian 12Muduo 库不支持 Windows 平台
Visual Studio Code1.100.2使用 VSCode 远程开发特性

基础概念

阻塞、非阻塞、同步、异步

提示

  • 下面提到的 "I/O 操作" 并不局限于网络 I/O,而是一个广义的 I/O 概念,它既包含网络 I/O(Socket 读写),也包含磁盘 I/O(文件读写)等所有涉及内核态与用户态之间数据交换的操作。
  • I/O 模型(如阻塞、非阻塞、同步、异步)更多用于讨论网络 I/O,原因是磁盘 I/O 的异步化由操作系统内核自动管理(页缓存 + 异步调度),应用层很少直接干预。
  • 同步与异步的区别

    • 同步:
      • 请求方 A 发起 I/O 调用后,由 A 自身完成数据的读写;
      • 无论阻塞与否,A 都要亲自执行读写操作。
    • 异步:
      • 请求方 A 发起 I/O 调用后,仅仅发出请求,并由操作系统内核来完成数据的读写;
      • A 不需要等待操作完成,可以继续做其他事情;当操作系统内核完成读写操作后,会通过回调、事件通知等机制通知 A 结果。
  • 阻塞与非阻塞的区别

    • 阻塞:
      • 调用未完成前,调用线程会一直等待;
    • 非阻塞:
      • 调用立即返回,即使操作未完成,也会返回错误码或状态提示(例如 EAGAIN)。
  • 典型的一次 I/O 操作可以分为两个阶段

    • 数据准备(阶段一):该阶段取决于系统 I/O 操作的就绪状态,即数据是否已经可以被读写
      • 阻塞:调用会等待数据准备好后再继续执行。
      • 非阻塞:调用会立即返回,无论数据是否就绪。
    • 数据读写(阶段二):该阶段取决于应用程序与操作系统内核之间的交互方式
      • 同步:由应用程序主动完成数据的读写,将数据从内核缓冲区拷贝到用户空间(或反之)。
      • 异步:由操作系统内核完成数据的读写,并在操作完成后通知应用程序。

总结

  • 同步 / 异步区分的是谁来完成 I/O 操作(调用方自己还是操作系统内核来完成数据读写)。
  • 阻塞 / 非阻塞区分的是调用方等待的方式(是否挂起等待处理结果)。

常见的四种 I/O 模型

I/O 模型数据准备阶段数据读写阶段调用方行为示例说明
同步阻塞阻塞等待数据准备好调用方执行读写整个过程会阻塞当前线程int size = recv(fd, buf, 1024, 0);(若无数据则阻塞等待)
同步非阻塞非阻塞轮询数据准备好调用方执行读写调用立即返回,但需要反复尝试调用设置 O_NONBLOCK,多次调用 recv() 检查是否有数据可读
异步阻塞阻塞等待事件完成操作系统内核完成读写等待通知,但数据读写由操作系统内核完成例如 Windows OVERLAPPED I/O + GetOverlappedResult 阻塞等待
异步非阻塞非阻塞提交请求操作系统内核完成读写并通知完全不阻塞,结果通过回调 / 事件返回例如 Linux aio_read()io_uring 提交请求后立即返回

陈硕大神的原话:在处理 I/O 的时候,阻塞和非阻塞都是同步 I/O,只有使用了特殊的 API 才是异步 I/O(如下图所示)。

特别注意

  • select / poll / epoll 本身只是事件就绪通知机制,它们并不直接完成数据读写,调用它们的线程仍然需要自己去 read()write() 数据。
  • 因此,从严格意义上看,它们属于同步 I/O 实现方式,因为最终的 I/O 读写(即数据读写)(即数据读写)是由调用线程自己完成的。
  • 但它们提供了非阻塞的事件等待,使得线程可以同时监听多个 fd,而不用一个线程阻塞在一个 fd 上。
  • 真正的异步 I/O 实现,在 Linux 上需要使用 aio_* 系列系统函数或者使用 io_uring

Unix/Linux 的五种 I/O 模型

Unix/Linux 支持以下五种 I/O 模型:

I/O 模型阻塞 / 非阻塞事件通知方式适用场景
阻塞 I/O 阻塞同步返回简单程序,低并发
非阻塞 I/O 非阻塞轮询少量 I/O,CPU 可支撑
I/O 多路复用阻塞或非阻塞操作系统内核返回就绪事件列表高并发网络服务器
信号驱动 I/O 非阻塞信号小规模异步通知
异步 I/O 非阻塞回调 / 事件高并发、对延迟敏感场景

阻塞 I/O(Blocking I/O)

  • 特征:应用程序调用 I/O 函数后,如果数据未就绪,调用线程会被阻塞,直到数据准备完成。
  • 优点:编程实现简单、逻辑直观。
  • 缺点:线程无法同时处理多个 I/O,吞吐量受限。

非阻塞 I/O(Non-Blocking I/O)

  • 特征:I/O 调用立即返回,即使数据未就绪也不会阻塞。应用程序需要通过轮询(Polling)或循环检查,目的是不断检测数据是否已经就绪,以便及时进行数据读写操作。
  • 优点:单线程可以处理多个 I/O。
  • 缺点:轮询会浪费 CPU 资源,逻辑较复杂。

I/O 多路复用(I/O Multiplexing)

  • 典型机制:selectpollepoll
  • 特征:单个线程可以同时监听多个 fd,通过操作系统内核返回就绪事件列表,再进行读写操作。
  • 优点:高效管理大量并发连接,避免轮询浪费。
  • 缺点:处理非常大量 fd 时,某些实现(如 selectpoll)效率有限。
  • 注意:在 I/O 多路复用中,复用的线程而不是 TCP 连接。由于最终的 I/O 读写(即数据读写)是由调用线程自己完成的,因此从严格意义上看,I/O 多路复用属于同步 I/O 实现方式。

信号驱动 I/O(Signal-Driven I/O)

  • 特征:应用程序注册信号处理函数(如 SIGIO),当 fd 可读或可写时,操作系统内核发送信号通知。
  • 优点:异步通知,无需轮询。
  • 缺点:信号处理复杂,信号丢失或竞态问题较多,不易大规模使用。
  • 注意:操作系统内核在第一个阶段(数据准备)是异步,在第二个阶段(数据读写)是同步;与非阻塞 I/O 的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统 API 的调用次数,提高了效率。

异步 I/O(Asynchronous I/O)

  • 特征:应用程序发起 I/O 调用后,立即返回;当数据准备好后,由操作系统内核完成数据读写;当数据读写操作完成后,通过信号、回调函数或事件机制通知应用程序。
  • 优点:真正的异步,高效利用 CPU,可处理大量并发 I/O。
  • 缺点:编程复杂,Linux 支持有限(传统 AIO 对网络 I/O 支持不好,io_uring 是新方案)。
  • 注意:这是真正的异步 I/O 实现,在 Linux 上需要使用 aio_* 系列系统函数或者使用 io_uring,Node.js 采用了该 I/O 模型。

优秀的网络服务器设计

在这个 CPU 多核时代,服务端网络编程如何选择线程模型呢?赞同 libev 作者的观点:”one loop perthread is usually a good model”,这样多线程服务端编程的问题就转换为如何设计一个高效且易于使用的 Event Loop, 然后每个线程运行一个 Event Loop 就行了(当然,线程间的同步、互斥少不了,还有其它的耗时事件需要起另外的线程来做)。Event Loop 是 Non-Blocking 网络编程的核心,可以简单理解为 Non-Blocking + epoll + thread-pool 的结合。在实际应用中,Non-Blocking 几乎总是与 I/O Multiplexing 一起使用,原因有以下两点:

  • 实际上没有人会采用轮询(Busy-Polling)方式不断检查某个 Non-Blocking I/O 操作是否完成,因为这会严重浪费 CPU 资源。
  • I/O Multiplexing 通常无法与 Blocking I/O 一起使用,因为在 Blocking I/O 中,accept()connect()read()write() 等调用都有可能阻塞当前线程,从而导致线程无法继续处理其他 Socket 上的 I/O 事件。

所以,当日常提到 Non-Blocking I/O 时,实际上指的是 Non-Blocking + I/O Multiplexing(如 epoll + thread-pool) 的组合,如何单独使用其中任意一种,都无法很好地实现高效的网络 I/O。


在网络编程领域中,主流的网络 I/O 模型有以下几种(不限于),Muduo 采用的是第四种(reactors in threads - one loop per thread)。

  • (1) accept + read/write

    • 不适用于并发服务器
  • (2) accept + fork - process-pre-connection

    • 适合并发连接数不大,计算任务工作量大于 Fork 的开销。
  • (3) accept + thread - thread-pre-connection

    • 比第二种网络 I/O 模型的开销小了一点,但是并发造成的线程堆积过多。
  • (4) reactors in threads - one loop per thread

    • 这是 Muduo 库的网络设计方案,底层实质上是基于 Linux 的 epoll + pthread 线程池实现,且依赖了 Boost 库,适用于并发连接数较大的场景。
    • 有一个 Main Reactor 负载 Accept 连接,然后将连接分发给某个 SubReactor(采用轮询的方式来选择 SubReactor),该连接的所用操作都在那个 SubReactor 所处的线程中完成。多个连接可能被分派到多个线程中被处理,以充分利用 CPU。
    • Main Reactor 中有一个 Base I/O Thread 负责 Accept 新的连接,接收到新的连接以后,使用轮询的方式在 Reactor Pool 中找到合适的 SubReactor 将这个连接挂载上去,这个连接上的所有任务都在这个 SubReactor 所处的线程中完成。
    • Reactor Poll 的大小是固定的,根据 CPU 的核心数量来确定。如果有过多的耗费 CPU 资源的计算任务,可以提交到 ThreadPool 线程池中专门处理耗时的计算任务。
  • (5) reactors in process - one loop pre process

    • 这是 Nginx 服务器的网络设计方案,基于进程设计,采用多个 Reactors 充当 I/O 进程和工作进程,通过一个 accept 锁,完美解决多个 Reactors 之间的 “惊群现象”。

reactors in process + fork 不如 reactors in threads 吗?

答案肯定是否定的,强大的 Nginx 服务器采用了 reactors in process 模型作为网络模块的架构设计,实现了简单好用的负载算法,使各个 fork 网络进程不会忙的越忙、闲的越闲,并且通过引入一把乐观锁解决了该模型导致的服务器惊群现象,功能十分强大。

Reactor 网络 I/O 模型

维基百科对 Reactor 的描述

The reactor design pattern is an event handling pattern for handling service requestsdelivered concurrently to a service handler by one or more inputs. The service handlerthen demultiplexes the incoming requests and dispatches them synchronously to theassociated request handlers. 翻译后:Reactor(反应器)设计模式是一种事件处理模式,用于处理由一个或多个输入并发传递到服务处理器的服务请求。然后,服务处理器对传入的请求进行多路分解,并同步地将它们分派给相应的请求处理器。

  • Reactor 是一种基于事件驱动(Event Driven)的网络 I/O 模型,核心思想是:

    • 主线程(或 I/O 线程)通过 I/O 多路复用(I/O Multiplexing)机制(如 selectpollepoll),监听多个连接的 I/O 事件。
    • 当某个事件就绪后,再分发(Dispatch)给对应的事件处理器(EventHandler)进行处理。
  • Reactor 虽然是网络 I/O 模型,但它通常与线程模型结合使用:

    • 单线程 Reactor:所有 I/O 事件的监听与处理都在同一个线程中完成。
    • 多线程 Reactor:I/O 事件的监听与业务处理分离,通常用线程池来处理业务逻辑。
    • 主从 Reactor:主 Reactor 负责连接建立,从 Reactor 负责 I/O 读写(即数据读写),结合多线程提升并发性能。
  • Reactor 的五大核心组件:

    核心组件作用
    Event(事件)表示 I/O 事件的抽象,如连接建立、可读、可写等,用于描述发生了什么类型的网络事件。
    Demultiplexer(事件分离器)负责监听并检测多个 I/O 事件的就绪状态(通常由 selectpollepoll 等系统调用实现),并将已就绪的事件返回给 Reactor。
    Reactor(反应堆)事件分发器,负责从 Demultiplexer 获取就绪事件,并将事件分发给对应的 EventHandler 处理。
    EventHandler(事件处理器)负责具体的事件处理逻辑,如读、写、连接、业务处理等,是应用层的回调逻辑。
    Acceptor(连接接收器)负责监听服务器端口并接收新的客户端连接,在多 Reactor 模型中通常独立运行,仅负责建立连接并将连接交给子 Reactor 处理。
  • Reactor 核心组件的工作流程:

  • Muduo 库的 Multiple Reactors 模型:

I/O 多路复用技术概述

跨平台特性的对比

技术是否支持跨平台支持的平台特点
select✅ 广泛跨平台 Linux / macOS / BSD / Windows / Unix 最老的接口,POSIX 标准定义
poll⚠️ 支持类 Unix 跨平台(不支持 Windows)Linux /macOS/ BSD / Solaris 等select 的改进版 ,无 fd 数量限制
epoll❌ Linux 独有仅 Linux(2.6+)高性能 I/O 多路复用技术
kqueue❌ BSD /macOS 独有 FreeBSD / macOS / NetBSD / OpenBSDepoll 的 BSD 平台对应物

select 与 poll 的缺点

I/O 多路复用技术 select 有以下缺点:

  • (1) 文件描述符数量限制:

    • 单个进程可监视的文件描述符数量存在上限,通常为 1024(可修改)。但由于 select 采用轮询扫描方式检查文件描述符,随着监视数量的增加,性能会明显下降。
    • 在 Linux 内核头文件中有如下定义:#define __FD_SETSIZE 1024
  • (2) 内核与用户空间的数据拷贝开销大:

    • 每次调用 select 都需要在内核空间与用户空间之间复制大量的文件描述符集合,这会造成显著的性能开销。
  • (3) 结果集遍历效率低:

    • select 返回的是一个包含所有文件描述符的数组,应用程序需要遍历整个数组才能判断哪些描述符处于就绪状态,效率较低。
  • (4) 水平触发机制(Level Trigger):

    • select 采用水平触发方式,如果应用程序没有及时处理已就绪的文件描述符,那么在后续的每次 select 调用中,这些描述符仍会被重复通知。

I/O 多路复用技术 pollselect 相比,使用链表来保存文件描述符,不再受文件描述符数量上限的限制,但仍然存在与 select 相同的其他三个缺点(数据拷贝开销大、结果集遍历效率低、水平触发),这里不再累述。

select 无法支持高并发连接

select 为例,若服务器需支持 100 万并发连接,在 __FD_SETSIZE 为 1024 的情况下,至少需要创建约 1000 个进程才能满足要求。如此不仅会带来大量的进程上下文切换开销,还会因频繁的内核空间 / 用户空间句柄拷贝与数组遍历操作,导致系统性能急剧下降。因此,基于 select 模型的服务器要实现百万级并发几乎是不可能的。

epoll 的原理以及优势

设想这样一个场景:有 100 万个客户端同时与一个服务器进程保持 TCP 连接,但在任意时刻,通常只有几百到上千个连接是活跃的(这也是现实中最常见的情况)。如何高效地支撑如此庞大的并发连接呢?在 select / poll 时代,服务器每次调用都需要将这 100 万个连接的文件描述符从用户态复制到内核态,让内核轮询这些套接字上是否有事件发生;轮询完成后,再将结果从内核态复制回用户态,供应用程序继续遍历处理。这种方式带来了巨大的内存拷贝和遍历开销,因此基于 select / poll 通常只能处理几千个并发连接。


epoll 的设计思想与 select 完全不同,因此它们的缺点在 epoll 中已不复存在。epoll 在 Linux 内核中引入了一种专用的事件管理机制,通过红黑树(用于管理所有已注册的文件描述符)和就绪链表(用于管理已触发事件的文件描述符)来组织事件,大幅降低了事件查找和分发的开销,使大规模并发连接的事件管理更加高效。

  • (1) epoll_create():创建一个 epoll 对象(内核在 epoll 文件系统中为该对象分配资源)。
  • (2) epoll_ctl():向 epoll 对象中添加、修改或删除需要监听的套接字(例如 100 万个 TCP 连接)。
  • (3) epoll_wait():等待并收集有事件发生的文件描述符。

其中 epoll_create() 在内核上创建的 eventpoll 结构如下:

1
2
3
4
5
6
7
8
9
10
11
struct eventpoll {
....(省略)

/* 红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件 */
struct rb_root rbr;

/* 双链表中则存放着将要通过 epoll_wait() 返回给用户的满足条件的事件 */
struct list_head rdlist;

....(省略)
}

得益于这种设计,只需在服务器启动时创建一次 epoll 对象,然后在连接建立或关闭时动态地添加或移除对应的套接字即可。更重要的是,epoll_wait() 的调用效率极高:

  • 它不需要在每次调用时复制所有文件描述符。
  • 内核也无需遍历全部连接,而是通过回调机制主动将就绪的文件描述符加入到就绪队列中。

因此,epoll 能够在单进程中轻松支撑数十万甚至上百万级的并发连接,这正是它区别于 select / poll 的根本优势所在。

epoll 的 LT 模式与 ET 模式

epoll 支持 LT(水平触发)与 ET(边缘触发),而 selectpoll 在设计上只支持 LT(水平触发),没有 ET(边缘触发)的概念。

  • LT 模式(Level Triggered,水平触发)

    • 语义:只要 fd 上有数据未被读取完,就会一直被 epoll 通知。
    • 特点:更 “宽松”,即使一次没读完,下次还会被提醒。
    • 行为示例:
      • 缓冲区有 100 字节可读;
      • 应用程序只读了 60 字节;
      • 下次 epoll_wait() 还会再次返回该 fd
    • 优点:编程简单、不易漏数据。
    • 缺点:频繁触发,效率略低。
  • ET 模式(Edge Triggered,边缘触发)

    • 语义:只有当状态发生变化(从无到有)时才触发一次事件。
    • 特点:仅在 “边缘” 通知,比如缓冲区从空变为非空。
    • 行为示例:
      • 缓冲区变为可读时触发;
      • 应用程序必须一次性读完所有数据(直到返回 EAGAIN);
      • 如果应用程序没读完,下次不会再收到通知。
    • 优点:减少系统调用次数,效率高。
    • 缺点:编程复杂,稍有疏忽就可能会 “丢事件”。
  • Muduo 采用的是 LT(水平触发)模式

    • 不会丢失数据或者消息
      • 应用程序没有读取完数据,内核是会不断上报数据的
    • 低延迟处理
      • 每次读数据只需要一次系统调用,照顾了多个连接的公平性,不会因为某个连接上的数据量过大而影响其他连接处理消息
    • 跨平台处理
      • select 一样可以跨平台使用

项目介绍

项目结构

项目技术栈

项目代码

参考资料