C++11线程thread、互斥量mutex、条件变量condition_variable和atomic原子类型的使用

C++11线程thread、互斥量mutex、条件变量condition_variable和atomic原子类型的使用

C++11线程库

在C++11之前,涉及到多线程问题,都是和平台相关的,比如 Windows 和 Linux 下各有自己的接口,Linux 使用 POSIX(Portable Operating System Interface of UNIX,可移植操作系统接口)标准,而 Windows 一般使用 win32 线程模型,这使得代码的可移植性比较差。C++11 中最重要的特性就是对线程进行支持了,使得 C++ 在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。

本篇参考thread - C++ Referencemutex - C++ Referencecondition_variable- C++ Reference 及各种相关链接。这里将从原文翻译和使用 2 个角度对 C++11 线程库及相关工具进行介绍。

thread线程类

cpp 复制代码
class thread;

表示单个执行线程的类。一个执行线程是指令序列,可在多线程环境中与其他这样的序列并发执行,同时共享相同的地址空间。

一个已初始化的线程对象表示一个活动的执行线程;这种线程对象是可合并的,并且拥有唯一的线程 id

一个默认构造的(未初始化的)线程对象是不可合并的,其线程 id 对所有不可合并的线程都是相同的。

如果一个可合并的线程被移动,或者对其调用了 joindetach,它将变为不可合并的。

thread 类的网页中,展示了它的成员。这里将对这些成员做简要介绍。

Member types 成员类型

| id | 线程标识(公有成员类型) |

native_handle_type 原生句柄类型(公有成员类型)

id类、get_id、join和detach

cpp 复制代码
class thread::id;

此类型的值由 thread::get_idthis_thread::get_id 返回,用于标识线程。

默认构造的 thread::id 对象的值标识不可合并的线程,因此与任何此类线程的成员 thread::get_id 返回的值相等。

对于可合并的线程,thread::get_id 返回此类型的唯一值,该值不与任何其他可合并或不可合并线程返回的值相等。

某些库实现可能会重用已终止且无法再合并的线程的 thread::id 值。

id 类型实际为 std::thread 命名空间下封装的一个类,该类中包含了一个结构体:

cpp 复制代码
// vs下查看
typedef struct { /* thread identifier for Win32 */
    void *_Hnd;  /* Win32 HANDLE */
    unsigned int _Id;
} _Thrd_imp_t;

要使用标准库中的线程,必须包含 thread 头文件。若运行环境为 Linux ,则在编译阶段添加选项 -lpthread 链接原生线程库;若运行环境为 Windows,且 GCC 支持 POSIX 线程模型(可通过 gcc -v 验证,找到最后 2 行)则不必要添加。这里的 id 类使用参考:

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

void f() {
    cout << "sldkfjklsdf\n";
}

int main() {
    thread t1, t2(f);
    cout << t1.get_id() << endl; // 简单查看调用
    cout << t2.get_id() << endl;
    return 0;
}

Linux 下输出,后续除非特殊说明,否则环境默认为 Linux 的 CentOS :

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -g -lpthread
./a.exe
thread::id of a non-executing threadsldkfjklsdf

139829174036224
terminate called without an active exception
make: *** [a.exe] Aborted
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
thread::id of a non-executing threadsldkfjklsdf

139688712324864
terminate called without an active exception
Aborted
[Bjarne@VM-8-8-centos cppTest]$ 

Windows 下输出:

powershell 复制代码
PS C:\projectTest\cppbyvscode> make
g++ a.cpp -o a.exe -std=c++11
.\a.exe
thread::id of a non-executing thread
2
terminate called without an active exception
PS C:\projectTest\cppbyvscode> 

Windows 下 MSVC 输出(会弹出调用 abort 终止进程的窗口):

复制代码
0
5404

这里显示 terminate called without an active exception 即没有活动异常的情况下调用了终止。在 C++ 中,当 std::thread 对象销毁时,如果它是 joinable 即关联了一个实际执行线程但没调用过 join()detach() 时,析构函数会直接调用 std::terminate 函数终止程序。

std::terminate 函数在某些严重错误发生时会直接终止进程,默认行为是调用 std::abort() 直接终止进程,不会进行堆栈展开,也不会抛出异常。

确保在 t2 对象生命周期结束前,要么等待线程结束join),要么将其分离detach)。所以让它不报警告,直接加接口让它等待或让它分离即可。

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

void f() {
    cout << "sldkfjklsdf\n";
}

int main() {
    thread t1, t2(f);
    cout << t1.get_id() << endl;
    cout << t2.get_id() << endl;
    // t2.join(); // 二选一即可
    t2.detach(); // 二选一即可
    return 0;
}

C++11 的 std::thread 本身不支持 直接获取线程函数的返回值,因为 join() 返回 void,也没有相关的成员函数。因此要从线程中获取返回值,更推荐使用输出型参数,即上传对象的引用或地址。

native_handle_type原生句柄类型

cpp 复制代码
typedef /* 由实现定义 */ native_handle_type;

原生句柄类型

此成员类型仅当库实现支持它时才存在于 thread 类中。

它是 thread::native_handle 返回的类型,包含特定于实现的线程信息。

这里网站叙述比较少,于是另外做补充:

native_handle_typestd::thread 提供的一个类型别名(typedef),用于暴露底层操作系统或线程库的"原生线程句柄"。它的主要作用是:

  1. 获取平台相关的线程标识
    当用户需要调用 C++ 标准库之外的、特定于平台的线程 API(例如 POSIX 的 pthread_* 函数族,或 Windows 的 SetThreadPriority 等)时,可以通过 thread::native_handle() 得到这个句柄,然后将其传递给这些原生函数。
  2. 突破标准库的抽象限制
    C++ 标准库的线程接口相对通用,无法覆盖操作系统提供的所有高级线程控制(如设置线程亲和性、修改调度策略、挂起/恢复线程等)。通过 native_handle_type,用户可以在必要时"回归"到平台原生能力,完成标准接口不支持的操作。
  3. 实现互操作性
    某些第三方库或遗留代码可能需要原生线程句柄作为参数。native_handle_type 使得 std::thread 能够与这些非 C++ 标准库的组件协同工作。

简言之,native_handle_type 是一个桥梁,让用户在需要的时候能够访问 std::thread 背后真实的操作系统线程句柄,以便进行更底层的控制。只有在需要精细控制线程在 OS 层的行为时才有用。后续若涉及这一块的学习时再补充。

构造函数、析构函数和赋值

cpp 复制代码
// 1. 什么都不加
thread() noexcept;
// 2. 支持万能引用
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
// 3. 拷贝构造被禁止
thread (const thread&) = delete;
// 4. 移动构造
thread (thread&& x) noexcept;
// 5. 移动赋值重载
thread& operator= (thread&& rhs) noexcept;
// 被禁用的赋值重载
thread& operator= (const thread&) = delete;

其中万能引用的第 1 个参数可以是:

  • 函数指针。
  • lambda 表达式。
  • 包装器。
  • 仿函数。

这里简单使用这些函数。

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

void f1() {
    cout << "f1()\n";
}

void f2(int arg) {
    cout << "f2() " << arg << "\n";
}

int main() {
    thread t1;                  // 默认构造函数
    thread t2(f1);              // 万能引用,但只有一个参数
    thread t3(f2, 10);          // 万能引用
    thread t4 = thread(f2, 66); // 移动构造
    t2.join();
    t3.join();
    t4.join();
    cout << "所有线程已回归.\n";
    t1 = thread(f2, 99); // 移动赋值
    t1.join();
    cout << "线程t1已回归.\n";
    t1 = thread([]() {
        cout << "[]()";
        cout << endl;
    }); // 借助lambda表达式,移动赋值还能这么玩
    t1.join();
    return 0;
}

输出:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -g -lpthread
./a.exe
f1()
f2() 10
f2() 66
所有线程已回归.
f2() 99
线程t1已回归.
[]()
[Bjarne@VM-8-8-centos cppTest]$ 

总结:

  • 默认构造 → 空线程
  • 带参构造 → 创建并运行新线程
  • 拷贝构造 → 不允许
  • 移动构造 → 所有权转移,原对象置空

thread 只要不是使用默认构造函数创建对象,一经创建就会自动运行。所以若想要控制 thread 对象随时可运行,需要借助默认构造加移动构造进行控制。

析构函数用于回收线程,若主线程没有通过 join() 等待线程回归或使用 detach() 将线程分离,则会触发断言终止进程。

如果允许拷贝构造,两个 std::thread 对象将指向同一个底层线程,这会造成严重的所有权歧义。例如这个代码:

cpp 复制代码
std::thread t1(func);
std::thread t2 = t1;  // 假设这是合法的

// 现在 t1 和 t2 都代表同一个线程
t1.join();  // 谁负责 join
t2.join();  // 二次 join,此时属于未定义行为

而移动构造从始至终都是一个线程的底层所有权的转让,从始至终都只有 1 个线程,不存在所有权歧义。

thread对象使用公共资源

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的 ,因此即使线程参数为引用类型,在线程中修改后也不能修改外部实参,也就无法直接使用公共资源,因为其实际引用的是线程栈中的拷贝,而不是外部实参

例如这个代码使用 ref 函数上传公共资源的引用。

cpp 复制代码
#include <functional>
#include <iostream>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

void f0(int x) {
    ++x;
}
void f1(int &x) {
    ++x;
}
void f2(int *x) {
    ++(*x);
}

int main() {
    int x = 0;
    thread t1(f1, std::ref(x)); // 使用C++11新增的ref函数上传引用
    t1.join();
    cout << x << '\n';

    t1 = thread(f2, &x); // 上传公共资源的地址
    t1.join();
    cout << x << '\n';

    t1 = thread(f0, x); // 一般的传值传参
    t1.join();
    cout << x << '\n';

    // // 编译错误的情况
    // thread t1(f1, x); // f1需要一个左值引用int&,无法绑定到右值
    // t1.join();

    return 0;
}

这里thread 的构造函数会把所有参数按值复制或移动 到新线程的内部存储中,然后再用这些副本去调用线程函数。即便传入的是一个左值 a,线程库也会产生一个右值临时副本 。而 thread(f1, x)x 是作为右值上传,但 f1 的参数是引用,不能上传右值,所以编译报错。

如果是类成员函数作为线程参数时,必须将 this 作为线程函数参数。

cpp 复制代码
#include <functional>
#include <iostream>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

