阅读:Dmitry Vyukov的有界MPMC队列

文章简介

这是Dmitry Vyukov写的一篇博客,名为《Bounded MPMC queue》。

这篇文章介绍了如何实现一个高效的、基于数组的多生产者多消费者(MPMC)有界无互斥锁并发队列。其核心特点是每次入队/出队仅需一次CAS(比较并交换)原子操作,且无需动态分配内存。入队/出队操作平均可达到75个时钟周期。

作者简介

Dmitry Vyukov是一位俄罗斯大神级程序员,曾设计并公开了一系列高性能无锁算法和新颖的同步算法。

同时,他长期于谷歌任职,是Go调度器(GMP模型)的设计者和实现者,开发了Go语言随机测试工具go-fuzz等。

并且,他在动态内存检测以及模糊测试等领域也有突出贡献,是TSan和Syzkaller的主要作者。

队列结构

数据结构cell_t

C++ 复制代码
template<typename T>
class mpmc_bounded_queue
{
private:
    struct cell_t
    {
        std::atomic<size_t>     sequence_;
        T                       data_;
    };
};

sequence_是序列号,用来协调生产者和消费者。

它的类型是作者自己写、伪装成标准库的原子变量std::atomic,以确保多线程并发读写时不会产生数据竞争。

同时,这里用了一个泛型data_来存放数据,使用泛型可以复用代码,同时也可以在编译时检查类型,避免在运行时的错误类型转换。

变量命以下划线结尾

像这里贴出来的sequence_data_,包括后续出现的一些变量,命名最后都以下划线结尾。

这是因为,该代码的作者遵循Google C++ 规范(Google C++ Style Guide),它明确规定:类的私有数据成员(Private Data Members)名称一律要在末尾加下划线。

存储处理

C++ 复制代码
class mpmc_bounded_queue
{
private:
    static size_t const         cacheline_size = 64;
    typedef char                cacheline_pad_t [cacheline_size];

    cacheline_pad_t             pad0_;
    cell_t* const               buffer_;
    size_t const                buffer_mask_;
    cacheline_pad_t             pad1_;
    std::atomic<size_t>         enqueue_pos_;
    cacheline_pad_t             pad2_;
    std::atomic<size_t>         dequeue_pos_;
    cacheline_pad_t             pad3_;
};

填充缓存行

CPU通常以缓存行形式操作内存,缓存行大小通常为32-256字节,最常见的是64字节。

如果两个不同线程的变量共享一个缓存行,哪怕看似毫不相关,也会出现写竞争问题。

所以为了避免这个问题,一般会使用填充缓存行的方式来确保独立的并发变量写入不同的缓存行。

填充缓存行就是,拿一些无意义的字节将不同变量隔开。

所以,这里定义了一个新类型cacheline_pad_t,用来表示填充缓存行的数据大小,它的本质是一个长度为64char数组。

char占用1个字节,所以这里用于将不同数据隔开的填充大小为64字节。

都是C++的无符号整数类型,为啥这里用size_t而不用unsigned int?

unsigned int的大小是固定的,在32/64位系统的大小均为4字节。如果使用unsigned int,如果队列过大,一旦换了平台,可能会导致溢出。

同时,size_t(size type)是 C++ 标准库中通过typedef定义的一种无符号整数类型,可以用来表示对象占用内存的大小(字节),也可以用来计数(无符号特性)。

size_t的大小不是固定的,因生成的程序类型而异,在32位中它一般占用4个字节(32位),表示的数字最大能有 2 32 − 1 2^{32} - 1 232−1;而在64位中,它一般占用8个字节(64位),表示的数字最大能有 2 64 − 1 2^{64} - 1 264−1。

同时,size_t在创建之初就被希望能够表示内存理论上能存放下的可能存在对象类型的最大大小,也就是说,如果内存中程序存在的对象大小大于size_t,那么这个类型并不被鼓励存在。

而由于这个对象类型还包括数组,所以有时候也鼓励for循环能够用(size_t i = 0;i < n;i++)的形式来写。

也是从这个角度看,当我们需要一个数字来描述内存中某事物的大小时,也推荐用size_t,例如void *malloc(size_t size)

所以,从大小上,size_tunsigned int灵活;而从size_t的语义功能来看,它设计之初就是为了描述内存中类型大小,而用在这里与填充缓存行有关正合适。

环形队列

buffer_是一个类型为cell_t的指针常量,后续它会指向一个数组的首地址。

不直接将它定义为数组,使得接下来被使用的数组大小可调、更加灵活。

buffer_mask_是掩码,用于后续对队列的位运算取模,相比于直接取模运算,前者速度快很多。

位运算取模需要设置buffer_容量为2^nbuffer_mask_大小为2^n-1,具体算法后续会提到。

不过,用了位运算来取模,也就默认这个队列是个环形队列。

