并发 Concurrency

概述

早期,由于摩尔定律带来的免费性能提升,高速的 Intel 单 CPU 是性价比最高的系统架构,没必要进行并发编程。

随之而来的主频提升停滞,(2001 年,Intel 已经有了主频 2.0 GHz 的 CPU,但目前我的PC主频是 2.1 GHz),服务器、台式机、笔记本、移动设备的处理器都转向了多核,计算要求则从单线程变成了多线程甚至异构,即不仅要使用 CPU,还得使用 GPU。

进程和线程

编译完执行的 C++ 程序,在操作系统看来就是一个进程,每个进程里可以有一个或多个线程。

  • 每个进程有自己的独立地址空间,不与其他进程分享;一个进程里可以有多个线程,共享同一个地址空间。
  • 堆内存、文件、套接字等资源都归进程管理,同一个进程里的多个线程可以共享使用。
  • 每个进程占用的内存和其他资源,会在进程退出或被杀死时返回给操作系统。
  • 并发应用开发可以用多进程或多线程的方式。多线程由于可以共享资源,效率较高;反之,多进程(默认)不共享地址空间和资源,开发较为麻烦,在需要共享数据时效率也较低。
  • 多进程安全性较好,在某一个进程出问题时,其他进程一般不受影响;而在多线程的情况下,一个线程执行了非法操作会导致整个进程退出。

C++ 里的并发,主要是多线程,从纯逻辑的角度,并发的思维模式比单线程更为困难。其中部分难点如下:

  • 编译器和处理器的重排问题
  • 原子操作和数据竞争
  • 互斥锁和死锁问题
  • 无锁算法
  • 条件变量
  • 信号量

mutex

互斥量的基本语义是,一个互斥量只能被一个线程锁定,用来保护某个代码块在同一时间只能被一个线程执行。

C++ 标准中,提供了不止一个互斥量类,最简单、最常用的 mutex 类。

mutex 只可默认构造,不可拷贝(或移动),不可赋值,主要提供的方法是:

  • lock:锁定,锁已经被其他线程获得时则阻塞执行
  • try_lock:尝试锁定,获得锁返回 true,在锁被其他线程获得时返回 false
  • unlock:解除锁定(只允许在已获得锁时调用)

如果一个线程已经锁定了某个互斥量,再次锁定时,对于 mutex,是危险的未定义行为。

如果有特殊需要可能在同一线程对同一个互斥量多次加锁,需要用到递归锁 recursive_mutex ,除了允许同一线程可以无阻塞地多次加锁外(也必须有对应数量的解锁操作),recursive_mutex 其他行为和 mutex 一致。

C++ 标准库还提供了:

  • timed_mutex:允许锁定超时的互斥量
  • recursive_timed_mutex:允许锁定超时的递归互斥量
  • shared_mutex:允许共享和独占两种获得方式的互斥量
  • shared_timed_mutex:允许共享和独占两种获得方式的、允许锁定超时的互斥量

头文件<mutex>中定义了锁的 RAII 包装类(如lock_guard),避免手动加锁、解锁的麻烦,以及在有异常或出错返回时发生漏解锁,一般应当使用 lock_guard,而不是手工调用互斥量的 lock 和 unlock 方法。

thread

#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>

using namespace std;

mutex output_lock;

void fun(const char* str)
{
    this_thread::sleep_for(1000ms);
    lock_guard<mutex> guard{ output_lock };
    cout << str << endl;
}

int main()
{
    thread t1{ fun, "A" };
    thread t2{ fun, "B" };
    t1.join();
    t2.join();
}
/*
B
A
*/

在 Linux 上编译线程相关的代码都需要加上 -pthread 命令行参数。

这里使用互斥量(mutex)锁定 cout,防止输出交错到一起(输出BA)。

程序流程如下:

  1. 传递参数,起两个线程
  2. 两个线程分别休眠 1000ms
  3. 使用互斥量(mutex)锁定 cout ,然后输出一行信息
  4. 主线程等待这两个线程退出后程序结束

thread 的构造函数的第一个参数是函数(对象),后面跟的是这个函数所需的参数。

thread 要求在析构之前 join(阻塞直到线程退出)或 detach(放弃对线程的管理),否则程序会异常退出。

