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

2023-05-16

受篇幅限制,上半部分不再更新,填坑的新内容都放在此文章中。

文章目录

  • 参考资料
  • 线程安全(续)
    • 互斥访问——互斥体(mutex)和锁(lock)
      • 什么是互斥体(mutex)
      • 为什么我们需要锁(lock)
      • 互斥锁(`unique_lock`)
      • 定时互斥体(`timed_mutex`)
      • 递归互斥体(`recursive_mutex`)
      • 同时抢夺多个互斥体的所有权
      • 共享互斥体(`shared_mutex`)与共享锁(`shared_lock`)
    • 条件变量——`condition_variable` 对象
      • 什么是条件变量
      • 条件变量与互斥体协同工作
        • 消费者线程
        • 生产者线程
        • 不上锁的后果
      • 条件变量与互斥体的双队列模型
      • `condition_variable_any`
      • pthread API

参考资料

  1. cppreference.com(该项引用的内容较多。由于这是一个手册性质的文档,因此请读者自行在其中查阅相应内容,本文不再额外指明引用自其中的哪几篇,也不显式地标明哪句话引自该文档)
  2. Mutex vs Semaphore, GeeksforGeeks.
  3. What is the difference between std::condition_variable and std::condition_variable_any? - Anthony Williams, stack overflow.
  4. What is the difference between std::condition_variable and std::condition_variable_any? - kennytm, stack overflow.
  5. POSIX线程, 百度百科.

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

线程安全(续)

上半部分我们已经介绍了原子操作和信号量,并提到了学习互斥锁和条件变量是很有必要的。下面我们介绍互斥锁和条件变量。

互斥访问——互斥体(mutex)和锁(lock)

什么是互斥体(mutex)

*互斥体(mutex)*的最简使用方法在 cppreference 中概括如下:

  1. 首先用 lock 方法占有互斥体的所有权。
  2. 线程占有互斥体时,其他试图占用互斥体所有权的线程将会阻塞。
  3. 因此占有互斥体所有权的线程可以安全地访问一些资源。访问结束后,调用 unlock 方法释放所有权,使其他线程有机会访问该互斥体保护的资源。

看上去,互斥体总是可以和二元信号量互相替换。程序 13(见上半部分)中,我们使用了二元信号量 bs 来保证输出语句的互斥访问,下面我们将用 C++ 标准库中的互斥体对象 std::mutex(需要包含 mutex 头文件)代替它。

程序 17:评测姬,但是用了互斥体
#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
#include <semaphore>

std::counting_semaphore s(std::thread::hardware_concurrency());
std::mutex m; // std::binary_semaphore bs(1);
void submitted_code(int id)
{
	s.acquire(); // 使用信号量限制访问者数量。
	for (int i = 0; i < 1e9; i++);
	m.lock(); // 使用互斥体保证同一时刻至多只有一个线程执行以下代码。
	std::cout << id << " accepted." << std::endl;
	m.unlock();
	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();
}

我们应该像程序 13 那样使用二元信号量,还是应该像程序 17 这样使用互斥体,来实现代码互斥访问?从性能上考虑,总是应该选择互斥体,可以参见下面程序 18 的运行结果。从语义上考虑,在该应用场景中也应当使用互斥体。互斥体在语义上表示互斥访问资源,而信号量在语义上表示等待一种信号的到来1。**总而言之,在可以使用互斥体时请使用互斥体,而不要使用二元信号量。**之后我们还会看到更重要的原因。

程序 18:互斥体与信号量的性能对比
#include <iostream>
#include <chrono>
#include <mutex>
#include <semaphore>
#include <string_view>

constexpr int times = int(1e7);
int counter;

void test_semaphore()
{
	std::binary_semaphore bs(1);
	for (int i = 0; i < times; i++)
	{
		bs.acquire();
		counter++;
		bs.release();
	}
}
void test_mutex()
{
	std::mutex m;
	for (int i = 0; i < times; i++)
	{
		m.lock();
		counter++;
		m.unlock();
	}
}
template<typename func_t>
void performance_counter(func_t f, std::string_view name)
{
	auto start = std::chrono::high_resolution_clock::now();
	f();
	auto end = std::chrono::high_resolution_clock::now();
	auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
	std::cout << name << ": " << duration.count() << " microseconds" << std::endl;
}

int main()
{
	performance_counter(test_semaphore, "semaphore");
	performance_counter(test_mutex, "mutex");
}

可能的运行结果:

semaphore: 152954 microseconds
mutex: 126929 microseconds

为什么我们需要锁(lock)

从程序 17 可以看出,互斥体本身就是一把完整的锁了:它可以锁(lock),也可以解锁(unlock)。那接下来要介绍的*锁(lock)*又是什么东西呢?

让我们从下面的代码中感受锁的作用。

程序 19:传统钥匙
#include <iostream>
#include <thread>
#include <mutex>

int count = 1; // count 需要互斥访问。
std::mutex m;
int calc_average() { // 非互斥访问版本。
	count--;
	if (!count)
		throw std::domain_error("Divided by zero!");
	return 114514 / count;
}
template<typename func_t>
auto reentrant_wrapper(func_t f) { // 互斥访问包装。
	return [f]() {
		m.lock(); f(); m.unlock();
	};
}
void routine() { // 一个线程。
	try {
		reentrant_wrapper(calc_average)();
	}
	catch (std::domain_error& e) {
		std::cout << e.what() << std::endl;
	}
}

