C++ 多线程编程导论(上)

  随着摩尔定律逼近失效和多核处理器快速发展,多线程编程变得越来越重要。本文将系统介绍在 C++ 中如何使用 STL 实现多线程编程。多线程编程博大精深,本文并不介绍多线程算法或多线程编程方法,而是把重点放在了 C++ 的多线程库上。如果你不懂多线程,那看完本文估计也还是不懂;如果你懂多线程,那看完本文你就可以用 C++ 编写多线程程序了。
  本文属于“C++ 前言语法”板块,因此要求编译器至少支持 C++20 标准。
  本文的上半部分已完结,下半部分正在近日填坑。欢迎点赞、收藏、关注!

文章目录

  • 参考资料
  • 什么是线程
  • 子线程管理
    • `thread` 对象
      • 创建子线程
      • 正确为子线程送终
      • 管理线程对象
    • `jthread` 对象
      • 概述
      • 结束标志(`stop_token`)
  • 线程安全
    • 原子操作——`atomic` 对象
      • 简单的使用方法
      • 原理小窥
    • 原子操作——`atomic_ref` 对象
      • 局部自动变量的陷阱
      • 与 `atomic` 对象相同的局限性
    • 信号量——`counting_semaphore` 对象
      • 什么是信号量
      • 应用举例:多线程评测
      • 应用实例:解决程序 8 退出时的问题
      • 死锁
      • 互斥与资源调度

参考资料

  1. cppreference.com(该项引用的内容较多。由于这是一个手册性质的文档,因此请读者自行在其中查阅相应内容,本文不再额外指明引用自其中的哪几篇,也不显式地标明哪句话引自该文档)
  2. longji - C++20 jthread
  3. 《深入理解计算机系统》, 机械工业出版社. Randal E. Bryant, David R. O’Hallaron 著. 龚奕利、贺莲 译.

  以上参考资料中,除了第一项,其余有引用的,会通过角标注明。没有用角标注明的,说明是我看过,但并没有引用其中作者的观点,读者可以将他们作为扩展资料阅读。

什么是线程

  这个我不解释了,这篇文章假设你已经了解线程的基本概念,不知道的话上网搜索吧。只送一句话:

进程事不关己,线程高高挂起

子线程管理

  自 C++11 起,就可以使用 thread 库进行一系列与子线程管理相关的操作。

#include 

thread 对象

创建子线程

  thread 库主要提供了 thread 对象,利用 thread 对象可以创建并管理子线程。

程序 1:斐斐波波那那契契
#include 
#include 

int ans[2];
int f(int n)
{
	if (n <= 1)
		return 1;
	return f(n - 1) + f(n - 2);
}
void interface(int idx, int n)
{
	ans[idx] = f(n);
}

int main()
{
	std::thread t1(interface, 0, 34);
	std::thread t2(interface, 1, 35);
	t1.join();
	t2.join();
	std::cout << "f(34) = " << ans[0] << "\n"
			  << "f(35) = " << ans[1] << std::endl;
}

  运行结果:

f(34) = 9227465
f(35) = 14930352

  可见,thread 对象在构造时即自动创建子线程并运行使用 thread 对象的好处时能够传递任意类型的参数给目标函数,这与较底层的创建线程的行为不同。 有关 thread 对象构造函数的更多行为,见 cpp reference,非常有必要看看,这里暂时不展开论述;这其中涉及到参数是否为引用的问题,程序 11 提供了一个这样的例子。

正确为子线程送终

  任何一个线程对象在创建子线程后都需要合并(join)或者分离(detach),且两者间只能选其一。否则,线程对象在被析构时将会抛出一个错误。

  join 方法会等待一个线程结束,这意味着调用 join 方法时会阻塞调用方线程,除非线程对象对应的线程已经运行完成。在操作系统内部,join 方法的调用会导致运行完成的线程的资源被回收,因此 join 方法是必要的。

  如果不希望等待一个线程结束,而是任它自己运行,则应该在创建线程后或者其他适当的时机调用 detach 方法。在操作系统内部,被分离的线程在运行结束后会自动被进行资源回收。而在 C++ 层面,一个线程对象调用 detach 方法后,该对象就不再管理任何线程,而变成了一个空线程对象空线程对象不能再进行 join 或者 detach,但它能够直接被正常析构。

  程序 1 给出了使用 join 方法的合理例子。下面的程序 2 给出一个使用 detach 方法的不合理例子。

