Chapter 9:TDD and Threading

对齐原书 Chapter 9:以 GeoServer 和 ThreadPool 为主线,先驱动同步领域行为,再把性能需求转化为异步方案;使用 latch、状态谓词、客户端线程、压力与 sanitizer 暴露丢任务、竞态、死锁和串行伪装。

学习目标

  • 能把 GeoServer 的纯查询行为、任务队列、ThreadPool 生命周期和客户端并发分层测试,避免从端到端线程场景一次驱动全部设计
  • 能把吞吐、并发进度和关闭语义写成可观察性能/并发需求,并用 latch、barrier 与状态谓词替代固定 sleep
  • 能主动暴露丢任务、数据竞争、死锁和串行伪装,组合确定性调度测试、客户端线程、压力重复和 ThreadSanitizer 证据

并发测试先分离“算什么”和“何时算”

GeoServer 接收位置查询并返回地理结果。查询计算本身可以是同步确定函数;只有吞吐或响应需求证明单线程不足时,才引入异步队列和线程池。若从一开始把领域逻辑、socket、队列和多个线程塞进同一测试,任何失败都难以定位。

测试驱动线程不意味着调度可完全预测。策略是把可确定的领域逻辑留在同步层,把并发层的契约收窄为任务接受、进度、互斥不变量、关闭和错误传播,再用受控同步点探索关键交错。

GeoServer 先有同步正确性基线

先对查询解析、距离计算、找最近点和错误输入写普通单元测试。同步版本提供正确性 oracle:后续并发批量结果应与同一输入的同步结果集合一致。这样并发测试无需复制领域算法。

TEST(GeoQueryTest, FindsNearestLocation) {
    GeoIndex index{knownLocations()};
    EXPECT_EQ("north-station", index.nearestTo({31.23, 121.47}).id());
}

结果顺序若不是契约,就比较按 request id 关联的集合,而非完成顺序。并发实现常改变调度顺序,测试不应把偶然顺序当行为。

性能需求必须可测且不替代功能需求

“需要更快”不能驱动设计。写下基线与目标,例如:在 8 核参考环境、10000 个独立查询下,吞吐至少达到同步实现的 2 倍;队列满时 submit 在 10 ms 内返回拒绝;stop 后已接受任务全部完成并在 2 秒内 join。环境、负载和统计方法必须同时记录。

functional: 每个 accepted request 恰好产生一个 success 或 failure
progress:   至少两个阻塞任务能同时进入 started 状态
capacity:   队列满时 submit 返回 rejected,不无限阻塞
shutdown:   drain 模式完成已接受任务,cancel 模式明确报告取消
performance: p95 与 throughput 在固定基准环境持续记录

单元测试适合功能、进度和生命周期契约;精确毫秒阈值易受机器噪声影响,应放入基准/性能环境并看分布趋势。不要把 EXPECT_LT(elapsed, 5ms) 塞进普通 CI 后制造随机失败。

异步方案需要显式所有权和关闭协议

ThreadPool 至少需要回答:任务由谁拥有,队列满怎么办,异常如何传出,stop 是否 drain,析构是否 join,stop 与 submit 并发时哪个赢。没有这些契约,测试只能追逐偶发现象。

class ThreadPool {
public:
    explicit ThreadPool(std::size_t workerCount);
    ~ThreadPool();
 
    std::future<GeoResult> submit(GeoQuery query);
    void stop(StopMode mode = StopMode::Drain);
 
private:
    std::mutex mutex_;
    std::condition_variable ready_;
    std::queue<Task> tasks_;
    std::vector<std::jthread> workers_;
    State state_{State::Running};
};

接口示意把生命周期变成公开契约。实现可不同,但析构不能留下访问已销毁对象的线程;submit 在 stopped 状态应明确拒绝;任务异常应由 future 重抛或转为领域失败,不应终止 worker。

不用 sleep 判断线程“应该已经运行”

固定睡眠依赖机器速度和调度:时间太短会随机失败,太长拖慢套件,成功也不能证明目标交错发生。测试应等待明确事件,并由测试控制何时释放。

TEST(ThreadPoolTest, StartsTwoTasksConcurrently) {
    ThreadPool pool{2};
    std::mutex mutex;
    std::condition_variable startedChanged;
    int started = 0;
    std::latch release{1};
 
    auto task = [&] {
        {
            std::lock_guard lock{mutex};
            ++started;
        }
        startedChanged.notify_all();
        release.wait();
        return 1;
    };
 
    auto first = pool.submit(task);
    auto second = pool.submit(task);
    std::unique_lock lock{mutex};
    const bool ranConcurrently = startedChanged.wait_for(
        lock, std::chrono::seconds{1}, [&] { return started == 2; });
    lock.unlock();
    release.count_down();
    ASSERT_TRUE(ranConcurrently);
    EXPECT_EQ(1, first.get());
    EXPECT_EQ(1, second.get());
}

一秒只是防止测试永久挂起的失败上限,正确性来自状态谓词确认两个任务都已开始,而不是“睡一秒后大概开始了”。代码在断言前先释放 latch,保证失败路径也不会让 worker 永久阻塞;更复杂 fixture 可把释放和 join 封装进 RAII 清理。

等待谓词必须抵抗虚假唤醒

生产和测试都应使用带谓词的 condition variable 等待:在锁内检查队列非空或停止状态,循环处理虚假唤醒。测试不要断言一次 notify 就必定对应一个任务;调度器允许合并和提前通知。

ready_.wait(lock, [this] {
    return state_ != State::Running || !tasks_.empty();
});

谓词是共享状态不变量的可执行表达。先写停止空队列、运行非空队列和停止仍有待 drain 任务等测试,再实现分支。

客户端线程验证公共 API 的并发入口