int main() {
	std::jthread t(routine);
	using namespace std::literals;
	std::this_thread::sleep_for(1s); // 休息一秒再数数吧。
	m.lock(); count++; m.unlock();
}

程序 19 假设了一个计算平均数的场景,calc_average 函数首先将计数器 count 减一,然后以 count 为除数计算一个除法,如果除数为零则抛出异常。因为该函数涉及两处计数器变量 count 的读写,所以它不是线程安全的:主函数中对 count 的写操作完全可以在 calc_average 中的自减和除法之间进行。为此,我们引入通用包装函数 reentrant_wrapper,它为传入的任何函数 f 提供装饰器,在调用 f 前额外加锁互斥体,调用 f 后解锁互斥体,从而保证了 calc_average 函数的线程安全。

看上去 reentrant_wrapper 设计得非常好,这是一种典型的装饰器设计模式。不过,这里有一个致命问题:reentrant_wrapper 没有考虑 f 会抛出异常。你可能会认为,在编写线程函数 routine 时,我们知道 reentrant_wrapper 不会捕获异常,所以才会用 try-catch 块捕捉 calc_average 的异常,这怎么叫没有考虑抛出异常呢?但事实上,我们只考虑了异常能否在 routine 中被捕获,却没有考虑异常对 reentrant_wrapper 闭包的影响。运行该程序,可以发现,控制台中确实出现了捕获异常成功的现象,输出了:

Divided by zero!

但程序并没有结束。而理论上程序没有死循环,应该在 1 秒后结束的。问题在于,闭包中将互斥体上锁后,因为异常就直接退出了,没有执行互斥体解锁的代码 m.unlock(),导致主函数执行 m.lock() 时发生死锁。

***锁(lock)的作用就是避免上述异常安全(exception safety)*问题。**最简单的锁是 lock_guard 类型,它将互斥体的 lock 方法和 unlock 方法包装,构造时自动调用 lock 方法,析构时自动调用 unlock 方法。由于发生异常时析构函数仍会被调用,所以即使发生异常,互斥体也仍能解锁。使用 lock_guard 改造程序 19,可以得到程序 20。经实验,程序 20 能够正常结束了。

程序 20:电子钥匙
#include <iostream>
#include <thread>
#include <mutex>

int count = 1; // 假设 count 需要互斥访问。
std::mutex m;
int calc_average() { // 非互斥访问版本。
	count--;
	if (!count)
		throw std::domain_error("Divided by zero!");
	return 114514 / count;
}
template<typename func_t>
auto reentrant_wrapper(func_t f) { // 互斥访问包装。
	return [f]() {
		std::lock_guard _(m); // 下划线,真正的朋友。
		f();
	};
}
void routine() { // 一个线程。
	try {
		reentrant_wrapper(calc_average)();
	}
	catch (std::domain_error& e) {
		std::cout << e.what() << std::endl;
	}
}

int main() {
	std::jthread t(routine);
	using namespace std::literals;
	std::this_thread::sleep_for(1s); // 休息一秒再数数吧。
	{
		std::lock_guard _(m); // 下划线,真正的朋友。
		count++;
	} // 退出该大括号时调用 lock_guard 的析构函数。
}

可以看出,锁好比一个智能的电子钥匙,能够自动地上锁和解锁,还能在发生“火灾”(异常)时自动帮我们解锁,助我们逃生。这也是互斥体相比二元信号量的又一大优势。都什么年代了,还在用传统钥匙?赶紧扔掉你的传统钥匙吧。

注:lock_guard 是一个模板类,模板参数为互斥体类型。自 C++17 标准起,可以根据构造函数的实参推导模板类的模板类型。在 C++17 之前,必须写作 std::lock_guard<std::mutex> _(m)

