C++ `std::atomic_flag` 的极致用法:构建最轻量级互斥量

好的,让我们来聊聊C++ std::atomic_flag 的极致用法,以及如何用它来构建一个轻量级的互斥量。准备好了吗?系好安全带,我们要开始一段“原子之旅”了!

讲座:std::atomic_flag 的极致用法:构建最轻量级互斥量

大家好!

今天我们要聊的是一个C++标准库里经常被忽略,但实际上非常强大的家伙:std::atomic_flag。 你可能会觉得它平平无奇,但如果运用得当,它能让你构建出极其轻量级的互斥量,甚至在某些场景下超越std::mutex。 听起来很酷,对吧?

std::atomic_flag 是什么?

简单来说,std::atomic_flag 是一个最基本的原子布尔标志。 它只有两个状态:set (已设置) 和 clear (未设置)。 它提供的操作非常简单:

  • test_and_set():原子地设置标志,并返回之前的值。
  • clear():原子地清除标志。

没了! 是不是觉得有点寒酸? 别急,正是这种简单性赋予了它强大的潜力。

为什么 std::atomic_flag 轻量级?

std::atomic_flag 的轻量级体现在以下几个方面:

  1. 简单的数据结构: 它只需要存储一个布尔值,占用空间小。
  2. 硬件原子操作: 现代CPU通常提供原子指令来直接操作内存中的布尔值,std::atomic_flag 充分利用了这些指令。
  3. 避免锁竞争: 在某些特定场景下,我们可以利用 std::atomic_flag 避免传统的锁竞争,从而提高性能。

std::atomic_flag 构建自旋锁

最常见的用法就是构建自旋锁(spinlock)。 自旋锁是一种当锁被占用时,线程会不断循环尝试获取锁,而不是进入阻塞状态的锁。 这种锁适用于临界区非常短的场景。

下面是一个简单的自旋锁实现:

#include <atomic>
#include <thread>
#include <iostream>

class spinlock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT; // 初始化为未设置状态

public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 自旋等待
            std::this_thread::yield(); // 让出CPU时间片,避免过度消耗
        }
    }

    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

spinlock my_lock;
int shared_data = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        my_lock.lock();
        shared_data++;
        my_lock.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Shared data: " << shared_data << std::endl; // 预期结果:200000
    return 0;
}

代码解释:

  • std::atomic_flag flag = ATOMIC_FLAG_INIT;: 我们用 ATOMIC_FLAG_INIT 初始化 std::atomic_flag,确保它一开始处于未设置状态。
  • lock() 函数:
    • flag.test_and_set(std::memory_order_acquire): 这是核心。 test_and_set() 会原子地设置 flag,并返回之前的值。 如果之前的值是 false (未设置),说明我们成功获得了锁,test_and_set() 返回 false,循环结束。 如果之前的值是 true (已设置),说明锁已经被占用,test_and_set() 返回 true,循环继续,线程自旋等待。
    • std::memory_order_acquire: 这是一个内存顺序(memory order)。 acquire 确保在获取锁之后,所有后续的读操作都发生在锁被释放之前。 简单来说,它防止编译器和CPU对指令进行重排序,保证数据一致性。
    • std::this_thread::yield();: 这是一个礼貌性的操作。 在自旋等待期间,我们调用 std::this_thread::yield() 让出CPU时间片,避免过度消耗CPU资源。 虽然自旋锁的特点是快速,但长时间的自旋会导致CPU空转。
  • unlock() 函数:
    • flag.clear(std::memory_order_release): 原子地清除 flag,释放锁。
    • std::memory_order_release: 这是一个内存顺序。 release 确保在释放锁之前,所有之前的写操作都发生在锁被获取之后。 同样是为了保证数据一致性。

自旋锁的优缺点

  • 优点:
    • 速度快: 如果临界区非常短,自旋锁的性能通常比互斥锁更好,因为它避免了线程切换的开销。
    • 轻量级: std::atomic_flag 本身非常轻量级。
  • 缺点:
    • CPU消耗: 如果锁长时间被占用,自旋锁会导致线程不断自旋,消耗大量CPU资源。
    • 可能导致优先级反转: 如果持有锁的线程优先级较低,而自旋等待的线程优先级较高,可能会导致优先级反转问题。

更高级的用法:基于 std::atomic_flag 的 Ticket Lock

为了解决自旋锁的CPU消耗问题,我们可以使用一种更高级的锁:Ticket Lock。 Ticket Lock 是一种公平的自旋锁,它使用原子计数器来分配锁的获取顺序,避免了线程饥饿问题。

#include <atomic>
#include <thread>
#include <iostream>