程序 2:这合理吗
#include 
#include 
#include 

void virus()
{
	for (int i = 0; i < 4000; i++)
		std::cout << "你的手机已中病毒,点击下载最新版浏览器\n";
}

int main()
{
	std::thread t(virus);
	t.detach();
	std::string name;
	std::cin >> name;
	std::cout << "君の名は" << name;
}

  病毒制作者当然不希望在弹出下载消息的时候你无法操作手机(堵塞主线程),因此使用子线程输出信息后撒手不管(detach)就是最佳选择。由于这是多线程,因此在病毒输出消息时,你仍然可以输入你的名字。

  手快的朋友可能发现,如果过早地完成了输入,程序会发生运行时错误。你想,调用了 cin 对象的析构函数后,病毒仍然在使用 cin 对象进行输出,这能不报错吗?这个也引出了后面我们对多线程编程一系列问题的讨论。

管理线程对象

  除了通过 detach 方法得到一个空线程对象,thread 对象也存在默认构造函数。通过默认构造函数构造出的 thread 对象也是一个空线程对象。 问题是,如何让这个空线程对象工作起来呢?

程序 3:半成品
#include 

class daemon
{
	std::thread t_background; // 默认构造函数:空线程对象。
	void background_routine()
	{
		while (true);
	}
	void launch()
	{
		// 启动线程:该怎么写?
	}
};

  这时需要注意:线程对象是不可复制构造和复制赋值的,一个线程不可能被两个线程对象管理所以不能使用 t_background = std::thread(...); 的方式来新建线程。

  有两个解决方法:

  1. 先析构空线程对象 t_background,再 placement new。
程序 4:placement new
#include 

int main()
{
	std::thread t;
	t.~thread();
	new(&t) std::thread([]() // placement new + lambda
		{
			for (int i = 0; i < 1e9; i++);
		});
	t.join();
}

  看起来就不太聪明的样子呢。

  1. 使用移动赋值。
程序 5:移动赋值
#include 

int main()
{
	std::thread t;
	t = std::move(std::thread([]()
		{
			for (int i = 0; i < 1e9; i++);
		}));
	t.join();
}

  这时程序 3 中的问题可以说是解决了一半,另一半问题是:如何将类成员函数作为新线程的入口函数。

程序 6:成品
#include 

class daemon
{
	std::thread t_background; // 默认构造函数:空线程对象。
	void background_routine()
	{
		while (true);
	}
	void launch()
	{
		t_background = std::move(
			std::thread(&daemon::background_routine, this)); // 注意这里。
	}
};

  总结:std::bind 类似,参数形如 &类名::成员函数名, this

更正:程序 5 和程序 6 中的 std::move 可以省略,因为其括号内的表达式本身就是右值,赋值时本来就会进行移动赋值。

jthread 对象

概述

  自 C++20 起,STL 标准库支持一种新的线程对象,名为 jthreadjthreadthread 的扩展(但不是子类),某些基本的表现与 thread 类似:

  1. 构造函数形式与 thread 相同。若不采用默认构造,则线程会被新建并执行。
  2. 同一线程不可能被两个 jthread 对象管理,即 jthread 没有复制构造和复制赋值。但有移动构造和移动赋值。
  3. jthread 对象可以手动 join 或者 detach

  与 thread 的首个不同点是,如果管理的线程既没有 join 又没有 detachjthread 对象会在析构时自动 join1这可以简化某些情况下的代码。

程序 7:烤机
#include 
#include 

void f()
{
	for (int i = 0; i < 1e9; i++);
}

int main()
{
	std::jthread t1(f);
	std::jthread t2(f);
}

  无需手动调用 join 方法,jthread 会在析构时自动等待线程运行结束