这里将buffer_buffer_mask_的值设置为const,可以保证后续程序运行时不会因为误操作将它们的值改掉,保证安全。

C++中数组的首元素、数组名、首地址与指针

假如我们有一个数组int array[5] = {1, 2, 3, 4, 5};

array[0]是数组的首元素,在这里值为1

数组名array,会被隐式转化为指向首元素的指针,即指向array[0]的指针。

所以在int* p = array或传参时,array的行为等于&array[0](取地址)。

但在sizeof(array)时,返回的是整个数组占用的字节而非指针大小;同时,对数组名取地址&array,这里的array也不是指针。

首地址是这个数组在内存中开始的字节的编号,在数值上,array(被隐式转换时)、&array[0]&array都是它。

首地址指针是指向数组首元素的指针。

在这个例子里就是array(被隐式转换时)和&array[0],类型是int*

步长为1,就是如果执行+1,那么指针会跳过1个int的大小,从array[0]array[1]

数组指针就是指向整个数组的指针。

在这个例子里就是&array,类型是int(*)[5],表示指向一个包含5个int的数组的指针。

步长为整个数组,如果执行&array + 1,指针会跳过整个数组的大小(这里是20个字节),直接指向数组末尾后面的内存。
指针常量和常量指针

指针常量,指针本身是常量,即地址不可改,例如int* const p = &a;,还有文中的cell_t* const buffer_

常量指针指的是,指向常量的指针,值本身不能改,但是地址能改,如const int* p = &a;

还有指向常量的指针常量,值和地址都不能改,如const int* const p

计数器

enqueue_pos_dequeue_pos_用于协调生产者和消费者对于环形队列的读取。

前者是入队计数器,它标记新来的数据应该写入队列的哪个位置。

它的初始值是0,当生产者线程想要写入数据时,这个线程就会通过CAS操作尝试把0改为1

如果抢占成功,那么该线程就可以在队列的第0个位置写入数据;否则,就只能去抢占下一个位置了。

后者是出队计数器,管理消费者线程的,具体思路和enqueue_pos_类似。

它们的类型std::atomic<size_t>是作者自己写的简易版原子操作,防止数据竞争。

禁用拷贝与赋值

C++ 复制代码
class mpmc_bounded_queue
{
private:
    mpmc_bounded_queue(mpmc_bounded_queue const&);
    void operator = (mpmc_bounded_queue const&);
};

代码中的两行内容,分别是一个拷贝构造函数和一个赋值运算符。

它们原本实现的分别是拷贝和赋值的功能,然而,作者通过将它们设为私有、只声明不实现的方式,实现了禁用拷贝与赋值的效果。

设为私有是为了防止外部调用,而不写具体函数体则是为了防止内部误调用,那么这样就会分别在编译期和链接阶段报错。

作者写这段代码的时间较早,所以不得不手动来实现禁用。

而在C++11及以后,有个等价写法:

C++ 复制代码
class mpmc_bounded_queue {
public:
    mpmc_bounded_queue(mpmc_bounded_queue const&) = delete;
    mpmc_bounded_queue& operator=(mpmc_bounded_queue const&) = delete;
};

这样就可以显式禁用该对象的这两个操作。

禁用的原因有这么几个,首先是像这种多线程同时访问的对象,在拷贝的那一瞬间是无法精确地复制它的状态的,容易出问题;其次,哪怕是可以精确地复制状态,但是内部的底层资源是独占的,无论是锁或原子变量,它们在 C++ 中的定义都是明确禁止拷贝的,主要是拷贝了一个锁,它没有可以管理的对象;最后,假如靠锁A复制出了一个锁B,万一线程1从A中写入,线程2却从B中读取,那么就永远都拿不到数据......

C++的拷贝构造函数

C++的拷贝构造函数是一个特殊的构造函数,它的作用是通过一个已存在的对象去初始化一个相同类型的新对象。

C++ 复制代码
class A {
public:
    A(const A& a) {
        // 具体拷贝逻辑
    }
};

拷贝构造函数的参数一般是同类对象的常引用,如const A& a

使用引用的原因是防止将实参传给形参时又调用拷贝,无限递归、直至栈溢出;至于是否一定要是常引用const,没有硬性规定,但一般都会这么写。

一般有三种情况会调用拷贝构造函数:

  • 用一个对象初始化另一个对象。
C++ 复制代码
// 用a初始化b
A a;
A b = a;
  • 以值传递的形式将对象作为函数传参
C++ 复制代码
// 参数x会由a拷贝生成
void useA(A x) {
    // code
}
A a;
useA(a);
  • 函数返回局部对象
C++ 复制代码
A reA() {  
    A a;  
    return a;  
}

一般编译器会生成默认拷贝构造函数,这个拷贝函数是浅拷贝。

浅拷贝会复制值,但如果类里面有指针,它会复制指针的地址,导致两个对象指向同一个内存。

