从零手写 C++ Reactor 网络库 ------ 我的 muduo 踩坑全记录
两周前还不知道 fd 是什么。现在拥有了一个完整的 epoll + 多线程 Reactor 网络库,2000+ 行代码,echo server / 多人聊天室都能跑。这篇文章记录每一层封装的设计决策、核心代码、以及踩过的坑。适合跟我一样想理解 Reactor 底层原理的 C++ 初学者。
为什么要自己写
muduo 是陈硕写的一个高质量 C++ Reactor 网络库,基于 epoll 实现 one loop per thread 模式。我在 GitHub 上看到这个名字很多次,每次都想搞懂它为什么这样设计------但光看源码完全看不进去。Channel、Poller、EventLoop 这几个名字很抽象,光读代码推不出它们各自解决什么问题。
有一天我突然想:与其硬看源码,不如自己从零写一个。目标不是替代 muduo,而是理解 Reactor 模式到底怎么工作的------为什么要有 Channel?为什么 epoll 外面要包一层 Poller?EventLoop 到底做了什么?
从零写的好处是你一个设计点都逃不掉。用别人库的时候 acceptor.listen() 一行完事,但你自己实现这行的时候,你必须回答------socket 怎么创建?bind 何时调?监听 fd 和连接 fd 有什么区别?Channel 为什么不拥有 fd?epoll 通知后怎么找回对应的回调函数?
这篇文章按代码的分层结构走,从裸 epoll 开始,一层层往上封装。每层解释:它要解决什么问题、最关键的代码是什么、跟上下层怎么配合。 最后是一个完整的多人聊天室 demo------用我写的 muduo 和另一个我写的日志库 my_logger 协同工作。
全部代码在 GitHub 上,MIT 协议:
my_muduo :
https://github.com/ch0sen1pm/my_muduomy_logger :https://github.com/ch0sen1pm/my_logger
整体架构(一张图)
从内核到用户的四层封装:
arduino
┌──────────────────────────────────────────┐
│ EventLoop │
│ while(true) { poll → dispatch } │ ← 循环骨架,最薄
│ │
│ ┌────────────────────────────────────┐ │
│ │ Poller │ │
│ │ epoll_create1 / ctl / wait │ │ ← 内核对接
│ │ map<fd, Channel*> 查表 │ │
│ │ │ │
│ │ ┌──────────┐ ┌────────────────┐ │ │
│ │ │ Socket │ │ Channel │ │ │
│ │ │ RAII 管 │ │ fd + 回调 + │ │ │ ← fd 层
│ │ │ fd 生死 │ │ 事件注册 │ │ │
│ │ └──────────┘ └────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Acceptor / TcpConnection │ │ │
│ │ │ 监听 → accept │ │ │ ← 业务层
│ │ │ 连接 → read/write │ │ │
│ │ └────────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ TcpServer │ │
│ │ 组合以上所有 + 线程池 │ │ ← 顶层封装
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
第 0 层:裸 epoll --- 理解底层
epoll 是 Linux 内核提供的 I/O 多路复用机制。一句话概括:你告诉内核"帮我盯着这些 fd",内核在某个 fd 可读/可写时叫醒你。整个过程只涉及三个系统调用:
cpp
int epfd = epoll_create1(EPOLL_CLOEXEC); // 1. 创建 epoll 实例,返回一个 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 2. 注册你要盯的 fd
int n = epoll_wait(epfd, events, max, ms); // 3. 阻塞等待,有事件就返回
核心数据结构 epoll_event:
cpp
struct epoll_event {
uint32_t events; // 内核告诉你发生了什么(EPOLLIN = 可读,EPOLLOUT = 可写)
epoll_data_t data; // 你塞什么内核原样返回什么。常用 data.fd = fd
};
epoll_create1 的 EPOLL_CLOEXEC 标志:exec 时自动关闭 fd,防止子进程泄漏。
epoll 的局限: data.fd 只能存一个 int。但实际需求是"fd 对应的回调函数是什么"------不同 fd 的行为完全不同。stdin 要打印、新连接要 accept、客户端数据要 echo。只靠一个 int 没法区分这些行为。所以需要第一层封装。
第 1 层:Poller --- epoll 的 C++ 封装
解决的问题: epoll 只返回 fd(一个数字),但我们需要根据 fd 找到对应的回调。
解决方案: 维护 map<int, Channel*>,epoll_wait 返回后查表。
cpp
class Poller {
int epollfd_; // epoll_create1 返回的 epoll 实例 fd
std::map<int, Channel*> channels_; // fd → Channel 映射(核心数据结构!)
std::vector<epoll_event> events_; // epoll_wait 的输出缓冲区,容量翻倍增长
void poll(int timeoutMs, std::vector<Channel*>& activeChannels) {
int numEvents = ::epoll_wait(epollfd_, events_.data(), events_.size(), timeoutMs);
// timeoutMs = 1000:1 秒超时。没事件就返回空,循环继续,不会卡死
if (numEvents > 0) {
fillActiveChannels_(numEvents, activeChannels);
// 如果返回的事件数等于缓冲区大小 → 可能还有更多事件 → 翻倍扩容
if (numEvents == events_.size())
events_.resize(events_.size() * 2);
}
}
void fillActiveChannels_(int n, std::vector<Channel*>& activeChannels) {
for (int i = 0; i < n; i++) {
int fd = events_[i].data.fd; // 拿到 fd
Channel* channel = channels_[fd]; // 查 map 找到对应 Channel
channel->set_revents(events_[i].events); // 填充内核返回的事件
activeChannels.push_back(channel); // 加入活跃列表
}
}
};
updateChannel 的三段逻辑:
cpp
void Poller::updateChannel(Channel* channel) {
int fd = channel->fd();
if (channels_.find(fd) == channels_.end()) {
channels_[fd] = channel; // 第一次出现 → 记录
update_(EPOLL_CTL_ADD, channel); // epoll_ctl(ADD)
} else if (channel->events() == 0) {
update_(EPOLL_CTL_DEL, channel); // events 全关了 → 从 epoll 移除
} else {
update_(EPOLL_CTL_MOD, channel); // 事件变更 → 更新
}
}
Poller 不关心"回调是什么",只负责"epoll 通知了我、我把事件交给对应的 Channel"。关注点分离------内核层的事全在这里处理,上层不需要碰 epoll。
第 2 层:Channel --- fd 的事件说明书
解决的问题: 把"fd + 关心什么事件 + 触发时调谁"打包成一个对象。
每一种 fd 有不同的行为------stdin 需要打印、监听 socket 需要 accept、客户端连接需要 echo。Channel 就是这些行为的"说明书"。
cpp
class Channel {
public:
using EventCallback = std::function<void()>;
Channel(EventLoop* loop, int fd)
: loop_(loop), fd_(fd), events_(0), revents_(0) {}
void setReadCallback(EventCallback cb) { readCallback_ = std::move(cb); }
void setWriteCallback(EventCallback cb) { writeCallback_ = std::move(cb); }
void enableReading() {
events_ |= kReadEvent; // 标记:我想盯可读事件
update(); // → EventLoop → Poller → epoll_ctl
}
void disableAll() {
events_ = kNoneEvent;
update(); // → 从 epoll 移除
}
// Poller 在 epoll_wait 后调这个,填好 revents_
void set_revents(int revt) { revents_ = revt; }
void handleEvent() {
if ((revents_ & kReadEvent) && readCallback_)
readCallback_(); // 触发可读回调
if ((revents_ & kWriteEvent) && writeCallback_)
writeCallback_(); // 触发可写回调
}
private:
static const int kNoneEvent = 0;
static const int kReadEvent = 1; // 用位掩码,epoll 的 EPOLLIN/EPOLLOUT 格式
static const int kWriteEvent = 2;
EventLoop* loop_;
int fd_; // 这个 Channel 管哪个 fd
int events_; // 我想盯什么(自己设的)
int revents_; // 内核实际返回了什么(Poller 填的)
EventCallback readCallback_;
EventCallback writeCallback_;
};
关键区分:events_ vs revents_
events_= 你想盯什么(你调enableReading()设的)revents_= 内核说发生了什么(Poller 在 epoll_wait 后帮你填的)
就像下外卖单------你选"麻辣口味"(events_),到货袋子上写"确实是麻辣"(revents_)。handleEvent 检查 revents_ 决定调哪个回调。
Channel 不拥有 fd。 fd 的生命周期由外层 Socket 管理------Channel 只是观察者。这样能把"资源管理"和"事件处理"分开:Socket 负责创建/关闭 fd,Channel 负责告诉 epoll"这个 fd 可读时叫我"。
update() 调用链(层层委托):
css
Channel::update() → EventLoop::updateChannel() → Poller::updateChannel() → epoll_ctl()
每层只跟自己的上一层说话,不跨级。Channel 不知道 Poller 的存在------它通过 EventLoop 中转。
第 3 层:EventLoop --- 事件循环
解决的问题: 提供一个无限循环的"发动机",把 Poller 和 Channel 串起来。本身不做具体工作,只负责等事件 → 调回调。
cpp
class EventLoop {
public:
EventLoop()
: poller_(std::make_unique<Poller>(this)), quit_(false) {}
void loop() {
while (!quit_) {
activeChannels_.clear();
poller_->poll(1000, activeChannels_); // 阻塞 1 秒等事件
for (auto* channel : activeChannels_)
channel->handleEvent(); // 逐个分发
}
}
// 委托 Poller 更新 Channel。Channel 通过这个中转,不直接接触 Poller
void updateChannel(Channel* channel) { poller_->updateChannel(channel); }
void removeChannel(Channel* channel) { poller_->removeChannel(channel); }
private:
std::unique_ptr<Poller> poller_; // 独占 Poller
std::vector<Channel*> activeChannels_; // 本轮活跃的 Channel
bool quit_;
};
EventLoop 是整个库最薄的一层------核心就一个 while(true)。但它是最关键的设计------每个线程最多一个 EventLoop,每个 EventLoop 独占一个 Poller。 这就是"one loop per thread"的由来。
"布线"和"通电"是两回事:
vbnet
main() 顺序执行 = 布线(设回调、注册事件、new Channel)
loop.loop() = 通电(死循环等事件,来了就调回调)
所有写在各种 setXxxCallback(...) 的 { } 里的代码,在布线阶段一行都不执行。等到事件来了,EventLoop 的 loop() 里才被调。这是 Reactor 模式最核心的思维转变------控制反转。
第 4 层:Socket --- RAII fd 管理 + C++ 移动语义
解决的问题: 前面几层用的 fd 都是 fd=0(stdin)这种本来就存在的。网络编程里的 fd 是自己 socket() 创建的,用完必须 close() 否则泄漏。用 RAII 保证自动释放。
cpp
class Socket {
public:
explicit Socket(int sockfd) : sockfd_(sockfd) {}
~Socket() {
if (sockfd_ >= 0) ::close(sockfd_); // RAII:对象销毁 → fd 自动关
}
int fd() const { return sockfd_; }
// ====== 移动语义 ======
Socket(Socket&& other) noexcept
: sockfd_(other.sockfd_) {
other.sockfd_ = -1; // 移走资源,原主人置空
}
Socket& operator=(Socket&& other) noexcept {
if (this != &other) {
if (sockfd_ >= 0) ::close(sockfd_); // 先放自己
sockfd_ = other.sockfd_; // 抢对方的
other.sockfd_ = -1;
}
return *this;
}
Socket(const Socket&) = delete; // 禁拷贝
Socket& operator=(const Socket&) = delete;
// ====== 网络操作 ======
static Socket createTcp() {
int fd = ::socket(AF_INET,
SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
return Socket(fd);
}
void bindAddress(const sockaddr_in& addr) {
::bind(sockfd_, (const sockaddr*)&addr, sizeof(addr));
}
void listen() { ::listen(sockfd_, SOMAXCONN); }
void setReuseAddr(bool on) {
int optval = on ? 1 : 0;
::setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
}
private:
int sockfd_;
};
C++ 核心知识------移动语义:
fd 是独占资源,不能有两个 Socket 对象持有同一个 fd。否则析构时同一个 fd 被 close 两次,程序崩溃。所以禁掉拷贝(= delete),允许移动(&&)。
css
Socket a = Socket::createTcp(); // a 持有 fd=5
Socket b = a; // ❌ 编译错误!拷贝被禁了
Socket b = std::move(a); // ✅ b 持有 fd=5,a 变 -1
std::move 不做任何移动------它只是把类型 Socket& 转成 Socket&&(右值引用),让编译器选移动版本。真正的移动是在移动构造函数里"把对方的 fd 抢过来、对方置 -1"。
什么时候自动移动、什么时候需要 std::move:
css
Socket b = Socket::createTcp(); // ✅ 临时对象 → 自动走移动,不用 move()
Socket b = a; // ❌ a 是左值 → 走拷贝 → 被 delete 拦截
Socket b = std::move(a); // ✅ 强转右值 → 走移动
为什么必须 SOCK_NONBLOCK?
阻塞模式下 accept() 没连接来 → 卡住等 → 整个线程死掉,其他所有事件全废。非阻塞模式:accept() 没连接 → 返回 -1 → 循环继续转下一圈 epoll。epoll 下的所有 fd 必须非阻塞------这是硬性要求,不是可选优化。
SOCK_CLOEXEC: fork + exec 启动另一个进程时,这个 fd 自动关闭,防止子进程泄漏。
SO_REUSEADDR: 允许重用 TIME_WAIT 状态的端口。没它的话程序关了马上重启会 "Address already in use"------开发调试的救命稻草。
第 5 层:Acceptor --- 监听封装
解决的问题: 上一层的 Socket 提供了基础操作,但每次写 socket → bind → listen → new Channel → setReadCallback 这一整套流程太啰嗦。Acceptor 把这些全部藏进构造函数。
cpp
class Acceptor {
public:
using NewConnectionCallback = std::function<void(int connfd, const sockaddr_in&)>;
Acceptor(EventLoop* loop, const sockaddr_in& listenAddr)
: loop_(loop),
acceptSocket_(Socket::createTcp()), // ① 创建监听 socket
acceptChannel_(loop, acceptSocket_.fd()) // ② 配一个 Channel 盯它
{
acceptSocket_.setReuseAddr(true); // ③ 端口复用
acceptSocket_.bindAddress(listenAddr); // ④ 绑定端口
acceptChannel_.setReadCallback([this]() { // ⑤ "fd 可读 = 有人连接"
handleRead();
});
}
void listen() {
acceptSocket_.listen(); // ::listen()
acceptChannel_.enableReading(); // 注册到 epoll
}
void setNewConnectionCallback(NewConnectionCallback cb) {
newConnectionCallback_ = std::move(cb);
}
private:
void handleRead() {
sockaddr_in peerAddr;
socklen_t addrLen = sizeof(peerAddr);
int connfd = ::accept(acceptSocket_.fd(), (sockaddr*)&peerAddr, &addrLen);
if (connfd >= 0 && newConnectionCallback_)
newConnectionCallback_(connfd, peerAddr); // 通知上层
}
EventLoop* loop_;
Socket acceptSocket_; // 持有监听 fd
Channel acceptChannel_; // 盯它
NewConnectionCallback newConnectionCallback_;
};
accept 返回什么? 一个全新的 fd------connfd,专门服务这个新连上来的客户端。这个 fd 和监听 fd 是两个完全不同的东西:
ini
监听 fd(比如 fd=3): 只做 accept,不做读写。整个程序生命周期只有一个
连接 fd(比如 fd=5,6,7):每个客户端一个,用来 read/write。断开就 close
Acceptor 把 30 行手写代码变成一行:
cpp
Acceptor acceptor(&loop, addr); // 内部:createTcp → reuseAddr → bind → Channel
acceptor.setNewConnectionCallback(fn); // 设回调
acceptor.listen(); // ::listen + enableReading
第 6 层:TcpConnection --- 客户端连接管理
解决的问题: 接管 accept 返回的 connfd,管理一个连接从建立到断开的完整生命周期。
cpp
class TcpConnection {
public:
using MessageCallback = std::function<void(const char* data, size_t len)>;
using CloseCallback = std::function<void(TcpConnection* conn)>;
TcpConnection(EventLoop* loop, int connfd)
: loop_(loop),
socket_(connfd), // ① Socket 接管 connfd,析构自动 close
channel_(loop, connfd) // ② Channel 盯这个 fd 的可读事件
{
channel_.setReadCallback([this]() { // ③ 可读 → handleRead
handleRead();
});
}
void connectEstablished() { channel_.enableReading(); }
void send(const std::string& msg) {
::write(channel_.fd(), msg.data(), msg.size());
}
private:
void handleRead() {
ssize_t n = inputBuffer_.readFd(channel_.fd()); // 从 fd 读到 Buffer
if (n > 0) {
// 按 \r\n 切行,每行触发一次回调
while (const char* crlf = inputBuffer_.findCRLF()) {
size_t lineLen = crlf - inputBuffer_.peek();
std::string msg = inputBuffer_.retrieveAsString(lineLen);
inputBuffer_.retrieve(2); // 跳过 \r\n
if (messageCallback_) messageCallback_(msg.data(), msg.size());
}
} else if (n == 0) {
channel_.disableAll(); // 必须禁用!不然 epoll 死循环
if (closeCallback_) closeCallback_(this);
}
}
EventLoop* loop_;
Socket socket_; // RAII 持有 connfd
Channel channel_;
Buffer inputBuffer_; // 输入缓冲区
MessageCallback messageCallback_;
CloseCallback closeCallback_;
};
跟 Acceptor 的镜像关系: Acceptor 等的 fd 是监听 socket → fd 可读意味着有人连接 → accept 得到新 fd。TcpConnection 等的 fd 是连接 socket → fd 可读意味着客户端发数据 → read 读数据。
读操作的三种返回值: n > 0 读到了 n 字节。n == 0 对端关闭连接(客户端 Ctrl+C 了)。n < 0 出错了。n==0 时必须 disable Channel------忘了的话 epoll 反复通知,打印几百行"客户端断开",CPU 飙满。
第 7 层:Buffer --- 双索引缓冲区(最巧妙的设计)
解决的问题: TCP 不保证消息边界------数据可能粘在一起(粘包),也可能被拆开(半包)。Buffer 负责攒数据 + 找边界 + 切完整消息。
核心设计:双索引不搬数据
csharp
buffer_: [ 已读过的 | 还没读的 | 空闲空间 ]
↑ ↑ ↑
0 readerIndex_ writerIndex_
readableBytes() = writerIndex_ - readerIndex_
retrieve 只移 reader,不删数据------O(1) 操作。
cpp
class Buffer {
public:
const char* peek() const { return begin() + readerIndex_; }
size_t readableBytes() const { return writerIndex_ - readerIndex_; }
void retrieve(size_t len) {
if (len < readableBytes()) readerIndex_ += len; // 只移 reader
else { readerIndex_ = 0; writerIndex_ = 0; } // 全部拿走 → 归零
}
};
readv 一次系统调用清空内核缓冲区:
cpp
ssize_t Buffer::readFd(int fd) {
char extrabuf[65536]; // 栈上 64KB 备用空间
struct iovec vec[2];
vec[0].iov_base = begin() + writerIndex_; // 先往 buffer 空闲区灌
vec[0].iov_len = writable;
vec[1].iov_base = extrabuf; // 满了溢到栈上
vec[1].iov_len = sizeof(extrabuf);
ssize_t n = ::readv(fd, vec, 2); // 一次系统调用
// 数据先灌 vec[0](buffer),满了溢到 vec[1](extrabuf),
// 再把 extrabuf 里的 append 到 buffer(触发扩容)
return n;
}
为什么用 readv 而不是 read? 普通 read(fd, buf, 4096) 一次只读 4KB。如果内核缓冲区有 10KB 数据,你读了 4KB 剩下 6KB 要等下轮 epoll 通知------多一次系统调用。readv 用两个 iovec 把 buffer 和 extrabuf 拼起来,内核一次性往两块内存灌------灌满 buffer 为止,多余的溢出到 extrabuf,之后 append 到 buffer。一次读走全部数据。
append 的"先搬再扩"策略:
cpp
void Buffer::append(const char* data, size_t len) {
// 1. 空闲不够 → 把可读数据搬到数组开头(复用死空间)
if (buffer_.size() - writerIndex_ < len) {
std::copy(begin() + readerIndex_, begin() + writerIndex_, begin());
readerIndex_ = 0;
writerIndex_ = readable; // 归位
}
// 2. 搬完还不够 → 扩容
if (buffer_.size() - writerIndex_ < len)
buffer_.resize(writerIndex_ + len);
// 3. 写进去
std::copy(data, data + len, beginWrite());
writerIndex_ += len;
}
先搬再扩的道理: 搬数据免费(复用已分配的空间),扩容收费(向系统申请新内存)。能搬不扩。
按行切消息------协议无关:
cpp
const char* findCRLF() const {
const char* crlf = std::search(peek(), beginWrite(), "\r\n", "\r\n" + 2);
return crlf == beginWrite() ? nullptr : crlf; // 找不到 = 还没攒够一行
}
换协议只换找边界的方法。\r\n 是 HTTP/Telnet 的分隔符,改成找 \n 就是 Linux 终端,改成找长度字段就是 Redis 协议。Buffer 的 reader/writer 双指针不变------协议无关。
第 8 层:TcpServer --- 最后的胶水
把 Acceptor + TcpConnection + 线程池串成一个完整的 TCP 服务器。main 从 40 行手写变成 10 行:
cpp
class TcpServer {
using MessageCallback = std::function<void(TcpConnection*, const char*, size_t)>;
using ConnectionCallback = std::function<void(TcpConnection*)>;
using CloseCallback = std::function<void(TcpConnection*)>;
TcpServer(EventLoop* loop, uint16_t port)
: loop_(loop),
acceptor_(loop, makeAddr_(port)) // 构造时算好地址,省去外部手动填协议栈
{
acceptor_.setNewConnectionCallback([this](int connfd, auto& peer) {
onNewConnection(connfd, peer); // ① 有人连 → 分配线程 + new TcpConnection
});
}
void onNewConnection(int connfd, const sockaddr_in& peer) {
EventLoop* ioLoop = threadPool_ ? threadPool_->getNextLoop() : loop_;
// ↑ 有线程池 → 轮转分一个 worker 线程
// 没线程池 → 用主线程
auto* conn = new TcpConnection(ioLoop, connfd);
conn->setMessageCallback([this, conn](const char* d, size_t l) {
messageCallback_(conn, d, l); // ② 有数据 → 回调 main
});
conn->setCloseCallback([this](TcpConnection* c) {
if (closeCallback_) closeCallback_(c); // ③ 断连 → 回调 main
});
conn->connectEstablished();
if (connectionCallback_) connectionCallback_(conn); // 通知 main"有人来了"
}
void setThreadNum(int n) {
threadPool_ = std::make_unique<EventLoopThreadPool>(loop_, n);
}
void start() {
if (threadPool_) threadPool_->start();
acceptor_.listen();
}
};
lambda 桥接------回调链怎么传参数:
TcpConnection 的回调签名是 void(data, len),只传 2 个参数。但 TcpServer 传给 main 的回调需要 void(conn, data, len)------多一个 conn 让 main 能 send 回去。
lambda 捕获变量充当桥接------在 onNewConnection 创建 TcpConnection 时,lambda 把 conn 捕获进自己体内:
cpp
conn->setMessageCallback([this, conn](const char* data, size_t len) {
// ↑ 捕获的:conn 在手
// ↑ 参数:data, len
messageCallback_(conn, data, len);
// ↑ 捕获 ↑ 参数 ↑ 参数
// 三个独立变量,凑一起传给 main
});
TcpConnection 不知道这个 lambda 内部干了什么------它只看到自己调的是 2 个参数的函数(签名匹配)。lambda 内部有捕获的 conn,自己凑够 3 个参数传给下一层。这是两次独立的函数调用 ,不是"两个参数变三个"。跟第一天写的 channel_.setReadCallback([this]() { handleRead(); }) 一模一样------lambda 可以当翻译官,把不同签名的回调串起来。
第 9 层:多线程 --- one loop per thread
EventLoopThread: 把 EventLoop + std::thread 打包。一个线程跑一个 EventLoop。
cpp
class EventLoopThread {
EventLoop* startLoop() {
thread_ = std::thread([this]() { threadFunc(); }); // 启动线程
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this] { return loop_ != nullptr; }); // 等子线程创建好
return loop_;
}
void threadFunc() {
EventLoop loop; // 子线程里创建 EventLoop
{
std::lock_guard<std::mutex> lock(mutex_);
loop_ = &loop; // 赋值
}
cond_.notify_one(); // 通知主线程:好了!
loop.loop(); // 死循环,阻塞在这里。子线程的"主循环"
}
EventLoop* loop_; // 子线程里的 EventLoop 指针
std::thread thread_; // C++ 线程对象
std::mutex mutex_;
std::condition_variable cond_;
};
condition_variable 的作用: 主线程调 startLoop() 后立即需要拿到子线程里的 EventLoop 指针------但子线程可能还没创建完 EventLoop。cond_.wait(lock, 条件) 让主线程等着,子线程创建完回头通知主线程"可以拿了"。没这个同步主线程会拿到 nullptr。
EventLoopThreadPool: 管理 N 个线程,新连接轮转分配。
cpp
class EventLoopThreadPool {
void start() {
for (int i = 0; i < numThreads_; i++) {
auto t = std::make_unique<EventLoopThread>();
EventLoop* loop = t->startLoop();
threads_.push_back(std::move(t)); // 必须存起来!不然线程析构
loops_.push_back(loop); // 收集所有 loop 指针
}
}
EventLoop* getNextLoop() {
if (loops_.empty()) return baseLoop_; // 没设线程 → 单线程模式
EventLoop* loop = loops_[next_];
next_ = (next_ + 1) % loops_.size(); // 轮转:0→1→2→0→1→...
return loop;
}
};
threads_ 必须存起来------unique_ptr<EventLoopThread> 析构时会 join 线程。不 push 进去的话临时对象一离开作用域就析构,线程被掐死。
第 10 层:TimerQueue --- 定时器
timerfd_create 创建 timer fd------跟 socket 一样就是一个 int。超时后 fd 自动可读,丢进 epoll 跟 socket fd 统一管理。零额外线程。
cpp
TimerQueue::TimerQueue(EventLoop* loop)
: timerfd_(timerfd_create(CLOCK_MONOTONIC,
TFD_NONBLOCK | TFD_CLOEXEC))
, timerChannel_(loop, timerfd_) // 包进 Channel,丢进 epoll
{ }
void TimerQueue::addTimer(const TimerCallback& cb, int64_t whenMs) {
timers_.emplace(whenMs, cb); // multimap 按时间自动排序
if (新定时器比最早的还早 || 还没注册到 epoll)
resetTimerFd(whenMs); // 重设 timerfd 为下一个到期时间
}
void TimerQueue::handleRead() {
uint64_t expired;
::read(timerfd_, &expired, sizeof(expired)); // 必须读!不读 epoll 死循环
int64_t now = nowMs();
auto it = timers_.begin();
while (it != timers_.end() && it->first <= now) {
it->second(); // 执行到期回调
it = timers_.erase(it); // 删掉
}
if (!timers_.empty())
resetTimerFd(getNextTimer()); // 还有 → 重设下一个
else
timerChannel_.disableAll(); // 没了 → 从 epoll 移除
}
时钟用 CLOCK_MONOTONIC: 不受用户修改系统时间影响,只往前走。用 CLOCK_REALTIME 的话用户改个时间你的定时器就乱了。
为什么必须 read timerfd: 跟 socket 一样------内核在 fd 里写了超时次数(通常是 1)。不读走 → fd 一直可读 → epoll 反复通知 → 死循环。读掉闹钟才复位。timerfd 本质就是内核维护的一个计数器------超时了计数 +1,你读了计数值归零,epoll 才会停止通知。
聊天室 demo --- muduo + my_logger 合体
所有类和所有回调都串起来后,一个多人聊天室的完整代码只有这么点:
cpp
#include "net/TcpServer.h"
#include "core/json_config.h"
#include "logger.h"
int main() {
json_config::load("chat_config.json"); // JSON 初始化 logger
auto log = registry::instance().get("app.chat");
EventLoop loop;
TcpServer server(&loop, 8080);
server.setThreadNum(4);
std::vector<TcpConnection*> clients;
server.setConnectionCallback([&](TcpConnection* conn) {
clients.push_back(conn);
LOG_INFO(log, "有人连线,在线: " + std::to_string(clients.size()));
conn->send("欢迎!在线人数: " + std::to_string(clients.size()) + "\r\n");
});
server.setMessageCallback([&](TcpConnection* conn, const char* data, size_t len) {
std::string msg(data, len);
LOG_INFO(log, "收到: " + msg);
for (auto* c : clients)
if (c != conn) c->send(msg + "\r\n"); // 广播给其他人(不发给自己)
});
server.setCloseCallback([&](TcpConnection* conn) {
clients.erase(std::find(clients.begin(), clients.end(), conn));
LOG_INFO(log, "断开,在线: " + std::to_string(clients.size()));
});
server.start();
loop.loop();
}
多个终端 telnet localhost 8080,打字互发消息。chat.log 文件同步记录每一条日志。两个自己造的轮子第一次协同工作------那种感觉很难描述。以前写代码都是用别人的库,这是第一次自己的两个库在同一个进程里配合。
telnet 发的是 \r\n,nc 发的是 \n------所以 nc 需要 findCRLF 改成找 \n,或者用 nc -C。
踩过的坑
1. 客户端断开后要 disable Channel。 Bug 最经典:read() 返回 0 时只打印了"客户端断开"------忘了把 Channel 从 epoll 摘掉。epoll 反复通知同一个 fd 可读,打印了几百行。一行 channel_.disableAll() 搞定。
2. Channel 不拥有 fd。 最开始想把 fd 存在 Channel 里管理。后来发现一个 fd 可能同时被 Socket 持有、Channel 观察、Poller 在 map 里引用。三者生命周期不同,Channel 拥有 fd 就会在析构时跟 Socket 的析构冲突(两次 close)。所有权分离之后各管各的。
3. timerfd 必须 read。 timer 超时内核在 fd 里记了超时次数。不读走 fd 一直可读→ epoll 死循环。读了闹钟才能复位。
4. localtime 不是线程安全的。 C 标准库的 localtime() 返回内部静态缓冲区指针,多线程同时调会互相覆盖。必须用 localtime_r(Linux)或 localtime_s(Windows),或者自己加锁。
5. std::vector<unique_ptr<T>> 存线程。 EventLoopThread 不可拷贝(std::thread 成员不能拷贝),只能通过 unique_ptr 托管生命周期。析构时 unique_ptr 自动 join 线程------忘了 push 进 vector 的话,临时创建的线程对象立刻析构,线程被掐死。
6. 构造 vs 赋值的区别。 Socket b = a 在声明行是拷贝构造。b = a 在赋值行是拷贝赋值。两个都禁了(= delete)。Socket b = std::move(a) 是移动构造。b = std::move(a) 是移动赋值。
项目结构
bash
muduo/
├── core/
│ ├── Channel.h / .cpp # fd 事件说明书
│ ├── Poller.h / .cpp # epoll 封装 + map<fd, Channel*>
│ ├── EventLoop.h / .cpp # while(true) 事件循环
│ └── TimerQueue.h / .cpp # timerfd 定时器
├── net/
│ ├── Socket.h / .cpp # RAII fd 管理
│ ├── Acceptor.h / .cpp # 监听 + accept 封装
│ ├── TcpConnection.h / .cpp # 连接读写
│ ├── Buffer.h / .cpp # 双索引缓冲区
│ └── TcpServer.h / .cpp # 顶层封装
├── thread/
│ ├── EventLoopThread.h / .cpp # 一个线程跑一个 EventLoop
│ └── EventLoopThreadPool.h / .cpp # 线程池 + 轮转调度
├── examples/
│ └── chat_server.cpp # 聊天室 demo(muduo + my_logger 合体)
└── main.cpp # echo server demo
10 个头文件 + 12 个 cpp,2000+ 行代码。单线程 + 多线程双模式,timerfd 定时器,epoll 全链路闭环。
更新记录
- 7/6 裸 epoll → Poller → Channel → EventLoop(stdin demo 跑通)
- 7/7 Socket(RAII + 移动语义)→ Acceptor(监听封装)
- 7/7 TcpConnection(连接读写 + echo server)
- 7/8 Buffer(readv + 双索引 + findCRLF 粘包处理)
- 7/8 TcpServer(10 行启动 echo server)
- 7/9 多线程(EventLoopThread + EventLoopThreadPool)
- 7/9 TimerQueue(timerfd 定时器)
- 7/10 目录重构(core/net/thread)+ Doxygen 注释
- 7/11 chat_server(muduo + my_logger 合体)
- 7/17 close 回调 + benchmark + JSON config + 完稿
项目已完结。两周从不知道 fd 是什么到自己的 Reactor 网络库。代码在 GitHub 上,MIT 协议,欢迎 star ⭐
my_muduo :
https://github.com/ch0sen1pm/my_muduomy_logger :https://github.com/ch0sen1pm/my_logger(配套日志库)