结束标志(stop_token

  jthread 的另一个重要功能是它内含了一个请求线程尽快停止运行的标志对象。若该标志对象的状态被置为了“停止”,则 jthread 对象管理的线程应当根据这个状态自行结束,例如,从 while(true) 循环中 break 出来。

  子线程的入口函数如何获取这个标志对象,总不能让这个子线程获取到管理它的 jthread 对象吧?正确的做法是:令子线程的入口函数的第一个参数为一个类型为 stop_tokenstop_token 类即为前文所说的标志对象)的变量,则 jthread 在创建线程时会把该标志对象传递给子线程入口函数。见程序 81

程序 8:按回车键停止发臭的屑程序
#include 
#include 
#include 

void f(std::stop_token st, std::string str) // 虽然传的不是引用,但肯定是有关联的。
{
	while (!st.stop_requested()) // 判断标志对象的状态是否为被请求停止。
	{
		std::cout << str;
		std::flush(std::cout);
		// 把线程挂起一段时间,不然太臭了。
		using namespace std::chrono_literals; // 重载字面量运算符 ""ms
		std::this_thread::sleep_for(100ms);
	}
}

int main()
{
	std::jthread jt(f, "啊"); // "啊" 是参数 2,不是参数 1。参数 1 是 stop_token。
	std::string temp;
	std::getline(std::cin, temp);
	jt.request_stop(); // 请求停止。效果是设置标志对象的状态为停止。
}

  总结:jthread 的关键方法是 request_stop,用于请求被管理的线程停止。stop_token 的关键方法是 stop_requested,用于获知是否被请求停止。

  补充五点:

  1. jthread 被析构时,如果没有手动 joindetach,则会先自动请求停止,再自动合并。这意味着程序 8 中的最后一句 jt.request_stop(); 不是必要的,因为会自动执行。
  2. 程序 7 中,jthreadthread 兼容,入口函数的第一个参数并不是 stop_token,这也行吗?
    事实上,在 jthread 的实现中,会判断传入函数的类型,如果能够以 f(stop_token, ...) 的形式传参,则传入标志对象。否则直接以 f(...) 的形式传参,忽略 stop_token,此时理论上标志对象不起任何作用。这也是为什么前文强调 stop_token 必须为函数的第一个参数
    这样的判断是编译时进行的,是利用 C++ 的模板元编程实现的,超出了我们的讨论范围。
  3. stop_token 本身是线程安全的。简而言之,你直接用就没问题。
  4. 聪明的读者很快就发现问题:程序 8 中,在请求停止后,我们仍然会等待至多 100 毫秒,因为子线程可能恰好准备开始 sleep 100 毫秒。如何避免这 100 毫秒的等待呢?
    jthread 似乎无法解决这个问题。
  5. jthread 管理的线程结束时,我们是可以收到通知的,方法是使用 std::stop_callback。这里暂不讨论,见 cpp reference 对 stop_callback 的介绍。

线程安全

  提多线程就一定要提线程安全,提线程安全就一定要提到下面这个自增程序。

程序⑨:1+1+...=?
#include 
#include 

int ans;
void inc()
{
	for (int i = 0; i < 1e8; i++)
		ans++;
}

int main()
{
	std::thread t1(inc);
	std::thread t2(inc);
	t1.join();
	t2.join();
	std::cout << ans << std::endl;
}

  可能的输出:

135895847

  反正你就是得不到 200000000!出现这个问题的原因是,两个线程指令的执行顺序可能是:

线程 1:取内存到寄存器(mov)
线程 2:取内存到寄存器
线程 1:寄存器自增(inc)
线程 1:写寄存器至内存(mov)
线程 2:寄存器自增
线程 2:写寄存器至内存

  虽然程序执行了两次“寄存器自增”,但最终效果不相当于只让内存中的值增加了 1 吗?这说明,在多线程环境中,有些代码被多个线程同时执行时是不安全的。换句话说,同一时间,至多只能有一个线程在执行某段代码。需要注意的是,这只是最简单常见的情形:同一段代码,多线程执行,引发数据竞争。还有更多更复杂的线程不安全的情形,我们将在后面看到。

  针对这一问题,有以下解决方案。

原子操作——atomic 对象

  至少需要 C++11。需包含

#include 