这样,当释放值的时候,可能会让同一块内存被释放两遍;或者修改值的时候互相影响。

而深拷贝和浅拷贝的区别就在于,前者会在堆内存中另外开辟空间来存储数据。

如果类中有指针,就需要自己写拷贝构造函数,为对象申请一块独立内存,把值复制过去。

所以,如果拷贝构造函数没有写好,容易让程序出问题。

一旦程序中需要手动管理资源,如指针等,那么就不只需要实现拷贝构造函数,还需要同时实现析构函数和拷贝赋值运算符。 而在C++引入了"移动语义"之后,除了上述三个函数,最好再加上移动构造函数和移动赋值运算符。

这就是三/五法则(Rule of Three/Five)。

而在更现代的C++中,如有需要,更推荐智能指针或使用STL容器等,以此避免手动管理内存。

前者是RAII机制的具体应用,它包装了原生指针等类对象,在析构时会自动释放内存;后者是C++提供的一套数据结构封装,如动态数组、链表等,同样实现了RAII机制,无需手动管理内存。

RAII,就是C++的一个设计理念,核心思想是让资源的生命周期和局部对象同步,这里的资源指的是内存之类的操作系统有限资源。

这样当这个局部对象超出它的作用域后,系统会自动调用它的析构函数。

如果想禁用这个,可以使用delete

C++ 复制代码
A(const A&) = delete;

C++的拷贝赋值运算符

C++的拷贝赋值运算符,它的作用是将一个已存在的对象的值,赋给另一个已存在的同类型对象。

C++ 复制代码
// 基本形式
T& operator=(const T& a);

// 例子
class A {
public:
    int x;

    A& operator=(const A& a) {
        if (this == &a) return *this;

        x = a.x;
        return *this;
    }
};

拷贝赋值运算符是在两个对象都存在,并进行赋值操作时触发的,如a2 = a1;

而拷贝构造函数,则是在对象创建并初始化时触发,如A a2 = a1;

一般编译器回默认生成拷贝赋值运算符,这个拷贝是浅拷贝。

而当类中包含指针或动态分配的内存时,同样也需要手动来写拷贝赋值运算符,防止两个指针对象指向同一个内存,出现问题。

同样地,一旦需要手动写拷贝赋值运算符时,那么剩下的拷贝构造函数、析构函数、移动构造函数和移动赋值运算符也需要手动写,满足三/五法则。

而现在更推荐的写法是使用swap

C++ 复制代码
class A {
public:
    int* p;
    
    // other code
    
    A& operator=(A a) {  
        std::swap(p, a.p);
        return *this;
    }
};

这里的参数是值传递,会在内存中开辟一块新空间,把原参数的值复制一份再放进去。

在这里swap会交换当前对象(this)与临时对象(a)的控制权。

当函数结束返回*this的时候,临时对象a会随着析构函数的调用被释放。

而如果参数是引用传递,那么直接把两个对象的数据交换了,并不符合一开始的赋值目的,也会让程序出问题;同时,自我赋值(p = p;)时,相当于自己和自己交换,既没释放掉没用的旧数据,也没给自己赋到新值;同时引用传递,并不会在函数结束时释放对象内存,可能会导致内存泄漏。

如果想禁用对象的拷贝赋值,可以使用delete

C++ 复制代码
A(const A&) = delete;
A& operator=(const A&) = delete;

构造函数和析构函数

C++ 复制代码
    // 构造函数
    mpmc_bounded_queue(size_t buffer_size)
        : buffer_(new cell_t [buffer_size])
        , buffer_mask_(buffer_size - 1)
    {
        assert((buffer_size >= 2) && ((buffer_size & (buffer_size - 1)) == 0));
        for (size_t i = 0; i != buffer_size; i += 1)
            buffer_[i].sequence_.store(i, std::memory_order_relaxed);
        enqueue_pos_.store(0, std::memory_order_relaxed);
        dequeue_pos_.store(0, std::memory_order_relaxed);
    }

    // 析构函数
    ~mpmc_bounded_queue()
    {
        delete [] buffer_;
    }

buffer_(new cell_t [buffer_size])在堆上连续分配了buffer_size个位置,用来存储数据。

buffer_mask_的大小为buffer_size - 1,以及使用断言assert要求队列的大小必须是2^n,这是为了后续位运算取模做准备。

位运算取模比一般的取模运算快得多,速度可以提升数十倍。

每一个位置的cell_t中不仅存储数据,还存储一个sequence_

这里通过for循环来初始化sequence_,第i个位置的sequence_值为i

最后两行就是初始化读写指针enqueue_pos_dequeue_pos,初始值均为0。

入队出队操作