class A {
public:
    A() : x(0) {}
    int x;
    void f() {
        x++;
    }
};

int main() {
    A a;
    cout << a.x << '\n';
    thread t1(A::f, &a); // 上传成员函数作为线程函数,则要上传对象地址
    // thread t1(a.f, &a); // a.f 不是一个合法的可调用对象或成员函数指针
    t1.join();
    cout << a.x << '\n';
    return 0;
}

线程上传成员函数的简单总结:

cpp 复制代码
std::thread( &类名::函数名, 对象指针/引用, 额外参数... )

joinable

cpp 复制代码
bool joinable() const noexcept;

joinable() 返回线程对象是否可合并。如果一个线程对象代表一个执行线程,那么它是可合并的。如果线程可合并,则返回 true。否则返回 false。该函数和之前的函数一样使用 noexcept 关键字修饰,所以不会抛出异常。

在以下任一情况下,线程对象不可合并:

  • 如果它是默认构造的。
  • 如果它已被移动(无论是用于构造另一个线程对象,还是赋值给另一个线程对象)。
  • 如果它的成员 joindetach 已被调用。

简单来说,joinable()std::thread状态查询函数 ,它的核心作用是在调用 join()detach() 之前,确认线程对象是否真的关联了一个活跃的执行线程

所以这里默认构造的、已经调用过 joindetach 的、已经将自身的线程通过右值移动赋值给其他 thread 对象的线程,背后都没有关联一个活跃中的线程,此时 joinable 会返回假。

使用示例:

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

void _f1() {
    cout << "_f1()\n";
}

void _f2(int arg) {
    cout << "_f2() " << arg << "\n";
}

void f1() {
    thread t1;                   // 默认构造函数不可合并
    thread t2(_f1);              // 万能引用可合并
    thread t3(_f2, 10);          // 万能引用可合并
    thread t4 = thread(_f2, 66); // 移动构造可合并

    cout << "分隔符working\n";
    cout << "主线程: t1是否可合并?" << t1.joinable() << endl;
    cout << "主线程: t2是否可合并?" << t2.joinable() << endl;
    cout << "主线程: t3是否可合并?" << t3.joinable() << endl;
    cout << "主线程: t4是否可合并?" << t4.joinable() << endl;
    t2.join(); // join或detach被调用,于是不可合并
    t3.join();
    t4.join();

    cout << "分隔符working\n";
    cout << "主线程: t2是否可合并?" << t2.joinable() << endl;
    cout << "主线程: t3是否可合并?" << t3.joinable() << endl;
    cout << "主线程: t4是否可合并?" << t4.joinable() << endl;
}

void f2() {
    thread t1 = thread(_f1);
    thread t2 = move(t1);
    cout << "分隔符working\n";
    cout << "主线程: t1是否可合并?" << t1.joinable() << endl;
    cout << "主线程: t2是否可合并?" << t2.joinable() << endl;
    // t1.join();//根据移动赋值的特性,t1成了空线程,使用会报异常
    thread t3;
    t3 = move(t2);
    cout << "主线程: t2是否可合并?" << t2.joinable() << endl;
    cout << "主线程: t3是否可合并?" << t3.joinable() << endl;
    t3.join();
}

int main() {
    cout << "----测试join和detach下的joinable的使用" << endl;
    f1(); // 测试join和detach下的joinable的使用
    cout << "----测试移动构造和移动赋值的情况" << endl;
    f2(); // 测试移动构造和移动赋值的情况
    return 0;
}

输出:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -g -lpthread
./a.exe
----测试join和detach下的joinable的使用
_f1()
分隔符working
主线程: t1是否可合并?0
主线程: t2是否可合并?1
主线程: t3是否可合并?1
主线程: t4是否可合并?1
_f2() 10
_f2() 66
分隔符working
主线程: t2是否可合并?0
主线程: t3是否可合并?0
主线程: t4是否可合并?0
----测试移动构造和移动赋值的情况
分隔符working
主线程: t1是否可合并?0
主线程: t2是否可合并?1
主线程: t2是否可合并?0
主线程: t3是否可合并?1
_f1()
[Bjarne@VM-8-8-centos cppTest]$ 

swap

cpp 复制代码
void swap (thread& x) noexcept;

swap 不同于函数模板 std::swap ,它用于交换两个线程对象所有权

swap 的主要应用:

  1. 交换线程所有权。即

    • 调用前:线程 A 可能正在运行一个函数,线程 B 是空壳。

    • 调用后:线程 A 变成空壳,线程 B 接管了原来 A 的执行线程。

    这个过程不中断或影响线程本身的执行 。甚至还可以交换两个正在运行的线程的所有权

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

int main() {
    thread t1 = thread([]() { cout << ""; });
    thread t2;
    cout << "主线程: t1是否可合并?" << t1.joinable() << endl;
    cout << "主线程: t2是否可合并?" << t2.joinable() << endl;
    t2.swap(t1);
    cout << "主线程: t1是否可合并?" << t1.joinable() << endl;
    cout << "主线程: t2是否可合并?" << t2.joinable() << endl;
    t2.join(); // 此时t1变成了空壳,直接等t2即可
    return 0;
}

输出:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -g -lpthread
./a.exe
主线程: t1是否可合并?1
主线程: t2是否可合并?0
主线程: t1是否可合并?0
主线程: t2是否可合并?1
[Bjarne@VM-8-8-centos cppTest]$ 
  1. 资源管理:用于安全"重置"或"释放"线程

    假设用户有一个线程需要退出,但因为安全等原因无法直接分离 (detach) 一个线程,此时可以借助一个空的线程对象,把"需要等待和销毁"的责任转移出去。

cpp 复制代码
thread t1(some_long_task); // 运行长任务
// ...
// 需要销毁 worker,但又不想直接 detach(可能不安全)
// 也不想在当前位置 join(可能阻塞太久)
thread t2; // 一个空壳
t1.swap(t2); // worker 变成空壳,可安全销毁

// 现在,处理"t2"的责任被独立出来了
if (t2.joinable()) {
    // 可以在一个合适的地方,或后台线程中去等待它
    t2.detach(); // 或 join()
}

使用内置的 thread::swap 而不用移动赋值以及移动赋值版的 std::swap ,是因为在无法保证线程的合并状态统一的情况下, thread::swap 能较完美地胜任这个工作。

hardware_concurrency

cpp 复制代码
static unsigned hardware_concurrency() noexcept;

hardware_concurrency 函数用于检测硬件并发能力。该函数返回硬件线程上下文的数量。如果该值无法计算或未明确给出,则返回 0。

对该值的解释取决于具体的系统和实现,它可能并不精确 ,而只是一个近似值

需要注意的是,这个值不一定与系统中实际的处理器或核心数量匹配:一个系统可以在每个处理单元上支持多个线程,或者会限制程序对资源的访问。

该函数最主要的作用是为用户并发程序提供一个"合理的线程数量"参考值 ,从而避免手工猜测系统能并行多少任务。这是一个系统级别的静态信息,与用户的程序具体创建了多少线程无关。

例如在创建线程池或启动多个并行任务时,用它作为默认的线程数量,可以最大限度地利用 CPU 资源,同时避免过多线程竞争带来的开销。

cpp 复制代码
// 创建一个与硬件并发数匹配的线程池
unsigned int n = std::thread::hardware_concurrency();
std::vector<std::thread> threadpool;

for (unsigned int i = 0; i < n; ++i) {
    threadpool.emplace_back(task, i);
}
for (auto& t : threadpool) {
    t.join();
}

对于计算密集型任务(CPU-bound),线程数通常设为 hardware_concurrency() 可以获得接近最优的吞吐量。

win32GCC不支持线程库的解决方案

若查询自己的 gcc 版本时发现:

bash 复制代码
PS C:\projectTest\cppbyvscode> gcc -v
# 省略
Thread model: win32
gcc version x.x.x (x86_64-win32-seh-rev0, Built by MinGW-W64 project) 
PS C:\projectTest\cppbyvscode>

Thread model: win32 表示当前的 MinGW 是 win32 线程模型,它不支持 C++11 的 std::thread<mutex><condition_variable> 等标准库组件。

MinGW-w64 在 Windows 上有 2 种发行版本:

  1. POSIX 线程模型,支持 C++11 的 thread 。基于 winpthreads,完整支持 C++11 并发。
  2. Win32 线程模型,仅支持 Windows API 原生线程(CreateThread),GCC 的 libstdc++ 在该模式下会禁用标准线程库。

所以需要更换支持 POSIX 线程模型的 GCC 。

例如 winlibs-x86_64-posix-seh-gcc-14.3.0-mingw-w64ucrt-12.0.0-r1.zip ,点击即可下载,将解压缩得到的 mingw64 替换原来的 mingw64 即可。

若查询 GCC 版本得到如下信息,则说明 thread 可用。

powershell 复制代码
PS C:\projectTest\cppbyvscode> gcc -v
# 省略一大堆信息
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 14.3.0 (MinGW-W64 x86_64-ucrt-posix-seh, built by Brecht Sanders)
PS C:\projectTest\cppbyvscode>

若是 MSVC ,则编译器会自动链接 Windows 的线程库,所以可直接使用 C++11 线程类。

若研究 GCC 底层源码可发现,thread 头文件使用条件编译识别当前的操作系统,进而选择合适的接口实现 thread 类,实现方式是条件变量。

总结

thread 类最重要的部分这里使用表格列出。

函数名 功能
thread 构造一个线程对象,没有关联任何线程函数,即没有启动任何线程
thread(fn,args1, args2,...) 构造一个线程对象,并关联线程函数fn,args1,args2,...为线程函数的 参数
get_id 获取线程id
jionable 线程是否还在执行,joinable代表的是一个正在执行中的线程。
jion 该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行
detach 在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离 的线程变为后台线程,创建的线程的"死活"就与主线程无关

mutex互斥量

线程在访问临界资源时,会存在多线程并发访问同一公共资源的情况,此时有很大可能会造成数据不可用。这个公共资源在操作系统原理中会称为临界资源,访问临界资源的代码片段被称为临界区。例如这个代码:

cpp 复制代码
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

void f(int &x, std::mutex &mx) {
    for (int i = 0; i < 10000; i++)
        ++x;
}

int main() {
    int x = 0;
    std::mutex mx;
    cout << x << ' ';
    thread t1(f, std::ref(x), std::ref(mx));
    thread t2(f, std::ref(x), std::ref(mx));
    t1.join();
    t2.join();
    cout << x << '\n';
    return 0;
}