sleep_for 是 this_thread 名空间下的一个自由函数,表示当前线程休眠指定的时间。

没有 output_lock 的同步,输出通常会交错到一起。

thread 封装

thread 不能在析构时自动 join (C++20有jthread),简单封装一下:

class m_jthread {
public:
    // 使用可变模板和完美转发来构造 thread 对象
    template <typename... Arg>
    m_jthread(Arg&&... arg) : m_thread(std::forward<Arg>(arg)...) {}
    
    // thread 可以移动
    m_jthread(m_jthread&& other) noexcept : m_thread(std::move(other.m_thread)) {}
    
    // thread 不能拷贝,禁用拷贝构造
    m_jthread(const m_jthread&) = delete;
    
    // 析构时自动 join
    ~m_jthread() { if (m_thread.joinable())  m_thread.join();  }
    
    m_jthread& operator=(m_jthread&& other) noexcept
	{ 
    	m_jthread(std::move(other)).swap(*this);
    	return *this;
	}
    void swap(m_jthread& rhs)
	{
    	std::swap(m_thread, rhs.m_thread);
	}
    
private:
    thread m_thread;
};

使用:

m_jthread t1{ fun, "A" };
m_jthread t2{ fun, "B" };

锁的本质属性是为事物提供访问保护

无锁

int cnt = 0;

void fun(int N)
{
	for(int i = 0; i < N; ++i){
		this_thread::sleep_for(10ms);
		++cnt;
	}
}

void test()
{
	const int N = 10;
	const int L = 100;
	m_jthread ts[N];
	for(int i = 0; i < N; ++i){
		ts[i] = m_jthread{fun, L};
	}
}

int main()
{
	test();
	cout << "cnt: " << cnt << endl; 
	return 0;
}

输出

cnt:992

由于++cnt不是原子操作,而是由多条汇编指令完成的。多个线程对同一个变量进行读写操作就会出现不可预期的操作。如果线程1读取到了100,线程2也读取到了100,分别执行自增操作,线程1和线程2分别将自增的结果写回cnt,不管写入的顺序如何,counter都会是101,但是线程1和线程2分别执行了操作,期望的结果是102。

加锁

mutex mtx;

void fun(int N)
{
	for(int i = 0; i < N; ++i){
		mtx.lock();
		this_thread::sleep_for(10ms);
		++cnt;
		mtx.unlock();
	}
}

输出

cnt:1000

  • 对于std::mutex对象,任意时刻最多允许一个线程对其进行上锁。
  • lock():调用该函数的线程尝试加锁。如果上锁不成功,即:其它线程已经上锁且未释放,则当前线程block。如果上锁成功,则执行后面的操作,操作完成后要调用unlock()释放锁,否则会导致死锁的产生。
  • try_lock():尝试上锁,与mtx.lock()的不同点在于:如果上锁不成功,当前线程不阻塞

死锁

考虑这样一个情况:假设线程1上锁成功,线程2上锁等待。但是线程1上锁成功后,抛出异常并退出,没有来得及释放锁,导致线程2永久的等待下去,此时就发生了死锁。

void fun(int N)
{
	for(int i = 0; i < N; ++i){
		mtx.lock();
		this_thread::sleep_for(1ms);
		++cnt;
		if(cnt == 100){
			throw runtime_error("runtime_error 666");
		}
		mtx.unlock();
	}
}

void fun_t(int N)
{
	try{
		fun(N);
	}
	catch (const exception& e){
		cout << "cnt: " << cnt << ", " << e.what() << endl;
	}
}

void test()
{
	const int N = 10;
	const int L = 100;
	m_jthread ts[N];
	for(int i = 0; i < N; ++i){
		ts[i] = m_jthread{fun_t, L};
	}
}

输出

cnt: 100, runtime_error 666

lock_guard 避免死锁

  • std::lock_guard对象构造时,自动调用mtx.lock()进行上锁
  • std::lock_guard对象析构时,自动调用mtx.unlock()释放锁
void fun(int L, int id)
{
	for(int i = 0; i < L; ++i){
		lock_guard<mutex> m_mtx(mtx);
		if(id == 9){
			throw runtime_error("runtime_error 666");
		}
		this_thread::sleep_for(1ms);
		++cnt;
	}
}

