仿 muduo 高并发服务器项目:实现 LoopThread 与 LoopThreadPool 多线程事件循环

前言

今天继续推进仿 muduo 的 C++ 高并发服务器项目。

前面已经实现了 ChannelPollerEventLoop,单线程 Reactor 的基本链路已经可以运行。今天先实现了 LoopThread,让一个 EventLoop 独占一个线程;随后实现 LoopThreadPool,统一创建多个事件循环,并通过轮询方式为连接选择处理线程。

这两个模块是后面实现主从 Reactor 和 TcpServer 多线程分发的基础。

一、为什么需要 LoopThread

一个 EventLoop 应该始终在固定线程内运行。为了把事件循环与线程绑定,我实现了 LoopThread

cpp 复制代码
class LoopThread {
private:
    std::mutex _mutex;
    std::condition_variable _cond;
    EventLoop *_loop;
    std::thread _thread;
};

其中 _thread 是事件循环所在的线程,_loop 指向该线程内部创建的 EventLoop。互斥锁和条件变量用于解决线程启动阶段的同步问题。

这里不能简单地在构造函数中启动线程后立刻返回 _loop,因为新线程什么时候真正运行是不确定的:线程对象可能已经创建成功,但 EventLoop 还没有完成实例化。此时直接访问 _loop 就可能得到空指针。

二、在线程内部创建 EventLoop

线程入口函数如下:

cpp 复制代码
void ThreadEntry() {
    EventLoop loop;
    {
        std::unique_lock<std::mutex> lock(_mutex);
        _loop = &loop;
        _cond.notify_all();
    }
    loop.Start();
}

EventLoop 在线程入口中创建,因此它的构造、运行都发生在所属线程中,符合 one loop per thread 的设计。

_loop 虽然保存的是局部对象地址,但只要 loop.Start() 一直运行,ThreadEntry() 就不会退出,栈上的 loop 也不会销毁,所以当前运行期间指针有效。不过一旦事件循环退出,loop 被析构,外部保存的 _loop 就会成为悬空指针,因此后续必须配套设计安全退出和线程回收流程。

三、条件变量解决启动竞态

获取事件循环的接口如下:

cpp 复制代码
EventLoop *GetLoop() {
    EventLoop *loop = NULL;
    {
        std::unique_lock<std::mutex> lock(_mutex);
        _cond.wait(lock, [&]() { return _loop != NULL; });
        loop = _loop;
    }
    return loop;
}

_loop 还没有初始化时,调用线程会在条件变量上等待;工作线程创建好 EventLoop、写入 _loop 后,再唤醒等待线程。

这里有三个容易写错的点:

  1. _loop 的写入和读取必须由同一把互斥锁保护,否则会产生数据竞争。
  2. wait() 必须使用谓词判断,不能只等待一次,因为条件变量允许虚假唤醒。
  3. wait(lock, pred) 在休眠时会自动释放锁,被唤醒后重新加锁,再检查条件,因此不会妨碍工作线程设置 _loop

这段同步关系可以概括为:

text 复制代码
创建工作线程
    |
    +-- GetLoop:_loop 为空 -> 等待
    |
    +-- ThreadEntry:创建 EventLoop -> 设置 _loop -> 唤醒
                                              |
                                              v
                                      GetLoop 返回指针

四、LoopThreadPool 管理多个事件循环

单个 LoopThread 只能提供一个工作线程,因此又实现了 LoopThreadPool

cpp 复制代码
class LoopThreadPool {
private:
    int _thread_count;
    int _next_idx;
    EventLoop *_baseloop;
    std::vector<LoopThread *> _threads;
    std::vector<EventLoop *> _loops;
};

_baseloop 一般是主线程的事件循环,负责监听 socket 和接收新连接;_loops 保存工作线程中的事件循环,后续连接可以分配给这些子循环处理。

Create() 根据线程数量创建 LoopThread,随后调用 GetLoop()。由于 GetLoop() 内部有条件变量同步,所以只有对应 EventLoop 准备完成后,指针才会加入 _loops

cpp 复制代码
void Create() {
    if (_thread_count > 0) {
        _threads.resize(_thread_count);
        _loops.resize(_thread_count);
        for (int i = 0; i < _thread_count; i++) {
            _threads[i] = new LoopThread();
            _loops[i] = _threads[i]->GetLoop();
        }
    }
}

当线程数为 0 时,不创建额外线程,所有任务继续交给 _baseloop,这样单线程和多线程模式可以共用一套接口。

五、Round-Robin 轮询分配

线程池使用 _next_idx 记录下一次选择的位置,希望按照 Round-Robin 方式循环分配:

