前言

实际上,我们在实现并发库的时候,对于互斥锁的使用还是有效率的损失的。(注意:一个程序的安全性是第一,其次谈效率)。这就是在于锁的申请和堵塞等问题。

C++11 退出了一个原子类模板:std::atomic。提供了一种雾锁机制来实现线程安全的机制。通过 std::atomic 原子操作避免了数据的竞争。

下面我们来简单介绍一下 std::atomic。关于接口的使用建议大家看官方文档:

1. atomic

atomic是⼀个模板的实例化和全特化均定义的原子类型,他可以保证对⼀个原⼦对象的操作是线程安全的。

官方文档的介绍十分详细,我们这里就不再过多介绍了,有需要可以查阅官方文档。小编在这里也是主要提出几个需要注意的点:

第一个问题

  • std::atomicT 类型的要求模板可用任何满足可复制构造 (CopyConstructible) 及可复制赋值(CopyAssignable) 的可平凡复制 (TriviallyCopyable) 类型 T 实例化,T类类型以下几个函数判断时,如果⼀个返回 f a l s e false false,则用于 atomic 不是原子操作
std::is_trivially_copyable<T>::value
std::is_copy_constructible<T>::value
std::is_move_constructible<T>::value
std::is_copy_assignable<T>::value
std::is_move_assignable<T>::value
std::is_same<T, typename std::remove_cv<T>::type>::value

我们可用简单理解为:

  • 能够支持逐字节拷贝的类型:整型、指针……

demo:

struct Date
{
	int _year = 1;
	int _month = 1;
	int _day = 1;
};

template <class T>
void check()
{
	cout << typeid(T).name() << endl;
	cout << std::is_trivially_copyable<T>::value << endl;
	cout << std::is_copy_constructible<T>::value << endl;
	cout << std::is_move_constructible<T>::value << endl;
	cout << std::is_copy_assignable<T>::value << endl;
	cout << std::is_move_assignable<T>::value << endl;
	cout << std::is_same<T, typename std::remove_cv<T>::type>::value << endl
		<< endl;
}

int main()
{
	check<int>();
	check<double>();
	check<int*>();
	check<Date>();
	check<Date*>();
	check<std::string>();
	check<std::string*>();

	return 0;
}

最终的结果是:

  • std::string 不可以使用原子类型。

    string 不能支持原子类型是情有可原的:string明显就是一个深拷贝的类。是无法支持原子类型的。

  • 剩下的都可以支持原子类型。

    虽然 Date 是一个自定义类型,但是其内部的成员都是不需要进行深拷贝的成员。即 Date 类型是一个支持逐字节拷贝的类

第二个问题

  • std::atomic 中有很多特化的成员函数

    在这里插入图片描述
    其中对整型功能支持最多。我们使用 std::atomic 很多时候也是用来对整型进行保护。我们也可以通过 std::atomic 实现对指针的无锁编程实现线程安全的数据结构:队列、栈……(后面我们会使用到)

    例如:C++11中的 std::shared_ptr 本身的计数引用时线程安全的,但是指向的对象不是线程安全的!所以C++20 推出了 std::shared_ptr 的特化版本。

    在这里插入图片描述

    C++20 支持了对其中两个智能指针的原子类型。

demo:

#include <atomic>
#include <memory>

struct MyData {
    int value;
    std::string name;
};

std::atomic<std::shared_ptr<MyData>> atomic_ptr;

// 线程安全的 shared_ptr 操作
void use_atomic_shared_ptr() {
    // 存储新的 shared_ptr
    auto new_data = std::make_shared<MyData>(42, "test");
    atomic_ptr.store(new_data);
    
    // 加载当前值
    auto current = atomic_ptr.load();
    if (current) {
        std::cout << current->value << std::endl;
    }
}

我们也不探讨具体是怎么实现的,可能就是:内部自己实现了互斥锁/无锁编程。上层用户使用感知不到。

性能考虑

  • 开销:比非原子 shared_ptr 有额外开销

  • 适用场景:需要在线程间传递所有权时使用

  • 替代方案:如果只需要共享读取,考虑 std::shared_ptr + 单独同步

2. CAS

