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

2023-05-16

  随着摩尔定律逼近失效和多核处理器快速发展,多线程编程变得越来越重要。本文将系统介绍在 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 对象,利用 thread 对象可以创建并管理子线程。

程序 1:斐斐波波那那契契
#include <iostream>
#include <thread>

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 <iostream>
#include <string>
#include <thread>

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 <thread>

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 <thread>

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 <thread>

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

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

程序 6:成品
#include <thread>

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 <iostream>
#include <thread>

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 <iostream>
#include <string>
#include <thread>

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 <iostream>
#include <thread>

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。需包含 <atomic>

#include <atomic>

简单的使用方法

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

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

程序 10:数学天才
#include <iostream>
#include <thread>
#include <atomic>

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<int> 即可

  之所以程序 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 <iostream>
#include <vector>
#include <thread>
#include <atomic>

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<int>&,在 inc_n 函数内部额外引入一个 atomic<int>

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

程序 12:atomic_ref
#include <iostream>
#include <vector>
#include <thread>
#include <atomic>

// 注意参数类型不可为 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 <semaphore>

什么是信号量

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

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 <iostream>
#include <vector>
#include <thread>
#include <semaphore>

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 <iostream>
#include <string>
#include <thread>
#include <semaphore>

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 怎么办?实在不放心可以看一看具体实现:

