线程同步机制的分级与性能开销

2,335 阅读7分钟

本文是 A Concurrency Cost Hierarchy 这篇文章的读后总结和摘要,希望能给读者带来点启发,也欢迎大家读完本文后再去详细看解释更为详尽的原文。

一、背景

在日常开发过程中,我们经常会需要使用多线程。而多线程应用程序中,为了保证线程安全,就需要用到原子操作或者各种锁。而且原子操作和锁也有很多种不同的使用方式,每种方式都有其特点和相应的开销,了解这些方式的区别以及其隐含的性能开销对我们实现合适的线程安全机制是非常必要的。

作者将线程同步带来的开销(从低到高)按其数据访问方式分为了六个等级,开发者的目标应该是尽量避免使用低等级(5 级最低)的技术方案。

等级方案开销(ns)
0无原子操作且无竞争极低
1有原子操作但无竞争10
2有竞争的原子操作或锁40 ~ 400
3系统调用1000
4线程上下文切换10,000
5忙等直到时间片用尽极高

注:此处附一下 Google 之前出的一个数据,用作对比:

二、具体示例和分析

以下具体介绍作者给的各等级方案的实现示例,示例要实现的功能是在保证线程安全的情况下给一个 uint64_t 类型的变量加 1。

等级 2:有竞争的原子操作

这种是日常开发中最常用的线程同步实现方式,可以作为后续其他方案测试的基准。

  • 方案 2.1:mutex add

    使用 std::mutex 锁实现线程安全

//T 使用 std::mutex

T lock;
uint64_t counter;

void bench(size_t iters) {
    while (iters--) {
        std::lock_guard<T> holder(lock);
        counter++;
    }
}
  • 方案 2.2:atomic add

    使用 std::atomic 的 ++ 操作符(原子操作)实现线程安全

    其底层使用的是 lock xadd 指令。

//使用 std::atomic 的 ++ 操作符

std::atomic<uint64_t> atomic_counter{};

void atomic_add(size_t iters) {
    while (iters--) {
        atomic_counter++;
    }
}
  • 方案 2.3:cas add

    使用 compare and swap 配合冲突处理逻辑实现线程安全

//使用 CAS

std::atomic<uint64_t> cas_counter;

void cas_add(size_t iters) {
    while (iters--) {
        uint64_t v = cas_counter.load();
        while (!cas_counter.compare_exchange_weak(v, v + 1))
            ;
    }
}

其性能数据如下:

本等级方案的运行时间在 40 ~ 400 ns 范围内,其中方案 2.2(atomic add)最快。

随着竞争线程数增加竞争变得激烈,各方案的速度会随之变慢。

注:等级 2 中的方法都会涉及将目标数据(cache line)提交到负责管理 cache 一致性的 CHA 部件(Caching Home Agent,一般位于 L3 Cache 中),然后再将其加载到下一个得到锁的 cpu 中的开销,单单这一操作本身就大概要耗费 70 ns(并非每个竞争状态下的原子操作都会耗费 70 ns,cpu 在独占 cache line 后可执行多条指令,所以获取 cache line 的开销应该平摊给所有这些指令。独占 cache line 后能执行多少指令取决于 cpu 的“公平程度”,公平的 cpu 不会让任何一个 cpu 核独占 cache line 太长时间,现在的 intel cpu 是比较公平的)。

等级 3:系统调用

这个等级中,基本每个加锁、解锁操作都会引发系统调用。

  • 方案 3:ticket yield

    这个方案实现了一个公平锁,线程在加锁时如果发现自己不是第一顺位,则调用 sched_yield() 让出 cpu。

    公平锁 + 线程间高频竞争导致此方案在运行时基本都会走 sched_yield() 分支。

/**
 * A ticket lock which uses sched_yield() while waiting
 * for the ticket to be served.
 */