atomic 的原理主要是硬件层面的⽀持,现代处理器提供了原子指令来支持原⼦操作。例如,在x86架构中有CMPXCHG(比较并交换)指令。这些原子指令能够在⼀个不可分割的操作中完成对内存的读取、比较和写⼊操作,简称CAS,Compare And Set,或是 Compare And Swap。另外为了处理多个处理器缓存之间的数据⼀致性问题,硬件采用了缓存一致性协议,当一个 atomic 操作修改了⼀个变量的值,缓存⼀致性协议会确保其他处理器缓存中的相同变量副本被正确地更新或标记为无效

  • CAS

    CAS 是 compare_and_swap 的缩写。简单来说:就是硬件在指令层面支持了两个操作是原子的:compareswap比较和交换通过这两个指令,我们可用实现无锁编程。

    std::atomic 提供了成员函数,支持这样的操作:

    在这里插入图片描述
    同时C++11提供了全局函数来支持这样的操作:

来看下面一个demo:

std::atomic<int> acnt;
int cnt;

void Add1(std::atomic<int>& cnt)
{
	int old = cnt.load();
	// 如果cnt的值跟old相等,则将cnt的值设置为old+1,并且返回true,这组操作是原⼦的。
	// 那么如果在load和compare_exchange_weak操作之间cnt对象被其他线程改了
	// 则old和cnt不相等,则将old的值改为cnt的值,并且返回false。
	while (!atomic_compare_exchange_weak(&cnt, &old, old + 1));
	//while (!cnt.compare_exchange_weak(old, old + 1));
}
void f()
{
	for (int n = 0; n < 100000; ++n)
	{
		++acnt;
		// Add1的⽤CAS模拟atomic的operator++的原⼦操作
		//Add1(acnt);
		++cnt;
	}
}
int main()
{
	std::vector<std::thread> pool;
	for (int n = 0; n < 4; ++n)
		pool.emplace_back(f);
	for (auto& e : pool)
		e.join();
	cout << "原子计数器为:" << acnt << '\n'
		<< "非原子计数器为:" << cnt << '\n';
	return 0;
}
  • 运行结果如下:

    在这里插入图片描述

2.1 原理分析

  • 分析

    这样是如何实现的呢?

    首先保证 compareswap 两个操作一起是原子的。这是硬件支持的。

来看下面这样的一个示例代码:

template <class T>
bool atomic_compare_exchange_weak (atomic<T>* obj, T* expected, T val) noexcept
{
	if(compare(obj, expected)) //compare、swap两步一起是原子的。
	{
		swap(*obj, val);
		return true;
	}
	else
	{
		*expected = *obj;
		return false;
	}
}
  • expected:这是一个旧的值。是我们在进行对原子变量进行访问之前保存的一个旧值;atomic<T>* obj:这是一个内存中的值。通过这个值,我们能找到内存中的当前原子变量的真实值。val:这是一个期望值,我们希望我们的原子变量改变的值。

    1. 进入函数,我们直接比较两个旧原子变量值和真实原子变量值的大小。如果相等,那么说明我们真实的原子变量值是没有被改变的(其它执行流),我们就使用 swap 将真实的原子变量值该为期望的原子变量值,直接返回 t r u e true true 说明修改成功;如果不相等说明,当前原子变量的值已经被其它执行流修改了,我们就不能进行swap操作,所以我们需要将 expected 修改为当前原子变量的值,返回 f a l s e false false 说明修改失败。

    2. 实现的核心就依靠的是:两步操作的原子性更新旧原子变量值

      原子操作的必要性我们就不谈了。这是最基础的。最主要的就是:为什么要更新旧的原子变量值呢?

      我们应该这么来理解 CAS:

      • C A S 失败 ≠ 程序错误 CAS失败 ≠ 程序错误 CAS失败=程序错误

      • C A S 失败 = " 值已改变,请重试 " CAS失败 = "值已改变,请重试" CAS失败="值已改变,请重试"。一定要理解 CAS 是一种尝试机制

      这是 CAS 操作是一个尝试性的操作,每次尝试都是带着用户以为的真实值确实的真实值期望值来进行操作。从反面来说:如果我们不改变用户以为的真实值,那么这个程序就是一个死循环,因为确实的真实值已经发生改变了,但是用户以为的真实值没有更新,两者除非有意更改否则不可能再次相等了。所以:传递指针是非常有必要的!为了改变实参的值!

C++11的CAS操作支持,atomic 对象跟 expected 按位比较相等,则利用 val 更新 atomic 对象并返回值 t r u e true true;若 atomic 对象跟 expected 按位比较不相等,则更新 expected 为当前的 atomic 对象并返回值 f a l s e false false

2.2 ABA问题