C++ 复制代码
bool enqueue(T const& data)
    {
        cell_t* cell;
        size_t pos = enqueue_pos_.load(std::memory_order_relaxed);
        for (;;)
        {
            cell = &buffer_[pos & buffer_mask_];
            size_t seq = cell->sequence_.load(std::memory_order_acquire);
            intptr_t dif = (intptr_t)seq - (intptr_t)pos;
            if (dif == 0)
            {
                if (enqueue_pos_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed))
                    break;
            }
            else if (dif < 0)
                return false;
            else
                pos = enqueue_pos_.load(std::memory_order_relaxed);
        }

        cell->data_ = data;
        cell->sequence_.store(pos + 1, std::memory_order_release);

        return true;
    }

    bool dequeue(T& data)
    {
        cell_t* cell;
        size_t pos = dequeue_pos_.load(std::memory_order_relaxed);
        for (;;)
        {
            cell = &buffer_[pos & buffer_mask_];
            size_t seq = cell->sequence_.load(std::memory_order_acquire);
            intptr_t dif = (intptr_t)seq - (intptr_t)(pos + 1);
            if (dif == 0)
            {
                if (dequeue_pos_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed))
                    break;
            }
            else if (dif < 0)
                return false;
            else
                pos = dequeue_pos_.load(std::memory_order_relaxed);
        }

        data = cell->data_;
        cell->sequence_.store(pos + buffer_mask_ + 1, std::memory_order_release);

        return true;
    }

位运算取模

在之前提到过,enqueue_pos_是一个全局计数器,相当于入队指针,它指向的位置就是生产者会写入的位置;同时,它会随着程序执行一直变大。

如果之前设置的buffer_size大小为1024,那么enqueue_pos_的值可以比1024要大。

因为这个队列在逻辑上被设计成一个环形缓冲区,一旦enqueue_pos_的大小超过buffer_size,那么写入队列的位置就会从队列尾变为队列头,重新开始写。

这一过程需要依赖取模运算。

然而传统的%速度往往需要几十个时钟周期,而与运算&则快得多。

幸运的是,只要大于buffer_size的数字与buffer_mask_(大小为buffer_size - 1)做一次按位与运算,高位会被全部截断,结果会被拉回0~buffer_size-1的范围。

这就是&代替%的依据。

在这里,首先通过pos取到当前时刻生产者需要写入的位置enqueue_pos_的值。

这里的load函数,包括它的参数,其实都是作者自己写的,用来实现原子读取操作。

然后通过pos & buffer_mask_来获取当前pos值对应的buffer_下标,得到cell

分支判断

得到cell后,同样通过原子读取操作来获得其中sequence_的值,为seq

计算seqpos的差,得到dif,通过判断dif的值来决定下一步的具体操作。

如果dif == 0,那么就表示此时正好有个空位可以写入。

那么,就会调用CAS操作。

如果enqueue_pos_的值仍旧等于pos,那么就说明在这期间并没有其他线程抢先修改了这个位置中的值,那么当前线程就可以让pos + 1,直接抢占这个位置的写入权。

如果不是,那么就表示这个位置已经被其他线程抢占,遗憾放弃,同时它的pos值会被修改成1,重新进入循环去抢下一个位置。

如果dif < 0,那么说明生产速度快于消费速度,此时队列满了,不写入。

如果dif > 0,那么就说明有另一个速度极快的生产者,就连抢占的机会都不留,已经把这个位置的值修改成它的了;也有可能是因为某些故障,当前线程拿到了过时的值。

那么当前生产者只好重新加载enqueue_pos_的值,继续抢位置。

读写数据

在位置抢完之后,生产者/消费者就可以安心在抢来的位置上写入/读取对应的数据了。

buffer_[0]为例,此时整个队列长度为1024。假如pos = 0,那么sequence_/seq的值均为0

一个生产线程想往buffer_[0]中写入数据,发现正好可以写入,那么它写入之后,就会将sequence_的值改为pos + 1的值,此时为1

当生产线程写完后,一个消费者正好也想读buffer_[0]的数据,发现此时sequence_ = 1,正好可以读取。

读取完之后,它会将sequence_的值改为0 + buffer_size = 1024,用于下一轮写入。

所以,当生产者快于消费者时,假如生产者的pos = 1024,但是buffer_[0]sequence_ = 0,即读出的seq = 0,此时dif < 0

而如果它已经被消费者读取过了的话,此时应该是sequence_ = 1024

这也就是为什么说,说dif < 0时生产速度快于消费速度。

简易版Atomic

由于作者在写这个MPMC队列的时候,C++11标准尚未普及,还并没有官方的内存模型和标准库std::atomic

于是他利用了x86/64架构的硬件特性,结合MSVC编译器的特有内联函数(Intrinsics),在没有官方支持的情况下实现了内存可见性和原子性。

内存顺序枚举

C++ 复制代码
enum memory_order
{
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst,
};

为了追求性能,编译器和CPU在执行时会重排代码顺序,这在多线程中,可能会导致程序运行结果错误。

而作者指定这些内存顺序枚举的目的,是限制代码在具体操作中的内存顺序重排,防止出现不可预测的结果。