worker 并发不等于 submit 线程安全。创建多个客户端线程,在 barrier 后同时提交带唯一 id 的任务,最终核对 accepted、completed 和 unique id 集合。测试应捕获线程内异常并回传主线程,否则子线程终止可能直接结束进程。

std::barrier start{clientCount};
std::vector<std::jthread> clients;
for (int client = 0; client < clientCount; ++client) {
    clients.emplace_back([&, client] {
        start.arrive_and_wait();
        for (int i = 0; i < tasksPerClient; ++i) {
            futures.add(pool.submit(queryFor(client, i)));
        }
    });
}

futures.add 本身也必须线程安全,或每个 client 保存局部结果后主线程合并。测试基础设施若有 race,会把自身缺陷误判为产品缺陷。

多 worker 的 ThreadPool 需要证明并行进度

只检查 100 个任务最终完成,单线程实现也能通过。要证明并行能力,阻塞第一个任务,确认第二个任务在第一个释放前已到达 started;要证明唯一执行,为每个 task id 使用原子计数或受锁集合,最终每个恰好一次。

线程池核心不变量包括:accepted = completed + failed + cancelled;队列中任务有唯一 owner;每个 worker 在锁外执行任务;stop 后不再接受;析构前所有 worker join。测试围绕不变量,而不是内部 vector 大小。

主动暴露并发问题

普通成功路径多跑一千次仍可能从未触发危险交错。针对共享状态,在关键点放 barrier:两个线程同时读取旧值,再同时写入;针对锁顺序,让两个路径各持一把锁后再继续;针对 submit/stop,固定二者交叉点并核对接受契约。

ThreadSanitizer 能发现数据 race,但不能证明无死锁、无丢任务或业务顺序正确;受控测试能证明特定契约,却不能枚举所有调度。两者与代码审查、压力/长稳测试互补。

cmake -S . -B build-tsan -DENABLE_TSAN=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build-tsan --parallel
ctest --test-dir build-tsan -L threading --output-on-failure

sanitizer 构建较慢,可在 CI 专门任务运行。报告必须视为失败并保存完整栈,不能因为“重跑没出现”而忽略。

时间测试与性能基准分开

功能测试使用 latch 与谓词,不比较微小时长;性能基准使用固定环境、预热、重复样本和分位统计。吞吐变好也不能证明结果正确,功能全绿也不能证明达成性能目标。

static void GeoQueries(benchmark::State& state) {
    GeoServer server{static_cast<std::size_t>(state.range(0))};
    for (auto _ : state) {
        runBatch(server, representativeQueries());
    }
}
BENCHMARK(GeoQueries)->Arg(1)->Arg(2)->Arg(4)->Arg(8);

线程数越多未必越快,锁竞争、缓存和调度开销会出现拐点。基准结果指导容量默认值,不要在单元测试里写死“8 线程必须比 4 线程快”。

错误、取消与关闭是线程设计的一部分

任务抛异常时 worker 应继续服务后续任务,错误通过 future 或结果通道返回。取消需要定义未开始与已开始任务的差别;强行终止线程会破坏 C++ 栈展开和资源安全。stop 要幂等,多个线程同时调用也应得到一致状态。

测试 stop 场景时用 latch 让一项任务运行、一项排队,再分别验证 Drain 与 CancelPending。不要依赖任务刚好处于某状态,测试必须先收到事件再调用 stop。

先预测:四类错误各需要什么探针

在查看实验图前,为丢任务、数据竞争、死锁和串行伪装各写一个不依赖 sleep 的探针。指出哪个用不变量计数,哪个用 sanitizer,哪个要双 barrier,哪个要阻塞首任务并观察第二任务 started。一个探针不能替代其他证据。

分步1 / 3

第一步:分层驱动同步行为与线程边界

用同步 Geo 查询建立正确性 oracle,再为队列容量、ThreadPool 生命周期和 GeoServer 请求编排分别写窄契约。

小结

  • 先把 GeoServer 的领域计算保持同步确定,再逐层测试队列、ThreadPool 与客户端并发
  • 性能需求要声明环境、负载和指标;功能、进度与关闭契约仍由确定性测试证明
  • 异步方案必须显式任务所有权、背压、异常、stop、cancel 和 join 语义
  • latch、barrier 与状态谓词控制事件,固定 sleep 既慢又不能证明目标交错
  • 多 worker 测试要证明同时进度和恰好一次执行,普通完成测试无法区分串行实现
  • 受控交错、客户端线程、压力和 ThreadSanitizer 互补,没有单一工具能证明并发正确

练习

  1. 问题 1:改写 sleep 测试。 测试 submit 两个任务后 sleep 100 ms,再断言计数为 2,CI 偶发失败。怎样重写?
  1. 问题 2:证明多 worker。 100 个任务最终都完成,为什么不能证明线程池并行?
  1. 问题 3:设计 submit/stop 测试。 要验证 drain 模式不丢已接受任务且 stop 后拒绝新任务,应怎样固定交错?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

线程测试驱动
以失败测试逐步建立线程边界、同步和生命周期契约的方法。
GeoServer
用于从同步地理查询演化到异步线程池的服务示例。
性能需求
以环境、负载和吞吐/延迟/容量等指标表达的可测契约。
异步方案
任务由队列和 worker 在当前调用栈之外执行并交付结果的设计。
受控同步测试
用事件和状态谓词观察控制线程交错的测试方式。
客户端线程测试
由多个调用线程同时使用公开 API、验证入口线程安全的测试。
多线程 ThreadPool
由多个 worker 提供并行进度并满足唯一执行与安全关闭的线程池。
暴露并发问题
用受控交错、重复、计数和 sanitizer 放大并发缺陷的策略。

讨论

评论区加载中…