ABA

  1. 场景

    线程1来修改一个变量x的值为A,但是这个时候线程2 先来到执行,线程2比较x 的值为A,此时线程2修改x的值为B,之后,再次修改x的值为A,此时线程2执行结束,交给线程1执行。线程1进行比较的时候发现x的值并没有发生改变,于是将x的值改为其它值。

    简单来说:一个线程认为某一个共享变量的值没有发生变化,但是实际上它已经被其它线程“悄悄地”修改过了然后又修改回来了

  2. 潜在问题

    无锁链表/队列:线程1 要删除头节点 A,它先读取 A->next 是节点 B。此时线程2 删除了节点A 和 节点B,然后又创建并重新插入了节点 A(地址可能和旧A一样),并将 A->next 指向节点C。线程1 执行 CAS,发现 head 还是 A,于是成功将 head 指向 节点B,但这时的 节点B 可能已经被释放,甚至被重用,导致内存错误或数据混乱

  3. 解决方案

    • 使用双字(Double-Word)CAS:双字CAS可以同时比较和交换两个连续的字(通常是64位系统中的128位)。这使得开发者可以将一个额外的计数器或版本号与数据一起存储和检查。这种方法在现代处理器中已经得到了支持,比如在Intel的处理器中可以使用CMPXCHG16B指令。

2.3 补充

compare_exchange_weak 在某些平台上,即使原⼦变量的值等于 expected,也可能“虚假地”失败(即返回 f a l s e false false)。这种失败是由于底层硬件或编译器优化导致的,但不会改变原子变量的。compare_exchange_strong 保证在原子变量的值等于 expected 时不会虚假地失败。只要原子变量的值等于 expected,操作就会成功。compare_exchange_weak 在某些平台上可能比 compare_exchange_strong 更快。compare_exchange_weak 可能会虚假的失败主要是由于硬件层间的缓存⼀致性和编译器优化等等,compare_exchange_strong 要避免这些原因就要付出⼀定的代价,比如要使用硬件的缓存⼀致性协议(如 MESI 协议)。

3. 自旋锁实现

C++11并没有为我们提供一个自旋锁的实现,所以我们需要自己手动造文字。

我们可用利用C++11提供的一个原子类型:std::atomic<bool>/std::atomic_flag来实现一个C++11的自旋锁

  • 最简单版本的自旋锁:
class SpinLock {
private:
		//第一次设置我们需要把锁的状态是无
    std::atomic_flag flag = ATOMIC_FLAG_INIT;

public:
    void lock() {
    //test_and_set:将flag设置为true,并且返回之前的flag的值
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 自旋等待
        }
    }

    void unlock() {
    //clear:将flag设置为false
        flag.clear(std::memory_order_release);
    }

    bool try_lock() {
        return !flag.test_and_set(std::memory_order_acquire);
    }
};

当一个线程过来申请自旋锁了,将flag设置为true,并且返回fasle,这个时候该线程就退出了。后面的线程来申请的时候,都会将flag设置为true,并且返回的都是true。只有当第一个申请的线程释放的时候,这个时候会有一个线程能够得到flag的状态是fasle(这些操作都是原子的),这个时候这个线程就能成功申请到自旋锁了。但是其它的线程仍然只有自旋等待。

但是上面的线程是一个100%浪费CPU资源的线程,因为他在自旋等待的时候并没有做任何事情!所以,应对这样的情况,我们还可以设计一些退避策略

// 1. 无退避 - 最差性能
while (flag.test_and_set()) {
    // 空循环,100% CPU
}

// 2. 固定退避 - 中等性能  
while (flag.test_and_set()) {
    std::this_thread::sleep_for(std::chrono::microseconds(10));
}

// 3. 线性退避 - 较好性能
int wait_time = 1;
while (flag.test_and_set()) {
    for (int i = 0; i < wait_time; ++i) {
        std::this_thread::yield();
    }
    wait_time += 1;  // 线性增长
}

// 4. 指数退避 - 最佳性能
int backoff = 1;
while (flag.test_and_set()) {
    for (int i = 0; i < backoff; ++i) {
        std::this_thread::yield();
    }
    backoff = std::min(backoff * 2, MAX_BACKOFF);  // 指数增长
}

各种策略都是让当前线程主动放弃CPU资源,让其它线程来执行。

  • 性能考虑

    • 纯自旋在竞争激烈时会导致CPU资源浪费

    • 退避策略可以减少CPU占用

    • 适合锁持有时间很短的场景(临界区较短)

  • 适用场景:

    • 锁竞争不激烈

    • 锁持有时间很短

    • 不希望线程被挂起(避免上下文切换开销)

当然互斥锁的实现,并不是只有这样的方式,我们仍然可用通过互斥量(try_lock)来实现自旋锁。

完。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