预期输出是 0 和 20000,实际输出时第 2 个数字可能低于 20000。因为 2 个线程的函数都捕获外部的 x 并对它进行自增操作,但这个自增操作由多个个汇编指令构成。例如在 VS2019 下的反汇编界面:

cpp 复制代码
void f(int& x) {
00007FF7ACDE3620  mov         qword ptr [rsp+8],rcx  
00007FF7ACDE3625  push        rbp  
00007FF7ACDE3626  push        rdi  
00007FF7ACDE3627  sub         rsp,108h  
00007FF7ACDE362E  lea         rbp,[rsp+20h]  
00007FF7ACDE3633  lea         rcx,[__7BD31ECD_a@cpp (07FF7ACDF702Eh)]  
00007FF7ACDE363A  call        __CheckForDebuggerJustMyCode (07FF7ACDE1474h)  
    for (int i = 0; i < 10000; i++)
00007FF7ACDE363F  mov         dword ptr [rbp+4],0  
00007FF7ACDE3646  jmp         f+30h (07FF7ACDE3650h)  
00007FF7ACDE3648  mov         eax,dword ptr [rbp+4]  
00007FF7ACDE364B  inc         eax  
00007FF7ACDE364D  mov         dword ptr [rbp+4],eax  
00007FF7ACDE3650  cmp         dword ptr [rbp+4],2710h  
00007FF7ACDE3657  jge         f+4Fh (07FF7ACDE366Fh)  
        ++x; // 可以看到这里有多个汇编语句
00007FF7ACDE3659  mov         rax,qword ptr [x]  
00007FF7ACDE3660  mov         eax,dword ptr [rax]  
00007FF7ACDE3662  inc         eax  
00007FF7ACDE3664  mov         rcx,qword ptr [x]  
00007FF7ACDE366B  mov         dword ptr [rcx],eax  
00007FF7ACDE366D  jmp         f+28h (07FF7ACDE3648h)  
}

这里的汇编语句可以认为是原子的,一旦开始执行就不可被打断。但多条汇编语句就不是原子的,存在被打断的可能。例如这里的 2 个线程 t1t2 发生多线程的并发问题的一种场景模拟:

  1. t1 线程刚把 x 的值加到 6666,然后就因为时间片结束被换下,汇编语句执行到刚准备将新修改的数值从 CPU 拷贝到内存;
  2. t2 线程这次将 x 的值加到 15000,然后又因为时间片结束被换下;
  3. t1 线程的时间片到期,从将新修改的数值从 CPU 拷贝到内存的汇编指令开始,将原本的 15000 覆盖,使得 x 重新变回 6666 。相当于 t2 线程的工作白干。

所以无论是进程还是线程,只要是多执行流访问公共资源,就有很大可能存在多线程的并发问题,此时需要对临界资源进行保护。在 Linux,互斥量 mutex 就适合这个工作,于是 C++11 也新增了一个互斥锁。

mutex头文件

这里参考: - C++ Reference

mutex 头文件提供了允许对代码关键部分的并发执行进行互斥(mutex)的设施,从而显式地避免数据竞争。

它包含互斥量类型、锁类型和特定函数:

  • 互斥量类型是可锁定的类型,用于保护对代码关键部分的访问:锁定互斥量可阻止其他线程锁定它(独占访问),直到解锁为止。
  • 锁是管理互斥量的对象,通过将互斥量的访问与锁自身的生命周期相关联。
  • 用于同时锁定多个互斥量的函数(try_locklock)以及直接防止特定函数并发执行的函数(call_once)。

该头文件下的类:

互斥量(mutex):

| mutex | 互斥量类 (类) |
| recursive_mutex | 递归互斥量类 (类) |
| timed_mutex | 定时互斥量类 (类) |

recursive_timed_mutex 递归定时互斥量 (类)

带有 RAII 特性的锁:

| lock_guard | 锁守卫 (类模板) |

unique_lock 唯一锁 (类模板)

其他类型:

| once_flag | call_once 的标志参数类型 (类) |
| adopt_lock_t | adopt_lock 的类型 (类) |
| defer_lock_t | defer_lock 的类型 (类) |

try_to_lock_t try_to_lock 的类型 (类)

函数:

| try_lock | 尝试锁定多个互斥量 (函数模板) |
| lock | 锁定多个互斥量 (函数模板) |

call_once 仅调用函数一次 (公有成员函数)

4个mutex互斥量共有的

这里的 4 个互斥量是 C++11 封装的互斥量类。

| mutex | 互斥量类 (类) |
| recursive_mutex | 递归互斥量类 (类) |
| timed_mutex | 定时互斥量类 (类) |

recursive_timed_mutex 递归定时互斥量 (类)

这里用一张表格展示所有互斥量类之间的相同功能的函数:

共同拥有的函数 函数的作用
lock 锁住互斥量。若互斥量被其他线程锁住则阻塞等待。
try_lock 锁住互斥量。若互斥量被其他线程锁住则不会阻塞,并返回bool值,并根据提前设计好的代码继续执行。
unlock 将被自己锁住的互斥量解锁。
native_handle 返回底层平台相关的互斥量句柄

接口原型:

cpp 复制代码
void lock();
bool try_lock();
// 递归型互斥量的不会抛出异常
bool try_lock() noexcept;
void unlock();
native_handle_type native_handle();

native_handle 获取更底层的类型,例如 Linux 的互斥量即 POSIX 的 pthread_mutex_t* ,Windows 下的 HANDLE。用于调用原生 API 进行扩展操作。它是为扩展功能保留的通道,比如修改 POSIX 互斥量的属性(如设置优先级继承),在绝大多数场景几乎用不到,等用到时再另外开一篇记录。

所以无论是哪一个互斥量,都是加锁、使用临界区资源、解锁的模式,且所有相关线程都能看得见相同的锁。

lock和unlock

例如之前存在多线程并发访问临界资源造成问题的代码,添加互斥量后就可以解决:

cpp 复制代码
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

void f(int &x, std::mutex &mx) {
    for (int i = 0; i < 10000; i++) {
        mx.lock(); // 添加锁,告诉其他线程我要用
        ++x;
        mx.unlock(); // 解锁锁,告诉其他线程我用完了
    }
}

int main() {
    int x = 0;
    std::mutex mx;
    cout << x << ' ';
    thread t1(f, std::ref(x), std::ref(mx));
    thread t2(f, std::ref(x), std::ref(mx));
    t1.join();
    t2.join();
    cout << x << '\n';
    return 0;
}

try_lock

try_lock 锁住互斥量。若互斥量被其他线程锁住则继续执行,并返回假,recursive_mutex 和其他互斥量的 try_lock 的区别是不会抛出异常。

使用 try_lock 时,若 try_lock 返回值为真,则说明当前线程抢到了锁,后续同样要解锁。

try_lock 的使用模式:

cpp 复制代码
if (my_mutex.try_lock()) {
    // 成功,操作共享数据
    my_mutex.unlock();
} else {
    // 没拿到锁,做其他事情,不要死循环重试
}

try_lock 使用示例:

cpp 复制代码
#include <chrono>
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

void f1(int &x, std::mutex &mx) {
    if (mx.try_lock()) {
        ++x;
        mx.unlock();
    } else {
        cout << "Can't wait lock, make onther tasks.\n";
    }
}

void f2(int &x, std::mutex &mx) {
    mx.lock();
    ++x;
    // 刻意使线程过10ms后再释放锁,使得f1线程无锁可用
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    mx.unlock();
}

int main() {
    int x = 0;
    std::mutex mx;
    cout << x << ' ';
    thread t1(f1, std::ref(x), std::ref(mx));
    thread t2(f2, std::ref(x), std::ref(mx));
    t1.join();
    t2.join();
    cout << x << '\n';
    return 0;
}

这里做一个反面场景:线程 f2 设计为等不到线程就一直执行同一操作的模式。

cpp 复制代码
#include <chrono>
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

void f1(int &x, std::mutex &mx, int &angry) {
    for (int i = 0; i < 100; i++) {
        while (!mx.try_lock()) {
            ++angry; // 积攒怒气值,但不推荐这样做
        }
        ++x;
        mx.unlock(); // 解锁
    }
}

void f2(int &x, std::mutex &mx) {
    for (int i = 0; i < 100; i++) {
        mx.lock();
        ++x;
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
        mx.unlock(); // 解锁
    }
}

int main() {
    int x = 0;
    std::mutex mx;
    cout << x << ' ';
    int angry = 0;
    thread t1(f1, std::ref(x), std::ref(mx), std::ref(angry));
    thread t2(f2, std::ref(x), std::ref(mx));
    t1.join();
    t2.join();
    cout << x << '\n';
    cout << angry << '\n';
    return 0;
}

try_lock 的设计初衷是试着拿锁,拿不到就立刻返回 false,线程可以去做别的事。这里一直循环直到拿到这把锁,本质就是自旋锁(详见自旋锁),甚至比直接用 lock 更差劲,因为 ++angry 消耗了 CPU 资源。这里是为了演示 try_lock 才这样使用,正式工程场景这样使用必被炒鱿鱼。

递归型互斥锁recursive_mutex

理论上学会原生线程库和 C++11 的互斥量 mutex 的使用就能应对 90% 的场景。但这里出于严谨,简单记录一下其他成员的使用。

cpp 复制代码
class recursive_mutex;

递归互斥量是一种可锁定对象,类似于 mutex,但允许同一线程对互斥量对象获取多级所有权

这允许从已经锁定互斥量对象的线程再次对其加锁 (或尝试加锁),从而获取该互斥量对象的新一级所有权:互斥量对象实际上将保持被该线程锁定的状态,直到其成员 unlock 被调用与该所有权级别同样多的次数。

recursive_mutexmutex 一样,都可通过 native_handle 获取底层平台句柄。它们的不同点见下表总结:

|----|------------|----------------------|
| 特性 | std::mutex | std::recursive_mutex |

这里仅举例一个递归型互斥锁的使用场景:部分项目要求对多叉树进行操作,而多叉树往往伴随着递归操作,所以多线程同时操作多叉树时,最适合使用递归型互斥锁。

例如这里随手搓一个多叉树, 2 个线程同时修改这个树。因为每层递归都加锁,所以基本上不会出现线程安全问题。但若 newdelete 抛出异常,这个代码会因为无法解锁导致死锁,因为这里的锁和指针没有使用 RAII 特性的工具进行管理,无法处理抛出异常的情况。

