外观
同步、缓存一致性与内存序
并行程序需要同时满足两种要求:语言层面不存在未同步的冲突访问,硬件层面尽量少为共享数据付出通信成本。原子操作能帮助建立前一种保证,但仍可能因为争用很慢。把变量拆成每线程一份能减少逻辑争用,却仍可能留下假共享。
cache coherence 管什么
多个核心可能缓存同一内存地址。缓存一致性协议协调同一位置的写入与副本,使处理器能够提供规定的可见性行为。用简化 MESI 术语思考:一条行可以处于 Modified、Exclusive、Shared、Invalid 等状态,某个核心写共享行时需要获得相应权限,其他副本可能失效。
这个模型帮助解释“为什么写不同变量也会互相干扰”,但不能代替具体硬件协议。真实处理器包含不同层次、目录、缓冲和优化;也不是每次写入都立即把整条数据送到 DRAM。缓存一致性与内存一致性不同:前者侧重同一位置的协调,后者还规定不同位置访问在其他执行流看来允许怎样排序。
false sharing:没有数据竞争也可能慢
假设线程 0 写 counter[0],线程 1 写 counter[1],它们是不同对象,却落在同一缓存行。每个线程取得写权限时可能使另一个核心的该行副本失效,于是硬件在传递整条行的所有权。这叫假共享,因为程序逻辑并没有共享同一计数器。
仓库 false_sharing.cpp 选择原子计数,使每次增加确实存在,避免编译器把普通局部累加优化成最后一次写回。每线程仍只操作自己的槽位:packed 间距为 8 字节,padded128 间距与对齐为 128 字节。两者的操作次数和原子语义一致,改变的是布局。
cpp
struct Packed { std::atomic<std::uint64_t> value{0}; };
struct alignas(128) Padded { std::atomic<std::uint64_t> value{0}; };每个计数器最终必须恰好等于迭代数。不要把加 padding 当成正确性修复:两种版本本来就正确;也不要把 128 当作所有 CPU 的固定缓存行大小。本次没有读取到硬件缓存行参数,没有用性能计数器证明确切流量来源。
cpp
// Same independent atomic increments, two memory layouts. No data race.
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
struct Packed { std::atomic<std::uint64_t> value{0}; };
struct alignas(128) Padded { std::atomic<std::uint64_t> value{0}; };
using Clock = std::chrono::steady_clock;
static std::size_t number(const char *text, std::size_t maximum) {
std::string s(text);
if (s.empty() || s.find_first_not_of("0123456789") != std::string::npos)
throw std::invalid_argument("positive decimal integers required");
auto value = std::stoull(s);
if (!value || value > maximum) throw std::invalid_argument("argument outside supported range");
return static_cast<std::size_t>(value);
}
template<class Slot>
static void run(const char *name, std::size_t workers, std::size_t iterations, std::size_t repeats) {
auto slots = std::make_unique<Slot[]>(workers);
std::vector<double> times;
for (std::size_t trial = 0; trial <= repeats; ++trial) {
for (std::size_t id = 0; id < workers; ++id) slots[id].value.store(0, std::memory_order_relaxed);
auto begin = Clock::now();
std::vector<std::thread> threads;
threads.reserve(workers);
try {
for (std::size_t id = 0; id < workers; ++id) threads.emplace_back([&, id] {
for (std::size_t i = 0; i < iterations; ++i)
slots[id].value.fetch_add(1, std::memory_order_relaxed);
});
} catch (...) {
for (auto &thread : threads) thread.join();
throw;
}
for (auto &thread : threads) thread.join();
auto end = Clock::now();
for (std::size_t id = 0; id < workers; ++id)
if (slots[id].value.load(std::memory_order_relaxed) != iterations)
throw std::runtime_error("counter mismatch");
if (trial) times.push_back(std::chrono::duration<double, std::milli>(end-begin).count());
}
std::sort(times.begin(), times.end());
double median = times[times.size()/2];
if (times.size()%2 == 0) median = (times[times.size()/2-1] + median)/2;
std::cout << name << ',' << sizeof(Slot) << ',' << alignof(Slot) << ',' << workers << ','
<< iterations << ',' << repeats << ',' << times.front() << ',' << median << ','
<< times.back() << ',' << iterations * workers << '\n';
}
int main(int argc, char **argv) {
try {
if (argc > 4) throw std::invalid_argument("usage: false_sharing [threads [iterations [repeats]]]");
std::size_t workers = argc > 1 ? number(argv[1], 128) : 4;
std::size_t iterations = argc > 2 ? number(argv[2], 100000000) : 1000000;
std::size_t repeats = argc > 3 ? number(argv[3], 31) : 5;
std::cout << "layout,stride,alignment,threads,iterations,repeats,min_ms,median_ms,max_ms,total\n"
<< std::fixed << std::setprecision(6);
run<Packed>("packed", workers, iterations, repeats);
run<Padded>("padded128", workers, iterations, repeats);
std::cerr << "all per-thread counters matched; timings include launch/join; no affinity set\n";
} catch (const std::exception &error) { std::cerr << error.what() << '\n'; return 1; }
}原子性不自动发布其他数据
考虑一次性发布消息:
cpp
int payload = 0;
std::atomic<bool> ready{false};
// 生产者,只执行一次:
payload = 42;
ready.store(true, std::memory_order_release);
// 消费者:
while (!ready.load(std::memory_order_acquire)) { }
use(payload);当 acquire load 观察到对应 release store 的 true,生产者先前写 payload 的动作就通过这条同步关系对消费者可见。payload 可以是普通变量,因为这里建立了必要顺序;此例限定一次写入与读取,不能直接扩展成无锁循环队列。
如果把两端都改为 relaxed,ready 本身的访问仍原子,但它不再负责发布普通 payload。消费者访问 payload 可能与生产者缺少 happens-before 关系,构成数据竞争。关键问题不是“编译器能不能重排这两行”,而是 C++ 内存模型是否建立足够的跨线程顺序。C++17 工作草案
为什么任务计数器可以 relaxed
benchmark.cpp 的 next 只需要唯一地分发区间。fetch_add 的原子读改写保证每次领取的起点不同,没有通过这个计数器传递另一线程刚生产的 payload。输入在启动线程前准备,结果写入互不重叠元素;join 后主线程才读取完整结果。因此可以对 next 使用 relaxed,而把输出可见性交给线程启动与 join 的同步关系。
这不是“计数器都可以 relaxed”的规则。如果计数器表示“已有 N 个结果准备好了”,另一个线程看到 N 后立刻读取这些结果,就同时承担了发布功能,需要重新设计同步协议。
锁、细粒度与无锁的代价
一把互斥锁可以方便地保护多个字段的不变量。细化锁能增加并行度,却需要处理加锁顺序、跨对象操作和生命周期。无锁算法避免某些阻塞问题,但并不意味着没有同步开销、每线程都能立即完成,或代码更容易验证。
经典 ABA 问题说明只比较指针值可能不够:线程看到头指针 A,另一线程把 A 移除、经过 B 后又恢复地址 A,第一次线程的 CAS 看到地址没变,却不知道对象状态已经变化。版本标记只能解决部分设计,安全内存回收还需专门协议。不要把学习 CAS 指令等同于能安全实现无锁容器。
实验与判断
运行 ./labs/parallel/false_sharing 4 1000000 5,先检查总计数 4000000,再比较时间分布。然后试 1、2、4 个线程:若 1 线程时差异较小、多线程时差异扩大,这是与共享行通信解释一致的证据,但仍需硬件计数器才能更精确定位。性能测试必须用普通优化构建,不使用 sanitizer 时间下结论。
自测:数据竞争与假共享为什么不能混称?
数据竞争是语言层面未同步的冲突访问,可导致未定义行为;假共享是不同对象因同一缓存行受到性能影响,程序可以完全正确。前者需要同步协议,后者可能通过布局或减少写频率优化。
自测:默认 seq_cst 总是最佳选择吗?
它提供更强、更容易开始推理的顺序,但未必成本最低。应先建立正确算法与同步需求,再有证据地减弱顺序;不能为猜测的性能收益随意改成 relaxed。