// 摘自 <semaphore>(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 <vector>
#include <semaphore>

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 著. 龚奕利、贺莲 译. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C++ 多线程编程导论(上) 的相关文章

  • Linux之索引节点inode(index node)

    inode就是索引节点 xff0c 它用来存放档案及目录的基本信息 xff0c 包含时间 档名 使用者及群组等 inode 是 UNIX Linux 操作系统中的一种数据结构 xff0c 其本质是结构体 它包含了与文件系统中各个文件相关的一
  • String、StringBuffer、Stringbulider的区别

    java中用于处理字符串常用的有三个类 java lang String java lang StringBuffer java lang StrungBuilder 三者共同之处 都是final类 不允许被继承 xff0c 主要是从性能和
  • 常用集合List、Set、Map的比较

    概述 List Set Map都是接口 xff0c 前两个继承至Collection接口 xff0c Map为独立接口 List有序 xff0c 可重复 1 ArrayList 底层 xff1a 数组 xff1b 查询快 xff0c 增删慢
  • Apache中的默认编码

    好吧 xff0c 在Django上测试挺好的GBK编码的静态文件 xff0c 部署到Apache后乱码了 xff0c 默认编码变成了UTF 8 xff0c 可是meta中明明指定了charset为GBK的 xff1a lt meta htt
  • SpringDoc使用

    文章目录 1 SpringDoc简介2 SpringDoc基础使用 1 SpringDoc简介 SpringDoc是一款可以结合SpringBoot使用的API文档生成工具 2 SpringDoc基础使用 POM文件 span class
  • Result统一响应数据

    目录 1 响应数据的格式 1 1 状态码划分 1 3 响应数据的封装 2 统一数据返回 1 响应数据的格式 在分离的环境中 xff0c 我们前后交互就显得尤为重要 前端按照接口文档中的URL地址和参数要求发送请求 xff0c 接着后端对业务
  • SpringBoot实现全局异常处理器

    1 本文使用 64 ControllerAdvice 64 ExceptionHandler 进行全局的 Controller 层异常处理 不用在 Controller 层进行 try catch xff01 package com exa
  • 排序算法的对比

    冒泡排序VS选择排序 两者时间复杂度O n2 冒泡排序找最大值放在最后 xff0c 选择排序找最小值放在前面 选择排序属于不稳定排序 xff1b 选择排序一般情况下优于冒泡排序 xff0c 每轮只交换一次 如果集合有序度高 xff0c 则选
  • Redis基本操作

    安装Redis docker compose安装 单节点 version 34 3 3 34 services master image redis 6 0 container name redis environment 34 disco
  • RabbitMQ初级操作

    MQ 消息队列 xff1a 一种进程通信或同一进程的不同线程的通信方式 采用链表结构实现 xff0c 拥有权限的进程向消息队列写入或读取消息 常用MQ xff1a RabbitMQ ActiveMQ kafka xff0c Redis的Li
  • SpringBoot——邮件模块

    SpringBoot集成邮件模块 邮件协议 原文链接 xff1a https blog csdn net qq 37745470 article details 89094227 什么是SMTP xff1f SMTP全称为Simple Ma
  • JVM内存结构

    JVM JVM xff1a Java Virtual Machine xff0c Java虚拟机 xff0c 识别 class后缀文件 xff0c 解析class的指令 xff0c 调用操作系统函数完成操作 JVM JRE JDK关系 xf
  • JVM类加载机制

    类加载过程3个阶段 xff1a 1 加载 将类的字节码载入方法区 xff0c 并创建 Class对象 加载符号引用到类常量池 xff0c 只是有这个名字UnresolvedClass Name 如果类的父类没有加载 xff0c 先加载父类加
  • nginx 反向代理配置

    Nginx 反向代理 xff1a 其实客户端对代理是无感知的 xff0c 因为客户端不需要任何配置就可以访问 xff0c 我们只需要将请求发送到反向代理服务器 xff0c 由反向代理服务器去选择目标服务器获取数据后 xff0c 在返回给客户
  • Cookies 和 Session的区别

    1 cookie 是一种发送到 客户 浏览器的文本串句柄 xff0c 并保存在客户机硬盘上 xff0c 可以用来在某个WEB站点会话间持久的保持数据 2 session 其实指的就是访问者从到达某个特定主页到离开为止的那段时间 Sessio
  • HashMap详解

    HashMap 16 xff0c 0 75 为什么能快速查找 xff1f put操作放入key时 获取key的hash值 在原始hash值的基础上再次获取hash值 二次hash值与容量取模运算 capacity 获得桶下标 根据桶下标 x
  • 设计模式——单例模式

    单例模式 饿汉式 类加载阶段被初始化就会创建实例 提前创建 span class token keyword class span span class token class name Singleton span span class
  • Java多线程

    并发 线程状态 Debug调试 xff0c 线程模式 java xff1a 6种状态 NEW 新建 startRUNNABLE 就绪 运行 阻塞I O cpu 调度TERMINATED 终结 代码执行完毕BLOCKED 阻塞 获取锁失败WA
  • JVM垃圾回收

    GC与分代回收算法 GC目的 xff1a 实现无用对内存自动释放 xff0c 减少内存碎片 加快分配速度 GC要点 xff1a 回收区域 xff1a 堆内存可达性分析算法 三色标记法GC具体实现称垃圾回收器GC采用分代回收思想 xff0c
  • 4种对象引用类型

    1 强引用 A a 61 new A 通过GC Root的引用链 xff0c 如果强引用不到该对象 xff0c 该对象才会被回收 2 软引用 SoftReference a 61 new SoftReference new A 如果仅有软引

