从单机并发到分布式
整卷我们都在讲同一台机器上的并发——一个进程里的多线程怎么安全地共享数据、怎么用原子操作做无锁同步、怎么用协程把异步代码写好看。这些知识非常扎实,但它们都建立在一个隐含的前提上:所有线程共享同一块内存,跑在同一个操作系统上,由同一个调度器管理。
现实是残酷的。当你的服务需要处理更多的请求、存储更多的数据时,一台机器迟早是不够的——不管是 CPU 算力、内存容量还是网络带宽,总有一个维度会先碰到天花板。你不得不把服务部署到多台机器上,让它们协同工作。这时候,"并发"的问题就从进程内扩展到了网络上。你面对的不再是一个 std::mutex,而是一个跨网络的锁协调服务;不再是 std::atomic,而是一组需要就某个值达成一致的分布式副本。
这一篇我们要聊的是:当你从单机走向分布式时,并发模型发生了什么根本性的变化。我们会看到,很多在单机上理所当然的假设——比如"消息一定到达"、"时钟一定是准的"、"一个操作要么成功要么失败"——在分布式环境下全部不成立。这不是为了吓唬你,而是为了让你在面对分布式系统时,有一个清晰的认知框架,知道哪些旧经验还能用,哪些必须重新思考。
单机与分布式的五大根本差异
我们先把最关键的差异摊开来,逐个看。
局部失败:别人崩了,你还活着
在单机上,如果一个线程因为未捕获的异常或者段错误崩了,通常整个进程都会被操作系统干掉——进程是资源隔离的基本单位,线程不是。你可以用 std::jthread(C++20 引入的自动 join 线程)或者写一个全局的信号处理函数来做一些善后,但本质上,进程内的所有线程共享同一个命运:要么都活着,要么都死了。
分布式系统完全不是这样。你有 10 台机器,其中 3 台突然断电(现实中这种情况比你想象的常见得多),剩下的 7 台还得继续服务。这就引出了一个单机上几乎不存在的问题:部分失败(partial failure)。一个操作可能在一部分机器上成功了,在另一部分机器上失败了——你该怎么处理?你能安全地重试吗?你需不需要回滚已经成功的那部分?
更棘手的是,你甚至不总是能确定对方到底有没有崩溃。发一个请求过去,超时了——是对方真的挂了,还是只是网络慢?是请求没到达,还是响应没回来?这种不确定性是分布式系统最让人头疼的地方。Jim Gray 在他关于容错系统的经典论述中把这类"观测时消失"的间歇性故障称为"Heisenbugs"——你挂上调试器想复现的时候它可能就不见了,因为网络恰好恢复了。
网络不可靠:共享内存的幻觉消失了
在单机上,线程通过共享内存通信。你写一个变量,另一个线程立刻就能读到(当然要考虑缓存一致性,但在正确使用 std::atomic 和内存序的前提下,这种行为是可预测的)。CPU 的缓存一致性协议(MESI 及其变体)保证了这一点。本质上,共享内存是一个可靠、有序、延迟极低的通信通道。
网络不是。消息可能延迟(而且延迟的时间可以非常不确定,从几毫秒到几秒都有可能),可能丢失(网络交换机丢包、TCP 重传超时),可能重复(应用层重试导致的),甚至可能乱序到达(走了不同的路由路径)。TCP 解决了一部分问题——它保证了字节流的可靠有序传输——但它解决不了所有问题:如果对方进程崩溃了,TCP 连接断开,你的"可靠传输"也就到头了。更别说很多分布式协议直接跑在 UDP 上,可靠性完全要自己在应用层保证。
这个差异的后果是深远的:在单机上,你可以假设一次函数调用要么返回结果要么抛异常,二选一;在分布式环境下,一次远程调用可能返回结果,也可能超时,超时了你甚至不知道对方到底处理了没有。你的代码必须处理这第三种状态——"不知道"。
没有全局时钟:谁先谁后说不清
单机上,你可以用一个 std::atomic<uint64_t> 做全局序号发生器,所有的操作按序号排序,谁序号小谁先发生。memory_order_seq_cst 的语义配合缓存一致性协议保证了所有核心看到的序号是一致的(我们在 ch03 深入讨论过这个话题)。
分布式系统没有这样的奢侈品。每台机器都有自己的本地时钟,而这些时钟是有偏差的。即使你用 NTP(Network Time Protocol)做时钟同步,典型情况下也只能做到毫秒级别的精度,而且时钟会漂移。Google 的 TrueTime 服务(用在 Spanner 里)通过 GPS 和原子钟做到了更精确的时钟同步,但那是极其昂贵的基础设施,不是谁都能用的。
没有全局时钟的后果是:你很难判断两个分别发生在不同机器上的事件谁先谁后。在单机上,事件的时间戳是明确的;在分布式环境下,两个事件的时间戳可能互相矛盾——A 机器说它的操作发生在 10:00:00.100,B 机器说它的操作发生在 10:00:00.099,但实际上 A 的操作可能比 B 早发生(因为 A 的时钟快了 2ms)。这就是为什么分布式系统需要用逻辑时钟(Lamport 时钟、向量时钟)来建立因果序,而不是依赖物理时间。
延迟量级变化:从纳秒到毫秒
让我们用具体的数字说话。这是每一个做系统的人都应该刻在脑子里的数字:
| 操作 | 典型延迟 |
|---|---|
| L1 缓存访问 | ~1 ns |
| L2 缓存访问 | ~5 ns |
| 主内存访问 | ~100 ns |
| 同机房网络往返 | ~500,000 ns (0.5 ms) |
| 同城网络往返 | ~1-2 ms |
| 跨国网络往返 | ~50-80 ms |
主内存访问大约 100 纳秒,同机房网络往返大约 0.5 毫秒——差了差不多 5000 倍,三个数量级。如果是跨国网络,差距更大。Jeff Dean 和 Peter Norvig 最早整理了这些延迟数据,Jonas Bonér 将它们汇总成了广为流传的参考表。社区基于这些数据做了一个非常直观的类比:如果把 L1 缓存访问比作伸手到桌子上拿一支笔(1 秒),那么一次数据中心网络往返相当于徒步 94 英里(约 150 公里)。这不是一个量级的变化,这是世界观的改变。
这个延迟差异意味着什么?意味着你在单机上做的很多优化——比如减少一次缓存行的争用——在分布式场景下可能完全不重要。你的瓶颈在网络上,而不在内存上。同样,分布式系统中的每一个网络往返都极其昂贵,所以你会看到分布式协议倾向于用批处理(batching)和流水线(pipelining)来摊薄单次请求的成本。
一致性成本:从加锁到共识
在单机上,保护共享数据的一个标准做法是加锁——std::mutex、std::shared_mutex、或者无锁的 std::atomic。这些操作的成本是纳秒级的(lock/unlock 通常在几十到几百纳秒),而且语义非常清晰:锁住、操作、解锁,三步走。
在分布式环境下,如果你想让多台机器上的副本就某个值达成一致,你需要的是共识协议(consensus protocol)——比如 Paxos 或者 Raft。这些协议需要多轮网络通信、多数派投票、日志复制……每一次"共识"的成本是毫秒级的,比单机加锁贵了四到六个数量级。而且实现起来远比 mutex 复杂——一个 Paxos 实现的正确性足以发一篇 SOSP 论文。
这不是说分布式系统就一定比单机慢。分布式系统的价值在于横向扩展——你可以通过增加机器来提升吞吐量。但每次需要强一致性的操作,都受限于共识协议的延迟。这就是为什么分布式系统设计中的一个核心问题就是:哪些操作需要强一致性,哪些可以接受弱一致性?
从 mutex 到分布式锁
理解了上述差异之后,我们来看一个具体的例子:如何把单机上的"互斥锁"搬到一个分布式环境里。
单机 mutex 的假设
一个 std::mutex 之所以能工作,是因为它依赖一整套在单机上理所当然的假设——所有线程共享同一块内存,所有线程由同一个操作系统调度,锁的持有者一定还活着(如果它死了,整个进程都死了,锁的问题也就不存在了)。这些假设在单机上是成立的。
分布式环境下,这些假设一个都不成立:多个进程跑在不同的机器上、各自有独立的调度器、一个进程可能随时崩溃而其他进程继续运行。所以当你需要一个跨机器的互斥锁时,必须用完全不同的方式来实现。
基于 Redis 的分布式锁
最简单也最常见的分布式锁实现是基于 Redis 的。核心思路是用 Redis 的 SET key value NX PX timeout 命令——NX 表示"只在 key 不存在时设置"(即加锁),PX 设置过期时间(即锁的超时保护)。value 通常是一个唯一标识符(比如 UUID),用来标识锁的持有者,防止误解锁。
让我们看一个用 C++ 通过 hiredis 库实现的简单分布式锁。
首先是加锁的逻辑:
#include <string>
#include <chrono>
#include <random>
/// @brief 基于 Redis 的简单分布式锁
class RedisDistributedLock {
public:
RedisDistributedLock(redisContext* context,
const std::string& lock_key,
int timeout_ms)
: context_(context)
, lock_key_(lock_key)
, timeout_ms_(timeout_ms)
, token_(generate_token())
, locked_(false)
{}
/// @brief 尝试获取锁,成功返回 true
bool try_acquire()
{
// SET lock_key token NX PX timeout
// NX: 只在 key 不存在时设置
// PX: 设置过期时间(毫秒)
// 使用 hiredis 的 %s 格式化参数来避免注入风险
auto* reply = static_cast<redisReply*>(
redisCommand(context_, "SET %s %s NX PX %d",
lock_key_.c_str(), token_.c_str(), timeout_ms_));
if (reply == nullptr) {
return false;
}
bool success = (reply->type == REDIS_REPLY_STATUS
&& std::string(reply->str) == "OK");
freeReplyObject(reply);
locked_ = success;
return success;
}
/// @brief 释放锁(只有持有者才能释放)
void release()
{
if (!locked_) {
return;
}
// 用 Lua 脚本保证原子性:
// 只有当 key 的值等于我们的 token 时才删除
// 防止误解锁别人的锁
const char* lua_script = R"(
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
)";
auto* reply = static_cast<redisReply*>(
redisCommand(context_,
"EVAL %s 1 %s %s",
lua_script, lock_key_.c_str(), token_.c_str()));
if (reply != nullptr) {
freeReplyObject(reply);
}
locked_ = false;
}
~RedisDistributedLock()
{
// RAII: 析构时自动释放锁
release();
}
private:
/// @brief 生成唯一的锁持有者标识
static std::string generate_token()
{
// 用随机数 + 时间戳生成唯一 token
std::random_device rd;
std::mt19937_64 gen(rd());
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
return std::to_string(now) + "-" + std::to_string(gen());
}
redisContext* context_;
std::string lock_key_;
int timeout_ms_;
std::string token_;
bool locked_;
};我们先看加锁部分。try_acquire() 通过 hiredis 的格式化接口发送 SET lock_key token NX PX timeout 命令,这里有几个关键点。首先要注意的是,我们用 hiredis 的 %s 占位符来传递参数,而不是手动拼接字符串——如果你直接把 key 和 token 拼进命令字符串,一旦 key 里包含空格或特殊字符,就可能导致命令注入问题。然后是 NX 选项,它保证只有当 key 不存在时才设置成功,这就是互斥的来源——谁先设成功谁就拿到锁。PX timeout 设置了过期时间,这是一个安全网:如果锁的持有者崩溃了(进程挂了、机器断电了),锁会在超时后自动释放,不会永远被占着。最后,value 用的是一个唯一 token 而不是简单的字符串,这个 token 标识了锁的持有者。
释放锁的部分更微妙,我们用了 Lua 脚本来保证"检查 token 然后删除 key"这两步的原子性。为什么要这么做?因为如果分成两步(先 GET 判断、再 DEL 删除),中间可能被别的操作插入——你的 GET 确认了这是你的锁,但在 DEL 之前锁恰好超时了、被别人获取了,你的 DEL 就把别人的锁给删了。Lua 脚本在 Redis 中是原子执行的,避免了这个问题。
使用方式非常简洁:
void do_synchronized_work(redisContext* redis)
{
// 尝试获取分布式锁,超时 5 秒
RedisDistributedLock lock(redis, "my_resource_lock", 5000);
if (!lock.try_acquire()) {
// 没拿到锁,说明有别人在操作
std::cerr << "获取分布式锁失败,稍后重试\n";
return;
}
// 拿到锁了,安全地操作共享资源
// ...
// 离开作用域时,析构函数自动释放锁(RAII)
}很好,到这里看起来一切都很完美。但事情到这里远远没有结束——真正的坑在后面。
分布式锁的本质困境
上面的实现有什么问题?很多。
第一个问题:锁超时和 GC 停顿。 假设锁的超时是 5 秒,你的进程拿到锁之后做了一次耗时的 GC(如果你跑的是 Java,Stop-The-World 停顿可以达到秒级),或者被操作系统的调度器挂起了(C++ 程序不会 GC,但你可能遇到页交换、CPU 争用),5 秒之后 Redis 上的锁超时了、被别人拿走了。等你的进程恢复执行,它还以为自己是锁的持有者——两个进程同时操作共享资源,互斥被打破了。
第二个问题:Redlock 也不够安全。 Redis 的作者 Salvatore Sanfilippo 提出了 Redlock 算法——用多个独立的 Redis 实例做分布式锁,客户端需要在多数派(N/2 + 1)实例上都成功获取锁才算成功。但 Martin Kleppmann(对,就是写《Designing Data-Intensive Applications》的那位)写了一篇非常有名的文章 How to do distributed locking 来反驳这个方案。他的核心论点是:Redlock 的安全性依赖于时钟同步的假设——它假设各 Redis 节点的时钟偏差是有限的。但分布式系统的时钟是不可靠的(我们前面已经说过),所以这个假设在极端情况下会被打破。更关键的是,Redlock 没有提供围栏令牌(fencing token)——一个单调递增的数字,让资源本身能判断哪个锁持有者是更新的。
⚠️ 踩坑预警 如果你用 Redis 做分布式锁,请务必理解它的适用场景:效率优先的场景(比如防止重复计算、限流)是可以的;正确性优先的场景(比如金融转账、库存扣减),Redis 分布式锁不够安全,应该用基于共识协议的锁服务。
第三个问题:分布式锁和 mutex 本质不同。 std::mutex 提供的是绝对的互斥保证——只要锁被持有,其他线程绝对进不来(除非你有 bug)。分布式锁做不到这一点——它只能提供"在大多数情况下的互斥",但在网络分区、时钟漂移、进程暂停等极端情况下,互斥可能被打破。这不是实现的问题,这是分布式系统的根本限制。
所以如果你需要强保证,应该用 ZooKeeper 或者 etcd 这样的基于共识协议的协调服务。它们用 ZAB(ZooKeeper)或 Raft(etcd)协议来保证一致性,配合临时节点(ephemeral node)和监听器(watcher)来实现分布式锁——客户端会话断开时临时节点自动删除,比 Redis 的超时机制更可靠。同时它们天然支持围栏令牌(通过数据的版本号或者 ZXID),可以避免上面提到的过期锁问题。
Redis vs ZooKeeper/etcd 分布式锁对比
我们把上面讨论的关键差异汇总成一张表,方便你根据实际场景做选型:
| 维度 | Redis (单实例/Redlock) | ZooKeeper / etcd |
|---|---|---|
| 一致性模型 | 异步复制,可能丢数据 | 共识协议(ZAB/Raft),强一致 |
| 锁的安全性 | 依赖时钟,不够安全 | 共识保证,可配合 fencing token |
| 性能 | 极高(内存操作) | 较低(需要多数派确认) |
| 运维复杂度 | 低 | 高(需要维护共识集群) |
| 适用场景 | 效率优先(防重复、限流) | 正确性优先(金融、库存) |
总结一下:分布式锁是一个有用的工具,但它不是 std::mutex 的等价替代品。在分布式环境下,"互斥"从一个确定性的保证变成了一个概率性的保证——你需要根据业务需求选择合适的工具,并且在设计上容忍极端情况下的不一致,或者用 fencing token 这样的机制来做兜底保护。
CAP 定理的工程直觉
聊分布式系统绕不开 CAP 定理。这个由 Eric Brewer 在 2000 年提出的猜想(2002 年被 Seth Gilbert 和 Nancy Lynch 证明)是分布式系统设计的基本约束。我们先不急着给定义,而是用一个场景来理解。
三个属性分别是什么
先说一致性(Consistency)。它要求所有客户端在任何时刻看到的都是同一份数据——你往节点 A 写入了一个值,立刻去读节点 B,应该能读到最新的值。这不是说"最终会一致",而是"随时都一致",这是最强的一致性保证,等价于线性一致性(linearizability)。
再说可用性(Availability)。它要求每一个请求都能收到一个非错误的响应——系统不拒绝服务,也不返回错误,哪怕网络出了问题,每一台存活的服务器都会尽力回答你的请求。注意,可用性只关心"能不能得到响应",至于响应里的数据是不是最新的——那是一致性要管的事。
最后是分区容忍(Partition Tolerance)。当网络分区发生时(一部分机器之间无法通信),系统仍然能继续工作。在分布式系统里,网络分区不是"会不会发生"的问题,而是"什么时候发生"的问题——网络总是不可靠的,所以分区容忍基本上是必选项。
为什么三者不可兼得
CAP 定理说的是:在一个分布式系统中,当网络分区发生时,你只能选择一致性(C)或者可用性(A),不能同时保证两者。
为什么?用一个具体的场景来说明。假设你有两台服务器 S1 和 S2,各自保存了一份数据副本。正常情况下,S1 收到写入后会同步到 S2,两边的读请求都能返回最新数据。现在网络分区了——S1 和 S2 之间无法通信。
这时候一个客户端向 S1 发起了写入请求。S1 有两个选择:
如果 S1 选择接受写入但无法同步到 S2,那么 S1 上有了新数据,S2 上还是旧数据。此时 S2 上的读请求会返回旧数据——一致性被打破了,但可用性保住了(S2 没有拒绝服务)。这就是选择了 AP。
如果 S1 选择拒绝写入(因为无法同步到 S2),那么一致性保住了(没有只在一半节点上生效的写入),但可用性被打破了(客户端收到了错误响应)。这就是选择了 CP。
不可能有第三个选项。你不可能在无法同步的情况下既接受写入又保证一致性——这在逻辑上就是矛盾的。
CP 和 AP 的选择
理解了 CAP 的核心思想之后,我们来看几个实际的系统选择。
一个典型的 CP 系统是 ZooKeeper。当发生网络分区时,如果 ZooKeeper 集群无法达到多数派(quorum),它会拒绝服务——宁可不可用,也不能返回不一致的数据。这对于它作为协调服务(存储配置、做 Leader 选举、提供分布式锁)的角色是合理的:这些场景对正确性的要求极高,宁可短暂不可用也不能出错。
另一边,Cassandra 是 AP 系统的代表。它的设计理念是"永远可用"——即使网络分区了,每个节点仍然接受读写请求,只不过可能返回旧数据。等网络恢复后,通过后台的读修复(read repair)和反熵(anti-entropy)机制来让副本最终一致。这对于很多互联网应用是合理的:社交媒体上的一秒钟延迟(看到旧数据)比"服务不可用"好得多。
⚠️ 踩坑预警 不要把 CAP 看成一个非此即彼的二元选择。实际上,在绝大多数时间里网络是正常的(没有分区),系统可以同时提供较好的一致性和可用性。CAP 只是在网络分区的极端情况下告诉你必须二选一。很多现代系统支持在不同的操作、不同的配置级别上做不同的选择——比如你可以配置 Cassandra 为 QUORUM 读写(偏一致)或 ONE 读写(偏可用)。
从线程间通信到网络通信
回过头来看,单机并发和分布式并发的差异虽然巨大,但从通信模型的角度看,有一个非常优雅的过渡。
在单机上,线程之间最自然的通信方式是共享内存 + 锁——这也是我们整卷大部分内容讨论的模型。但你可能还记得,在 ch07 我们讨论了 Actor 模型和 CSP/Channel 模型。这些模型的核心思想是:不通过共享内存来通信,而通过通信来共享内存(Don't communicate by sharing memory; instead, share memory by communicating)。
这个思想在分布式环境下更加重要。分布式系统没有共享内存——你不能让两台机器上的进程共享一个 std::mutex。它们只能通过网络消息来协调。所以 Actor 模型和 CSP 模型天然就是为分布式场景设计的:一个 Actor 可以在本地,也可以在远程机器上;消息可以是进程内的函数调用,也可以是网络上的 RPC 请求。从编程模型的角度看,它们没有本质区别。
这就是为什么很多分布式系统框架选择了 Actor 模型(比如 Akka、Orleans)——它把"本地还是远程"这个决策推迟到了部署阶段,而不是硬编码在程序逻辑里。你在本地写一个 Actor 的消息处理逻辑,部署的时候把它放到不同的机器上,代码几乎不需要改动。
在现代 C++ 生态中,连接"并发"和"分布式"的关键基础设施是 RPC 框架,其中最主流的是 gRPC。gRPC 使用 Protocol Buffers 定义服务和消息格式,自动生成客户端和服务端的桩代码(stub),底层用 HTTP/2 做传输,支持流式通信。它本质上就是一个跨网络的"函数调用"——你调用一个远程方法,就像调用一个本地函数一样(当然,语义上有重要的区别,比如超时和重试)。
从并发模型的角度看,gRPC 的每一次调用都可以看作是一个 Actor 之间的消息传递:客户端 Actor 发送请求消息,服务端 Actor 接收消息、处理、返回响应消息。我们用 C++20 协程包装 gRPC 的异步 API(这个在下一篇会展示),就能用一种非常自然的方式写出分布式并发代码——跟写本地协程几乎一样的结构,只是底层的传输从函数调用变成了网络请求。
我们的位置
这一篇我们做了一件非常重要的事情:建立单机并发和分布式系统之间的认知桥梁。我们看到了五大根本差异——局部失败、网络不可靠、没有全局时钟、延迟量级变化、一致性成本暴涨——每一个差异都深刻影响着并发模型的选择。我们通过分布式锁这个具体案例,理解了从 std::mutex 到 Redis 再到 ZooKeeper/etcd 的演进脉络,也明白了"分布式锁不是 mutex 的等价替代品"这个关键洞察。CAP 定理给了我们在分布式设计中的基本约束框架,而 Actor/Channel 模型则提供了从单机并发平滑过渡到分布式并发的编程范式。
但理解差异只是第一步。下一篇我们要进入分布式系统的核心难题——一致性。当多台机器上的副本需要就某个值达成一致时,事情远比"加个锁"复杂得多。我们会看到从线性一致性到最终一致性的完整谱系,了解 Paxos/Raft 这些共识协议的核心思想,并用 gRPC + C++20 协程展示在 C++ 中写分布式通信代码的方向。
参考资源
- Designing Data-Intensive Applications — Martin Kleppmann — 分布式系统领域公认最好的入门书籍,CAP、一致性、共识协议讲得非常透彻
- CAP Theorem — Wikipedia — CAP 定理的正式定义与历史
- How to do distributed locking — Martin Kleppmann — 对 Redlock 的经典反驳,引入了 fencing token 的概念
- Latency Numbers Every Programmer Should Know — Jonas Bonér — 各种操作延迟的直观对比(原始数据来自 Jeff Dean / Peter Norvig)
- Is Redlock safe? — Salvatore Sanfilippo (antirez) — Redis 作者对 Kleppmann 批评的回应
- Raft Consensus Algorithm — Raft 协议的官方资源,包含可视化演示