校招C++20并发系列07-保障线程公平性:Ticket Spinlock手写与吞吐权衡

3 阅读5分钟

📺 配套视频:校招C++20并发系列07-保障线程公平性:Ticket Spinlock手写与吞吐权衡

校招C++20并发系列07-保障线程公平性:Ticket Spinlock手写与吞吐权衡

在并行计算中,性能优化的目标往往不是单一的。虽然吞吐量(Throughput)——即单位时间内完成的工作量——是许多高性能场景的核心指标,但它并非唯一的标准。当系统需要为多个用户或服务提供响应时,单纯的吞吐量优化可能导致严重的“饥饿”现象,即部分线程长期无法获得资源。本期教程将深入探讨如何在 C++ 并发编程中实现公平性(Fairness),并通过手写一个基于票据机制的自旋锁(Ticket Spinlock),对比其与标准 pthread_spinlock_t 在等待时间分布上的显著差异。

公平性与吞吐量的权衡

为了理解为什么需要公平性,我们可以想象一台高负载服务器。如果只追求吞吐量,最优策略可能是让主线程连续处理用户 A 的请求,直到耗尽,再切换至用户 B。这种做法虽然最大化了 CPU 利用率,但会导致用户 B、C、D 等面临极高的响应延迟甚至超时。

在多线程锁的语境下,这种权衡尤为明显:

  • 非公平锁(如普通自旋锁):谁先抢到锁归谁。这通常能带来更高的吞吐量,因为减少了上下文切换和排队开销,但容易导致某些线程长时间无法获取锁(饥饿)。
  • 公平锁(如票据锁):严格按照请求顺序分配锁。这牺牲了一定的峰值吞吐量,但保证了每个线程都能在可预见的时间内获得资源,提升了系统的整体响应稳定性。

基线测试:标准 pthread 自旋锁的表现

为了量化公平性的缺失,我们首先建立一个基准测试。该测试旨在测量线程获取锁时的最大等待时间。

测试逻辑设计

我们生成 8 个线程,每个线程循环执行 2222^{22} 次迭代。在每次迭代中,线程尝试获取锁,记录耗时,然后释放锁。关键代码如下:

// 伪代码逻辑示意
std::atomic<int> max_wait_time{0};
for (int i = 0; i < (1 << 22); ++i) {
    auto start = std::chrono::system_clock::now();
    
    // 获取锁
    pthread_spin_lock(&spinlock);
    
    auto end = std::chrono::system_clock::now();
    // 计算持续时间并更新最大值
    int duration_us = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
    if (duration_us > max_wait_time.load()) {
        max_wait_time.store(duration_us);
    }
    
    // 释放锁
    pthread_spin_unlock(&spinlock);
}

运行结果分析

使用 -O3 优化级别和 C++20 标准编译后,多次运行结果显示,各线程的最大等待时间存在极大的波动性。例如,某个线程可能仅需 229 微秒,而另一个线程却需要等待近 52,000 微秒。这种巨大的方差表明,标准自旋锁完全偏向于“先抢先得”,导致部分线程陷入严重的饥饿状态。

手写 Ticket Spinlock:实现公平性

为了解决上述问题,我们引入票据自旋锁。其核心思想借鉴现实生活中的叫号系统:每个人进店拿一张票(排队号),柜台显示当前服务号码,只有当你的票号等于当前服务号时,才能进入办理业务。

核心数据结构

票据锁内部维护两个原子变量:

  1. line:下一个分配的排队号码。
  2. serving:当前正在服务的号码。
class TicketSpinLock {
private:
    std::atomic<int> line{0};      // 下一个排队号
    std::atomic<int> serving{0};   // 当前服务号
public:
    void lock() {
        // 1. 获取当前排队号,并将全局排队号加一
        int my_ticket = line.fetch_add(1, std::memory_order_relaxed);
        
        // 2. 忙等待,直到轮到自己
        while (serving.load(std::memory_order_acquire) != my_ticket) {
            // x86 架构下的暂停指令,减少功耗并避免总线冲突
            _mm_pause(); 
        }
    }

    void unlock() {
        // 3. 通知下一个排队者
        serving.fetch_add(1, std::memory_order_release);
    }
};

原理详解

  • 获取锁 (lock):通过 fetch_add 原子地获取当前队列位置,并立即将队列指针后移。随后进入自旋循环,检查 serving 是否等于自己的 my_ticket。如果不等,调用 _mm_pause() hint 告诉 CPU 当前处于忙等待状态,从而优化性能。
  • 释放锁 (unlock):只需将 serving 加 1。由于内存序设置为 release,这确保了之前的临界区操作对所有后续获取锁的线程可见。

性能对比与结论

我们将同样的测试逻辑应用于自定义的 TicketSpinLock,保持相同的编译参数和线程配置。

测试结果对比

运行结果显示,票据锁显著改善了等待时间的均匀性:

  • 非公平锁:等待时间从几百微秒到几万微秒不等,方差极大。
  • 票据锁:几乎所有线程的最大等待时间都集中在 13,000 到 17,000 微秒之间,彼此非常接近。

总结

虽然票据锁引入了额外的原子操作和严格的排队逻辑,可能在极端高竞争下略微降低绝对吞吐量,但它成功消除了线程饥饿现象。对于对响应时间一致性要求较高的应用场景(如实时交易系统或交互式服务),这种公平性保障至关重要。

易错点提示:在实现票据锁时,务必注意内存序的选择。lock 中的比较应使用 acquire 语义以确保看到最新的 serving 值,而 unlock 应使用 release 语义以正确发布临界区内的数据修改。

速查表

概念说明
公平性 vs 吞吐量吞吐量关注总处理能力,公平性关注单个线程的等待上限;两者往往此消彼长。
pthread_spinlock_t标准 POSIX 自旋锁,非公平实现,适合低竞争场景,高竞争下易产生饥饿。
Ticket Spinlock基于原子计数器的公平锁,通过“排队号”与“当前号”匹配机制保证 FIFO 顺序。
_mm_pause()x86 汇编指令,用于忙等待循环中,提示 CPU 减少功耗并避免缓存行争用。
内存序选择lock 读取 servingmemory_order_acquireunlock 写入 servingmemory_order_release