void fun_t(int L, int id)
{
	try{
		fun(L, id);
	}
	catch (const exception& e){
		cout << "cnt: " << cnt << ", " << e.what() << endl;
	}
}

void test()
{
	const int N = 10;
	const int L = 100;
	m_jthread ts[N];
	for(int i = 0; i < N; ++i){
		ts[i] = m_jthread{fun_t, L, i};
	}
}

输出

cnt: 119, runtime_error 666 cnt: 900

c++还提供了lock_guard的加强版 uniqe_lock,它提供了更多的接口,使其更加灵活,但性能方面也会有些受损。

返回数据

要在某个线程执行一些后台任务,然后取回结果,传统的做法是使用信号量或者条件变量。

C++17 不支持信号量,要模拟传统的做法,只能用条件变量。

#include <condition_variable> // 条件变量

void calculate(condition_variable& cv, int& val)
{
    // 计算时间 3s
    this_thread::sleep_for(3s);
    val = 666;
    cv.notify_one();
}

int main()
{
    condition_variable cv; 
    mutex cv_m; 
    int val;
    m_jthread t{ calculate, ref(cv), ref(val) };
    // do others
    cout << "waiting" << endl; 
    unique_lock lock{ cv_m }; 
    cv.wait(lock);
    cout << "val: " << val << endl;
}

用 ref 模板告诉 thread 的构造函数,需要传递条件变量和结果变量的引用,因为 thread 默认复制或移动所有的参数作为线程函数的参数。这种复杂性并非逻辑上的复杂性,而只是实现导致的,不是我们希望的写代码的方式。

notify_one() 与 notify_all()

notify_one():只唤醒等待队列中的第一个线程;不存在锁争用,能够立即获得锁。其余的线程不会被唤醒,需要等待再次调用notify_one()或者notify_all()。

notify_all():会唤醒所有等待队列中阻塞的线程,存在锁争用,只有一个线程能够获得锁。其余未获取锁的线程会继续尝试获得锁,当持有锁的线程释放锁时,这些线程中的一个会获得锁,而其余的会接着尝试获得锁。

mutex mtx;
condition_variable cv;
bool ready = false;

void fun(int i) {
	std::unique_lock<mutex> lck(mtx);
	while (!ready) cv.wait(lck);
    cout << "thread: " << i << endl;
}

void start() {
	std::unique_lock<std::mutex> lck(mtx);
	ready = true;
	cv.notify_one();  // cv.notify_one()
}

int main()
{
    const int N = 10;
    m_jthread threads[N];
    for (int i = 0; i < N; ++i) threads[i] = m_jthread(fun, i);
	cout << "thread start..." << endl;
	start();                      
	return 0;
}

使用gcc编译:gcc -o main main.cpp -lstdc++ -static,-static 防止找不到程序输入点。

输出如下,线程启动不全,并且程序没有退出,一直处于阻塞状态。

thread start... thread: 0 thread: 6 thread: 7 thread: 8

改为notify_all(),输出如下,线程全部启动,程序退出。

thread start... thread: 9 thread: 6 thread: 7 thread: 8 thread: 5 thread: 4 thread: 3 thread: 2 thread: 1 thread: 0

future

使用 async 会返回一个future。

async 也不是真正的异步,而是封装的多线程。

C++20 都没有提供完整的异步支持,因为 I/O 方面的异步没有标准化,可以看看 Boost.Asi。

int fun()
{
	this_thread::sleep_for(3s);
	return 666;
}

int main()
{
	// #include <future> 
	auto f = async(launch::async, fun);
	cout << f.get() << endl; // 666
	return 0;
}
  • fun 函数不需要考虑条件变量之类的实现细节了,返回结果就可以了。

  • 用 async 获得一个未来量,launch::async 是运行策略,告诉函数模板 async 在新线程里异步调用目标函数。

    在一些老版本的 GCC 里,不指定运行策略,默认不会起新线程。

  • async 函数模板可以根据参数来推导出返回类型,这里返回类型是 future<int>
  • 未来量上调用 get 成员函数可以获得其结果。这个结果可以是返回值,也可以是异常,如果 fun 抛出了异常,main 里在执行 f.get() 时也会得到同样的异常,需要有相应的异常处理代码程序才能正常工作。

注意