随机推荐

  • SQL语句

    Select SQL 执行顺序 fromonjoinwheregroup byhavingselectdistinctorder bylimit WHERE 字段比较 代码作用 61 等于 lt gt 61 不等于 lt lt 61 小于
  • Bootstrap笔记

    Bootstrap样式 CSS导入 span class token tag span class token tag span class token punctuation lt span link span span class to
  • JSTL与EL表达式

    什么是JSTL JSTL是对EL表达式的扩展 xff0c JSTL是标签语言 xff01 规范了每个标签的职责范围 JSTL标签库 core 核心标签库 fmt 格式化标签库 导标签包 span class token operator l
  • Ubuntu中/usr/local 和 ~/.local 之间的区别

    Ubuntu中 usr local 和 local 之间的区别 usr local 是一个可供所有用户使用的软件可由管理员安装的地方 local bin 是一个用户可以安装软件供自己使用的地方 不同发行版和社区使用的目录结构的历史有些混乱
  • Centos7配置yum镜像源(base,extras,updates,epel,local)

    一 备份默认源 由于默认源都在国外 xff0c 速度非常慢 xff0c 需要把默认的源配置文件备份后删除 span class token comment 进入配置文件目录 span span class token function cd
  • win10彻底关闭windows update 自动更新的方法

    转载自 xff1a https jingyan baidu com article 6181c3e0d75aaa152ef15326 html 其实保留更新还是很有用的 xff0c 毕竟官方一直在修复漏洞 但是服务器虚拟机中运行的win10
  • 解决Centos 7 VNC黑屏

    在配置Centos 7下VNC时发现root用户可以正常登陆VNC桌面 xff0c 而普通用户VNC桌面黑屏 xff0c 分析 vnc xstarup 后发现是普通用户没有执行 etc X11 xinit xinitrc的权限 bin sh
  • 一个与cni0相关的pod创建问题

    今天查看k8s xff0c 发现有个coredns的pod创建失败 xff0c 查看这个POD的信息 xff0c 显示如下错误 combined from similar events Failed to create pod sandbo
  • Debian10安装SSH、配置NTP、安装配置UFW防火墙、配置PATH

    一 SSH安装配置 1 1 安装SSH span class token comment 安装SSH客户端 span apt span class token function install span openssh client spa
  • Debian10 创建用户、用户组、切换用户

    span class token comment 新建用户组 span span class token function groupadd span hausers span class token comment 新建用户并加入用户组
  • C++关于循环依赖的问题

    C 43 43 关于循环依赖的问题 xff1a 循环情况 xff1a class B class A public B b class B public A a 若两个类之间存在循环依赖则在编译时会报错 xff0c 原因是两个类中存在相互的
  • Rust小项目一:Rust 网络编程,实现一个Tcp server

    近日学习Substrate的开发入门 xff0c 之前没有接触过Rust编程 xff0c 今天跟着视频做个小项目练练手 项目目标 xff1a 编写一个Tcp server端与一个Tcp client端 xff0c 客户端中输入内容后 xff
  • Python 项目打包并发布到私有 PyPI 服务器

    推广博客 xff1a Python 项目打包并发布到私有 PyPI 服务器
  • C++ 零碎特性

    摘自 C 43 43 17 入门经典 几乎不会再更新 文章目录 使用花括号初始化变量零初始化使大整型字面量更加易读二进制的整型字面量 96 size t 96 类型浮点数的特殊情况 xff1a NaN xff08 Not a Number
  • Python 学习笔记——进阶

    文章目录 一 模块 xff08 一 xff09 1 导入外部模块2 导入时重命名3 标准库 xff08 一 xff09 96 sys 96 96 argv 96 变量 96 exit 96 函数 96 modules 96 变量 96 pa
  • C++ UTF-8 编码与 UTF-32 编码的互相转换

    C 43 43 UTF 8 编码与 UTF 32 编码的互相转换 代码实现基本照搬了秦建辉的博客 这里不介绍原理 xff0c 只提供可以直接使用的代码 要求 C 43 43 编译器的语言标准至少为 C 43 43 17 如果编译器支持的语言
  • C++ 默认移动构造函数的调用情况

    C 43 43 默认移动构造函数的调用 直接上测试代码 xff1a include lt cstdio gt include lt iostream gt include lt string gt class MyClass int a 6
  • LaTeX 003:使用 MikTeX 组件实现 pdf 转 eps

    气死了气死了 xff0c 这玩意儿居然直接百度不到一个很好的答案 xff0c 百度到的全是用带图形化界面的软件 xff0c 您不累吗 xff1f 唯一找到的一个 xff0c 效果不好 xff0c 是糊的 最后还是上谷歌镜像站 xff0c 一
  • 【深入浅出ios开发】UIStoryboardSegue详解

    一个UIStoryboardSegue对象负责执行两个试图控制器之间的视觉过渡 另外 xff0c segue对象通常用来准备从一个控制器过渡到另一个控制器 segue对象包含了涉及过渡的控制器的信息 当segue被触发 xff0c 并且在视
  • C++ 多线程编程导论(上)

    随着摩尔定律逼近失效和多核处理器快速发展 xff0c 多线程编程变得越来越重要 本文将系统介绍在 C 43 43 中如何使用 STL 实现多线程编程 多线程编程博大精深 xff0c 本文并不介绍多线程算法或多线程编程方法 xff0c 而是把