cpp 复制代码
#include <chrono>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using std::cout;
using std::recursive_mutex;
using std::string;
using std::thread;
using std::vector;

// 多叉树节点
struct DirNode {
    string name;               // 节点名
    vector<DirNode *> subDirs; // 子节点名
    DirNode(const string &_name = "") : name(_name) {
        subDirs.clear();
    }
};

// 多叉树
class DirTree {
private:
    DirNode *root;       // 多叉树根节点
    recursive_mutex rmx; // 递归型互斥锁

public:
    DirTree() {
        root = new DirNode{"1"};
    }

    bool addNode(const string &parentNode, const string &name) {
        rmx.lock();
        bool flag = _addNode(root, parentNode, name);
        rmx.unlock();
        return flag;
    }

private:
    bool _addNode(DirNode *rt, const string &parentNode,
                  const string &name) { // 在指定节点parentNode下添加节点name
        rmx.lock();                     // 递归型互斥锁允许每层递归都加锁
        if (rt == nullptr) {
            rmx.unlock();
            return false;
        }
        if (rt->name == parentNode) {
            rt->subDirs.push_back(new DirNode{name});
            rmx.unlock();
            return true;
        }
        for (auto x : rt->subDirs) {
            bool flag = _addNode(x, parentNode, name);
            if (flag) {
                rmx.unlock();
                return true;
            }
        }
        rmx.unlock();
        return false;
    }

    void deleteTree(DirNode *rt) {
        rmx.lock();
        if (rt == nullptr) {
            rmx.unlock();
            return;
        }
        for (auto x : rt->subDirs)
            deleteTree(x);
        delete rt;
        rmx.unlock();
    }

public:
    ~DirTree() {
        rmx.lock();
        if (root != nullptr) {
            deleteTree(root);
            root = nullptr;
        }
        rmx.unlock();
    }

public:
    int size() {
        rmx.lock();
        int ans = _count(root);
        rmx.unlock();
        return ans;
    }

private:
    int _count(DirNode *rt) {
        rmx.lock();
        if (rt == nullptr) {
            rmx.unlock();
            return 0;
        }
        int num = 0;
        for (auto x : rt->subDirs)
            num += _count(x);
        rmx.unlock();
        return num + 1;
    }
};

void createTree(DirTree &dt) {
    dt.addNode("1", "2");
    dt.addNode("1", "3");
    dt.addNode("1", "4");
    dt.addNode("2", "5");
    dt.addNode("3", "6");
    dt.addNode("3", "7");
    dt.addNode("3", "8");
}

int main() {
    DirTree dt;
    createTree(dt);
    thread t1([&dt]() {
        dt.addNode("5", "9");
        cout << dt.size() << '\n';
    });
    thread t2([&dt]() {
        dt.addNode("5", "9");
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        cout << dt.size() << '\n';
    });
    t1.join();
    t2.join();
    return 0;
}

定时互斥量类timed_mutex

定时互斥量 timed_mutex 在普通互斥量 mutex 的基础上,额外支持定时的尝试加锁请求。

因此,timed_mutex 有两个额外的成员:try_lock_fortry_lock_until。其他的成员函数 lockunlocknative_handle 见普通互斥量 mutex 。这里重点介绍这 2 个。

成员函数

| try_lock_for | 尝试在一段时间内锁定(公有成员函数) |

try_lock_until 尝试直到某个时间点锁定(公有成员函数)

原型

cpp 复制代码
template <class Rep, class Period>
bool try_lock_for (const chrono::duration<Rep,Period>& rel_time);
template <class Clock, class Duration>
bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);

try_lock_for

try_lock_for 尝试锁定定时互斥量,最多阻塞 rel_time 这么长的时间:

  • 如果定时互斥量当前没有被任何线程锁定 ,则调用线程锁定它。从锁定互斥量时起,直到调用其成员 unlock 为止,该线程拥有此定时互斥量。
  • 如果定时互斥量当前被另一个线程锁定 ,则调用线程的执行将被阻塞直到互斥量被解锁 ,或者 rel_time 时间段已经过去,以先发生者为准。在此期间,其他未被锁定的线程继续执行。
  • 如果定时互斥量当前被调用此函数的同一线程锁定 ,则再次尝试锁定时会产生死锁(行为未定义)。即定时互斥锁不能和递归型互斥锁一样,在同一线程被多次执行加锁操作,除非互斥量是递归定时互斥量 recursive_timed_mutex

对定时互斥量的所有加锁和解锁操作遵循单一全序关系,所有可见效果在加锁操作和对同一对象先前的解锁操作之间同步。

参数rel_time:线程为了等待获取锁而将阻塞的最大时间跨度。 duration 是一个表示特定相对时间的对象。

返回值 :如果函数成功为该线程锁定了定时互斥量,则返回 true。否则返回 false

示例

cpp 复制代码
// timed_mutex::try_lock_for 示例
#include <chrono>   // std::chrono::milliseconds
#include <iostream> // std::cout
#include <mutex>    // std::timed_mutex
#include <thread>   // std::thread

std::timed_mutex mtx;

void fireworks() {
    // 等待获取锁:每个线程每隔 200ms 打印一个 "-"
    while (!mtx.try_lock_for(std::chrono::milliseconds(200))) {
        std::cout << "-";
    }
    // 获得了锁!------ 等待 1s,然后该线程打印 "*"
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
    std::cout << "*\n";
    mtx.unlock();
}

int main() {
    std::thread threads[10];
    // 创建 10 个线程:
    for (int i = 0; i < 10; ++i)
        threads[i] = std::thread(fireworks);

    for (auto &th : threads)
        th.join();

    return 0;
}

可能输出(大约 10 秒后,各行的长度可能略有不同):

复制代码
------------------------------------*
----------------------------------------*
-----------------------------------*
------------------------------*
-------------------------*
--------------------*
---------------*
----------*
-----*
*

数据竞争

timed_mutex 对象被作为原子操作访问/修改(不会引发数据竞争)。

异常安全

如果 timed_mutex 当前被调用线程锁定,则会导致未定义行为。即timed_mutex 不是递归锁 ,如果当前线程已经持有该锁 (比如之前调用了 lock() 成功),再次调用 try_lock_fortry_lock_until 就属于同一线程重复加锁 ,这是标准明确禁止的,结果未定义

最典型的未定义表现就是死锁 :线程会一直阻塞在 try_lock_for/try_lock_until 里,等待自己释放锁,而自己永远不可能释放,程序卡死。

否则,它提供与对 duration 对象的操作相同级别的保证(对于 <chrono> 头文件中的类型实例化,这是无抛出保证)。即在正常使用 (即当前线程未持有该锁)的前提下,try_lock_for/try_lock_until 不会抛出异常 (no-throw guarantee)。

这是因为它们内部依赖的 chrono::durationchrono::time_point 操作本身是无抛出的,所以这些函数也承诺不抛异常

try_lock_until

cpp 复制代码
template <class Clock, class Duration>
bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);

尝试锁定定时互斥量,最多阻塞到 abs_time

  • 如果定时互斥量当前没有被任何线程锁定 ,则调用线程锁定它 。从锁定互斥量时起,直到调用其成员 unlock 为止,该线程拥有此定时互斥量。。
  • 如果定时互斥量当前被另一个线程锁定 ,则调用线程的执行将被阻塞 ,直到互斥量被解锁,或者到达 abs_time 时间点,以先发生者为准。在此期间,其他未被锁定的线程继续执行。
  • 如果定时互斥量当前被调用此函数的同一线程锁定,则会产生死锁。即定时互斥锁不能和递归型互斥锁一样,在同一线程被多次执行加锁操作。

参数 abs_time:线程将停止阻塞并放弃锁定尝试的时间点。

time_point 是一个表示特定绝对时间的对象。

返回值 :如果函数成功为该线程锁定了定时互斥量,则返回 true。否则返回 false

示例

cpp 复制代码
// timed_mutex::try_lock_until 示例
#include <chrono>   // std::chrono::system_clock
#include <ctime>    // std::time_t, std::tm, std::localtime, std::mktime
#include <iostream> // std::cout
#include <mutex>    // std::timed_mutex
#include <thread>   // std::thread

std::timed_mutex cinderella;

// 获取下一个午夜的时间点:
std::chrono::time_point<std::chrono::system_clock> midnight() {
    using std::chrono::system_clock;
    std::time_t tt = system_clock::to_time_t(system_clock::now());
    struct std::tm *ptm = std::localtime(&tt);
    ++ptm->tm_mday;
    ptm->tm_hour = 0;
    ptm->tm_min = 0;
    ptm->tm_sec = 0;
    return system_clock::from_time_t(mktime(ptm));
}

void carriage() {
    if (cinderella.try_lock_until(midnight())) {
        std::cout << "1 乘坐马车回家\n";
        cinderella.unlock();
    } else
        std::cout << "2 马车变回南瓜\n";
}

void ball() {
    cinderella.lock();
    std::cout << "3 在舞会上...\n";
    cinderella.unlock();
}

int main() {
    std::thread th1(ball);
    std::thread th2(carriage);

    th1.join();
    th2.join();

    return 0;
}

可能输出(行的顺序可能相反,或者马车变回南瓜):

复制代码
3 在舞会上...
1 乘坐马车回家

数据竞争

timed_mutex 对象被作为原子操作访问/修改(不会引发数据竞争)。

异常安全

这里网页原文和 try_lock_for 的异常安全部分一样,不再赘述。

守卫锁lock_guard与unique_lock

手动 unlockunlock 始终存在安全隐患,例如程序员忘记解锁,只要是人都可能出现这种问题。

所以 C++11 采用 RAII 的方式对锁进行了封装,即 lock_guardunique_lock

| lock_guard | 锁守卫 (类模板) |

unique_lock 唯一锁 (类模板)

原型:

cpp 复制代码
template <class Mutex> class lock_guard;
template <class Mutex> class unique_lock;

lock_guard

std::lock_gurad 是 C++11 中定义的模板类。定义如下:

cpp 复制代码
template <class _Mutex>
class lock_guard {
public:
    // 在构造lock_gard时,_Mtx还没有被上锁
    explicit lock_guard(_Mutex &_Mtx) : _MyMutex(_Mtx) {
        _MyMutex.lock();
    }
    // 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
    lock_guard(_Mutex &_Mtx, adopt_lock_t) : _MyMutex(_Mtx) {}
    ~lock_guard() _NOEXCEPT {
        _MyMutex.unlock();
    }
    lock_guard(const lock_guard &) = delete;
    lock_guard &operator=(const lock_guard &) = delete;

private:
    _Mutex &_MyMutex;
};