text 复制代码
loop0 -> loop1 -> loop2 -> loop0 -> ...

不过当前代码存在一个容易忽略的下标问题:

cpp 复制代码
_next_idx = (_next_idx + 1) % _thread_count;
return _loops[_next_idx];

_next_idx 初始值为 0,但代码先递增再返回,因此线程数大于 1 时,第一次会返回 _loops[1],而不是 _loops[0]

更自然的写法是先保存结果,再移动下标:

cpp 复制代码
EventLoop *NextLoop() {
    if (_thread_count == 0) {
        return _baseloop;
    }

    EventLoop *loop = _loops[_next_idx];
    _next_idx = (_next_idx + 1) % _thread_count;
    return loop;
}

这也是面试中常见的循环队列、轮询调度边界问题。

六、当前实现中的生命周期问题

当前版本已经完成主要结构,但资源回收还需要继续完善。

首先,LoopThread 中的 std::thread 如果在仍然可连接时析构,会直接触发 std::terminate()。因此不能只补一个简单析构函数,而应先让 EventLoop 退出,再调用 join() 等待线程结束。

其次,线程池使用 new LoopThread() 创建对象,却没有对应的 delete,会造成内存泄漏。后续可以改用:

cpp 复制代码
std::vector<std::unique_ptr<LoopThread>> _threads;

这样线程对象的所有权更加清晰,但仍要保证 LoopThread 析构前已经完成安全退出和 join()

最后,SetThreadCount()Create()NextLoop() 当前默认由同一个线程在启动阶段调用。如果多个线程同时修改 _next_idx 或线程池状态,还需要额外同步;正常的 Reactor 设计通常让主循环线程负责连接分配,从而避免为轮询下标加锁。

七、面试重点

1. 为什么 EventLoop 要在线程内部创建?

为了保证事件循环从构造到运行都归属于同一个线程,方便检查线程身份,并让 Channel、Poller 和任务队列遵守固定的线程边界。

2. 为什么不能用 sleep 等待 _loop 初始化?

sleep 无法保证工作线程已经执行到目标位置:时间太短仍可能失败,时间太长又浪费启动时间。条件变量能够在条件不满足时阻塞,在初始化完成后立即唤醒。

3. 条件变量为什么要配合谓词?

因为条件变量可能虚假唤醒,而且通知也可能早于等待发生。谓词会在持锁状态下检查真实条件,使这两种情况都能正确处理。

4. Reactor 线程池如何分工?

EventLoop 通常负责监听和接收连接,子 EventLoop 负责已连接 socket 的读写事件。新连接通过轮询分配给不同子循环,之后该连接的 IO 操作固定在所属线程中完成。

总结

今天先实现了 LoopThread,使用互斥锁和条件变量解决线程启动与 EventLoop 初始化之间的竞态;随后实现 LoopThreadPool,统一创建多个事件循环,并通过 Round-Robin 方式分配处理线程。

核心关系可以总结为:

text 复制代码
LoopThreadPool
    |
    +-- base EventLoop:接收新连接
    |
    +-- LoopThread 0 -> EventLoop 0
    +-- LoopThread 1 -> EventLoop 1
    +-- LoopThread 2 -> EventLoop 2

当前版本还需要修正 NextLoop() 的首次取值下标,并补充 EventLoop 退出、线程 join() 和智能指针资源管理。完成这些生命周期细节后,就可以继续把线程池接入 TcpServer,实现新连接在多个 Reactor 线程之间的分发。

相关推荐
微道道1 小时前
Linux盒子局域网内使用 .local 域名访问,告别IP变化烦恼
linux·运维·服务器·hermes
霸道流氓气质1 小时前
CodeBuddy中配置Playwright MCP 浏览器自动化完全指南
运维·自动化
Bug退散师2 小时前
多路IO复用[select版TCP服务器与poll版TCP服务器]与常用网络编程函数详解
linux·服务器·c语言·网络·驱动开发·tcp/ip
国服第二切图仔2 小时前
05-构建与特性开关
linux·服务器·ubuntu
刘马想放假2 小时前
Docker 从入门到进阶:一篇写给工程师的系统化实战指南
运维·docker·容器
梦想的旅途23 小时前
利用 API 实现企业微信消息自动化推送
运维·自动化·企业微信
Acrellea3 小时前
固态变压器(SST)热管理破局:安科瑞交直流专属测温方案护航无人值守运维
运维·网络
寒冰碧海3 小时前
大模型部署从0到1(一):通用环境全流程准备|NVIDIA驱动+Docker+NVIDIA Container Toolkit
运维·docker·容器
风曦Kisaki4 小时前
# Linux 系统资源查看命令总结(附命令快查表)
linux·运维·服务器