class ticket_yield {
    std::atomic<size_t> dispenser{}, serving{};

public:
    void lock() {
        auto ticket = dispenser.fetch_add(1, std::memory_order_relaxed);

        while (ticket != serving.load(std::memory_order_acquire))
            sched_yield();
    }

    void unlock() {
        serving.store(serving.load() + 1, std::memory_order_release);
    }
};

注:区别于等级 4,等级 3 的代码运行时需要保证:线程数 <= cpu 核数,否则 sched_yield() 完再调度回来时又加上线程上下文切换的开销,就不是纯粹的等级 3 了

  • 性能数据比对:

    • 比等级 2 的方案时间高了一个数量级

注:方案 2 中用 std::mutex,其内部实现比较复杂,根据不同竞争情况会采取不同策略,真正走到其内部调用系统调用分支的几率并不高,这也是它区别于等级 3 的主要原因。

等级 4:线程上下文切换

这个等级的方案的主要特点是通过大量的并发线程数来触发线程上下文切换。

  • 方案 4.1:ticket blocking

    这个方案也是实现了一个公平锁,当前线程在加锁时如果发现自己不是第一顺位,则等待一个 condition variable。

与方案 3 中使用 sched_yield() 让出 cpu 不同,本方案等待一个条件变量,此时操作系统会将本线程休眠,直到锁被释放再唤醒。

当锁被释放时,系统会唤醒所有等待者,本方案会有惊群效应。

void blocking_ticket::lock() {
    auto ticket = dispenser.fetch_add(1, std::memory_order_relaxed);

    if (ticket == serving.load(std::memory_order_acquire))
        return; // uncontended case

    std::unique_lock<std::mutex> lock(mutex);
    while (ticket != serving.load(std::memory_order_acquire)) {
        cvar.wait(lock);
    }
}

void blocking_ticket::unlock() {
    std::unique_lock<std::mutex> lock(mutex);
    auto s = serving.load(std::memory_order_relaxed) + 1;
    serving.store(s, std::memory_order_release);
    auto d = dispenser.load(std::memory_order_relaxed);
    assert(s <= d);
    if (s < d) {
        // wake all waiters
        cvar.notify_all();
    }
}
  • 方案 4.2:queued fifo

    这是方案 4.1 的一个改进版,每个线程等待自己的 condition variable,不会发生惊群效应。

struct fifo_queued::queue_elem {
    std::condition_variable cvar;
    bool owner = false;
};  

void fifo_queued::lock() {
    std::unique_lock<std::mutex> guard(mutex);
    if (!locked) {
        locked = true;
        return;
    }

    queue_elem node;
    cvar_queue.push_back(&node);

    do {
        node.cvar.wait(guard);
    } while (!node.owner);

    assert(locked && cvar_queue.front() == &node);
    cvar_queue.pop_front();
}

void fifo_queued::unlock() {
    std::unique_lock<std::mutex> guard(mutex);
    if (cvar_queue.empty()) {
        locked = false;
    } else {
        auto& next = cvar_queue.front();
        next->owner = true;
        next->cvar.notify_one();
    }
}

  • 性能比对:

    • 比等级 3 的方案时间高了一个数量级

    • 是否发生惊群并不太影响最终性能,影响性能的是锁转移带来的线程上下文切换

    • 公平锁会在各线程间轮转,当线程数大于 cpu 核数时就可能会出现每次锁释放时,下一个轮转到的线程还未被分配到 cpu,这时系统将它调到到 cpu 上,就会进行一次线程上下文切换。
    • 非公平锁丧失了公平性,但同一线程如果频繁加锁解锁的话,可以直接在当前的 cpu 上进行,不需要将锁让渡给别的排队线程,也省去了上下文切换的开销。

等级 5:灾难

  • 方案 5:ticket spin

    将 ticket lock 方案中的 sched_yield() 方法换成“;”(即循环忙等)

    此时正在 cpu 上运行而得不到锁(非第一顺位)的线程将空转并占住 cpu,别的第一顺位而未获得 cpu 的线程将不得不等到空转的线程 cpu 时间片用尽才能成功加锁。

    时间片一般是以毫秒计的。

  • 性能比对:

    • 在线程数大于 cpu 核数时,比等级 4 的方案时间高一个数量级以上且方差级大

    上图中 Skylake 只有 4 个 cpu,当竞争线程数达到 5 和 6 时其性能变得极差。