通过上述代码可以看到,lock_guard 类模板主要是通过 RAII 的方式,对其管理的互斥量进行了封装 。即 lock_guard 对象在构造时,互斥量对象被调用线程锁定;在析构时,互斥量被解锁。在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个 lock_guard,调用构造函数成功上锁,出作用域前,lock_guard 对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。

之前的代码的 lockunlock 操作完全可以使用守卫锁代替。

cpp 复制代码
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>

using std::cout;
using std::thread;

void f(int &x, std::mutex &mx) {
    for (int i = 0; i < 10000; i++) {
         // 就这么放着就行,作用域为最近的{},可加{}控制它的生命周期
        std::lock_guard<std::mutex> lg(mx);
        ++x;
    }
}

int main() {
    int x = 0;
    std::mutex mx;
    cout << x << ' ';
    thread t1(f, std::ref(x), std::ref(mx));
    thread t2(f, std::ref(x), std::ref(mx));
    t1.join();
    t2.join();
    cout << x << '\n';
    return 0;
}

lock_guard 的缺陷:太单一,这个守卫锁不以任何方式管理互斥量对象的生命周期,互斥量对象的生命周期应至少延续到锁定它的 lock_guard 被销毁为止,这意味着用户没有更多的操作空间。因此C++11又提供了unique_lock

unique_lock

cpp 复制代码
template <class Mutex> 
class unique_lock;

lock_gard 类似,unique_lock 类模板也是采用 RAII 的方式对锁进行了封装,并且也是以独占所有权的方式管理互斥量对象的上锁和解锁操作,即其对象之间不能发生拷贝。

在构造或移动赋值时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化 unique_lock 的对象时,自动调用构造函数上锁,unique_lock 对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。

lock_guard 不同的是,unique_lock 更加的灵活,提供了更多的成员函数:

  • 上锁 /解锁操作locktry_locktry_lock_fortry_lock_untilunlock

  • 修改操作

    • 移动赋值。
    • 交换 swap:与另一个 unique_lock 对象互换所管理的互斥量所有权。
    • 释放 release:返回它所管理的互斥量对象的指针,并释放所有权。
  • 获取属性

    • owns_lockoperator bool() ,返回当前对象是否上了锁,是则返回真。
    • mutex 返回当前 unique_lock 所管理的互斥量的指针。
构造函数
cpp 复制代码
// 默认构造函数,对象不管理任何互斥量对象。
unique_lock() noexcept;
// 对象管理m,并通过调用m.lock()将其锁定(如果需要则阻塞)。
explicit unique_lock (mutex_type& m);
// 对象管理m,并通过调用m.try_lock()尝试将其锁定(不阻塞)。
unique_lock (mutex_type& m, try_to_lock_t tag);
// 对象管理m,但不将其锁定。m应当是一个当前未被构造线程锁定的互斥量对象。
unique_lock (mutex_type& m, defer_lock_t tag) noexcept;
// 对象管理m,该互斥量对象当前已被构造线程锁定(对象获得对该锁的所有权)。
unique_lock (mutex_type& m, adopt_lock_t tag);
// 对象管理m,并通过调用m.try_lock_for(t) 尝试在t时间段内将其锁定。
template <class Rep, class Period>
unique_lock (mutex_type& m, const chrono::duration<Rep,Period>& rel_time);
// 对象管理m,并通过调用m.try_lock_until(t)尝试在t之前将其锁定。
template <class Clock, class Duration>
unique_lock (mutex_type& m, const chrono::time_point<Clock,Duration>& abs_time);
// 不可拷贝
unique_lock (const unique_lock&) = delete;
// 移动构造
unique_lock (unique_lock&& x);

这里的参数:

m 代表用户上传的互斥锁。

tag 用于选择构造函数。tag 可取的值如下:

描述 类型
(no tag) 即不填,构造时通过调用成员lock进行锁定。
try_to_lock 构造时通过调用成员 try_lock 尝试进行锁定 try_to_lock_t
defer_lock 构造时不进行锁定(并假定该互斥量尚未被线程锁定) defer_lock_t
adopt_lock 接管当前锁(假定该互斥量已被线程锁定)。 adopt_lock_t

`rel_time` 是线程为了等待获取锁而可能阻塞的最大时间跨度。如果该时间耗尽,对象将以不拥有锁的状态初始化。`duration` 是一个表示特定相对时间的对象。

abs_time 是线程将停止阻塞并放弃获取锁的时间点。如果到达该时间点,对象将以不拥有锁的状态初始化。time_point 是一个表示特定绝对时间的对象。

简单使用这些构造函数:

cpp 复制代码
// unique_lock 构造函数示例
// std::cout
#include <iostream>
// std::mutex, std::lock, std::unique_lock
#include <mutex>
// std::thread
#include <thread>
// std::adopt_lock, std::defer_lock
std::mutex foo, bar;

void task_a() {
    std::lock(foo, bar); // 同时锁定(防止死锁)
    // 调用用于接管的构造函数
    std::unique_lock<std::mutex> lck1(foo, std::adopt_lock);
    std::unique_lock<std::mutex> lck2(bar, std::adopt_lock);
    std::cout << "task a\n";
    // (在 lck1 和 lck2 销毁时自动解锁)
}

void task_b() {
    // foo.lock(); bar.lock(); // 替换为:
    std::unique_lock<std::mutex> lck1, lck2;
    // 构造时不进行锁定,而是改用手动操作,全让用户来
    lck1 = std::unique_lock<std::mutex>(bar, std::defer_lock);
    lck2 = std::unique_lock<std::mutex>(foo, std::defer_lock);
    // 用户已经调用锁定函数
    std::lock(lck1, lck2);
    std::cout << "task b\n";
    // (在 lck1 和 lck2 销毁时自动解锁)
}

int main() {
    std::thread th1(task_a);
    std::thread th2(task_b);

    th1.join();
    th2.join();

    return 0;
}

lock 是 C++11 新增的函数,主要用于同时锁定多个互斥量。

cpp 复制代码
template <class Mutex1, class Mutex2, class... Mutexes>
void lock (Mutex1& a, Mutex2& b, Mutexes&... cde);
其他成员函数

锁定/解锁

| lock | 锁定互斥量(公有成员函数) |
| try_lock | 如果未锁定,则锁定互斥量(公有成员函数) |
| try_lock_for | 尝试在一段时间内锁定互斥量(公有成员函数) |
| try_lock_until | 尝试直到某个时间点锁定互斥量(公有成员函数) |

unlock 解锁互斥量(公有成员函数)

原型:

cpp 复制代码
void lock();
bool try_lock();
template <class Rep, class Period>
bool try_lock_for (const chrono::duration<Rep,Period>& rel_time);
template <class Clock, class Duration>
bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);
void unlock();

这里的 5 个用于锁定和解锁的成员函数可参考定时互斥量。但 unique_lock 对象使用这些函数手动管理时,建议调用tagdefer_lock 的构造函数,否则会因为重复加锁导致不确定的未定义行为。即想要手动管理,就用 defer_lock 构造 unique_lock 对象。

修饰符(Modifiers)

| operator= | 移动赋值 unique_lock(公有成员函数) |
| swap | 交换唯一锁(公有成员函数) |

release 释放互斥量(公有成员函数)

原型:

cpp 复制代码
unique_lock& operator= (unique_lock&& x) noexcept;
unique_lock& operator= (const unique_lock&) = delete;
void swap (unique_lock& x) noexcept;
mutex_type* release() noexcept;

观察者(Observers)

| owns_lock | 是否拥有锁(公有成员函数) |
| operator bool | 返回是否拥有锁(公有成员函数) |

mutex 获取互斥量(公有成员函数)

原型:

cpp 复制代码
bool owns_lock() const noexcept;
explicit operator bool() const noexcept;
mutex_type* mutex() const noexcept;

condition_variable条件变量

cpp 复制代码
class condition_variable;

条件变量是一种能够阻塞调用线程直到收到通知才恢复执行的对象。

当调用它的某个等待函数时,它会使用一个 unique_lock(基于互斥量)来锁定线程。线程将保持阻塞状态,直到被另一个在同一 condition_variable 对象上调用通知函数的线程唤醒。

condition_variable 类型的对象始终使用 unique_lock<mutex> 来等待。如果需要适用于任何类型可锁定对象的替代方案,请参阅 condition_variable_any , 2 个类提供的接口完全相同。

condition_variable 类只介绍了构造函数和析构函数 2 个成员,对这里的描述并无太大影响,所以省略。

等待函数和通知函数

等待函数有 3 个。

| wait | 等待直到收到通知(公有成员函数) |
| wait_for | 等待超时或直到收到通知(公有成员函数) |

wait_until 等待直到收到通知或到达某个时间点(公有成员函数)

原型:

cpp 复制代码
// 当前线程(当前互斥量lck必须被锁定的情况下)的执行将被阻塞,直到收到通知
template <class Lock>
void wait (Lock& lck);
// 可选等待过程中是否被阻塞,只有当pred返回false时,该函数才会阻塞
template <class Lock, class Predicate>
void wait (Lock& lck, Predicate pred);

// 线程将在 rel_time 时间段内被阻塞,或收到通知,以先发生者为准
template <class Lock, class Rep, class Period>
cv_status wait_for (Lock& lck,
const chrono::duration<Rep,Period>& rel_time);
// 同样可选等待过程中是否被阻塞,只有当pred返回false时,该函数才会阻塞	
template <class Lock, class Rep, class Period, class Predicate>
bool wait_for (Lock& lck,
const chrono::duration<Rep,Period>& rel_time, Predicate pred);

// 线程将在 abs_time 时间段内被阻塞,或收到通知,以先发生者为准
template <class Lock, class Clock, class Duration>
cv_status wait_until (Lock& lck,
const chrono::time_point<Clock,Duration>& abs_time);

// 可选等待过程中是否被阻塞,只有当pred返回false时,该函数才会阻塞	
template <class Lock, class Clock, class Duration, class Predicate>
bool wait_until (Lock& lck,
const chrono::time_point<Clock,Duration>& abs_time,Predicate pred);