class ticket_lock {
private:
    std::atomic<unsigned int> ticket{0}; //  下一个可用的ticket
    std::atomic<unsigned int> serving{0}; //  当前正在服务的ticket

public:
    void lock() {
        unsigned int my_ticket = ticket.fetch_add(1, std::memory_order_relaxed); // 原子地获取一个ticket
        while (serving.load(std::memory_order_acquire) != my_ticket) {
            std::this_thread::yield();
        }
    }

    void unlock() {
        serving.fetch_add(1, std::memory_order_release); // 原子地增加serving计数器
    }
};

ticket_lock my_lock;
int shared_data = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        my_lock.lock();
        shared_data++;
        my_lock.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Shared data: " << shared_data << std::endl; // 预期结果:200000
    return 0;
}

代码解释:

  • std::atomic<unsigned int> ticket{0};: 用于分配ticket的原子计数器,初始值为0。
  • std::atomic<unsigned int> serving{0};: 用于记录当前正在服务的ticket的原子计数器,初始值为0。
  • lock() 函数:
    • unsigned int my_ticket = ticket.fetch_add(1, std::memory_order_relaxed);: 原子地获取一个ticket。 fetch_add() 会原子地增加 ticket 计数器,并返回之前的值,也就是当前线程的ticket。 std::memory_order_relaxed 表示对 ticket 的操作不需要强制的同步,可以提高性能。
    • while (serving.load(std::memory_order_acquire) != my_ticket): 自旋等待,直到当前线程的ticket被服务。 serving.load() 原子地读取 serving 计数器的值。 std::memory_order_acquire 确保在读取 serving 之后,所有后续的读操作都发生在 serving 被更新之前。
  • unlock() 函数:
    • serving.fetch_add(1, std::memory_order_release);: 原子地增加 serving 计数器,表示下一个ticket可以被服务。 std::memory_order_release 确保在更新 serving 之前,所有之前的写操作都发生在 serving 被读取之后。

Ticket Lock 的优点

  • 公平性: 线程按照请求锁的顺序获得锁,避免了线程饥饿问题。
  • 减少CPU消耗: 相比于简单的自旋锁,Ticket Lock的自旋通常会更快结束,减少了CPU消耗。

std::atomic_flag 的内存顺序 (Memory Order)

内存顺序是理解原子操作的关键。 它决定了编译器和CPU如何对指令进行重排序,以及不同线程之间如何同步内存访问。 C++提供了多种内存顺序,常用的有:

  • std::memory_order_relaxed: 最宽松的内存顺序,只保证原子性,不保证同步。
  • std::memory_order_acquire: 用于读取操作,确保在读取操作之后,所有后续的读操作都发生在读取操作之前。
  • std::memory_order_release: 用于写入操作,确保在写入操作之前,所有之前的写操作都发生在写入操作之后。
  • std::memory_order_acq_rel: 同时具有 acquirerelease 的语义。
  • std::memory_order_seq_cst: 最强的内存顺序,保证所有原子操作的全局顺序一致,性能最差。

选择合适的内存顺序非常重要。 过强的内存顺序会导致性能下降,过弱的内存顺序会导致数据竞争。

std::atomic_flag 的适用场景

std::atomic_flag 适合以下场景:

  • 临界区非常短: 自旋锁适用于临界区非常短的场景,因为自旋等待的开销很小。
  • 需要高性能: 在某些情况下,std::atomic_flag 的性能比 std::mutex 更好,因为它避免了线程切换的开销。
  • 需要自定义锁: std::atomic_flag 可以作为构建更高级锁的基础,例如 Ticket Lock。

std::atomic_flag 的注意事项

  • 避免长时间自旋: 长时间自旋会导致CPU过度消耗。 如果锁长时间被占用,应该考虑使用互斥锁。
  • 注意内存顺序: 选择合适的内存顺序非常重要,错误的内存顺序会导致数据竞争。
  • 避免死锁: 在使用多个锁时,需要注意避免死锁。

std::atomic_flag vs std::mutex

特性 std::atomic_flag (自旋锁) std::mutex
线程切换 不会引起线程切换 可能会引起线程切换
CPU消耗 可能较高(长时间自旋) 较低
适用场景 临界区短,高性能要求高 临界区长,竞争激烈
复杂性 较低 较高
公平性 默认不保证公平性 保证公平性

总结

std::atomic_flag 是一个简单而强大的工具,它可以用来构建轻量级的互斥量。 虽然它有一些限制,但在某些特定场景下,它可以提供比 std::mutex 更好的性能。 理解 std::atomic_flag 的工作原理和适用场景,可以帮助你编写更高效、更可靠的多线程程序。

记住,不要过度迷信任何一种锁。 选择合适的锁取决于具体的应用场景。 仔细分析你的需求,权衡各种锁的优缺点,才能做出最佳的选择。

最后,送给大家一句忠告:

“原子操作虽好,可不要贪杯哦!”

希望这次讲座对大家有所帮助。 谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注