等级 1:无竞争的原子操作

  • 单线程运行原子操作(无竞争)的开销

    注:上图中柱状图中的数字指原子操作指令的个数

    下表显示了这些方法用到的原子操作指令个数、总指令数及其时间开销:

    可以看出时间开销主要由原子操作指令个数决定。

  • 方案 1:cas multi

    使用线程局部存储,每个线程使用 compare and swap 操作自己线程独享的数据(无竞争),需要读总数据时再汇总各线程的所有数据。

class cas_multi_counter {
    static constexpr size_t NUM_COUNTERS = 64;

    static thread_local size_t idx;
    multi_holder array[NUM_COUNTERS];

public:

    /** increment the logical counter value */
    uint64_t operator++(int) {
        while (true) {
            auto& counter = array[idx].counter;

            auto cur = counter.load();
            if (counter.compare_exchange_strong(cur, cur + 1)) {
                return cur;
            }

            // CAS failure indicates contention,
            // so try again at a different index
            idx = (idx + 1) % NUM_COUNTERS;
        }
    }

    uint64_t read() {
        uint64_t sum = 0;
        for (auto& h : array) {
            sum += h.counter.load();
        }
        return sum;
    }
};

由上图可见无竞争情况下 cas multi 的开销较小,且不随着线程数的增加而增加。

等级 0:只调用不加锁的Load、Store指令(且无竞争)

  • 方案 0:tls add

    每个线程操作线程局部存储中的数据(无竞争),且操作时使用普通的 Load、Store 指令,不使用原子操作(无内存屏障)。

    读总数据时也需要汇总所有线程的全部数据。

/**
 * Keeps a counter per thread, readers need to sum
 * the counters from all active threads and add the
 * accumulated value from dead threads.
 */
class tls_counter {
    std::atomic<uint64_t> counter{0};

    /* protects all_counters and accumulator */
    static std::mutex lock;
    /* list of all active counters */
    static std::vector<tls_counter *> all_counters;
    /* accumulated value of counters from dead threads */
    static uint64_t accumulator;
    /* per-thread tls_counter object */
    static thread_local tls_counter tls;

    /** add ourselves to the counter list */
    tls_counter() {
        std::lock_guard<std::mutex> g(lock);
        all_counters.push_back(this);
    }

    /**
     * destruction means the thread is going away, so
     * we stash the current value in the accumulator and
     * remove ourselves from the array
     */
    ~tls_counter() {
        std::lock_guard<std::mutex> g(lock);
        accumulator += counter.load(std::memory_order_relaxed);
        all_counters.erase(std::remove(all_counters.begin(), all_counters.end(), this), all_counters.end());
    }

    void incr() {
        auto cur = counter.load(std::memory_order_relaxed);
        counter.store(cur + 1, std::memory_order_relaxed);
    }

public:

    static uint64_t read() {
        std::lock_guard<std::mutex> g(lock);
        uint64_t sum = 0, count = 0;
        for (auto h : all_counters) {
            sum += h->counter.load(std::memory_order_relaxed);
            count++;
        }
        return sum + accumulator;
    }

    static void increment() {
        tls.incr();
    }
};

这种方案是最快的,它没有使用原子操作指令,只是简单的 Load/Store 指令,在有 cache 的情况下,开销极小。

下表展示了上图三种方案的原子指令数、总指令条数和开销:

三、总结

文章最后也总结了一些从低等级方案(3,4,5)改进为高等级方案(0,1,2)的常用方法。从我个人的角度来说,最受启发的还是通过这些例子和数据理解这几种线程同步等级之间的不同点以及其带来的具体性能开销。

参考资料