代码中顺序枚举的含义和解释如下:

内存顺序 含义 解释
memory_order_relaxed 松散内存顺序 只保证操作本身是原子操作,不保证任何顺序
memory_order_consume 消费顺序 后续的与当前要读取内存有关的读写操作不能重排到该操作之前
memory_order_acquire 获取顺序 后续的所有读写操作不能重排到当前操作之前
memory_order_release 释放顺序 语句前所有读写操作都不能重排到当前操作之后
memory_order_acq_rel 获取-释放顺序 当前操作之前的读写指令,不能重排到当前操作之后; 当前操作之后的读写指令,不能重排到当前操作之前; 保证有关线程看到的顺序是正确的,不保证无关线程看到的顺序
memory_order_seq_cst 顺序一致顺序 当前操作之前的读写指令,不能重排到当前操作之后; 当前操作之后的读写指令,不能重排到当前操作之前; 所有线程看到的顺序都是统一的

被操作的值

C++ 复制代码
class atomic_uint
{
private:
    unsigned volatile           val_;
};

C++中的volatile关键字表示变量可能会被一些未知的东西修改,例如硬件、其他线程等。

故而,如果程序中使用了该变量,那么它会禁止编译器优化相关变量,确保每次都从内存读取。

由于volatile是为了硬件修改对象而定义的,所以它并不能保证在多线程中的安全。

但是,Windows的MSVC编译器扩展了它的语义,使其能够在访问中满足acquire/release的内存顺序。

所以这里定义了一个类型为usigned的无符号整数,并通过volatile关键字来修饰它,让它能够与后续代码配合,防止重排。

原子操作实现

读取操作

C++ 复制代码
class atomic_uint
{
public:
    unsigned load(memory_order mo) const volatile
    {
        (void)mo;
        assert(mo == memory_order_relaxed
            || mo == memory_order_consume
            || mo == memory_order_acquire
            || mo == memory_order_seq_cst);
        unsigned v = val_;
        _ReadWriteBarrier();
        return v;
    }
};

函数头中的const表示在当前函数中val_不会被修改;volatile表示,允许通过该类型的对象来调用这个函数。

因为这是个读取函数,所以assert用来在Debug模式下进行拦截,防止误传写入语义memory_order_releasememory_order_acq_rel

_ReadWriteBarrier作用是防止编译器对这行代码前后的读写指令进行重排优化。就是,在它之前的所有操作,在生成的汇编代码里,不能挪到它后面去;在它之后的所有操作,也不能挪到它前面去。

同时,这个函数是依赖x86x64架构实现的,不利于跨平台、也容易翻车,所以目前已被弃用。

但是这个特点用在却有意想不到的效果,因为x86/x64架构采用了强内存模型(Strong Memory Model,TSO),它的特点是除了写-读(Store-Load)重排,其他顺序(Load-Load、Load-Store、Store-Store)不重排,然而我们的代码里并不存在写-读的顺序,所以就在上面防止编译器重排之外,保证了CPU的不重排。

所以,load的功能就是,以多线程安全的形式,从内存中读取变量val_的最新值,并保证它执行的理想顺序。

重排情景

假如有两个内存变量xy,有个线程A,不同的重排是在不同的情景发生的:

  • 读读重排(Load-Load):A先读取变量x的值,紧接着读取变量y的值。
  • 读写重排(Load-Store):A先读取变量x的值,紧接着写入变量y的值。
  • 写写重排(Store-Store):A先写入变量x的值,紧接着写入变量y的值。
  • 写读重排(Store-Load):A先写入变量x的值,紧接着读取变量y的值。

写入操作

C++ 复制代码
class atomic_uint
{
public:
    void store(unsigned v, memory_order mo) volatile
    {
        assert(mo == memory_order_relaxed
            || mo == memory_order_release
            || mo == memory_order_seq_cst);

        if (mo == memory_order_seq_cst)
        {
            _InterlockedExchange((long volatile*)&val_, (long)v);
        }
        else
        {
            _ReadWriteBarrier();
            val_ = v;
        }
    }
};

它有两个分支,如果要求memory_order_seq_cst,那么会调用_InterlockedExchange函数。

这个函数是Windows平台的编译器内建函数,从逻辑上,它将一个变量替换为新值,并返回它的旧值,但是这个过程是原子性的,即不会被线程切换打断;同时,它生成全内存屏障,即在它之前的读写操作不能放到它后面,在它之后的读写操作不能放到它前面,并且让最新的执行结果所有线程可见,满足memory_order_seq_cst语义。

This function generates a full memory barrier (or fence) to ensure that memory operations are completed in order.

该函数生成全内存屏障,以确保内存操作按顺序完成。

虽然有这个分支,但其实,在这个队列的实现中,store函数是没用到这个的。