通知函数有 2 个,即通知一个线程和通知所有线程。

| notify_one | 通知一个线程(公有成员函数) |

notify_all 通知所有线程(公有成员函数)

原型:

cpp 复制代码
void notify_one() noexcept;
void notify_all() noexcept;

wait和notify_all

wait 也许是最基础的等待函数。

cpp 复制代码
// 当前线程(当前互斥量lck必须被锁定的情况下)的执行将被阻塞,直到收到通知
template <class Lock>
void wait (Lock& lck);
// 可选等待过程中是否被阻塞,只有当pred返回false时,该函数才会阻塞
template <class Lock, class Predicate>
void wait (Lock& lck, Predicate pred);

参数

lck 一个当前由此线程锁定的可锁定对象。对此对象的所有 wait 成员函数的并发调用都应使用相同的底层互斥量对象。

Lock 应为可锁定类型。pred 一个可调用对象或函数,它不接受任何参数,并返回一个可被评估为 bool 的值。此谓词会被重复调用,直到它评估为 true

简单使用 waitnotify_all 示例:

cpp 复制代码
// condition_variable 示例
#include <condition_variable> // std::condition_variable
#include <iostream>           // std::cout
#include <mutex>              // std::mutex, std::unique_lock
#include <thread>             // std::thread

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void print_id(int id) {
    std::unique_lock<std::mutex> lck(mtx);
    while (!ready)
        cv.wait(lck); // 每个线程都在等这个锁被唤醒
    std::cout << "thread " << id << '\n';
}

void go() {
    std::unique_lock<std::mutex> lck(mtx); // 互斥锁
    ready = true;
    cv.notify_all(); // 同时唤醒所有线程
}

int main() {
    std::thread threads[10];
    // 创建 10 个线程:
    for (int i = 0; i < 10; ++i)
        threads[i] = std::thread(print_id, i);

    std::cout << "10 threads ready to race...\n";
    go(); // 出发!

    for (auto &th : threads)
        th.join();

    return 0;
}

可能的输出(线程顺序可能不同):

复制代码
10 threads ready to race...
thread 2
thread 0
thread 9
thread 4
thread 6
thread 8
thread 7
thread 5
thread 3
thread 1

多线程存在一个概念叫惊群现象,简单来说就是尽量不要把所有线程都唤醒,让这些线程去盲目地竞争资源。

这里暂时省略异常安全。因为内容太多。

wait_for和notify_one

cpp 复制代码
// 线程将在 rel_time 时间段内被阻塞,或收到通知,以先发生者为准
template <class Lock, class Rep, class Period>
cv_status wait_for (Lock& lck,
const chrono::duration<Rep,Period>& rel_time);
// 可选等待过程中是否被阻塞,只有当pred返回false时,该函数才会阻塞	
template <class Lock, class Rep, class Period, class Predicate>
bool wait_for (Lock& lck,
const chrono::duration<Rep,Period>& rel_time, Predicate pred);

当前已锁定 lck 的线程,它的执行将在 rel_time 时间段内被阻塞 ,或直到收到通知。以先发生者为准。

在线程被阻塞的那一刻,该函数会自动调用 lck.unlock(),从而允许其他被锁定的线程继续执行。

一旦收到通知或 rel_time 已过,该函数会解除阻塞并调用 lck.lock(),使 lck 恢复到调用该函数时的状态。然后函数返回。最后一次互斥量锁定可能会在线程返回前再次阻塞线程。

通常,该函数是通过另一个线程调用成员 notify_one 或成员 notify_one 来被通知唤醒的。但某些实现可能会在没有任何这些函数被调用的情况下产生虚假唤醒调用。因此,此函数的使用者应确保其恢复执行的条件已得到满足。

如果指定了 pred 参数,则只有当 pred 返回 false 时,该函数才会阻塞,并且只有当 pred 变为 true 时,通知才能解除线程阻塞(这对于检查虚假唤醒调用特别有用)。它的行为如同按以下方式实现:

cpp 复制代码
return wait_until(lck,chrono::steady_clock::now()+rel_time,std::move(pred));

参数

lck 为一个当前由此线程锁定的可锁定对象。对此对象的所有 wait 成员函数的并发调用都应使用相同的底层互斥量对象。Lock 应为可锁定的类型,即四大互斥量。

rel_time 时线程将阻塞以等待被通知的最大时间跨度。duration 是一个表示特定相对时间的对象。

pred 是一个可调用对象或函数,它不接受任何参数,并返回一个可被评估为 bool 的值。此谓词会被重复调用,直到它评估为 true

返回值

无条件版本 cv_status wait_for(lck,rel_time) 如果函数因 rel_time 已过而返回,则返回 cv_status::timeout,否则返回 cv_status::no_timeout

谓词版本 bool wait_for (lck,rel_time,pred); 返回 pred(),无论超时是否被触发。

这里简单演示 wait_fornotify_one

cpp 复制代码
// condition_variable_any::wait_for 示例
#include <chrono>             // std::chrono::seconds
#include <condition_variable> // std::condition_variable_any, std::cv_status
#include <iostream>           // std::cout
#include <mutex>              // std::mutex
#include <thread>             // std::thread

std::condition_variable_any cv;

int value;

void read_value() {
    std::cin >> value;
    cv.notify_one();
}

int main() {
    // 我是一只负责打印的dots,请输入一个整数
    std::cout << "Please, enter an integer (I'll be printing dots): \n";
    std::thread th(read_value);

    std::mutex mtx;
    mtx.lock();
    while (cv.wait_for(mtx, std::chrono::seconds(1)) ==
           std::cv_status::timeout) {
        std::cout << '.';
    }
    std::cout << "You entered: " << value << '\n';
    mtx.unlock();

    th.join();

    return 0;
}

可能的输出:

复制代码
Please, enter an integer (I'll be priniting dots): 
.....2....0..
You entered: 20

数据竞争

该函数执行三个原子操作:

  • lck 的初始解锁以及同时进入等待状态。
  • 等待状态的解除。
  • 在返回前锁定 lck

对象的原子操作按照单一全序关系进行排序,其中该函数中的三个原子操作按上述相同的相对顺序发生。

这里暂时省略异常安全。因为内容太多。

wait_until

cpp 复制代码
// 线程将在 abs_time 时间段内被阻塞,或收到通知,以先发生者为准
template <class Clock, class Duration>
cv_status wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time);
// 可选等待过程中是否被阻塞,只有当pred返回false时,该函数才会阻塞	
template <class Clock, class Duration, class Predicate>
bool wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time,Predicate pred);

当前已锁定 lck 的线程,它的执行将被阻塞,直到收到通知或到达 abs_time,以先发生者为准。

通常,该函数是通过另一个线程调用成员 notify_one 或成员 notify_all 来被通知唤醒的。但某些实现可能会在没有任何这些函数被调用的情况下产生虚假唤醒调用。因此,此函数的使用者应确保其恢复执行的条件已得到满足。

如果指定使用 pred 版本,则只有当 pred 返回 false 时,该函数才会阻塞,并且只有当 pred 变为 true 时,通知才能解除线程阻塞(这对于检查虚假唤醒调用特别有用)。它的行为如同按以下方式实现:

cpp 复制代码
while (!pred())
  if ( wait_until(lck,abs_time) == cv_status::timeout)
    return pred();
return true;

参数

lck 同样是一个 unique_lock 对象,其互斥量对象当前已被此线程锁定。对此对象的所有 wait 成员函数的并发调用都应使用相同的底层互斥量对象(由 lck.mutex() 返回)。

abs_time 是一个时间点,线程将在此时间点停止阻塞,允许函数返回。time_point 是一个表示特定绝对时间的对象。

pred 是一个可调用对象或函数,它不接受任何参数,并返回一个可被评估为 bool 的值。

此谓词会被重复调用,直到它评估为 true

返回值

无条件版本 cv_status wait_until (lck,abs_time); 如果函数因到达 abs_time 而返回,则返回 cv_status::timeout,否则返回 cv_status::no_timeout

谓词版本 bool wait_until (lck,abs_time,pred); 返回 pred(),无论超时是否被触发。

数据竞争

该函数执行三个原子操作:

  • lck 的初始解锁以及同时进入等待状态。
  • 等待状态的解除。
  • 在返回前锁定 lck

对象的原子操作按照单一全序关系进行排序,其中该函数中的三个原子操作按上述相同的相对顺序发生。

简单来说,wait_for 是只等若干秒、若干分钟这种时间段,而 wait_until 是等到一个确定的时间点例如几时几分几秒。

简单使用 wait_until

cpp 复制代码
// condition_variable_any::wait_until 示例
#include <chrono>             // std::chrono::seconds
#include <condition_variable> // std::condition_variable_any, std::cv_status
#include <iostream>           // std::cout
#include <mutex>              // std::mutex
#include <thread>             // std::thread

std::condition_variable_any cv;

int value;

void read_value() {
    std::cin >> value;
    cv.notify_one();
}

int main() {
    // 要求用户在5秒内输入,否则终止程序
    std::cout << "Please, enter an integer within 5 seconds: \n";
    std::thread th(read_value);

    std::mutex mtx;
    mtx.lock();
    for (auto deadline = std::chrono::steady_clock::now() +
                         std::chrono::seconds(5); // 设计截止时间为5秒后
         cv.wait_until(mtx, deadline) == std::cv_status::timeout;) {
        // 这里相当于等了5秒用户都没有输出,于是直接退出不再等待
        std::cout << "You can't input, you are f**k.\n";
        mtx.unlock();
        th.detach();
        return 0;
    }
    std::cout << "You entered: " << value << '\n';
    th.detach();
    return 0;
}

总结等待函数

函数 等待类型 超时条件 返回信息
wait 无限等待 无超时
wait_for 相对时间等待 时间段 rel_time 耗尽 cv_status 或 bool
wait_until 绝对时间等待 到达时间点 abs_time cv_status 或 bool

wait 适用于条件最终一定会被满足,或用户愿意一直等下去的场景。

wait_for 适用于希望有个最长等待时限,超时后可以放弃或执行备选逻辑的场景。如网络请求超时、等待资源但不想无限等。

wait_until 适用于需要等待到一个确切的时刻 (例如等到明天零点、等到某个截止时刻),或者需要避免 wait_for 循环可能累积的漂移误差时,用 wait_until 更精确的场景。

所有三个函数都支持传入一个谓词 (predicate),它会自动处理虚假唤醒,只有当谓词返回 true 时才真正返回,这使得代码更安全可靠。在实际开发中,尽量使用带谓词的版本

