第30章 综合实战:高并发日志系统、并发Web服务器、并发计算框架的设计与实现
终于到了最后一章。说实话,我心里还有点不舍。
前面29章,咱们把C++并发编程的边边角角都啃了一遍。从最基础的std::thread,到内存模型、无锁数据结构,再到各种并发模式。但学完理论,终究要落到实战上。
这一章,我挑三个最典型的并发系统——高并发日志系统、并发Web服务器、并发计算框架——带你完整走一遍设计思路和实现要点。这三个系统,基本覆盖了日常开发中90%的并发场景。
30.1 高并发日志系统:写日志不能成为瓶颈
先说说日志系统。你可能觉得写日志有什么难的?不就是fprintf一下吗?
我在项目中遇到过,线上服务一上量,日志先扛不住了。磁盘IO打满,业务线程全卡在写日志上。更可怕的是,日志系统崩溃导致关键信息丢失,排查问题像大海捞针。
30.1.1 设计目标
- 低延迟:写日志不能阻塞业务线程
- 高吞吐:每秒能处理数十万条日志
- 可靠性:进程崩溃时不丢日志
- 可配置:支持不同级别、不同输出目标
30.1.2 架构设计
核心思路就一句话:生产者-消费者模型。业务线程只管往队列里扔日志,后台专门有一个写线程负责刷盘。
关键设计点:
- 使用无锁队列(或高性能有锁队列)避免锁竞争
- 批量写入,减少系统调用次数
- 双缓冲机制,写线程和业务线程操作不同的缓冲区
// 简化版双缓冲日志系统
class Logger {
std::vector<std::string> buffer1_;
std::vector<std::string> buffer2_;
std::atomic<std::vector<std::string>*> current_;
void writeThread() {
while (running_) {
auto* buf = current_.exchange(&buffer1_);
// 批量写入文件
for (auto& msg : *buf) {
fwrite(msg.data(), 1, msg.size(), file_);
}
buf->clear();
fflush(file_);
}
}
};
我的经验:缓冲区大小要调优。太小了刷盘频繁,影响吞吐;太大了内存占用高,崩溃时丢的日志也多。我一般设成4MB左右,你可以根据日志量调整。
30.1.3 避坑指南
我曾经踩过一个坑:日志系统里用了std::mutex保护队列,结果高并发下锁竞争严重,性能直接腰斩。后来换成std::atomic + CAS操作,吞吐量提升了3倍。
注意:日志系统里不要做任何IO操作(除了写线程)。格式化字符串、获取时间戳这些操作,尽量在业务线程完成,或者用线程局部缓存。
30.2 并发Web服务器:从单线程到多线程的进化
Web服务器是并发编程的经典场景。你想想看,一个服务器要同时处理成千上万个连接,每个连接还有多个请求。怎么设计?
30.2.1 三种常见模型
| 模型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单线程Reactor | 简单,无锁 | 不能利用多核 | IO密集型,CPU负载低 |
| 多线程Reactor | 充分利用多核 | 需要处理共享状态 | 通用场景 |
| 多Reactor多线程 | 扩展性最好 | 实现复杂 | 高并发、高性能场景 |
我个人习惯用多线程Reactor模型。一个主线程负责accept新连接,然后把连接分发给多个工作线程。每个工作线程有自己的事件循环,处理读写请求。
// 多线程Reactor核心结构
class TcpServer {
std::vector<EventLoop*> workers_;
EventLoop* mainLoop_;
void onNewConnection(int fd) {
// 轮询选择一个工作线程
auto* loop = workers_[next_++ % workers_.size()];
loop->addEvent(fd, EPOLLIN);
}
};
关键点:连接分配要均匀。我用的是简单的轮询,但如果你有长连接和短连接混合的场景,建议用最少连接数分配策略。
30.2.2 性能优化技巧
- 使用epoll(Linux)或IOCP(Windows):不要用select/poll,性能差太多
- 减少内存拷贝:用零拷贝技术(如sendfile)传输静态文件
- 连接池:避免频繁创建销毁连接对象
- 超时处理:用时间轮或小根堆管理超时,不要遍历所有连接
避坑:我曾经在epoll里直接处理业务逻辑,结果一个慢请求阻塞了整个事件循环。记住:事件循环里只做IO操作,业务逻辑扔到线程池里处理。
30.3 并发计算框架:让CPU跑满
最后说说计算密集型场景。比如图像处理、科学计算、机器学习推理。这类任务的特点是:CPU是瓶颈,IO很少。
30.3.1 设计思路
说白了就是分而治之。把大任务拆成小任务,扔到线程池里并行执行。
// 简单的并行计算框架
class ParallelExecutor {
ThreadPool pool_;
template<typename Func>
void parallelFor(size_t start, size_t end, Func func) {
size_t chunk = (end - start) / pool_.size();
for (size_t i = 0; i < pool_.size(); ++i) {
size_t s = start + i * chunk;
size_t e = (i == pool_.size() - 1) ? end : s + chunk;
pool_.enqueue([s, e, &func]() {
for (size_t j = s; j < e; ++j) func(j);
});
}
}
};
注意:任务切分要均匀。如果任务粒度不均匀,会出现「长尾效应」——大部分线程都跑完了,就剩一个还在跑。我一般用std::atomic<size_t>做工作窃取,动态分配任务。
30.3.2 数据局部性优化
嗯,这里要注意。现代CPU有三级缓存,如果线程频繁访问不同核的缓存行,性能会急剧下降。这就是伪共享问题。
// 伪共享示例(不要这样写!)
struct alignas(64) AlignedData {
int value; // 每个线程操作自己的value
char padding[60]; // 填充到缓存行大小
};
AlignedData data[4]; // 每个线程访问不同的元素
我的经验:用alignas(64)或__attribute__((aligned(64)))来避免伪共享。但别过度使用,填充太多会浪费内存。
30.4 三个系统的对比与总结
| 系统 | 核心模式 | 主要挑战 | 关键优化点 |
|---|---|---|---|
| 日志系统 | 生产者-消费者 | IO瓶颈、可靠性 | 批量写入、双缓冲 |
| Web服务器 | Reactor | 连接管理、超时 | epoll、零拷贝 |
| 计算框架 | 分治+线程池 | 负载均衡、伪共享 | 工作窃取、缓存优化 |
这三个系统,其实背后都是同一个道理:找到瓶颈,然后用并发去解决它。日志系统的瓶颈在IO,Web服务器的瓶颈在网络,计算框架的瓶颈在CPU。不同的瓶颈,需要不同的并发策略。
最后说一句:并发编程没有银弹。不要为了用并发而用并发。我见过太多项目,加了一堆线程,性能反而下降了。先分析瓶颈,再设计并发方案,这才是正道。
写在最后:这三套系统,你可以在自己的项目里反复实践。刚开始可能写得很粗糙,没关系。慢慢优化,慢慢打磨。并发编程就是这样,踩的坑多了,自然就懂了。