不过既然代码并不阻止store传入这个memory_order_seq_cst,那么最好就处理一下;当然,也不排除作者直接将之前写过的atomic通用工具类复制在这里的可能。

否则,只需使用_ReadWriteBarrier保证当前线程之前的所有写入操作都不要重排到这次写入操作之后即可。

CAS操作

C++ 复制代码
class atomic_uint
{
public:
    bool compare_exchange_weak(unsigned& cmp, unsigned xchg, memory_order mo) volatile
    {
        unsigned prev = (unsigned)_InterlockedCompareExchange((long volatile*)&val_, (long)xchg, (long)cmp);
        if (prev == cmp)
            return true;
        cmp = prev;
        return false;
    }
};

cmp是预期值,xchg是新值。

_InterlockedCompareExchange的功能是查看val_值,如果val_ = cmp,那么将val_改成xchg;而无论val_cmp是否相等,都返回旧值。

同样地,这个函数也生成全屏障。

而后续的代码,就是拿返回的旧值prevcmp对比,看看是否抢占成功。

如果抢占失败,还让cmp = prev,方便线程继续抢占新的位置。

所以,这个函数就是,拿着预期值来与当前关心的值做对比,如果相同,就修改当前值;否则,就更新预期值,方便线程投入对下一个位置的抢占。

使用准备

模板特化

C++ 复制代码
template<typename T>
class atomic;

template<>
class atomic<unsigned> : public atomic_uint
{
};

这里通过模板特殊化,用作者手写的atomic_uint替换了atomic

也就是说,在这种情况下,只要有人使用了atomic<unsigned>,调用的就是作者手写的atomic_uint的代码。

注入命名空间

C++ 复制代码
namespace std
{
    using ::memory_order;
    using ::memory_order_relaxed;
    using ::memory_order_consume;
    using ::memory_order_acquire;
    using ::memory_order_release;
    using ::memory_order_acq_rel;
    using ::memory_order_seq_cst;
    using ::atomic_uint;
    using ::atomic;
};

这里通过using将作者自己写的atomic,包括一系列memory_order的枚举都在std命名空间里注册了一遍。

所以,以后就可以直接使用像std::memory_order_acquire这样的写法了。

这里还有一个比较微妙的点,Dmitry Vyukov的这篇博客在2010年发布,C++11在2011年发布,而作者在这个时候可能已经大概知道C++的官方atomic库是什么样的了。

所以他让自己写的atomic_uint库的使用方法和未来会出的官方库的使用方法统一,可能是为了等真正的官方标准出来之后,可以不用大改其中这一部分的使用代码。

测试与使用

参数配置

C++ 复制代码
size_t const thread_count = 4;
size_t const batch_size = 1;
size_t const iter_count = 2000000;

bool volatile g_start = 0;

说明如下:

参数名 说明
thread_count 4 启动4个并发线程
batch_size 1 每次批量操作的数量为1
iter_count 2000000 每个线程循环执行200万次
g_start 0 用来控制所有线程同时启动

线程工作函数

C++ 复制代码
typedef mpmc_bounded_queue<int> queue_t;

unsigned __stdcall thread_func(void* ctx)
{
    queue_t& queue = *(queue_t*)ctx;
    int data;

    srand((unsigned)time(0) + GetCurrentThreadId());
    size_t pause = rand() % 1000;

    while (g_start == 0)
        SwitchToThread();

    for (size_t i = 0; i != pause; i += 1)
        _mm_pause();

    for (int iter = 0; iter != iter_count; ++iter)
    {
        for (size_t i = 0; i != batch_size; i += 1)
        {
            while (!queue.enqueue(i))
                SwitchToThread();
        }
        for (size_t i = 0; i != batch_size; i += 1)
        {
            while (!queue.dequeue(data))
            SwitchToThread();
        }
    }

    return 0;
}

这是每个子线程实际运行的函数。

它首先获取属于自己的测试队列queue和不同的暂停时间pause

设置不同的暂停时间,是为了打散不同线程的执行节奏,防止惊群效应,也是为了执行的情况更加真实。

而当主线程使g_start的值变换,当前线程就会不再让出CPU,稍微暂停后开始执行自己的任务。

接着,就会进入循环,开始高频入队出队,直至结束。

所以这个函数实际上是一个并发压力测试器,通过线程的错峰"起跑"和高频出入队来测试队列的性能。

为何指定__stdcall

__stdcall是Windows专门为Win32 API制定的规则,是一个C++函数调用约定,它的特点是函数参数会从右向左入栈,如函数func(a,b)b会先入栈,然后是a

同时,由于被调用的函数需要自己负责清理内存参数,所以它不支持处理参数可变的函数,在代码里使用它时必须事先写好被调用的函数声明。

而在这里,如果不明确写__stdcall的话,那么就会默认使用__cdecl调用约定。 __cdecl的特点是参数从右到左入栈,但是它需要调用者清理栈上的数据。