简单的使用方法

  前文已经分析了程序⑨中 1+1 都算错的原因。如果程序⑨中的自增操作本身就只需要一条指令,不就没这个问题了吗?如果真有这样的“指令”,那么称这种(在逻辑上)不可再分割运行的程序是原子的

  最底层的硬件层面可能不存在这样的“指令”,但是在高级语言层面却总存在一段原子的程序。atomic 对象即为我们封装了原子的程序。它是一个类模板,程序 10 展现了它最简单的用法。

程序 10:数学天才
#include 
#include 
#include 

std::atomic<int> ans;
void inc()
{
	for (int i = 0; i < 1e8; i++)
		ans++;
}

int main()
{
	std::thread t1(inc);
	std::thread t2(inc);
	t1.join();
	t2.join();
	std::cout << ans << std::endl;
}

  运行结果:

200000000

  正确!且非常实用,只需要把原来的 int 改成 std::atomic 即可

  之所以程序 10 能够这么简单,是因为 atomic 类对 int 这样的整数型(满足概念 std::integral 的类型)进行了偏特化,重载了赋值、自增、自减等一系列写操作(当然是原子的),并且重载了类型转换运算符

  自 C++20 起,std::atomic 才实现了 floatdouble 这种浮点类型的偏特化

  最后一种特殊的类型是指针类型。atomic 对指针类型实现了偏特化,并且自 C++20 起,atomic 对智能指针实现了偏特化

原理小窥

  标准库提供了一系列函数,例如 __atomic_add_fetch,来实现对整数的一系列原子操作。atomic 类包装了对这些函数的调用,使得我们能够用平常的方式来使用原子化的整数。对再内部的运行原理超出了我们的讨论范围。

  可以看出,__atomic_add_fetch 等函数的存在决定了 atomic 对整数类型的偏特化存在,指针类型和浮点数类型同理。那对于其他类型,atomic 仍能保证其原子性吗?答案是否定的,或者说,这样的 atomic 对象完全不便于你使用

  对于没有偏特化的类型,atomic 对象只支持下面三种基本操作:

  1. 读(load)。
  2. 写(store)。
  3. 读然后写(exchange)。

  相当于你无法调用一个对象的成员函数,这能忍吗?这不能忍。但 atomic 对象也没有办法,因为确实并不是所有类型的所有成员函数都是可以轻易原子化的。

原子操作——atomic_ref 对象

局部自动变量的陷阱

  为了引入 atomic_ref,见下面的程序。

程序 11:局部自动变量也会数据竞争吗?
#include 
#include 
#include 
#include 

void inc(int& num) { for (int i = 0; i < 1e8; i++) num++; }
void inc_n(int& num, int n)
{
	std::vector<std::thread> vec;
	for (int i = 0; i < n; i++)
		vec.emplace_back(inc, std::ref(num)); // 注意引用必须用 std::ref 包装,见前文对 std::thread 参数的解释。
	for (auto& t : vec)
		t.join();
}

int main()
{
	int n = 0; // 局部自动变量。
	inc_n(n, 2); // 用多线程加 2 次 1e8。
	std::cout << n << std::endl;
}

  可能的运行结果:

109726353

  程序 11 用最简单的示意代码描述了一个情形:一个用于修改作为引用传入的参数的函数,内部使用多线程进行加速。尽管整个程序没有全局变量,但仍然会因此引发线程安全问题。

  在程序 11 中,如何在不修改 main 函数的情况下解决这个问题?自然会想到,如果 inc_n 能够替我们解决这个问题就好了。目前的解决方法是:将 inc 函数的参数类型更换为 atomic&,在 inc_n 函数内部额外引入一个 atomic

  自 C++20 起,新引入的 atomic_ref 对象可以避免上述额外引入的 atomic它为非原子的类型(如 int)提供了一个原子的引用,透过它看原来的对象,可以我们操作的是一个原子的对象。见程序 12。

程序 12:atomic_ref
#include 
#include 
#include 
#include 

// 注意参数类型不可为 int&。
void inc(std::atomic_ref<int> num) { for (int i = 0; i < 1e8; i++) num++; }
void inc_n(int& num, int n)
{
	std::vector<std::thread> vec;
	for (int i = 0; i < n; i++)
		vec.emplace_back(inc, std::atomic_ref(num)); // 注意不可再用 std::ref。
	for (auto& t : vec)
		t.join();
}