2个线程交替打印奇数和偶数

假设 2 个线程:t1 和 t2 ,它们轮流打印数字, t1 打印奇数, t2 打印偶数。此时就要分情况讨论:

  1. t1 先启动, t2 后启动,此时 t1 打印完后通知 t2 打印。
  2. t2 先启动, t1 后启动,此时 t2 应先阻塞,直到 t1 打印完再通知 t2。

所以无论什么情况,都需要 t1 先打印奇数,打印完后通知 t2 并进入阻塞, t2 打印完偶数后通知 t1 并进入阻塞。因此第一次启动 2 个线程时,尽管没办法在不使用及时的情况下控制哪个线程先启动,但 t1 一开始不能被阻塞直到打印完数字, t2 一开始要被阻塞直到 t1 完数字。

这里的阻塞可用条件变量实现。但条件变量只负责阻塞,无法满足线程要了解在某个具体情况是否应该阻塞的需求,所以还需要用一个 bool 变量控制当前线程是否应该阻塞。然后每个线程打印完各自的数字后,都需要做 2 件事:

  1. 修改公共变量 flag
  2. 通知对方。

因此这个代码的一种参考如下:

cpp 复制代码
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

using std::condition_variable;
using std::cout;
using std::endl;
using std::mutex;
using std::thread;
using std::try_to_lock;
using std::unique_lock;
using std::chrono::milliseconds;
using std::this_thread::sleep_for;

mutex mx;
condition_variable cv;
bool flag = false; // 加了互斥量,所以非原子变量也可以使用

void _t1() {
    for (int x = 1; x <= 100; x += 2) {
        unique_lock<mutex> lck(mx, try_to_lock);
        while (flag) // 初始值flag为假,为让t1先运行,这里不可被阻塞
            cv.wait(lck);
        cout << "t1: " << x << endl;
        sleep_for(milliseconds(500)); // 暂时沉睡500ms,只为了看清
        flag = true;
        cv.notify_one();
    }
}

void _t2() {
    for (int x = 2; x <= 100; x += 2) {
        unique_lock<mutex> lck(mx, try_to_lock);
        while (!flag) // 初始值flag为假,t2一开始就会被阻塞,直到t1运行完成
            cv.wait(lck);
        cout << "t2: " << x << endl;
        sleep_for(milliseconds(500)); // 暂时沉睡500ms,只为了看清
        flag = false;
        cv.notify_one();
    }
}

int main() {
    thread t2(_t2);
    thread t1(_t1);
    t1.join();
    t2.join();
    return 0;
}

atomic原子性操作库

多线程最主要的问题是共享数据带来的问题 (即线程安全) 。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,因为一般的修改方式都不是原子的,或者说一般的修改方式都是由多条汇编指令构成,所以会出现数据不一致的问题。比如:

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

unsigned long sum = 0L;
void fun(size_t num) {
    for (size_t i = 0; i < num; ++i)
        sum++;
}
int main() {
    cout << "Before joining,sum = " << sum << std::endl;
    thread t1(fun, 10000);
    thread t2(fun, 10000);
    t1.join();
    t2.join();
    cout << "After joining,sum = " << sum << std::endl;
    return 0;
}

C++98中传统的解决方式:可以对共享修改的数据可以加锁保护。

cpp 复制代码
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;
mutex m; // 使用互斥量保护临界资源
unsigned long sum = 0L;
void fun(size_t num) {
    for (size_t i = 0; i < num; ++i) {
        m.lock();
        sum++;
        m.unlock();
    }
}
int main() {
    cout << "Before joining,sum = " << sum << std::endl;
    thread t1(fun, 10000);
    thread t2(fun, 10000);
    t1.join();
    t2.join();
    cout << "After joining,sum = " << sum << std::endl;
    return 0;
}

虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对 sum++ 时,其他线程就会被阻塞,会影响程序运行的效率;而且锁本身也需要占用 CPU 资源,如果控制不好,还容易造成死锁。

因此 C++11 中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。这里的原子操作参考 atomic - C++ Reference

atomic的内容

contained type (包含类型) atomic type (原子类型) description (描述)
bool atomic_bool
char atomic_char 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
signed char atomic_schar 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
unsigned char atomic_uchar 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
short atomic_short 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
unsigned short atomic_ushort 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
int atomic_int 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
unsigned int atomic_uint 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
long atomic_long 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
unsigned long atomic_ulong 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
long long atomic_llong 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
unsigned long long atomic_ullong 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
wchar_t atomic_wchar_t 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
char16_t atomic_char16_t 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
char32_t atomic_char32_t 用于基本整数类型的原子类型。 这些要么是 atomic 类模板相应全特化的 typedef,要么是该特化的基类。
intmax_t atomic_intmax_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
uintmax_t atomic_uintmax_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
int_leastN_t atomic_int_leastN_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
uint_leastN_t atomic_uint_leastN_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
int_fastN_t atomic_int_fastN_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
uint_fastN_t atomic_uint_fastN_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
intptr_t atomic_intptr_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
uintptr_t atomic_uintptr_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
size_t atomic_size_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。
ptrdiff_t atomic_ptrdiff_t 用于基于宽度的整数类型(在 <cinttypes> 中定义)的原子类型。 其中每一个要么是上述用于基本整数类型的原子类型的别名,要么是 atomic 类模板针对某个扩展整数类型的全特化。 其中 N 可以是 8、16、32、64 或库支持的任何其他类型宽度。

需要使用以上原子操作变量时,必须添加头文件。

cpp 复制代码
#include <atomic>
#include <iostream>
#include <thread>
using namespace std;
atomic_long sum{0};
void fun(size_t num) {
    for (size_t i = 0; i < num; ++i)
        sum++; // 原子操作
}
int main() {
    cout << "Before joining, sum = " << sum << std::endl;
    thread t1(fun, 10000);
    thread t2(fun, 10000);
    t1.join();
    t2.join();
    cout << "After joining, sum = " << sum << std::endl;
    return 0;
}

在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问。

更为普遍的,程序员可以使用 atomic 类模板,定义出需要的任意原子类型。

cpp 复制代码
atmoic<T> t;    // 声明一个类型为T的原子类型变量t

注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及 operator= 等,为了防止意外,标准库已经将 atmoic 模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。

cpp 复制代码
#include <atomic>
int main() {
    atomic<int> a1(0);
    // atomic<int> a2(a1);   // 编译失败
    atomic<int> a2(0);
    // a2 = a1;               // 编译失败
    return 0;
}

无锁编程

std::atomic 是设计用来实现无锁并发的高层抽象,在绝大多数情况下它的底层原理就是无锁,但它保留了一种有锁的退化实现以覆盖所有类型。无锁即不适用互斥锁的情况下实现原子操作。

除了少部分汇编指令,可以理解为一条汇编指令是原子的,即 CPU 保证一条汇编指令必定能执行完且不可中断。但 C 语言常见的类型,操作上几乎都可解释为多条汇编语句。

例如之前的 ++sum 可以解读为 3 条汇编指令:

assembly 复制代码
mov  eax, [sum]   ; 读,把数据从内存移交到CPU
inc  eax          ; 改,CPU更改数据
mov  [sum], eax   ; 写,CPU将改好的数据移交回内存

若修改临界资源的代码的用时短,是不适合使用互斥锁保护的,它会导致线程频繁的堵塞。此时这种场景更适合自旋锁,即申请锁失败后不会阻塞,而是反复申请。

一般库里不提供自旋锁,也许第三方库有。所以要使用自旋锁时,若找不到第三方库就需要重新手搓。例如:

cpp 复制代码
std::mutex mx;
void f(){
    //...
    while(mx.try_lock())
        //这时可以做点别的,例如通过函数把时间片让出来
        this_thread::yield();
    //
}

所以原子类型特别是 C 语言的那些内置类型对应的部分,绝大多数情况都不用锁即互斥量来实现。而无锁编程在底层都是 CAS (compare and swap,即比较并交换) ,有的地方会把当成一种无锁算法。

可以参考 无锁 | 酷 壳 - CoolShell 的子文章 无锁队列的实现 | 酷 壳 - CoolShell,这里只描述部分情况。

CAS操作原理

CAS 是由 CPU 支持的,它的原理:假设某个多线程环境,以全局变量为例,每个线程在修改全局变量时都会先保存旧值 ,然后判断原来的值是否发生变化 (compare),若没变化 说明没有线程修改数据,可将新值赋值给它(swap);否则本次操作失败,重新再来一次 CAS 操作。

若使用 C++ 代码大致描述原理,可参考文章给出的函数:

cpp 复制代码
int compare_and_swap(int *reg, int oldval, int newval) {
    int old_reg_val = *reg;
    if (old_reg_val == oldval) {
        *reg = newval;
    }
    return old_reg_val;
}

代码试图将 reg 指向的内存空间修改为新值 newval ,需要保证在这个过程中没有因为时间片不够被换下等原因,旧值发生变化。

代码还可以变形:

cpp 复制代码
bool compare_and_swap(int *addr, int oldval, int newval) {
    if (*addr != oldval) {
        return false;
    }
    *addr = newval;
    return true;
}

所以 CAS 操作实现原子的原理是先比较 CPU 内的值是否和旧值一样,不一样则说明其他线程替换了这个值。

例如 ++x 这个操作,只要最后一句汇编指令变成 CAS 操作以及 x 支持这个操作,就可以实现这个操作是原子的。

