前言
今天继续推进仿 muduo 的 C++ 高并发服务器项目。
前面已经实现了 Channel、Poller 和 EventLoop,单线程 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 后,再唤醒等待线程。
这里有三个容易写错的点:
- 对
_loop的写入和读取必须由同一把互斥锁保护,否则会产生数据竞争。 wait()必须使用谓词判断,不能只等待一次,因为条件变量允许虚假唤醒。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 线程之间的分发。