int main()
{
	int n = 0;
	inc_n(n, 2);
	std::cout << n << std::endl;
}

  当然这么做是要有一定限制的,你总不能再写一个 int& 类型的函数然后新建线程调用,这样做仍然会有数据竞争的问题。更多的限制条件,见 cpp reference 文档的介绍。

atomic 对象相同的局限性

  atomicatomic_ref 对象最大的局限性是:它们都只能很好地支持已经偏特化的类型,即整数、浮点数(自 C++20)、指针和智能指针(自 C++20)。对于其他类型,你也最多只能做最基本的读、写和读写操作,可以说完全无法使用。如果类型是不平凡的,你甚至无法通过编译(平凡的定义我也不太懂,但举两个例子,一个只包含整数的结构体肯定是平凡的,而一个 vector 绝不是平凡的)。

  诚然,出现这个问题最主要的原因是,只有整数、浮点数这种简单的东西才有可能使得它们的每一个过程都原子化。vector 有这么多复杂的操作,不可能都做到原子化。并且,要使得一个 vector 是线程安全的,首先得搞清楚,什么样的 vector 才能叫做线程安全的,这已经足够复杂了。不像整数,线程安全可以简单地认为是当有多个线程进行 n n n 次加法时,运行完后该整数确实加了 n n n 次;只要加法满足这个条件,别的运算也可以同理定义。

  所以进一步,要实现一个线程安全的 vector,就肯定得用别的工具了。

信号量——counting_semaphore 对象

  自 C++20 起,STL 才提供了内置的信号量对象。但由于信号量是一种非常简单的工具、历史悠久(基于信号量的线程同步方法由 Dijkstra 提出2)、运用广泛,因此我们把信号量放在前面。

  需包含 semaphore 头。

#include 

什么是信号量

  信号量在数据域上仅仅是一个非负整数,表示一个计数器。该计数器表示还可以有多少个访问者进行访问。如果有访问者进行访问,则计数器减一。当访问结束时,计数器(默认)加一。如果开始进行访问时计数器为零,则线程阻塞,直至计数器非零(由于有其他访问者结束访问,计数器数值增加),访问才真正开始。显然,该计数器需要是线程安全的,即该计数器的加减操作需要是原子的,这由信号量的内部实现保证;否则,可能会出现两个线程同时真正开始访问,而计数器只减少了 1 的情况。

  可见,计数器的初始值是一个值得关注的东西。对于 C++20 提供的 counting_semaphore信号量计数器的初始值由构造函数显式地给出,但这个初始值不能大于信号量允许的最大值,而这个最大值由模板参数给出,默认为一个极大值。作为一个特例情况,最大值为 1 的信号量称为二元信号量2。二元信号量在处理变量跨线程共享问题方面有着非常重要的应用,因此 C++20 为它提供了一个单独的类型:

using binary_semaphore = std::counting_semaphore<1>;

  根据以上描述,我们在下面列出信号量的重要方法。

  1. acquire:开始访问,无返回值。运行结束后信号量计数器减一。
  2. release:结束访问,无返回值。效果是信号量计数器(默认)加一。还可以指定信号量计数器增加的值,但必须非负,且不能使得计数器的值大于最大值。
  3. try_acquire_for:尝试开始访问,至多阻塞一个给定的时间,如果超时,说明开始访问失败,返回 false,否则说明访问成功,返回 true,计数器减一。

  重点是前两个方法,要实现前面的描述确实也只需要前两个方法。第三个方法的问题在于,文档指出,允许 try_acquire_for 虚假地返回 false,即使计数器非零。所以我们不把重点放在该方法上。

  读者应该注意,在上面的描述中,并没有要求 acquirerelease 一定是配对的。只要信号量对象存在,那么可以随时进行 acquirerelease

应用举例:多线程评测

  考察以下情形:有 n n n 个任务要进行,每个任务之间没有数据需要共享。但每个任务都需要很多 CPU 时间,为了保证运行效率,减少运行过程中进行的线程切换,要求至多只能有处理器线程个线程同时运行。程序 13 使用信号量模拟了这一情形。