cpp 复制代码
void f(int& x) {
// 省略大量汇编准备工作
    for (int i = 0; i < 10000; i++)
// 省略大量汇编语句控制循环
        ++x; // 可以看到这里有多个汇编语句
00007FF7ACDE3659  mov rax,qword ptr [x]  // 引用底层是指针
00007FF7ACDE3660  mov eax,dword ptr [rax]// 将旧值放进eax寄存器
00007FF7ACDE3662  inc eax                // 自加操作
00007FF7ACDE3664  mov rcx,qword ptr [x]  
00007FF7ACDE366B  mov dword ptr [rcx],eax// 这句和x支持CAS操作就能实现原子
00007FF7ACDE366D  jmp f+28h (07FF7ACDE36
}

假设 x 开始为 1 ,最后一个 mov 操作在底层使用 CAS 实现,则最后一个 mov 函数相当于执行了一次 CAS(addr,1,2)

cpp 复制代码
void f(int& x) {
// 省略大量汇编准备工作
    for (int i = 0; i < 10000; i++)
// 省略大量汇编语句控制循环
        ++x; // 可以看到这里有多个汇编语句
00007FF7ACDE3659  mov rax,qword ptr [x]  // 引用底层是指针
00007FF7ACDE3660  mov eax,dword ptr [rax]// 将旧值放进eax寄存器
00007FF7ACDE3662  inc eax                // 自加操作
00007FF7ACDE3664  mov rcx,qword ptr [x]  
00007FF7ACDE366B  CAS(ptr[x],oldval,newval)// CAS代表CPU支持的CAS操作
//将旧的mov dword ptr [rcx],eax变成CAS操作
00007FF7ACDE366D  jmp f+28h (07FF7ACDE36
}

用C++11的接口实现原子操作

将之前的代码再做改进:

cpp 复制代码
#include <atomic>
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>

using std::atomic;
using std::atomic_compare_exchange_weak;
using std::cout;
using std::thread;

void f(atomic<int> &x, std::mutex &mx) {
    for (int i = 0; i < 10000; i++) {
        // ++x; // 旧的++x操作不是原子的,不需要
        int oldval = x, newval = 0;
        do {
            oldval = x;
            newval = oldval + 1;
        } while (!atomic_compare_exchange_weak(&x, &oldval, newval));
    }
}

int main() {
    atomic<int> x{0}; // 将临界值替换为原子类型
    std::mutex mx;
    cout << x << ' ';
    thread t1(f, std::ref(x), std::ref(mx));
    thread t2(f, std::ref(x), std::ref(mx));
    t1.join();
    t2.join();
    cout << x << '\n';
    return 0;
}

atomic_compare_exchange_weak 是 C++11 新增的 CAS 操作接口,使用时需展开头文件 atomic 。它的作用是将 obj 中包含的值与 expected 所指向的值进行比较:

  • 如果相等,则将包含的值替换为 val
  • 如果不相等,则将 expected 所指向的值替换为包含的值。

这正是之前提到的 CAS 操作。

它的返回值 :如果 *expected 与包含的值比较相等(并且未发生虚假失败),则返回 true

否则返回 false

cpp 复制代码
// volatile表示在最高优化等级下依旧保持修饰类型的内存可见性
template <class T>
bool atomic_compare_exchange_weak(volatile atomic<T>* obj, T* expected, T val) noexcept;
template <class T>
bool atomic_compare_exchange_weak (atomic<T>* obj, T* expected, T val) noexcept;
// A表示其他重载的原子类型(如果库未将C风格原子类型实现为atomic的实例化)
bool atomic_compare_exchange_weak (volatile A* obj, T* expected, T val) noexcept;
bool atomic_compare_exchange_weak (A* obj, T* expected, T val) noexcept;

类似的原子操作在 atomic 中还有很多,因为篇幅原因这里选择省略。

原语的原子性

偶然发现的概念,这里简单记录。

原语 (Primitive) 是操作系统内核中由若干条机器指令构成 的、用于完成特定功能的一段程序

原语的原子性操作是指一个操作中的所有动作,要么成功完成,要么全不做。

也就是说,原语操作是一个不可分割的整体。为了保证原语操作的正确性,必须保证原语具有原子性。

在单机环境下,操作的原子性的实现方式:

  • 关闭中断 (Disable Interrupts) 。在单核CPU上,只要关了中断,当前执行流就不会被调度器打断,自然保证了原子性。由于中断是计算机与外设通信的重要手段,关闭中断会对系统产生很大的影响,所以在实现时一定要避免原语操作花费时间过长,绝对不允许原语中出现死循环
  • 原子指令 (Atomic Instructions):利用硬件提供的原子操作指令,如 x86 的 xchgcmpxchg,ARM 的 LDREX/STREX。这些指令在硬件层面保证了操作的原子性。
  • 自旋锁 /互斥锁:基于原子指令和内存屏障实现,用于多核环境,但在单机(单核)下也是可用的。
  • 总线锁:某些原子指令在硬件层面会锁总线或缓存行。

多线程相关OJ

1114. 按序打印 - 力扣(LeetCode)

1114. 按序打印 - 力扣(LeetCode)

和 2 个线程交替打印奇数和偶数一个性质的题,但要求 2 种顺序:firstsecond 之前,secondthird 之前。所以在 2 个线程交替打印奇数和偶数的基础上再多加一套互斥锁、条件变量和布尔变量即可。

例如 firstsecond 的变量集合为 mx12cv12v12secondthird 的变量集合为 mx23cv23v23

当然这不是唯一的解法,更不是最好的解法,之所以使用这个解法,是因为在本篇有相似的案例。

参考程序:

cpp 复制代码
class Foo {
public:
    mutex mx12, mx23;
    bool v12, v23;
    condition_variable cv12, cv23;
    Foo() : v12(false), v23(false) {}

    void first(function<void()> printFirst) {
        {
            unique_lock<mutex> lk(mx12, try_to_lock);
            while (v12)
                cv12.wait(lk);
            // printFirst() outputs "first". Do not change or remove this line.
            printFirst();
            v12 = true;
            cv12.notify_one();
        }
    }

    void second(function<void()> printSecond) {
        {
            unique_lock<mutex> lk(mx12, try_to_lock);
            while (!v12)
                cv12.wait(lk);
            v12 = false;
            cv12.notify_one();
        }
        {
            unique_lock<mutex> lk(mx23, try_to_lock);
            while (v23)
                cv23.wait(lk);
            // printSecond() outputs "second". Do not change or remove this
            // line.
            printSecond();
            v23 = true;
            cv23.notify_one();
        }
    }

    void third(function<void()> printThird) {
        {
            unique_lock<mutex> lk(mx23, try_to_lock);
            while (!v23)
                cv23.wait(lk);
            // printThird() outputs "third". Do not change or remove this line.
            printThird();
            v23 = false;
            cv23.notify_one();
        }
    }
};

官方题解为使用信号量,还有大佬玩出个种姿势,这里就不一一列举了。

1117. H2O 生成 - 力扣(LeetCode)

1117. H2O 生成 - 力扣(LeetCode)

读题:题目会给 water.size() 个线程,这些线程只有 2 个种类。这些线程同时进行一次性的离子生产工作,要求这列线程生成的离子每 3 个为一组,生成一个水分。

定义 H 和 O 2 个变量分别代表 H + \text{H}^{+} H+ 和 O 2 − \text{O}^{2-} O2− 的数量。题目要求 n u m ( H + ) = 2 num(\text{H}^{+})=2 num(H+)=2 时需阻塞并等待 O 2 − \text{O}^{2-} O2− 生成,抵消掉 H + \text{H}^{+} H+ 的数量,同理 n u m ( O 2 − ) = 1 num(\text{O}^{2-})=1 num(O2−)=1 时需阻塞并等待 H + \text{H}^{+} H+ 生成,抵消掉 O 2 − \text{O}^{2-} O2− 的数量。此时只需要每个线程在自身离子达到上限时进行自我阻塞直到对方将离子扣完生成水分为止,且互斥锁的引入正好可以使同类型的离子线程互斥,防止出现问题。

至于顺序控制可参考上文的条件变量 + + + 互斥锁 + + + 全局变量的模式,只是原来的 bool 变量需更换成整型表示离子数量,同时每个线程都只生产一个离子,生产完成之后需要通知其他线程 。当 O 2 − \text{O}^{2-} O2− 线程拿到互斥锁并生成 O 2 − \text{O}^{2-} O2− 时,它至少要等 2 个 H + \text{H}^{+} H+ 完成离子生产才能生成水分,为提升效率可一次性唤醒所有 H + \text{H}^{+} H+ 线程。一次只唤醒一个的话会造成死锁,例如一个 O 2 − \text{O}^{2-} O2− 线程生成了一个 O 2 − \text{O}^{2-} O2− ,将互斥锁交出去,拿到互斥锁的若是 O 2 − \text{O}^{2-} O2− 则会因为存在 O 2 − \text{O}^{2-} O2− 使线程继续捏着锁进入阻塞,其他 H + \text{H}^{+} H+ 拿不到锁也会阻塞,此时就陷入循环等待的死锁情况。

参考程序:

cpp 复制代码
class H2O {
public:
    condition_variable cv;
    mutex mx;
    int H,O;
    H2O() :H(0),O(0){ }

    void hydrogen(function<void()> releaseHydrogen) {
        unique_lock<mutex> lk(mx,try_to_lock);
        while(H>1) // 数量达上限时自我阻塞,直到水分子消耗掉离子即可
            cv.wait(lk);
        // releaseHydrogen() outputs "H". Do not change or remove this line.
        releaseHydrogen(); // 生成H离子
        H++;
        if(H>1){
            if(O>0)
                O-=1; // 模拟水分子生成消耗离子
        }
        cv.notify_all();
    }

    void oxygen(function<void()> releaseOxygen) {
        unique_lock<mutex> lk(mx,try_to_lock);
        while(O>0)
            cv.wait(lk);
        // releaseOxygen() outputs "O". Do not change or remove this line.
        releaseOxygen(); // 生成O离子
        O++;
        if(O>0){
            if(H>1)
                H-=2; // 模拟水分子生成消耗离子
        }
        cv.notify_all();
    }
};
相关推荐
呱呱巨基1 小时前
Docker 基础概念学习
linux·c++·学习·docker
郝学胜_神的一滴3 小时前
C++20 高级编程 001:从极简HelloWorld到现代类型体系全梳理
c++·后端
Lazionr3 小时前
list容器详解——双向链表的封装与使用
数据结构·c++·链表·list
Nil2083 小时前
C++ emplace_back 与 push_back 详解
开发语言·c++
漂流瓶jz3 小时前
UVA-12174 Shuffle的播放记录 题解答案代码 算法竞赛入门经典第二版
数据结构·c++·算法·图论·滑动窗口·算法竞赛入门经典·uva
Darkwanderor3 小时前
线程概念和Linux线程
linux·c语言·c++
lingran__3 小时前
C++ 高阶数据结构:红黑树万字详解|完整原理推导 + 插入实现 + 完整性校验【STL 底层】
数据结构·c++·面试·红黑树·二叉搜索树·平衡二叉树·stl底层
东东最爱敲键盘4 小时前
day9.C++多态之虚函数
开发语言·c++
Darkwanderor4 小时前
Linux线程控制
linux·c语言·c++