互斥锁(unique_lock

lock_guard 只有构造函数和析构函数是可以公开访问的,构造函数也不管互斥体的状态,总是会对互斥体进行上锁,在智能的同时缺少了一点可定制化。如果说 lock_guard 是电子钥匙 1.0,那 unique_lock 可谓是电子钥匙 2.0:它可以提前将互斥体解锁、延迟对互斥体上锁,还能接收已经上锁的互斥体。同时,unique_locklock_guard 完全兼容,程序 20 中所有的 unique_lock 都可以替换为 lock_guard,而不用对其他代码进行任何修改。

要提前对互斥体解锁,只需调用 unique_lockunlock 方法。要在构造 unique_lock 时不立刻对相应互斥体上锁,只需向构造函数额外传入一个标志对象 defer_lock,随后可以调用 unique_locklock 方法对互斥体上锁。同理,向构造函数额外传入一个标志对象 adopt_lock 表示接收一个已经上锁的互斥体,不再额外上锁。

程序 21:三七二十一
#include <iostream>
#include <vector>
#include <thread>
#include <mutex>

int count;
std::mutex m;

void routine_1()
{
	std::unique_lock lock(m);
	count += 7;
	lock.unlock(); // 提前手动解锁。
} // 已解锁,不会发生什么事。
void routine_2()
{
	// 构造时不上锁,之后手动上锁。
	std::unique_lock lock(m, std::defer_lock);
	lock.lock(); // 注意:绝不要再使用 m.lock()。
	count += 7;
} // 析构时自动解锁。
void routine_3()
{
	// 接收已经上锁的互斥体,构造时不再上锁。
	m.lock();
	std::unique_lock lock(m, std::adopt_lock);
	count += 7;
} // 析构时自动解锁。

int main()
{
	std::vector<std::thread> ts;
	for (auto f : { routine_1, routine_2, routine_3 })
		ts.emplace_back(f);
	for (auto& t : ts)
		t.join();
	std::cout << count << std::endl;
}

**在定义 unique_lock 后,绝不要再调用互斥体的成员函数。**因为 unique_lock 内部会维护一个互斥体是否已上锁的布尔型变量,所以在定义 unique_lock 后调用互斥体的成员函数一定会使程序出错。

最后我们解释下为什么把 unique_lock 叫作互斥锁。语义上来讲,使用 unique_lock 保护一个资源时,无论读写,同一时间都只能有一段代码访问该资源,即锁住互斥体的 unique_lock 是唯一的。之后介绍的共享互斥体和共享锁则允许同一时间有多段代码读一个资源,以在*读者-写者问题(reader-writer problem)*中发挥更好的性能。

定时互斥体(timed_mutex

程序 15(见上半部分) 中,我们用到了信号量的 try_acquire_for 方法,以实现等待信号并设定超时时间的需求。在 C++20 前,标准库中没有信号量,如何使用互斥体实现程序 15 的功能?

前面提到,互斥体的基本工作流程就是上锁(lock)和解锁(unlock),似乎不能有计时功能。事实上,C++ 标准规定,支持 lockunlock 的互斥体满足基本可锁定(Basic Lockable)的要求,在此基础上支持 try_lock 的互斥体满足可锁定(Lockable)的要求,在此基础上再支持 try_lock_fortry_lock_until 的互斥体满足可定时锁定(TimedLockable)的要求。**前文中的互斥体都满足可锁定的要求**,但不满足可定时锁定的要求。只有标准库提供的 timed_mutex 是满足可定时锁定要求的互斥体类型

timed_mutexlock_guard 以及 unique_lock 完全兼容,程序 22 展示了使用 timed_mutex 实现程序 15 的方法。

程序 22:按回车键停止发臭的屑程序 3.0
#include <iostream>
#include <string>
#include <thread>
#include <mutex>

std::timed_mutex m;
void f()
{
	while (true)
	{
		std::cout << "啊";
		std::flush(std::cout);
		// 把线程挂起一段时间,不然太臭了。
		using namespace std::chrono_literals; // 重载字面量运算符 ""ms
		std::unique_lock lock(m, std::defer_lock); // 稍后手动上锁。
		if (m.try_lock_for(100ms)) // 如果等待成功(lock_guard 已析构),则退出。
			break;
	} // 离开作用域后,自动解锁。
}

int main()
{
	std::thread t;
	{
		std::lock_guard _(m); // 先上锁,再新建线程。
		t = std::thread(f);
		std::string temp;
		std::getline(std::cin, temp);
	} // 解锁互斥体。
	t.join();
}

关于程序 22,其工作原理与程序 15 是相同的,但读者可以思考这些细节:

  1. 第 25 行为什么要先上锁再新建线程?程序 15 中关于这点又是怎么实现的?

  2. 第 18 行强调离开作用域后自动解锁,这里的解锁的目的是什么?

  3. 第 15 行说稍后手动上锁。事实上 unique_lock 的构造函数也可以额外传入时间:

    std::unique_lock lock(m, 100ms);
    

    为什么我们还是要稍后手动上锁?

定时互斥体 timed_mutex 比普通互斥体 mutex 有更多功能,但它们都可以使用 lock_guardunique_lock 锁定,这是 C++ 模板编程的功劳:使用模板后,只需编写一次 unique_lock 的代码,就可以应用于不同类型的互斥体。

递归互斥体(recursive_mutex

顾名思义,递归互斥体允许我们在递归中多次锁定同一互斥体,而不出现死锁。除此之外,它与普通互斥体 mutex 的功能是一样的。同样的,得益于模板,递归互斥体兼容 lock_guradunique_lock

递归互斥体当然可以用于递归函数中,但它更常用于保证类成员函数的互斥访问——即使这些成员函数可以互相调用,也不会出现死锁问题。

程序 23:二二三三
#include <iostream>
#include <thread>
#include <mutex>

class two_and_three
{
private:
	std::recursive_mutex _m;
	int count{};

public:
	void two()
	{
		std::lock_guard _(_m);
		count += 2;
	}
	void three()
	{
		std::lock_guard _(_m);
		// 复用 two 方法,之后就只用写 ++ 了。
		two();
		count++;
	}
};

int main()
{
	two_and_three _2233;
	_2233.three();
}

递归互斥体的特点是在同一线程中多次上锁不会产生死锁。程序 23 中,我们编写了 two 方法后,想在 three 方法中复用 two。如果我们用普通互斥体代替递归互斥体,则在调用 three 方法时会因为对 _m 连续上锁两次而产生死锁。之所以递归互斥体能避免这样的死锁,是因为它在上锁时会额外检查当前线程的 ID 是否与最初上锁的线程相同,如果是,递归互斥体只会把引用计数加一,而不会傻傻等待。

除此之外,还有递归定时互斥体 recursive_timed_mutex,我们不再赘述。

同时抢夺多个互斥体的所有权

介绍完两种更高级的互斥体后,我们再来看一种更高级的锁。问题的出发点仍然是死锁问题。这个死锁问题非常经典,可以描述如下:

  1. 有两个资源 A A A B B B
  2. 线程 1 和线程 2 都需要同时掌握 A A A B B B 的所有权才能工作。
  3. 实际工作时,线程 1 先掌握了 A A A 的所有权,同时线程 2 也掌握了 B B B 的所有权。
  4. 结果线程 1 开始无限地等待 B B B 的所有权,线程 2 开始无限地等待 A A A 的所有权,两个线程均进入死锁。

要解决这一问题,关键在于要对 A A A B B B “同时”上锁。但我们不能对上锁代码再一次加锁,因为这无异于把 A A A B B B 两个资源合并成一个资源;另一方面,只要允许单独访问某个资源,额外加锁对解决死锁问题是无济于事的,因为这相当于允许代码在任意时间对任意资源加锁,案例如下。

1: 占有 A。
2: 占有 B, A。成功占有了 B,等待 A。
1: 占有 B。等待 B。死锁了。

我们无法否认实际中不存在这样的场景,只能另想办法。标准库为我们提供了免死锁算法的加锁函数 std::lock,可以“同时”对两个资源加锁,而不会产生死锁。使用方法如程序 24 所示。

程序 24:免死令牌
#include <iostream>
#include <thread>
#include <mutex>

std::mutex m1, m2;
int a, b;

void routine()
{
	// 首先创建延迟上锁的 unique_lock。
	std::unique_lock lock1(m1, std::defer_lock);
	std::unique_lock lock2(m2, std::defer_lock);
	// 然后调用 lock 函数对它们同时上锁。
	std::lock(lock1, lock2);
	// 交换两个变量。
	std::swap(a, b);
}

int main()
{
	std::jthread thread(routine);
	{
		std::lock_guard _(m1);
		a += 114;
	}
	using namespace std::literals;
	std::this_thread::sleep_for(100ms);
	{
		std::lock_guard _(m2);
		b += 514;
	}
	{
		std::unique_lock lock1(m1, std::defer_lock);
		std::unique_lock lock2(m2, std::defer_lock);
		std::lock(lock1, lock2);
		std::cout << a << " " << b << std::endl;
	}
}

尽管程序 24 的运行结果是不确定的,但它确实和上文中的案例相符。程序 24 保证不会产生上述类型的死锁,说明 lock 函数的存在是有必要的。

自 C++17 标准起,考虑到这种写法非常常见,标准库推出了 lock_guard 的升级版:scoped_lock它的用法与 lock_guard 一样,只是构造函数可以接收任意多的互斥体作为参数。scoped_lock 在内部使用 lock 函数对这些互斥体上锁。(注:由于 lock 是模板函数,所以它既可以对锁上锁,也可以直接对互斥体上锁)

程序 25:免死令
#include <iostream>
#include <thread>
#include <mutex>

std::mutex m1, m2;
int a, b;

void routine()
{
	// 直接使用 scoped_lock 即可免死锁上锁。
	std::scoped_lock _(m1, m2);
	// 交换两个变量。
	std::swap(a, b);
}

int main()
{
	std::jthread thread(routine);
	{
		std::lock_guard _(m1);
		a += 114;
	}
	using namespace std::literals;
	std::this_thread::sleep_for(100ms);
	{
		std::lock_guard _(m2);
		b += 514;
	}
	{
		std::scoped_lock _(m1, m2);
		std::cout << a << " " << b << std::endl;
	}
}

scoped_lock 的出现离不开 C++17 的推导指引特性。请记住在 C++17 之前定义变量总是需要写出所有模板参数。

共享互斥体(shared_mutex)与共享锁(shared_lock

共享互斥体的背景是读者-写者问题。读者指只对资源进行读操作,不修改资源的代码;写者指要对资源进行写操作的代码。显然,读者与写者、写者与写者之间应该是互斥的,但读者与读者之间却可以不互斥,因为只有读操作存在时是不会有线程安全问题的。

如果我们不对读者和写者进行分类,而是只要访问资源时就上锁,我们将得到程序 26。

程序 26:字母表
#include <iostream>
#include <vector>
#include <string>
#include <array>
#include <chrono>
#include <thread>
#include <mutex>

#include <ranges>
constexpr auto range = std::ranges::views::iota;

std::mutex m;
std::string str;

void reader(char letter)
{
	int count{};
	while (count < 1000000)
	{
		std::lock_guard _(m);
		for (char ch : str)
			if (ch == letter)
				++count;
	}
}
void writer()
{
	for (int i : range(0, 10))
		for (char ch : range('a', 'z' + 1))
		{
			std::lock_guard _(m);
			str.push_back(ch);
		}
}

int main()
{
	auto start = std::chrono::high_resolution_clock::now();
	{
		std::vector<std::jthread> ts;
		for (char ch : range('a', 'z' + 1))
			ts.emplace_back(reader, ch);
		ts.emplace_back(writer);
	}
	auto end = std::chrono::high_resolution_clock::now();
	auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
	std::cout << duration.count() << " milliseconds" << std::endl;
}

程序 26 假设需要互斥访问的资源是一个字符串。多个读者需要不停地遍历该字符串,统计各字母的出现次数,直到数够为止。写者只有一个,它向字符串中写入了少量的不同字母。(请思考,如果不互斥访问该字符串,会有怎样的后果?)

程序 26 在读字符串和写字符串时均对资源上锁。可以预见,在写者的任务很快结束后,会只剩下读者进行读操作,此时虽然没有必要进行互斥访问,但我们却不得已而为之,导致多个读者线程被迫串行运行,程序效率大幅降低。程序可能的运行结果:

5791 milliseconds

要提高程序的效率,方法是区别对待读者和写者:如果一个读者已对该资源上锁,那么其他读者也有机会共享该互斥体;除此之外的场景仍然保持互斥。针对该应用场景,C++17 推出了共享互斥体 shared_mutex(需包含头文件 shared_mutex)。对于写者,应当像使用普通互斥体一样使用共享互斥体;对于读者,应当调用 lock_sharedunlock_shared 而非 lockunlock。为保证读者的异常安全,C++17 一并推出了共享锁 shared_lock,它是为读者准备的 unique_lock 的代替品。利用 shared_mutex 改进程序 26,我们可以得到程序 27。

程序 27:快速字母表
#include <iostream>
#include <vector>
#include <string>
#include <array>
#include <chrono>
#include <thread>
#include <shared_mutex>

#include <ranges>
constexpr auto range = std::ranges::views::iota;

std::shared_mutex m; // 使用共享互斥体。
std::string str;

void reader(char letter)
{
	int count{};
	while (count < 1000000)
	{
		// 读者使用 shared_lock,内部调用 lock_shared, unlock_shared 函数。
		std::shared_lock lock(m);
		for (char ch : str)
			if (ch == letter)
				++count;
	}
}
void writer()
{
	for (int i : range(0, 10))
		for (char ch : range('a', 'z' + 1))
		{
			// 写者仍然使用 unique_lock,或者功能更弱的 lock_guard。
			std::lock_guard _(m);
			str.push_back(ch);
		}
}

int main()
{
	auto start = std::chrono::high_resolution_clock::now();
	{
		std::vector<std::jthread> ts;
		for (char ch : range('a', 'z' + 1))
			ts.emplace_back(reader, ch);
		ts.emplace_back(writer);
	}
	auto end = std::chrono::high_resolution_clock::now();
	auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
	std::cout << duration.count() << " milliseconds" << std::endl;
}

可能的运行结果:

1399 milliseconds

同理,共享互斥体也存在定时版本 shared_timed_mutex,此处不再赘述。读者-写者问题是一个非常难的问题,可以提出非常复杂的要求,例如可以要求在读者和写者同时等待时优先让写者写。shared_mutex 只是读者-写者问题的一个基本解决方案,有更复杂需求时必须自行设计并行算法,这超出了我们的讨论范围。

条件变量——condition_variable 对象

什么是条件变量

必须承认,*条件变量(condition variable)*是本文中最难的概念。我们将从应用背景(本小节)、内部原理(下一小节)两个方面来解释什么是条件变量。

条件变量的应用背景是一个线程需要等待一个条件成立再继续执行,而这个条件的成立是另一个线程导致的。程序 28 即符合条件变量的应用背景,但它使用*自旋锁(spinning lock)*来解决问题。

程序 28:自旋锁
#include <iostream>
#include <chrono>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <numeric>

constexpr int number = 114514;
constexpr int target = 1919810;
std::mutex m;
int inc = 0;
int dec = target;
bool pred() { // 要等待的条件。
	return inc == dec;
}

void calculate()
{
	for (int i = 0; ; i++)
		if (std::gcd(i, number) == 1)
		{
			std::lock_guard _(m); // 保证 inc 和 dec 被同时修改。
			inc++; dec--;
			if (pred())
				break; // 满足条件,结束运算。
		}
	// 算点别的。
	for (int i = 0; i < target; i++)
		std::lock_guard _(m); // 防止编译器优化这个循环。
}

int main()
{
	std::jthread t(calculate);
	while (true) // 自旋锁:一直判断,直到条件满足。
	{
		{
			std::lock_guard _(m);
			if (pred())
				break;
		}
		std::cout << "我在等" << std::endl;
		using namespace std::literals;
		std::this_thread::sleep_for(100ms);
	}
	std::cout << "终于等到你" << std::endl;
}

程序 28 创建了一个线程进行两步计算,而主线程需要等待第一步计算结束;计算结束的标志是条件判断函数 pred 返回真。该例中,条件判断函数并不算简单,必须在互斥锁的保护下才能正确工作。(请思考,如果不互斥访问 incdec 这两个变量,会有怎样的后果?)

程序 28 的主函数非常憋屈:它需要等待条件满足,但又不知道何时才能结束,于是它使用一个循环进行判断。通过循环检查条件是否满足的方法被称为自旋锁(spinning lock),在短时等待的情境下具有较高的效率。不幸的是,程序 28 的计算任务较为复杂,通过自旋锁进行线程同步会极大地影响整个操作系统的运行效率。

聪明的读者马上就会想到,既然信号量的语义可以是发送信号,我们用等待信号量来代替自旋锁的循环,不就可以完美解决自旋锁浪费计算资源的问题了?下图给出了使用信号量设计该程序的思路(具体的代码并不难写,可以留作练习)。

图 1:使用信号量解决程序 28 自旋锁效率低下的问题

信号量在这个问题中具有完全正确的语义,但问题真的被完美解决了吗?如果等待需求再复杂一点,使用信号量就很烧脑了。

图 2:如果有三个独立的计算线程,主线程要等待其中任意两个计算完毕,该怎么写?

图 2 的场景把计算任务从 1 个变成了 3 个,把等待 1 个计算任务完成修改为了等待其中 2 个。各个计算线程相互独立,即它们无法得知其他计算任务是否完成,也无法得知一共有多少计算任务已经完成。

这个场景完全是有可能的。假设我为一个计算问题编写了包含三个函数的库,并且这三个函数可以自由地单独调用。考虑到库的复用性,很难让其他函数知道一个函数计算完毕。现在遇到一个实际问题,只需要比较其中任何两个函数的运算结果就可以知道答案,而这三个函数的运算时间与具体问题相关,所以我可以让三个函数同时运算,等到其中两个计算结束后就告诉用户结果,并向没有结束的函数发送 stop_token,从而保证程序的运行效率最高(当然,前提是处理器核心数要大于等于 3)。

如何使用信号量解决图 2 的同步问题?我想不出来。不过,如果用自旋锁,问题反而简单了,如下图所示。

图 3:自旋锁只需不断检查计算任务是否已经结束

图 3 中,我们等待的条件是“三个结束变量之和大于等于 2”,而检查这个条件只需要在任何“结束变量”被修改后进行,能不能用像图 2 中的那样,“发送信号”后再检查条件?

图 4:在自旋锁的基础上,等待信号到来后才检查条件

图 4 正是条件变量的思路!条件变量将复杂条件抽象为互斥访问某些变量的代码,把线程分为了生产者和消费者两类。消费者(图 4 中的主线程)等待(wait)条件变量的信号,而生产者在修改条件后通过条件变量通知(notify)消费者检查条件。如果消费者发现条件满足,就可以结束等待,反之可以重新调用条件变量的等待方法继续等待。

聪明的读者很快想到,把图 4 中的“等待信号”用信号量实现,不也可以实现吗?但问题再复杂些,如果有多个消费者线程该怎么办?信号量的根本问题在于太难设计了,而即使在多消费者背景下,条件变量也仍然可以用图 4 描述。

条件变量与互斥体协同工作

消费者线程

前文提到,条件变量将复杂条件抽象为了互斥访问某些变量的代码,其中互斥访问是通过互斥体实现的。不难发现,条件的修改和检查必须是互斥的,因此条件变量必须和互斥体协同工作。对于等待操作,条件变量和互斥体协同工作可以用下图表示。

图 5:等待操作示意图

图 5 中,wait 函数首先将传入的互斥体解锁,这是因为我们在一开始应该先对互斥体上锁以检查条件。如果条件通过,就不需要 wait 了;如果条件不通过,wait 结束后应该再次检查条件,由于 wait 函数执行前后互斥体都被当前消费者线程占有,所以 wait 函数返回后可以继续检查条件。消费者线程判断条件通过后,当前线程应该释放互斥体,表明不再需要读取条件,可以在其他线程执行涉及条件的代码。综上,消费者线程检查条件直到条件满足的流程可以用下图表示。

图 6:消费者线程检查条件流程图

C++ 标准库为 wait 函数提供了两个重载,分别对应图 6 的两个淡色框。结合对以上流程的理解,可以写出如下消费者线程的代码。注意,需要包含 condition_variable 头文件。

#include <mutex>
#include <condition_variable>

std::mutex m; // 保护条件的互斥体,与条件变量协同工作。
std::condition_variable cv;
bool pred() {
	// ...
}
void consumer() {
	{ // 等待条件满足。
		std::unique_lock lock(m); // 对互斥体上锁。
		while (!pred()) // 条件?
			cv.wait(lock); // 解锁互斥体,等待条件变量信号,对互斥体上锁。
	} // 解锁互斥体。
	// 做别的事。
}

或者使用 wait 的重载函数:

void consumer() {
	{ // 等待条件满足。
		std::unique_lock lock(m); // 对互斥体上锁。
		cv.wait(lock, pred); // 与上一份代码完全等同。
	} // 解锁互斥体。
	// 做别的事。
}

**总是应该在 while 循环中调用 wait 函数,或者使用 wait 函数的谓词重载版本。**因为标准规定允许虚假唤醒——即使条件没有发生任何变化,没有任何生产者进行通知,wait 函数也可能返回。

生产者线程

生产者线程通过调用条件变量的 notify_onenotify_all 函数来向消费者线程发送唤醒消息(它们两者的区别在下一小节介绍)。在发送唤醒消息前,生产者一定会修改条件对应的变量,否则条件不变,也没有必要唤醒消费者了。前文反复提到,这些变量必须是互斥访问的,所以修改时一定要加锁。修改后,调用 notify_onenotify_all 函数发送唤醒消息。这里留有一个问题:发送唤醒消息时,生产者是否应当持有锁呢?事实上,答案是都可以,我们将在下一小节解释。

据此,可以写出如下生产者线程的代码。

#include <mutex>
#include <condition_variable>

std::mutex m; // 保护条件的互斥体,与条件变量协同工作。
std::condition_variable cv;
void producer() {
	{
		std::lock_guard _(m); // 对互斥体上锁。
		// 修改条件……
	} // 解锁互斥体。
	cv.notify_one(); // 向消费者发送消息。
}

或者在持有锁时发送消息。

void producer() {
	{
		std::lock_guard _(m); // 对互斥体上锁。
		// 修改条件……
		cv.notify_one(); // 向消费者发送消息。
	} // 解锁互斥体。
}

不上锁的后果

已经强调多次,条件变量的理念是将复杂条件抽象为互斥访问某些变量的代码。读者可能会认为,如果条件足够简单,读写时本身就是互斥的(比如仅仅是一个 atomic<bool>),是不是就不需要互斥体了?碍于函数的形式,消费者线程必须要使用互斥体,那就生产者线程不使用互斥体吧。程序 29 实现了这种想法,但程序 29 以趋于 1 的概率无法正常结束。

程序 29:难产
#include <chrono>
#include <thread>
#include <atomic>
#include <mutex>
#include <condition_variable>

using namespace std::literals;

std::atomic<bool> is_ok; // 条件。
std::mutex m; // 碍于 cv.wait 的函数形式,必须有一个互斥体。
std::condition_variable cv;
void producer() {
	std::this_thread::sleep_for(1s); // 1 s 后计算出结果。
	is_ok = true; // 不上锁了。
	cv.notify_one();
}
void consumer() {
	std::unique_lock l(m);
	cv.wait(l, [&]() {
		bool ret = is_ok;
		std::this_thread::sleep_for(2s); // 模拟操作系统不分配时间片。
		return ret;
		});
}
int main() {
	std::jthread t1(consumer);
	std::jthread t2(producer);
	// 期待程序在 4 s 后结束,但真的能结束吗?
}

虽然有些夸张,但程序 29 的确无法正常退出,也的确只有一个原子变量作为条件——尽管操作系统出了点岔子,把读到的 is_ok 的值延迟了两秒返回。稍微整理下程序 29 的运行流程,就能知道为什么程序 29 无法正常退出了,如下图所示。

图 7:不上锁的后果

图 29 的生产者发送信号后,条件变量发现还没有消费者需要等待条件变量,因此就什么都不做。而当消费者开始等待条件变量信号后,却不会再有唤醒信号发送了。可见,将复杂条件抽象为互斥访问某些变量的代码,在保护读写相关变量的线程安全的同时,还保护了条件变量收发信号的线程安全

程序 30 是程序 29 的修正版。它在写单个变量时上了锁,使得程序可以在大约 4 s 后顺利结束。由于相关变量已被互斥体保护,所以使用条件变量时无需使用原子类型。

程序 30:上锁,上锁!
#include <chrono>
#include <thread>
#include <atomic>
#include <mutex>
#include <condition_variable>

using namespace std::literals;

bool is_ok; // 条件。所有条件均无需使用原子类型。
std::mutex m;
std::condition_variable cv;
void producer() {
	std::this_thread::sleep_for(1s); // 1 s 后计算出结果。
	{
		std::lock_guard _(m); // 读写相关变量时互斥,信号的收发就随之正确了。
		is_ok = true;
	}
	cv.notify_one();
}
void consumer() {
	std::unique_lock l(m);
	cv.wait(l, [&]() {
		bool ret = is_ok;
		std::this_thread::sleep_for(2s); // 模拟操作系统不分配时间片。
		return ret;
		});
}
int main() {
	std::jthread t1(consumer);
	std::jthread t2(producer);
	// 期待程序在 4 s 后结束。
}

你可能会担心,万一信号在“解锁互斥体”之后,开始“等待条件变量信号”之前到来,不还是会永远陷入休眠吗?事实上,标准规定“解锁互斥体”和开始“等待条件变量信号”是原子的,互斥体被解锁就意味着消费者线程已经进入到等待条件变量信号的状态。综上,程序 30 的运行流程可以用下图表示。

此处不再赘述条件变量超时等待的函数。不过需要注意,尽管使用的是普通互斥体,仍然可以使用条件变量的超时等待函数。

条件变量与互斥体的双队列模型

当讨论的问题包含多个生产者和多个消费者时,我们有必要进一步了解条件变量的内部细节。一般我们使用双队列模型来描述条件变量。

双队列是指,互斥体和条件变量内部会各自维护一个队列,表示正在等待互斥体所有权和条件变量信号的线程。据此,我们可以画出 wait 函数调用过程中双队列的变化。

图 9:双队列模型,堵住的一头是队首

引入双队列模型后,notify_onenotify_all 的区别就不言而喻了。不过需要注意,其中的“队列”有可能不是严格先进先出的,其行为取决于具体实现。

标准规定,条件变量总是应当与一个特定的互斥体绑定使用;如果传入 wait 函数的互斥体不是同一个,则行为是不确定的。所以从条件变量的角度看,只会涉及两个队列,不会涉及更多队列。

condition_variable_any

下面我们继续讨论条件变量在 C++ 语言层面的问题。程序 30 中使用的 condition_variable 与一个 std::mutex 绑定,能否将 std::mutex 替换为 recursive_mutex,以满足我们嵌套调用的需求呢(见程序 23)?直接使用 conditon_variable 是不行的,可以发现,condition_variable 强制规定了参数类型必须为 unique_lock<mutex>

condition_variable_any 则允许参数为任何*基本可锁定(Basic Lockable)*的类型,例如 recursive_mutexrecursive_mutex。消费者使用时,只需编写:

std::recursive_mutex m;
std::condition_variable_any cv;
void consumer()
{
	std::unique_lock lock(m);
	cv.wait(m, [] {
		// ...
	});
}

在某些平台上,condition_variablecondition_variable_any 的效率更高2,所以一般情况下最好只使用 condition_variable

自 C++20 起,condition_variable_any 还允许传入一个 stop_token 作为停止等待的标志3。据此我们可以写出漂亮的按回车键停止发臭的屑程序。

程序 31:按回车键停止发臭的屑程序 4.0
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex m;
std::condition_variable_any cv;
void f(std::stop_token st)
{
	while (!st.stop_requested())
	{
		std::cout << "啊";
		std::flush(std::cout);
		// 把线程挂起一段时间,不然太臭了。
		using namespace std::chrono_literals; // 重载字面量运算符 ""ms
		std::unique_lock lock(m);
		cv.wait_for(lock, st, 100ms, // 如果 stop_token 为真,则立刻停止等待。
			[] { return false; }); // 防止虚假唤醒。
	}
}

int main()
{
	std::jthread t(f);
	std::string temp;
	std::getline(std::cin, temp);
} // 设置 stop_token,然后 join。

pthread API

pthread 是 POSIX thread 的简写,它提供了一套操作系统无关的线程接口4。利用 pthread API,可以实现线程管理和线程同步,因此可以把 pthread API 作为 C++ 线程标准库的一种可能实现。

标准的 pthread 支持互斥体、条件变量、线程私有存储(TLS)、*屏障(barrier)*等;此外,pthread 有一个扩展的信号量库4。可见,C++11 标准的线程同步原语是按 pthread 中最常用的互斥体和条件变量设计的。

自 C++20 起,标准库支持 pthread 中的 barrier,我们将在下一节学习。


  1. Mutex vs Semaphore, GeeksforGeeks. ↩︎

  2. What is the difference between std::condition_variable and std::condition_variable_any? - kennytm, stack overflow. ↩︎

  3. What is the difference between std::condition_variable and std::condition_variable_any? - Anthony Williams, stack overflow. ↩︎

  4. POSIX线程, 百度百科. ↩︎ ↩︎

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

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

  • 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 而是把
  • 使用 WaitForSingleObject 等待进程结束错误代码 5(拒绝访问)问题的解决

    说明使用 OpenProcess 的权限不够 xff0c 但是哪个权限呢 xff1f 查阅文档发现是 SYNCHRONIZE SYNCHRONIZE 0x00100000L 使用对象的同步机制的权限 该权限允许线程等待该对象 xff0c 直
  • LaTeX 004:隐藏 hyperref 超链接的红框

    针不戳 xff0c 很快找到答案了针不戳 以往的方法是设置超链接红框的颜色 hypersetup pdfauthor 61 UnnamedOrange colorlinks 61 true linkcolor 61 black anchor
  • LaTeX 005:删除一个命令

    C 43 43 的预编译系统中 xff0c 可以使用 undef 来取消 xff08 Undefine xff09 一个宏 LaTeX LaTeX L A T E X 中是否有类似于这样的功能呢 xff1f 网上的一个评论给出了解决方案 x
  • LaTeX 006:添加一个只有文字没有标号的脚标

    想要如下的效果 xff1a 该脚标只想要写一句注释 xff0c 没有对应的正文文本 如何实现 xff1f 直接使用 footnotetext 是不佳的 xff0c 前面会加上当前的脚标标号 xff0c 而且该标号不会自动递增 虽然 foot
  • GetExitCodeProcess 所需运行时间

    GetExitCodeProcess 在 Windows 中用于获取进程的返回代码 这个看上去只有一个读操作的函数运行速度如何呢 xff1f 直觉上不会很快 xff0c 因为它肯定涉及操作系统进程表的操作 下面做了一个实验 代码 xff1a
  • C++ 继承时返回值或参数的类型是父类的行为

    见以下代码的 base t amp operator 61 const base t amp another 还没有搞清楚 span class token macro property span class token directive
  • LaTeX 007:texify 调用 zhmakeindex

    如文档所述 xff0c 在系统增加一个值为 zhmakeindex 路径的环境变量 MAKEINDEX 即可
  • 转载:LaTeX 定义参数变长的命令

    本文作者 xff1a Liam Huang 本文链接 xff1a https liam page 2017 07 30 define a new command with different amount of parameters in
  • 一个简单的 Lex 词法分析程序示例

    作为一个学习 Lex 词法分析程序的例子 xff0c 下面的 lex 程序将会生成一个分析 LaTeX 中命令的词法分析器 下面的程序包含了很多 lex 语言的语法 xff0c 正则表达式除外 正则表达式的用法网上比较多 xff0c 这里不
  • mysql数据库conf配置详解

    mysqld port 61 6033 skip grant tables datadir 61 usr tools mysql data socket 61 usr tools mysql mysql sock user 61 mysql
  • LaTeX 008:比较方便的键入下划线的方式

    在 LaTeX 中 xff0c 我们有时会需要输入下划线 直接键入 是不行的 xff0c 会出现的编译错误 xff0c 正如网友所述 xff0c LaTeX 为了简化对编译错误的处理禁止在文本模式 xff08 text mode xff09
  • LaTeX 009:自定义带有 * 号的命令

    LaTeX 中 xff0c 我们经常见到 section 和 section xff0c 分别表示有编号的 section 和没有编号的 section 我们也想自己定义带有 号的命令 xff0c 但写下面的代码时却报错了 xff1a ne
  • 2022 New Year‘s Resolution

    Some Might Say 2022 New Year 39 s Resolution Some might say we are on the edge of the new era Always are they saying thi
  • C++ 多线程编程导论(中)

    受篇幅限制 xff0c 上半部分不再更新 xff0c 填坑的新内容都放在此文章中 文章目录 参考资料线程安全 xff08 续 xff09 互斥访问 互斥体 xff08 mutex xff09 和锁 xff08 lock xff09 什么是互