程序 13:评测姬
#include 
#include 
#include 
#include 

std::counting_semaphore s(std::thread::hardware_concurrency());
std::binary_semaphore bs(1);
void submitted_code(int id)
{
	s.acquire(); // 使用信号量限制访问者数量。
	for (int i = 0; i < 1e9; i++);
	bs.acquire(); // 使用二元信号量保证同一时刻至多有一个线程执行输出的代码。
	std::cout << id << " accepted." << std::endl;
	bs.release();
	s.release();
}

int main()
{
	std::vector<std::thread> ts;
	for (int i = 0; i < std::thread::hardware_concurrency() * 2; i++)
		ts.emplace_back(submitted_code, i);
	for (auto& t : ts)
		t.join();
}

  可能的运行结果(没错我电脑 8 核 16 线程):

14 accepted.
5 accepted.
4 accepted.
6 accepted.
3 accepted.
1 accepted.
13 accepted.
11 accepted.
0 accepted.
2 accepted.
8 accepted.
9 accepted.
15 accepted.
10 accepted.
7 accepted.
12 accepted.
18 accepted.
17 accepted.
23 accepted.
19 accepted.
16 accepted.
22 accepted.
21 accepted.
29 accepted.
24 accepted.
28 accepted.
20 accepted.
30 accepted.
25 accepted.
31 accepted.
27 accepted.
26 accepted.

  出现前 16 行后,会过一会儿再出现后 16 行。

  程序 13 中我们使用了二元信号量 bs 来保证 std::cout 的线程安全。std::cout 由其他人实现,是一个复杂对象,也不能简单地看作是数据的共享,显然不能原子化。要使得使用 std::cout 的程序仍然是线程安全的,可以要求在同一时刻至多只有一个线程执行相关代码,而这正符合二元信号量的功能。作为对比,程序 14 展示了不使用二元信号量的结果。

程序 14:评测女
#include 
#include 
#include 
#include 

std::counting_semaphore s(std::thread::hardware_concurrency());
void submitted_code(int id)
{
	s.acquire(); // 使用信号量限制访问者数量。
	// 为了突出效果,略去循环代码。
	// 不使用二元信号量。
	std::cout << id << " accepted." << std::endl;
	s.release();
}

int main()
{
	std::vector<std::thread> ts;
	for (int i = 0; i < std::thread::hardware_concurrency() * 2; i++)
		ts.emplace_back(submitted_code, i);
	for (auto& t : ts)
		t.join();
}

可能的运行结果:

039 accepted.4 accepted.
6 accepted.1 accepted.
 accepted.
28 accepted.21 accepted.
30 accepted.
15 accepted.
7 accepted.
2 accepted.


12 accepted.
22 accepted.

18 accepted.
17 accepted.
19 accepted.
13 accepted.
16 accepted.
26 accepted.
27 accepted.
29 accepted.
31 accepted.
 accepted.25 accepted.
14 accepted.
23 accepted.

8 accepted.11 accepted.
24 accepted.
20 accepted.

5 accepted.
10 accepted.

  即使 cout<< 运算符是原子的,std::cout << id << " accepted." << std::endl; 这整句代码也一定不是线程安全的!在这个例子中,线程安全定义的复杂性就显现出来了。从程序 13 和程序 14 我们可以学到,通过使用二元信号量使得同一时刻至多只有一个线程运行指定代码,能让很多代码按我们的想法正确地运行,即保证了线程安全。称这种手段为互斥访问2

应用实例:解决程序 8 退出时的问题

  程序 8 的“补充五点”中,第 4 点提出了一个这样的问题:

  聪明的读者很快就发现问题:程序 8 中,在请求停止后,我们仍然会等待至多 100 毫秒,因为子线程可能恰好准备开始 sleep 100 毫秒。如何避免这 100 毫秒的等待呢?

  下面,我们利用二元信号量的计数器功能,配合 try_acquire_for 方法来重写一个完美的程序 8。

程序 15:按回车键停止发臭的屑程序 2.0
#include 
#include 
#include 
#include 