一个 future 上只能调用一次 get 函数,第二次调用为未定义行为,通常导致程序崩溃。

一个 future 是不能直接在多个线程里用的,可以直接拿 future 来移动构造一个 shared_future,或调用 future 的 share 方法来生成一个 shared_future,结果就可以在多个线程里用。每个 shared_future 只能调用一次 get 函数。

future执行get函数的时候,如果此时还没生成结果,就是阻塞等待了,直到有返回值为止。

promise

用 promise 的实现方式。

void fun(promise<int> p)
{
	this_thread::sleep_for(3s);
	p.set_value(666);
}

int main()
{
	promise<int> p;
	auto f = p.get_future();
	m_jthread t1{fun, std::move(p)};
	cout << f.get() << endl; // 666
	return 0;
}

promise 和 future 在这里成对出现,可以看作是一个一次性管道。

std::move(p)把 prom 移动给新线程,老线程就不需要管理它的生命周期了。

需要注意的是,一组 promise 和 future 只能使用一次,既不能重复设,也不能重复取。

promise 和 future 还有个有趣的用法是使用 void 类型模板参数,这种情况下,两个线程之间不是传递参数,而是进行同步:当一个线程在一个 future 上等待时(使用 get() 或 wait()),另外一个线程可以通过调用 promise 上的 set_value() 让其结束等待、继续往下执行。

packaged_task

打包任务 packaged_task。

int fun()
{
	this_thread::sleep_for(3s);
	return 666;
}

int main()
{
	packaged_task<int()> p_t{fun};
	auto f = p_t.get_future();
	m_jthread t1{std::move(p_t)};
	cout << f.get() << endl; // 666
	return 0;
}

打包任务里打包的是一个函数,模板参数是一个函数类型。跟 thread、future、promise 一样,packaged_task 只能移动,不能复制。它是个函数对象,可以像正常函数一样被执行,也可以传递给 thread 在新线程中执行。可以从它得到一个未来量。通过这个未来量,可以得到打包任务的返回值,或者至少知道这个打包任务已经执行结束了。

执行顺序

下面的事实可能会产生不符合直觉预期的结果:

  • 为了优化的必要,编译器是可以调整代码的执行顺序的。唯一的要求是,程序的可观测外部行为是一致的。
  • 处理器会对代码的执行顺序进行调整(所谓的 CPU 乱序执行)。在单处理器的情况下,这种乱序无法被程序观察到;在多处理器的情况下,在另一个处理器上运行的另一个线程就可能会察觉到这种不同顺序的后果。

假设我们有两个全局变量:

int x = 0;
int y = 0;

在线程1里执行:

x = 1;
y = 2;

在线程2里执行:

if(y == 2){
    x = 3;
    y = 4;
}

可能出现 x = 1,y = 4 的情况,原因如下:

  • 编译器没有义务一定按代码里给出的顺序产生代码。跟据上下文调整代码的执行顺序,使其最有利于处理器的架构,是优化中很重要的一步。就单个线程而言,先执行 x = 1 和先执行 y = 2 是无关紧要的,没有外部可观察的区别。
  • 在多处理器架构中,各个处理器可能存在缓存不一致性问题。取决于具体的处理器类型、缓存策略和变量地址,对变量 b 的写入有可能先反映到主内存中去。之所以这个问题似乎并不常见,是因为常见的 x86 和 x86-64 处理器是在顺序执行方面做得最保守的,大部分其他处理器,如 ARM、DEC Alpha、PA-RISC、IBM Power、IBM z/ 架构和 Intel Itanium 在内存序问题上都比较松散。x86 使用的内存模型基本提供了顺序一致性(sequential consistency);相对的,ARM 使用的内存模型就只是松散一致性(relaxed consistency)。
  • 虽说 Intel 架构处理器的顺序一致性比较好,但在多处理器(包括多核)的情况下仍然能够出现写读序列变成读写序列的情况,产生意料之外的后果。

volatile

在某些编译器里用 volatile 可以达到内存同步效果。这不是 volatile 设计意图,也不能通用地达到内存同步效果。volatile 的语义只是防止编译器优化掉对内存的读写,合适用法主要是用来读写映射到内存地址上的 I/O 操作。

由于 volatile 不能在多处理器的环境下确保多个线程能看到同样顺序的数据变化,在通用应用程序中,不应该再看到 volatile 的出现。

