文章 · 2026-02-25

票据锁:消除长尾延迟的公平艺术

在高并发系统的深水区,我们往往会发现一个反直觉的现象:最快的锁未必是最好的锁。

标准的自旋锁(Spinlock)通常基于 CAS (Compare-and-Swap) 竞争。虽然在低争用场景下极其高效,但在高负载下,它就像一群人在挤公交车——身强力壮(或者运气好)的线程总能抢先一步,而运气差的线程可能被饿死,导致系统的长尾延迟(Tail Latency)飙升。

今天我们来聊聊一种更"文明"的同步原语:票据锁 (Ticket Lock)。它通过引入排队机制,将无序的丛林法则变成了有序的 FIFO(先来先服务),并在 Zig 中展示如何利用内存布局优化来对抗虚假共享。

1. 为什么我们需要排队?

想象一下银行的柜台业务。如果采用标准自旋锁模式,所有人都会挤在柜台前。每当柜员喊"下一个",所有人同时冲上去抢椅子。结果是:

  1. 不公平:刚进门的人可能比等了一小时的人先抢到。
  2. 混乱:所有人的冲抢动作(CAS 操作)导致了大量的无效竞争,消耗了巨大的总线带宽。

票据锁引入了两个计数器:

线程领到号后,只需要盯着 current_ticket 看。一旦屏幕上的数字等于手中的号码,就轮到它了。

这种机制带来的核心价值是 可预测的公平性。在高并发场景下,它消除了线程饥饿,让 P99 延迟变得平滑可控。

2. 基础参考:C++20 实现

在审视缓存感知版本之前,先看看最朴素的实现有助于理解核心机制。这个 C++20 版本清晰地展示了双计数器逻辑,未作任何内存布局约束:

#include <atomic>
#include <thread>

class TicketLock {
    // 排队号:下一个进入等待的线程领取的号码
    std::atomic<size_t> next_ticket_{0};
    // 服务号:当前允许进入临界区的号码
    std::atomic<size_t> now_serving_{0};

public:
    void lock() {
        // 1. 原子地获取一个号码,并将排队号加1
        // memory_order_relaxed 足够,因为 fetch_add 本身保证原子性,
        // 且后续的加载会有同步屏障
        size_t my_ticket = next_ticket_.fetch_add(1, std::memory_order_relaxed);

        // 2. 自旋等待,直到叫到我的号
        while (now_serving_.load(std::memory_order_acquire) != my_ticket) {
            // 提示 CPU 我们在自旋,避免过度消耗流水线资源
            #if defined(__x86_64__) || defined(_M_X64)
                __builtin_ia32_pause();
            #elif defined(__aarch64__)
                asm volatile("yield");
            #endif
        }
    }

    void unlock() {
        // 3. 叫下一个号
        // 使用 release 语义,确保临界区的写入对下一个线程可见
        size_t current = now_serving_.load(std::memory_order_relaxed);
        now_serving_.store(current + 1, std::memory_order_release);
    }
};

这份代码捕获了算法本质,但对变量的缓存位置毫无保证——在规模扩大时代价高昂,这正是下面要解决的问题。

3. Zig 实现:显式的内存控制

在 C++ 或 Rust 中,控制内存布局往往需要复杂的属性标记。而在 Zig 中,我们能以极其直白的方式处理它。

以下是一个生产级的票据锁实现。请注意我们如何处理缓存行(Cache Line):

const std = @import("std");
const atomic = std.atomic;

/// 票据锁:保证公平性的同步原语
/// 通过双计数器实现 FIFO 顺序
pub const TicketLock = struct {
    // 核心优化:将两个计数器强制对齐到 64 字节(常见缓存行大小)
    // 这避免了 "虚假共享" (False Sharing)
    next_ticket: atomic.Value(u64) align(64) = atomic.Value(u64).init(0),
    current_ticket: atomic.Value(u64) align(64) = atomic.Value(u64).init(0),

    /// 获取锁:领票并等待
    pub fn acquire(self: *TicketLock) void {
        // 1. 原子领票 (Fetch-and-Add)
        // 这是一次性的原子写入操作,确定了你的服务顺序
        const my_ticket = self.next_ticket.fetchAdd(1, .Monotonic);

        // 2. 自旋等待 (Read-Only Loop)
        // 只要当前票号不等于我的票号,就持续检查
        while (self.current_ticket.load(.Acquire) != my_ticket) {
            // 提示 CPU 我们在自旋,避免流水线空转过热
            std.atomic.spinLoopPause();
        }
    }

    /// 释放锁:叫下一个号
    pub fn release(self: *TicketLock) void {
        // 3. 递增当前票号 (Store-Release)
        // 这一步会立即使得持有 (my_ticket + 1) 的线程退出循环
        const current = self.current_ticket.load(.Unordered);
        self.current_ticket.store(current + 1, .Release);
    }
};

4. 深度解析:虚假共享的代价

在上面的代码中,align(64) 并不是装饰品,它是性能的关键。

如果 next_ticketcurrent_ticket 紧挨着放在同一个缓存行(Cache Line,通常 64 字节)里,会发生什么?

  1. 线程 A 调用 acquire,修改 next_ticket
  2. 线程 B 调用 release,修改 current_ticket

虽然它们修改的是不同的变量,但因为处于同一缓存行,CPU 的缓存一致性协议(如 MESI)会强制该缓存行在核心之间来回失效(Invalidate)和传输。这就是 虚假共享 (False Sharing)

通过 align(64),我们将这两个高频更新的变量隔离在不同的缓存行中:

上面的 C++20 版本会悄无声息地承受这种争用。Zig 版本则将修复方案变得显式、可在编译期验证。

5. 权衡分析

优势

确定性:在竞争同一个锁的线程中,饥饿被彻底消除。每个线程严格按到达顺序获得服务——这是基于 CAS 的锁无法提供的硬性保证。

低开销:在无竞争或低竞争场景下,只需一次原子递增和一次比较,比任何依赖操作系统调度的互斥锁都更轻量。

全局自旋问题

票据锁虽然解决了公平性,但也引入了新的问题:全局自旋

acquire 阶段,所有等待的线程都在不断读取同一个 current_ticket 变量。

这种"惊群效应"在几百个核心的机器上依然会造成总线风暴。对于极度敏感的场景,MCS 锁CLH 锁是更优的选择——每个线程只在自己的本地变量上自旋,将一致性流量限制在单个节点内,代价是更高的实现复杂度。对于中等程度的竞争,或当处理顺序是硬性要求时,票据锁依然是正确的工具。

总结

票据锁是并发设计中"以空间换时间,以秩序换公平"的典范。它告诉我们:

  1. 公平性是有代价的,但在长尾延迟敏感的系统中,这个代价值得付。
  2. 硬件细节不可忽略,简单的 align(64) 可能带来成倍的吞吐提升。
  3. 秩序即稳定:对于中等程度的竞争,或当处理顺序是硬性要求时,稳定的 FIFO 队列在工业级系统中往往是最可靠、最确定的选择。
© 2026 Yuxu Ge ·