所以这样可能会让调用者和被调用函数都默认对方会清理栈上内容,导致栈上本该清理的东西未清理;同时,创建线程的函数_beginthreadex明确规定了调用约定为__stdcall,为了避免编译报错,也要指定__stdcall调用约定。
从ctx到queue

入参ctx是一个void类型的指针,编译器此时只知道它是一个内存地址,但不知道它是队列queue(1)的地址。

那么,就无法直接调用上面队列类中的函数。

所以,需要将ctx的类型转换过去。

首先,通过强制类型转换(queue_t*)ctx,让它变成了队列指针。

其次,通过对转换后类型的解引用*(queue_t*)ctx,得到了该指针存储地址中的队列对象。

最后,通过queue_t& queue = ... 绑定到引用,就是为*(queue_t*)ctx起了个别名叫queue(2)。

这样,以后如果有用到队列*(queue_t*)ctx时就不用写一大串字符而用简短直观的queue代替;并且可以不用通过指针箭头->形式调用函数,可以通过.来调用,例如queue.enqueue(i);

虽然queue(1)和queue(2)名字相同,但是(1)是在main函数定义的长度为1024的队列,(2)是这个函数定义的引用。
pause

1. srand产生随机种子:

rand函数其实生成的是伪随机数,当随机种子不变时,每次生成的随机数序列也都一样。

srand(seed)的作用就是初始化随机种子,那么根据不同的种子,每次就会生成不同的结果。

time(0)返回的是自1970年以来的总秒数,时间每秒都在变,那么没秒获取time(0)的值不相同。

从这个角度看,time(0)作为seed似乎已经足够。

然而,在多线程程序中,多个线程可能在同一个毫秒级的时间内被创造出来,那么当这些线程调用time(0)时,仍旧会得到完全相同的数字,那么srand就会生成相同的随机数序列,无法满足测试要求。

所以,为了保险起见,为让time(0)GetCurrentThreadId()一起作为随机种子。
GetCurrentThreadId()用于获取当前线程的ID。

由于每个线程的ID都不同,那么即使获取的time(0)相同,最终得到的随机种子也不同。

而如果只用当前线程ID作为随机种子,难免落于死板。

2. pause的生成:

pause在这里是一个局部变量,用于存储当前线程的空转次数。

在上面srand生成随机种子之后,就会由rand()生成随机数。

rand() % 1000则是把pause的大小限制在0~999之间。
rand()生成的随机数范围一般在0~RAND_MAX之间,其中RAND_MAX是一个预定义的值,可能会很大。

所以通过一个取模运算来限制生成的随机数大小,可以防止线程空转太久,影响测试效率。

3. 为什么要有pause

当线程开始跑时,会让它先根据pause的大小停留一段时间,才正式起跑。

这是为了防止线程们在同一时间去争夺同一资源。

而这可以让多线程的运行状态更加真实;在一定程度上,也可以防止惊群效应浪费大量CPU资源。

同时,下面用的是_mm_pause(),并未切换线程,只是短暂停留一下,把线程们的进度错开,而并没有真正将它们挂起。
SwitchToThread() vs _mm_pause()

1. SwitchToThread:

SwitchToThread()是Windows的一个函数,它的作用是让当前线程让出CPU的执行权,让操作系统挑选其他线程先跑。

在操作系统层面,CPU通过划分时间片来管理多线程。当线程使用这个函数进行执行权的让渡时,其有效时间最多只有一个线程调度时间片。

并且这种"让出"是对于同一个CPU核心(处理器)上的线程而言的。如果当前核心没有其他线程运行,其他线程有,哪怕当前线程让出执行权,也不会给其他核心上的线程,而是马上收回去。

而当让出的时间片耗尽之后,操作系统会重新为这个让步线程进行调度,具体顺序由该线程自身优先级以及其他线程状态共同决定。

同时,线程池能容纳的线程数量是有上限的,假如上限为4,如果这四个线程中的某一个调用SwitchToThread()想让第5个线程进来一起工作,那么操作系统是不会切换该线程的。

如果这时候,线程池中的某个/多个线程如果依赖第5个线程的结果,那么就会一直卡住,造成死锁。

2. _mm_pause:

_mm_pause()是一个硬件级别的CPU指令,告诉CPU在当前线程正在自旋等待,目的是为了降低功耗。

3. SwitchToThread() vs _mm_pause()

对比角度 SwitchToThread _mm_pause
执行者 操作系统 硬件支持
等待时间 相对长,最多一个CPU时间片 相对短,几十个时钟周期
切换线程

main函数