C++11 内存模型

C++11 里引入了适合多线程的内存模型,有了原子对象(atomic)和使用原子对象的获得(acquire)、释放(release)语义,可以真正精确地控制内存访问的顺序性,保证我们需要的内存序。

上面的例子中,如果我们希望只能是 1、2 或 3、4,即满足完全存储序(total store ordering)。

需要在 x = 1 和 y = 2 两语句之间加入内存屏障,禁止这两句语句交换顺序。最常用的两个概念是获得和释放:

  • 获得是一个对内存的读操作,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去。
  • 释放是一个对内存的写操作,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去。
int x = 0;
atomic<int> y = 0;

线程1使用释放语义,确保 b 的赋值在 a 后面。

x = 1;
y.store(2, memory_order_release);

线程2使用获得语义,确保判断语句在前面;存储用松散内存序即可,这里a、b存储语句执行先后不影响结果。

if (y.load(memory_order_acquire) ==2) {
	x = 3;
    y.store(4, memory_order_relaxed);
}

下面的图可以看出:每一边的代码都不允许重排越过黄色区域,且如果 y 上的释放早于 y 上的获取的话,释放前对内存的修改都在另一个线程的获取操作后可见。

img
img

把 y 改成 atomic 之后,两个线程的代码一行不改,执行结果都会是符合我们的期望的。因为 atomic 变量的写操作缺省就是释放语义,读操作缺省就是获得语义。即:

  • y = 2 相当于 y.store(2, memory_order_release)
  • y == 2 相当于 y.load(memory_order_acquire) == 2

缺省行为可能是对性能不利的:我们并不需要在任何情况下都保证操作的顺序性。 另外,acquire 和 release 通常都是配对出现的,目的是保证如果对同一个原子对象的 release 发生在 acquire 之前的话,release 之前发生的内存修改能够被 acquire 之后的内存读取全部看到。

atomic

C++11 在头文件 <atomic>中引入了 atomic 模板,对原子对象进行了封装,可以将其应用到任何类型上去。

  • 对于整型量和指针等简单类型,通常结果是无锁的原子对象。
  • 而对于另外一些类型,比如 64 位机器上大小不是 1、2、4、8(有些平台 / 编译器也支持对更大的数据进行无锁原子操作)的类型,编译器会自动为这些原子对象的操作加上锁。
  • 编译器提供了一个原子对象的成员函数 is_lock_free,可以检查这个原子对象上的操作是否是无锁的。

原子操作有三类:

  • 读:在读取的过程中,读取位置的内容不会发生任何变动。
  • 写:在写入的过程中,其他执行线程不会看到部分写入的结果。
  • 读‐修改‐写:读取内存、修改数值、然后写回内存,整个操作的过程中间不会有其他写入操作插入,其他执行线程不会看到部分写入的结果

<atomic>定义的内存序:

  • memory_order_relaxed:松散内存序,只用来保证对原子对象的操作是原子的
  • emory_order_consume:目前不鼓励使用
  • memory_order_acquire:获得操作,在读取某原子对象时,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见
  • memory_order_release:释放操作,在写入某原子对象时,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去,并且当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见
  • memory_order_acq_rel:获得释放操作,一个读‐修改‐写操作同时具有获得语义和释放语义,即它前后的任何读写操作都不允许重排,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见,当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见
  • memory_order_seq_cst:顺序一致性语义,对于读操作相当于获取,对于写操作相当于释放,对于读‐修改‐写操作相当于获得释放,是所有原子操作的默认内存序

这里的可见可以理解成获得和释放操作的两个线程能观察到相同的内存修改结果

