800 行代码手写 Reactor 网络库:从 epoll 到聊天室

从零手写 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_muduohttps://github.com/ch0sen1pm/my_muduo my_loggerhttps://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_create1EPOLL_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\nnc 发的是 \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_muduohttps://github.com/ch0sen1pm/my_muduo my_loggerhttps://github.com/ch0sen1pm/my_logger(配套日志库)

相关推荐
旖-旎2 小时前
LeetCode 494:目标和(动态规划/01背包问题)—— 题解
c++·算法·leetcode·动态规划·01背包
ysa0510303 小时前
【板子】德州扑克模拟
c++·笔记·板子
慧都小项3 小时前
C/C++ 静态分析为什么要前移?Parasoft C/C++test 在 IDE 与 CI/CD 中的应用
c语言·c++·ide·功能测试·ai
qq_33776320193 小时前
国际期货资管系统开发方案|内外盘交易平台源码授权与二次开发支持
java·c语言·开发语言·c++·c#
zmzb010314 小时前
C++课后习题训练记录Day157
开发语言·c++
从零开始的代码生活_18 小时前
C++ 多态详解:虚函数、动态绑定、抽象类与虚表原理
开发语言·c++·后端·学习·算法
大彼方..18 小时前
C++ STL Vector 深度剖析:从内存管理到性能优化
开发语言·c++
炸薯条!19 小时前
从零开始学C++ (内存管理)
java·jvm·c++
cpp_250121 小时前
P6625 [省选联考 2020 B 卷] 卡牌游戏
数据结构·c++·算法·前缀和·贪心·洛谷题解·省选