第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。不同的瓶颈,需要不同的并发策略。

最后说一句:并发编程没有银弹。不要为了用并发而用并发。我见过太多项目,加了一堆线程,性能反而下降了。先分析瓶颈,再设计并发方案,这才是正道。

高并发系统设计核心知识体系 日志系统 生产者-消费者模型 双缓冲机制 批量写入 无锁队列 异步刷盘 关键指标: 延迟 < 1μs 吞吐 > 50万条/秒 Web服务器 Reactor模式 多线程事件循环 epoll/IOCP 零拷贝传输 连接池管理 关键指标: QPS > 10万 连接数 > 1万 计算框架 分治策略 线程池 工作窃取 缓存优化 伪共享防护 关键指标: 加速比 > 7x (8核) 负载均衡 > 95% 核心原则:分析瓶颈 → 选择模式 → 优化细节 → 持续调优

写在最后:这三套系统,你可以在自己的项目里反复实践。刚开始可能写得很粗糙,没关系。慢慢优化,慢慢打磨。并发编程就是这样,踩的坑多了,自然就懂了。

公众号:蓝海资料掘金营,微信deep3321