std::binary_semaphore bs{ 0 };
void f()
{
	while (true)
	{
		std::cout << "啊";
		std::flush(std::cout);
		// 把线程挂起一段时间,不然太臭了。
		using namespace std::chrono_literals; // 重载字面量运算符 ""ms
		if (bs.try_acquire_for(100ms)) // 如果等待成功(bs.release() 已调用),则退出。
			break;
	}
}

int main()
{
	std::thread t(f);
	std::string temp;
	std::getline(std::cin, temp);
	bs.release();
	t.join();
}

  即使将代码中的 100ms 改成 10s,按下回车键程序也能立刻退出,问题得到解决!程序 15 说明,信号量本身是一个非负整数(二元信号量本身是一个布尔值)的性质是可以加以利用的。

  不过,查阅文档可以发现,你并不能直接获得信号量计数器的值,事实上确实不能:信号量的一个可能实现中,该计数器是一个 private 的原子整数。无法在不产生其他影响或不编写失格代码获取信号量计数器的值,可能是信号量的共性

  万一程序 15 中的 try_acquire_for 总是为我直接返回 false 怎么办?实在不放心可以看一看具体实现:

// 摘自 (MSVC)
    template <class _Rep, class _Period>
    _NODISCARD bool try_acquire_for(const chrono::duration<_Rep, _Period>& _Rel_time) {
        auto _Deadline = _Semaphore_deadline(_Rel_time);
        for (;;) {
            // "happens after release" ordering is provided by this exchange, so loads and waits can be relaxed
            // TRANSITION, GH-1133: should be memory_order_acquire
            unsigned char _Prev = _Counter.exchange(0);
            if (_Prev == 1) {
                return true;
            }
            _STL_VERIFY(_Prev == 0, "Invariant: semaphore counter is non-negative and doesn't exceed max(), "
                                    "possibly caused by preconditions violation (N4861 [thread.sema.cnt]/8)");
            const auto _Remaining_timeout = __std_atomic_wait_get_remaining_timeout(_Deadline);
            if (_Remaining_timeout == 0) {
                return false;
            }
            __std_atomic_wait_direct(&_Counter, &_Prev, sizeof(_Prev), _Remaining_timeout);
        }
    }

  没有直接返回 false 的代码,那就放心用吧!

死锁

  线程同步的机制一引入,就会引出一个著名的问题:死锁。死锁可以定义为:线程被阻塞,且等待的条件永远不可能为真2。程序 16 使用信号量制造了一个死锁。

程序 16:Deadlock
#include 
#include 

std::binary_semaphore s{ 1 };
std::vector<unsigned> seq;
void make_seq(unsigned target)
{
	if (target == 0)
		return;
	s.acquire();
	seq.push_back(target);
	make_seq(target - 1);
	s.release();
}

int main()
{
	make_seq(1);
	make_seq(2);
}

  这是一个单线程程序,所以这个例子有点拙劣了,但它确实是一个死锁。调用 make_seq(1) 后,程序正常运行;但调用 make_seq(2) 会导致程序在进入第二层递归时等待二元信号量释放;但二元信号量释放的条件是第二层递归运行结束!所以这个等待将会永远地持续下去,便构成了一个死锁。

  死锁当然是要避免的,但并不是一个容易事。这里我们不再展开讨论。

互斥与资源调度

  程序 13 是一个使用信号量进行互斥访问(bs资源调度(s 的极简例子,这也是线程安全问题中最主要的两个解决手段。要实现这两点,除了信号量,还有其他模型,在过去的 C++ 中主要的模型为互斥锁和条件变量2。相比之下,信号量更为简单清晰,但限于信号量在 C++20 中才被支持,某些功能,例如 try_acquire_for,它们的定义不太良好,所以学习互斥锁和条件变量是很有必要的。


  1. longji - C++20 jthread ↩︎ ↩︎

  2. 《深入理解计算机系统》, 机械工业出版社. Randal E. Bryant, David R. O’Hallaron 著. 龚奕利、贺莲 译. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

你可能感兴趣的:(C++,前沿语法,编程语言,c++,多线程,编程语言)