C++ 复制代码
int main()
{
    queue_t queue (1024);

    HANDLE threads [thread_count];
    for (int i = 0; i != thread_count; ++i)
    {
        threads[i] = (HANDLE)_beginthreadex(0, 0, thread_func, &queue, 0, 0);
    }

    Sleep(1);

    unsigned __int64 start = __rdtsc();
    g_start = 1;

    WaitForMultipleObjects(thread_count, threads, 1, INFINITE);

    unsigned __int64 end = __rdtsc();
    unsigned __int64 time = end - start;
    std::cout << "cycles/op=" << time / (batch_size * iter_count * 2 * thread_count) << std::endl;
}

主函数首先初始化队列长度为1024,创建thread_count个线程,并让主线程挂起1毫秒。

接着,计算子线程在并发过程中所占用的时间,输出测试结果。

HANDLE

HANDLE在Windows系统用来标识和管理某个系统资源(如线程、文件等)。

C++ 复制代码
typedef void *HANDLE;

它现在是一个void*,即无类型指针;不过在理论层面上,HANDLE可以是任何东西,指针、整数或结构体等,并且对用户不透明。

所以用户并不需要关心它是什么,如何实现的,只需要知道可以通过它操作某种资源就行。

之所以要有HANDLE,首先是因为,在系统运行过程中,有些对象会因内存资源不足而被移出内存、或移到另一个内存区域,如果这时操作的是指针,那么这个指针就作废了,而HANDLE始终指向那个资源。

打个比方,假如要看电影,一个真实的内核对象就是电影院里的一个座椅,指针记录这个座椅在影厅的空间位置,HANDLE就是电影票,上面有排数和座号(假如上面是3排6号)。当这个影厅把所有座椅的位置往前挪动一米之后,每个座椅就不在它原来的位置上了(指针失效),但是仍然可以凭借3排6号这个信息找到自己的位置。

同时,系统隐藏实现细节,将资源的信息封装为HANDLE,而非直接给出指针,也有利于权限控制和安全性;也方便向前兼容迭代。
Sleep(1) vs g_start

Sleep(1)的功能是将当前线程挂起1毫秒。

这里在创建完所有线程后,使用这个函数的目的是为了防止后面线程还在创建时,先创建的线程已经提前开始运行,导致结果不准确。

然而,在前一部分曾经提到,而当主线程修改g_start的值时,子线程才会真正开始执行自己的任务。

理论上都是为了对齐子线程运行的时间,为什么既要有Sleep(1)又要有g_start呢?

拿跑步举例,Sleep(1)是为了保证所有人的出发点都在起跑线上;g_start的存在是为了保证所有人能在同一时刻出发,相当于发令枪。

如果只有Sleep(1),那么,那么就相当于先创建的线程陆续抢跑,不仅在执行时间上不公平;同时,等主线程从1ms的等待中醒来开始计时时,这个测试已经跑了大半了,导致计时不准确。

如果只有g_start,由于线程创造需要时间,那么当发令枪打出信号时,可能只有第一个线程站在了起跑线上,其他线程相当于还在前往起跑线的阶段(还在创造中),那么这时也就只有第一个线程能跑并且没有其他线程竞争,难以体现出高并发多线程下的压力测试场景,并且得到的性能结果会偏好。
_beginthreadex、__int64、__rdtsc、WaitForMultipleObjects

1. _beginthreadex函数

_beginthreadex函数用于创建线程。

2. __int64

__int64是Windows原生的64位有符号整型,与long long等效。

它是Windows在C++11尚未统一64位整数之前引入的扩展关键字,所以这种写法多见于老代码。

3. __rdtsc

TSC全称是时间戳计时器,实际上是CPU内部的一个64位寄存器,计数随CPU时钟周期增加。

它的计数也经历了从不稳定到不被CPU变频/休眠影响的发展,一般用于高精度计时(纳秒级)。

__rdtsc是Windows上的一个编译器内联函数,用于生成rdtsc指令,返回自重置(一般是开机)以来的CPU时钟周期数。

4. WaitForMultipleObjects函数

WaitForMultipleObjects函数的作用是,让当前线程进入等待状态,直到被指定的一个或多个对象进入"有信号"状态、或超过时间间隔仍无消息。

相关推荐
clint4566 小时前
C++进阶(1)——前景提要
c++
夜悊10 小时前
C++代码示例:进制数简单生成工具
c++
郝学胜_神的一滴11 小时前
CMake 021: IF 条件判据详诠
c++·cmake
_wyt0011 天前
洛谷 B3930 [GESP202312 五级] 烹饪问题 题解
c++·gesp
玖玥拾1 天前
C/C++ 数据结构(七)栈、容器适配器
c语言·数据结构·c++··容器适配器
один but you1 天前
constexpr函数
c++
凡人叶枫1 天前
Effective C++ 条款41:了解隐式接口和编译期多态
java·开发语言·c++·effective c++
凡人叶枫1 天前
Effective C++ 条款42:了解 typename 的双重意义
java·linux·服务器·c++
小胖xiaopangss1 天前
BRpc使用
c++·rpc
-森屿安年-1 天前
63. 不同路径 II
c++·算法·动态规划