EventLoop 与线程整合:LoopThread 模块设计
一、设计目标
将 EventLoop 模块与线程整合起来,保证一个 EventLoop 对象只属于一个固定线程。
EventLoop 与线程之间是一一对应的关系:
一个线程对应一个 EventLoop;
一个 EventLoop 只能在所属线程中运行。
二、EventLoop 如何判断当前线程
EventLoop 对象在构造时,会记录当前线程的 ID,并保存到 _thread_id 成员中。
后续执行某个操作时,可以通过比较下面两个线程 ID,判断当前代码是否运行在 EventLoop 所属线程中:
当前运行线程的 ID;
EventLoop 对象保存的 _thread_id。
判断结果如下:
比较结果 说明
线程 ID 相同 当前代码运行在 EventLoop 所属线程中
线程 ID 不同 当前代码运行在其他线程中
因此,EventLoop 对象必须在目标线程内部创建。只有这样,构造函数记录的 _thread_id 才是正确的线程 ID。
三、为什么不能先创建 EventLoop
一种错误的设计方式是:
- 先创建多个 EventLoop 对象;
- 再创建多个线程;
- 最后将各个线程的 ID 重新设置给 EventLoop。
这种方式存在明显问题。
从 EventLoop 对象创建完成,到重新设置 _thread_id 之前,EventLoop 内部保存的线程 ID 是错误的。在这段时间内,如果 EventLoop 被调用,就可能产生线程判断错误。
两种创建方式对比如下:
对比项 错误方式 正确方式
创建顺序 先创建 EventLoop,再创建线程 先创建线程,再创建 EventLoop
创建位置 在外部线程创建 在目标线程内部创建
线程 ID 后续手动修改 构造时自动记录
安全性 存在不可控时间段 线程绑定关系始终正确
因此,正确的创建顺序应该是:
先创建线程,再在线程入口函数中创建 EventLoop 对象。
四、LoopThread 模块
为了统一管理线程和 EventLoop,可以封装一个新的模块:LoopThread。
LoopThread 的主要功能是:
- 创建并管理一个线程;
- 在线程入口函数中创建 EventLoop;
- 建立 EventLoop 与线程之间的绑定关系;
- 向外部返回创建完成的 EventLoop 对象。
五、LoopThread 的工作流程
LoopThread 的整体执行流程如下:
外部创建 LoopThread 对象
↓
LoopThread 启动一个新线程
↓
进入线程入口函数
↓
在线程内部创建 EventLoop 对象
↓
EventLoop 构造函数记录当前线程 ID
↓
EventLoop 开始事件循环
↓
LoopThread 向外部提供 EventLoop 对象
关系示意:
LoopThread
├── Thread
│ └── EventLoop
│ └── _thread_id = 当前线程 ID
└── 对外返回 EventLoop
六、模块职责
EventLoop 的职责:
处理事件循环;
保存所属线程的 ID;
检查当前操作是否运行在所属线程中。
LoopThread 的职责:
创建线程;
在线程内部创建 EventLoop;
管理线程与 EventLoop 的生命周期;
向外部提供 EventLoop 对象。
七、实现时的注意事项
外部获取 EventLoop 时,子线程可能还没有完成 EventLoop 的创建。
因此,LoopThread 返回 EventLoop 之前,必须确保线程入口函数已经完成 EventLoop 的实例化。实现时可以通过互斥锁和条件变量进行线程同步,避免外部获得空指针或未初始化对象。
八、总结
EventLoop 对象必须在线程内部创建,因为它会在构造时记录当前线程的 ID。
如果先创建 EventLoop,再创建线程并修改线程 ID,会出现一段状态不可控的时间。
LoopThread 模块通过"先创建线程,再在线程中创建 EventLoop"的方式,将 EventLoop 与线程安全地绑定在一起,同时向外部提供创建完成的 EventLoop 对象。
我们需要将 EventLoop 和线程绑定起来。因为 EventLoop 中会记录当前线程的线程 ID,所以它必须在对应的工作线程中实例化,不能在主线程中提前创建。
为此,可以定义一个线程入口函数。工作线程启动后,在线程入口函数内部创建 EventLoop 对象,然后启动事件循环。
外部有时需要获取该线程对应的 EventLoop,因此类中需要保存一个 EventLoop* 指针,并提供 getLoop() 接口。但是线程启动后,EventLoop 不一定已经完成初始化。如果此时直接调用 getLoop(),可能得到空指针。
所以需要使用 std::mutex 和 std::condition_variable 进行线程同步。调用 getLoop() 时,如果 EventLoop 尚未创建,就通过条件变量等待。在线程入口函数中创建好 EventLoop 后,将其地址赋值给成员指针,再通知等待线程。
线程入口函数的主要流程是:创建 EventLoop、保存其地址、通知等待线程,然后调用 loop() 启动事件循环。loop() 内部通常是一个持续运行的循环,不断监听事件、处理事件并执行任务,直到收到退出指令。
当外部获取到 EventLoop 后,就可以将连接绑定到该事件循环。之后对连接进行事件监听或任务操作时,可以将任务加入对应 EventLoop 的任务队列,由其所属线程统一执行。
这种设计保证了每个工作线程拥有独立的 EventLoop,同时通过互斥锁和条件变量解决了 EventLoop 初始化过程中的线程同步问题。
LoopThread 构造函数
LoopThread 的构造函数主要负责初始化成员变量:
- 将
loop_初始化为空。 - 使用
std::thread创建线程,并将threadEntry()作为线程入口函数。 threadEntry()是类的成员函数,包含隐含的this指针,因此创建线程时需要传入成员函数地址以及当前对象的this指针。
完成这些初始化后,LoopThread 对象构造完毕。
getLoop() 的实现
getLoop() 用于获取当前线程对应的 EventLoop 对象地址。
首先定义一个局部指针:
EventLoop* loop = nullptr
由于创建 LoopThread 后,线程中的 EventLoop 可能还没有完成实例化,因此不能直接返回 loop_。当 loop_ 为空时,当前线程需要等待,直到 loop_ 被赋值。
具体处理过程如下:
-
使用
std::mutex和条件变量 保护对loop_的访问。 -
通过
std::unique_lock<std::mutex>管理mutex_,完成加锁。 -
调用
cond_.wait()等待:cond_.wait(lock, [&]() { return loop_ != nullptr; }) -
wait()的第二个参数是一个函数,其返回值为布尔值:- 当
loop_ == nullptr时,条件不满足,线程继续等待。 - 当
loop_ != nullptr时,条件满足,线程继续向下执行。
- 当
-
条件满足后,将
loop_赋值给局部指针loop。 -
退出加锁区域后返回局部指针
loop。
对 loop_ 的读取和赋值都需要进行加锁保护。使用局部指针保存结果后再返回,可以保证这部分操作的安全性。
threadEntry() 的实现
threadEntry() 是新线程的入口函数,主要负责创建并运行该线程对应的 EventLoop。
首先在函数内部实例化一个 EventLoop 对象:
EventLoop loop
这里直接创建局部对象,而不是通过 new 创建对象,使该 EventLoop 的生命周期由当前 LoopThread 对应的线程执行过程管理。
由于 EventLoop 是在线程入口函数中创建的,因此它对应的线程 ID 就是当前新创建线程的 ID。
对象创建完成后,需要执行以下操作:
-
对共享数据进行加锁。
-
将局部对象的地址赋值给成员变量:
loop_ = &loop -
赋值完成后,调用:
cond_.notify_all()唤醒所有正在
getLoop()中等待loop_初始化完成的线程。 -
调用:
loop.start()运行
EventLoop的循环任务。
start() 内部已经改为通过 while 循环持续运行任务,因此正常情况下不会退出。只要事件循环仍在运行,threadEntry() 中的局部对象 loop 就不会被销毁。
整体执行流程
LoopThread 构造时创建新线程,并将 threadEntry() 作为线程入口。
新线程进入 threadEntry() 后:
- 实例化
EventLoop。 - 加锁并将其地址保存到
loop_。 - 调用
cond_.notify_all()唤醒等待线程。 - 调用
loop.start()运行事件循环。
外部线程调用 getLoop() 时,如果 loop_ 尚未完成初始化,就通过条件变量阻塞等待;当 threadEntry() 创建好 EventLoop 并发出通知后,等待线程被唤醒,获取并返回对应的 EventLoop 指针。
LoopThreadPool 线程池设计
针对 LoopThread 设计一个线程池。LoopThreadPool 模块负责对所有的 LoopThread 进行管理和分配。
功能
-
线程数量可配置
从属线程数量可以配置为 0 个或多个。
在服务器的主从 Reactor 模型中,主线程只负责获取新连接,从属线程负责新连接的事件监控和处理。
因此,线程池中的从属线程数量可能为 0。此时实现的是单 Reactor 服务器,一个线程既负责获取连接,也负责连接的处理。
-
管理所有线程
对所有线程进行管理,本质上就是管理 0 个或多个
LoopThread对象。 -
提供线程分配功能
当主线程获取一个新连接后,需要将新连接挂载到从属线程上,由从属线程进行事件监控和处理。
- 如果有 0 个从属线程,则直接将连接分配给主线程的
EventLoop进行处理。 - 如果有多个从属线程,则采用 RR 轮转 思想进行线程分配:获取对应线程的
EventLoop,并将其设置给对应的Connection。
- 如果有 0 个从属线程,则直接将连接分配给主线程的
LoopThreadPool 类设计
LoopThreadPool 负责管理所有从属线程,并为新连接分配对应的 EventLoop。
成员变量
-
int _thread_count保存从属线程的数量 。根据该数量创建相应数量的
LoopThread对象。 -
std::vector<LoopThread*> _threads保存所有创建出来的
LoopThread对象。 -
EventLoop* _baseloop保存主线程中的主
EventLoop。当从属线程数量为 0 时,所有操作都在
_baseloop中进行;当存在多个从属线程时,主EventLoop主要运行在主线程中。 -
std::vector<EventLoop*> _loops保存所有从属线程对应的
EventLoop。由于EventLoop与线程一一对应,因此分配EventLoop就相当于分配线程。 -
int _next_loop_idx保存下一次需要分配的
EventLoop下标,用于从_loops中依次获取EventLoop。
EventLoop 分配逻辑
当需要为新连接分配线程时:
- 如果
_thread_count等于 0,则直接返回主线程的_baseloop。 - 如果
_thread_count大于 0,则根据_next_loop_idx从_loops中获取一个EventLoop并返回。
返回的 EventLoop 指针会设置给对应的 Connection,相当于将该连接挂载到对应的从属线程上。
对外接口
cpp
class LoopThreadPool
{
private:
int _thread_count; // 从属线程的数量
int _next_loop_idx;
EventLoop* _baseloop; // 主 EventLoop,运行在主线程
std::vector<LoopThread*> _threads; // 保存所有 LoopThread 对象
std::vector<EventLoop*> _loops; // 保存从属线程对应的 EventLoop
public:
LoopThreadPool();
void SetThreadCount(int count); // 设置线程数量
void Create(); // 创建所有从属线程
EventLoop* NextLoop(); // 获取下一个 EventLoop
};
SetThreadCount(int count) 用于设置从属线程数量。
Create() 用于创建所有从属线程。原本考虑将该接口命名为 Start(),但该接口的主要作用是创建从属线程,因此最终命名为 Create()。
NextLoop() 用于获取下一次分配的 EventLoop,完成线程分配。
看loopthreadpool代码,以及整合
TcpServer 模块:服务器各模块的统一整合
一、TcpServer 模块的作用
TcpServer 是整个服务器的上层管理模块,负责对监听套接字、事件循环、连接对象以及线程池进行统一整合。
通过实例化一个 TcpServer 对象,即可较为简单地完成服务器的搭建。
其核心职责包括:
- 监听并接收客户端的新连接;
- 为新连接创建对应的
Connection对象; - 将连接分配给从属线程中的
EventLoop; - 管理服务器中的所有连接;
- 为连接设置统一的业务回调;
- 管理非活跃连接超时销毁和定时任务。
二、TcpServer 管理的核心对象
| 核心对象 | 主要作用 |
|---|---|
Acceptor |
创建监听套接字,监听客户端连接请求,并获取新连接 |
EventLoop(baseLoop) |
对监听套接字进行事件监控 |
std::unordered_map<uint64_t, PtrConnection> _conns |
保存并管理服务器中已经建立的连接 |
LoopThreadPool |
创建从属 EventLoop 线程池,对新连接进行事件监控和处理 |
1. Acceptor
Acceptor 负责创建监听套接字,并将监听套接字挂载到 baseLoop 中。
当监听套接字产生可读事件时,说明有新的客户端连接到来,此时由 Acceptor 获取新连接。
2. baseLoop
baseLoop 是服务器的主事件循环,主要负责监听 Acceptor 对应的监听套接字。
它的主要任务是接收新连接,而不是处理已经建立连接的业务数据。
3. _conns
服务器使用以下哈希表保存所有已经建立的连接:
std::unordered_map<uint64_t, PtrConnection> _conns
其中:
uint64_t用于标识一个连接;PtrConnection表示连接对象的智能指针;_conns负责统一管理服务器中的连接对象。
4. LoopThreadPool
LoopThreadPool 用于创建多个从属 EventLoop。
新连接建立后,不再由 baseLoop 负责后续的数据处理,而是将连接分配给线程池中的某个从属 EventLoop。
从属 EventLoop 负责监控连接上的事件,并处理客户端发送的数据。
三、TcpServer 提供的主要功能
1. 设置从属线程池数量
服务器可以设置 LoopThreadPool 中从属线程的数量。
每个从属线程通常对应一个 EventLoop,用于处理已经建立的客户端连接。
2. 启动服务器
服务器启动后,需要完成以下工作:
- 启动监听;
- 启动
baseLoop; - 启动从属线程池;
- 开始接收并处理客户端连接。
3. 设置业务回调函数
用户将业务回调函数设置给 TcpServer,当服务器获取到新连接后,再由 TcpServer 将这些回调函数设置给对应的 Connection 对象。
主要包括:
- 连接建立完成回调;
- 消息回调;
- 连接关闭回调;
- 任意事件回调。
这样可以将网络通信流程与具体业务逻辑分离。
TcpServer 负责连接管理,用户只需要实现对应的业务处理函数。
4. 非活跃连接超时销毁
TcpServer 可以选择是否启用非活跃连接超时销毁功能。
启用后,如果某个连接在规定时间内没有发生数据通信,则可以按照超时规则释放该连接,避免无效连接长期占用服务器资源。
5. 添加定时任务
TcpServer 可以向事件循环中添加定时任务,在指定时间执行对应的处理函数。
四、TcpServer 的整体结构
TcpServer 中各模块之间的关系如下:
cpp
TcpServer
│
├── Acceptor
│ └── 监听套接字
│ └── 挂载到 baseLoop
│
├── EventLoop(baseLoop)
│ └── 监听新连接事件
│
├── std::unordered_map<uint64_t, PtrConnection> _conns
│ └── 管理所有已经建立的连接
│
└── LoopThreadPool
└── 多个从属 EventLoop
└── 监控并处理 Connection
整体职责可以概括为:
baseLoop负责接收新连接;LoopThreadPool中的从属EventLoop负责处理连接;_conns负责保存和管理连接;TcpServer负责对所有模块进行统一调度。
五、TcpServer 的工作流程
阶段一:初始化服务器
1. 创建 Acceptor 和 baseLoop
在 TcpServer 中实例化一个 Acceptor 对象,同时保存一个 EventLoop 对象作为 baseLoop。
Acceptor 负责创建监听套接字,baseLoop 负责对监听套接字进行事件监控。
2. 将 Acceptor 挂载到 baseLoop
将 Acceptor 创建的监听套接字注册到 baseLoop 中。
此后,baseLoop 会持续监控监听套接字上是否产生可读事件。
阶段二:获取新连接
3. 监听套接字产生可读事件
当客户端发起连接请求时,监听套接字会产生可读事件。
baseLoop 检测到该事件后,执行 Acceptor 设置的读事件回调函数,从而获取新建立的连接。
4. 创建 Connection 对象
获取新连接后,为该连接创建一个对应的 Connection 对象。
后续对该客户端连接的事件监控、数据读取和连接管理,都通过这个 Connection 对象完成。
阶段三:配置连接
5. 为 Connection 设置回调函数
创建 Connection 后,需要将 TcpServer 中保存的业务回调设置给该连接,包括:
- 连接建立完成回调;
- 消息回调;
- 连接关闭回调;
- 任意事件回调。
用户只需要向 TcpServer 设置一次回调,后续创建的新连接都会使用这些回调函数。
6. 设置非活跃连接超时规则
如果服务器启用了非活跃连接超时销毁功能,则需要为新创建的 Connection 设置对应的超时规则。
当连接长时间没有发生事件时,可以按照规则自动销毁该连接。
阶段四:分配连接
7. 将 Connection 分配给从属 EventLoop
新连接创建并配置完成后,从 LoopThreadPool 中选择一个从属线程对应的 EventLoop。
然后将该 Connection 挂载到选中的 EventLoop 中,由这个从属 EventLoop 负责后续事件监控。
也就是说:
baseLoop负责监听并接收新连接;- 从属
EventLoop负责处理已经建立的连接。
阶段五:处理客户端数据
8. Connection 产生可读事件
当客户端向服务器发送数据时,对应的 Connection 会产生可读事件。
从属 EventLoop 检测到可读事件后,执行该连接的读事件回调函数。
读事件回调函数完成以下操作:
- 从套接字中读取客户端发送的数据;
- 将数据保存到连接的接收缓冲区;
- 调用
TcpServer为该连接设置的消息回调; - 由用户实现的消息回调处理具体业务。
整体调用过程如下:
cpp
客户端发送数据
↓
Connection 产生可读事件
↓
从属 EventLoop 检测到事件
↓
执行 Connection 的读事件回调
↓
读取套接字数据
↓
调用 TcpServer 设置的消息回调
↓
执行业务逻辑
六、完整执行流程总结
TcpServer 的完整工作流程可以总结为:
cpp
创建 TcpServer
↓
创建 Acceptor 和 baseLoop
↓
将监听套接字挂载到 baseLoop
↓
baseLoop 监听新连接
↓
Acceptor 获取新连接
↓
创建 Connection 对象
↓
设置连接、消息、关闭和任意事件回调
↓
设置非活跃连接超时规则
↓
从 LoopThreadPool 中选择一个 EventLoop
↓
将 Connection 挂载到从属 EventLoop
↓
从属 EventLoop 监听 Connection
↓
Connection 产生可读事件
↓
读取数据并调用消息回调
七、核心设计思路
TcpServer 采用主从事件循环的处理方式:
- 主事件循环
baseLoop专门负责监听新连接; - 从属事件循环负责处理已经建立的连接;
Connection封装单个客户端连接;_conns统一管理所有连接;TcpServer负责整合各个模块,并向连接传递业务回调。
这种设计将"新连接的接收"和"连接数据的处理"分离,使各模块职责更加清晰,也便于通过线程池处理多个客户端连接。