atomic 常用的成员函数:

  • 默认构造函数(只支持零初始化)
  • 拷贝构造函数被删除
  • 使用内置对象类型的构造函数(不是原子操作)
  • 可以从内置对象类型赋值到原子对象(相当于 store
  • 可以从原子对象隐式转换成内置对象(相当于 load)
  • store,写入对象到原子对象里,第二个可选参数是内存序类型
  • load,从原子对象读取内置对象,有个可选参数是内存序类型
  • is_lock_free,判断对原子对象的操作是否无锁(是否可以用处理器的指令直接完成原子操作)
  • exchange,交换操作,第二个可选参数是内存序类型(这是读‐修改‐写操作)
  • compare_exchange_weak 和 compare_exchange_strong,两个比较加交换(CAS)的版本,你可以分别指定成功和失败时的内存序,也可以只指定一个,或使用默认的最安全内存序(这是读‐修改‐写操作)
  • fetch_add 和 fetch_sub,仅对整数和指针内置对象有效,对目标原子对象执行加或减操作,返回其原始值,第二个可选参数是内存序类型(这是读‐修改‐写操作)
  • ++ 和 --(前置和后置),仅对整数和指针内置对象有效,对目标原子对象执行增一或减一,操作使用顺序一致性语义,并注意返回的不是原子对象的引用(这是读‐修改‐写操作)
  • += 和 -=,仅对整数和指针内置对象有效,对目标原子对象执行加或减操作,返回操作之后的数值,操作使用顺序一致性语义,并注意返回的不是原子对象的引用(这是读‐修改‐写操作)

可以将 m_shared_ptr 修改为线程安全版本。

//#include<atomic>

public:
	// 在 increase 中执行简单的 ++、使用顺序一致性语义略有浪费,下面的做法更合理:
	void increase()noexcept 
	{ 
		count.fetch_add(1, std::memory_order_relaxed);
	}

private:
	//long count;
	std::atomic_long count;

安全双重检查锁定

在多线程可能对同一个单件进行初始化的情况下,有一个双重检查锁定的技巧。

目的是消除大部分执行路径上的加锁开销。原本的意图是:如果 inst_ptr 没有被初始化,执行才会进入加锁的路径,防止单件被构造多次;如果 inst_ptr_ 已经被初始化,那它就会被直接返回,不会产生额外的开销。但即使花上再大的力气,这个用法仍然有着非常多的难以填补的漏洞。本质上还是上面说的,优化编译器会努力击败你试图想防止优化的努力,而多处理器会以令人意外的方式让代码走到错误的执行路径上去。

  • 互斥量的加锁操作(lock)具有获得语义
  • 互斥量的解锁操作(unlock)具有释放语义
class singleton 
{
public:
	static singleton* instance();
private:
	static std::mutex lock;
	static std::atomic<singleton*> inst_ptr;
};

std::mutex singleton::lock;
std::atomic<singleton*> singleton::inst_ptr;
  
singleton* singleton::instance()
{
	singleton* ptr = inst_ptr.load(std::memory_order_acquire);
    
	if (ptr == nullptr) {
    	std::lock_guard<std::mutex> guard{lock};
    	ptr = inst_ptr.load(std::memory_order_relaxed);
    	if (ptr == nullptr) {
      		ptr = new singleton();
      		inst_ptr.store(ptr, std::memory_order_release);     
    	}
  	}
  	return inst_ptr;
}    

为了和 inst_ptr.load 语句对称,在 inst_ptr.store 时使用了释放语义;不过,由于互斥量解锁本身具有释放语义,这么做并不是必需的。

这里要double check其实是赋值顺序的问题。至少在某些处理器上,其他线程可能先看到 inst_ptr 被修改,再看到单件的构造完成。

一些问题

  • 原则上任何多线程访问的变量应该要么是原子量,要么有互斥量来保护。特别要考虑内存序的,就是有多个有逻辑相关性的共享变量。对于单个的变量,比如检查线程是否应该退出的布尔变量,只要消除了编译器优化,不需要保证访问顺序也可以正常工作;这样原子量可以使用 relaxed 的访问方式。
  • 用原子量的地方,用互斥量加锁都可以。但如果锁导致阻塞的话,性能比起原子量那是会有好几个数量级的差异了。锁即使不导致阻塞,性能也会比原子量低。锁本身的实现就会用到原子量,是个复杂的复合操作。
  • 用互斥量加锁的地方不能都改用原子量。原子量本身没有阻塞机制,没有保护代码段的功能。
  • 对单独没有逻辑联系的变量,直接使用原子量的relaxed就够了,没必要加上内存序。
  • 对于有联系的多个多线程中的变量,需要考虑使用原子量的内存序。
  • 对于代码段的保护,由于原子量没有阻塞,必须使用互斥量和锁来解决。
  • 互斥量是个对象,(加/解)锁是互斥量支持的动作。