第一次提交:搭建框架
1.CMake 的模块化构建设计
CMake 负责"读你的构建规则 → 生成 Makefile"
Make 负责"读 Makefile → 调用 g++ 编译 + 链接"
cmake .. # "照我的 CMakeLists.txt,生成这个项目的构建脚本"
make # "执行构建脚本,编译全部文件 + 链接成可执行程序"
两级cmake分工
根目录:CMakeLists.txt 是 C++ 项目的构建配置文件,它的作用是告诉 CMake 工具如何编译和链接你的项目。
比如说我的项目就是告诉编译器,我的项目名,用C++11标准编译,提供了两种模式,DEBUG,RELEASE,我的头文件在根目录,要链接的库,哪些源文件需要进行编译,要不要优化代码?优化就是release模式,删除了调试信息,不优化就是debug版本。
子目录:每个模块的 CMakeLists.txt 只声明自己的编译规则 和自己依赖谁。如果全部写在根文件里,当模块数量从现在的 10 个膨胀到 50 个时,根文件会变成上千行的巨无霸,谁都改不动。
对比
| 文件 | 一句话职责 |
|---|---|
根 CMakeLists.txt |
全局配置 + 声明"有哪些子模块" |
common/CMakeLists.txt |
编译 libcommon.a(logger + 公共类型) |
threadpool/CMakeLists.txt |
编译 libthreadpool.a,链接 common |
protocol/CMakeLists.txt |
编译 libprotocol.a(目前只有头文件占位) |
test/CMakeLists.txt |
编译可执行文件 fileserver_test,链接全部库 |
| ... 其他 6 个 | 同上,预留的模块骨架 |
2.第一次的测试
<>代表去系统路径找
""代表先在项目目录找
日志测试
// 测试日志级别过滤
//把日志级别调到 kWarning。Logger::Instance() 获取日志单例——全局只有一个 Logger 对象,不管在哪里调用拿到的都是同一个
//级别从低到高:DEBUG → INFO → WARNING → ERROR → FATAL
Logger::Instance().SetLevel(LogLevel::kWarning);
LOG_DEBUG("这条调试信息不应该出现");
LOG_WARNING("这条警告信息应该出现");
LOG_ERROR("这条错误信息应该出现");
std::cout << "\n=== 测试: 日志模块 ===\n" << std::endl;
LOG_DEBUG("这是一条调试信息");//宏,预处理会把LOG_DEBUG替换为Logger::Instance().Log(LogLevel::kDebug, "test/main.cpp", 16, "TestLogger", "这是一条调试信息");
LOG_INFO("这是一条普通信息");
LOG_WARNING("这是一条警告信息");
LOG_ERROR("这是一条错误信息");
线程池测试
// 创建一个包含 4 个工作线程的线程池
ThreadPool pool(4);
std::cout << "工作线程数: " << pool.WorkerCount() << std::endl;
// 提交几个简单任务
auto future1 = pool.Submit([]() -> int {
LOG_INFO("任务1 在工作线程上运行");// 这里的 LOG_INFO 宏会把日志输出到控制台,显示任务1在工作线程上运行
return 100;
});
auto future2 = pool.Submit([](int a, int b) -> int {
LOG_INFO("任务2 在工作线程上运行: " + std::to_string(a) + " + " + std::to_string(b));
return a + b;
}, 3, 7);
auto future3 = pool.Submit([]() -> std::string {
LOG_INFO("任务3 在工作线程上运行");
return "hello from threadpool";
});
// 等待并验证结果
assert(future1.get() == 100);
assert(future2.get() == 10);
assert(future3.get() == "hello from threadpool");
Submit 内部做的事:
-
用
std::packaged_task把 lambda 包装成可调用对象 -
加互斥锁,把任务塞进
tasks_队列 -
cond_.notify_one()--- 唤醒一个正在wait的工作线程 -
返回 future
// 批量提交任务,测试并发执行
const int kNumTasks = 20;
std::vector<std::future<int>> futures;
for (int i = 0; i < kNumTasks; ++i) {
futures.push_back(pool.Submit([i]() -> int {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
return i * i;
}));
}
for (int i = 0; i < kNumTasks; ++i) {
assert(futures[i].get() == i * i);//// 这里的 get() 会阻塞,直到对应的任务完成并返回结果
}
循环 20 次,每次 Submit 一个任务。关键细节:
-
[i]--- lambda 的捕获列表里写了i,表示按值捕获 ,把循环变量i的值复制一份给 lambda 内部使用。如果不捕获,lambda 里面访问不到外面定义的i -
sleep_for--- 每个任务睡 10 毫秒,模拟真实业务中的耗时操作(如数据库查询) -
4 个线程并发处理 20 个任务 → 每个线程大约处理 5 个
-
Submit 之后不等待,直接进入下一次循环 → 20 个任务几乎同时入队
错误码测试
auto printCode = [](ErrorCode code, const char* name) {
std::cout << " " << name << " = " << static_cast<int32_t>(code) << std::endl;
};
printCode(ErrorCode::kOk, "kOk (成功)");
printCode(ErrorCode::kUnknown, "kUnknown (未知错误)");
printCode(ErrorCode::kAuthFailed, "kAuthFailed (认证失败)");
printCode(ErrorCode::kNotFound, "kNotFound (未找到)");
复用lambda
static_cast<int32_t>(code):是 C++ 的类型转换------枚举值不能直接当整数打印,需要显式转换。因为我们需要打印整数。
第二次提交:构建网络服务层
线程模型:搭建了主从 Reactor 架构
基于 muduo 实现了一个高并发 TCP 服务器
主线程进行事件循环,只做连接,有连接了分发连接到子线程(4个),每个子线程里面有也事件循环机制,监听socket,有任务了,调用合适的用户回调函数解析请求(读/写),再调用自定义回调函数,处理业务逻辑
代码分层:
-
入口:启动事件循环,创建 Server(muduo服务器对象),进入事件循环
-
封装:包 muduo TcpServer,设回调(OnMessage:拿到已经读好的 Buffer,以后去做业务处理),管理连接
-
管理:管理Session (是连接档案,有连接ID,连接弱引用weak_ptr,客户端IP地址端口号,连接的建立时间,最后活跃的时间)增删查,线程安全
-
会话:存连接信息(ID、地址、时间)
-
底层:epoll、accept、socket 读写、IO 线程池
客户端发消息处理逻辑
-
客户端经过TCP三次握手建立连接,主线程拿到新的socket,通过轮询机制,分发给子线程,
-
子线程把socket注册到epoll,
-
muduo创建tcp连接对象,通过共享智能指针管理对象,把连接信息记录到sessions中进行存储,调用连接回调
-
连接管理器对sessions进行管理,(接下来执行自定义的连接回调),返回,epoll继续等待
-
此时发送的数据信息到达了内核,数据可读
-
muduo先调用read,把数据存到缓冲区buffer,再去调用OnMessage
-
OnMessage:如果你有自定义的逻辑,优先用自定义处理业务,最后发送给客户端,如果没有,执行conn->send("hello") ,把 "hello" 追加到 TcpConnection 的 输出缓冲区
send 不是直接调 write。它是先把数据放进内部的输出队列排队,等 epoll 说 socket 可写了再真正 write。这样如果网络慢、一次 write 不完,数据不会丢,下次继续写。
你的代码 muduo 内部 内核 │ │ │ conn->send("hello") → 追加到输出缓冲区 │ │ │ │ │ epoll 说可写 │ │ │ │ │ write(fd, buf, len) → 拷贝到发送缓冲区 │ │ │ │ │ 网卡取走 → 网络所以你调
conn->send("hello")是非阻塞的------调完立刻返回,不卡住,真正发数据是 epoll 在后台完成的。 -
muduo 内部调 write() 从 缓冲区取出数据写入 socket内核发送缓冲区→ 数据发回客户端。返回,epoll继续等待
缓冲区 [h][e][l][l][o] ← 数据在这里面 │ write(fd, 缓冲区, 5) ← 从缓冲区取数据,写入 socket │ ▼ socket 内核发送缓冲区 → 网卡 → 客户端 -
客户端收到数据,关闭客户端连接
-
epoll检测到socket关闭,调用函数,关闭这个连接,遍历sessions删除这条连接信息,(再执行自定义断开连接回调)
-
释放连接,执行析构,删除连接对象。
muduo 负责:accept、epoll、read/write、创建/销毁 TcpConnection 你的代码负责:记录 Session、管理连接、决定收到数据后做什么
第二次测试
先开启服务端进行阻塞监听
开启客户端,并发连接100个客户端
结果
============================================
FileServer - 客户端并发测试
目标: 127.0.0.1:8080
并发客户端数: 100
============================================
============================================
测试结果
============================================
连接成功: 100/100
连接失败: 0
发送成功: 100/100
发送失败: 0
接收成功: 100/100
接收失败: 0
耗时: 50 ms
============================================
全部测试通过!
============================================
这验证了主从 Reactor 模型的优势------4 个 Sub Reactor 线程通过 epoll 并发处理 100 个连接的 IO 事件,没有线程阻塞等待。如果换成单线程阻塞模型,100 个连接串行处理至少要几秒。
为什么没有线程阻塞等待?
操作系统:epoll层
调用epoll_wait(),一个线程等待100个socket的事件,他不会阻塞等待某一 个socket,而是谁就绪了处理谁
读和写操作不会阻塞,epoll_wait 会阻塞
多线程:有四个子线程。分摊100个连接,四个线程并行处理