受篇幅限制,上半部分不再更新,填坑的新内容都放在此文章中。
以上参考资料中,除了第一项,其余有引用的,会通过角标注明。没有用角标注明的,说明是我看过,但并没有引用其中作者的观点,读者可以将他们作为扩展资料阅读。
上半部分我们已经介绍了原子操作和信号量,并提到了学习互斥锁和条件变量是很有必要的。下面我们介绍互斥锁和条件变量。
*互斥体(mutex)*的最简使用方法在 cppreference 中概括如下:
lock
方法占有互斥体的所有权。unlock
方法释放所有权,使其他线程有机会访问该互斥体保护的资源。看上去,互斥体总是可以和二元信号量互相替换。程序 13(见上半部分)中,我们使用了二元信号量 bs
来保证输出语句的互斥访问,下面我们将用 C++ 标准库中的互斥体对象 std::mutex
(需要包含 mutex
头文件)代替它。
#include
#include
#include
#include
#include
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。**总而言之,在可以使用互斥体时请使用互斥体,而不要使用二元信号量。**之后我们还会看到更重要的原因。
#include
#include
#include
#include
#include
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
从程序 17 可以看出,互斥体本身就是一把完整的锁了:它可以锁(lock
),也可以解锁(unlock
)。那接下来要介绍的*锁(lock)*又是什么东西呢?
让我们从下面的代码中感受锁的作用。
#include
#include
#include
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 能够正常结束了。
#include
#include
#include
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
。
unique_lock
)lock_guard
只有构造函数和析构函数是可以公开访问的,构造函数也不管互斥体的状态,总是会对互斥体进行上锁,在智能的同时缺少了一点可定制化。如果说 lock_guard
是电子钥匙 1.0,那 unique_lock
可谓是电子钥匙 2.0:它可以提前将互斥体解锁、延迟对互斥体上锁,还能接收已经上锁的互斥体。同时,unique_lock
和 lock_guard
完全兼容,程序 20 中所有的 unique_lock
都可以替换为 lock_guard
,而不用对其他代码进行任何修改。
要提前对互斥体解锁,只需调用 unique_lock
的 unlock
方法。要在构造 unique_lock
时不立刻对相应互斥体上锁,只需向构造函数额外传入一个标志对象 defer_lock
,随后可以调用 unique_lock
的 lock
方法对互斥体上锁。同理,向构造函数额外传入一个标志对象 adopt_lock
表示接收一个已经上锁的互斥体,不再额外上锁。
#include
#include
#include
#include
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++ 标准规定,支持 lock
和 unlock
的互斥体满足基本可锁定(Basic Lockable)的要求,在此基础上支持 try_lock
的互斥体满足可锁定(Lockable)的要求,在此基础上再支持 try_lock_for
和 try_lock_until
的互斥体满足可定时锁定(TimedLockable)的要求。**前文中的互斥体都满足可锁定的要求**,但不满足可定时锁定的要求。只有标准库提供的 timed_mutex
是满足可定时锁定要求的互斥体类型。
timed_mutex
与 lock_guard
以及 unique_lock
完全兼容,程序 22 展示了使用 timed_mutex
实现程序 15 的方法。
#include
#include
#include
#include
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 是相同的,但读者可以思考这些细节:
第 25 行为什么要先上锁再新建线程?程序 15 中关于这点又是怎么实现的?
第 18 行强调离开作用域后自动解锁,这里的解锁的目的是什么?
第 15 行说稍后手动上锁。事实上 unique_lock
的构造函数也可以额外传入时间:
std::unique_lock lock(m, 100ms);
为什么我们还是要稍后手动上锁?
定时互斥体 timed_mutex
比普通互斥体 mutex
有更多功能,但它们都可以使用 lock_guard
或 unique_lock
锁定,这是 C++ 模板编程的功劳:使用模板后,只需编写一次 unique_lock
的代码,就可以应用于不同类型的互斥体。
recursive_mutex
)顾名思义,递归互斥体允许我们在递归中多次锁定同一互斥体,而不出现死锁。除此之外,它与普通互斥体 mutex
的功能是一样的。同样的,得益于模板,递归互斥体兼容 lock_gurad
和 unique_lock
。
递归互斥体当然可以用于递归函数中,但它更常用于保证类成员函数的互斥访问——即使这些成员函数可以互相调用,也不会出现死锁问题。
#include
#include
#include
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
,我们不再赘述。
介绍完两种更高级的互斥体后,我们再来看一种更高级的锁。问题的出发点仍然是死锁问题。这个死锁问题非常经典,可以描述如下:
要解决这一问题,关键在于要对 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 所示。
#include
#include
#include
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
是模板函数,所以它既可以对锁上锁,也可以直接对互斥体上锁)
#include
#include
#include
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。
#include
#include
#include
#include
#include
#include
#include
#include
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_shared
和 unlock_shared
而非 lock
和 unlock
。为保证读者的异常安全,C++17 一并推出了共享锁 shared_lock
,它是为读者准备的 unique_lock
的代替品。利用 shared_mutex
改进程序 26,我们可以得到程序 27。
#include
#include
#include
#include
#include
#include
#include
#include
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)*来解决问题。
#include
#include
#include
#include
#include
#include
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
返回真。该例中,条件判断函数并不算简单,必须在互斥锁的保护下才能正确工作。(请思考,如果不互斥访问 inc
和 dec
这两个变量,会有怎样的后果?)
程序 28 的主函数非常憋屈:它需要等待条件满足,但又不知道何时才能结束,于是它使用一个循环进行判断。通过循环检查条件是否满足的方法被称为自旋锁(spinning lock),在短时等待的情境下具有较高的效率。不幸的是,程序 28 的计算任务较为复杂,通过自旋锁进行线程同步会极大地影响整个操作系统的运行效率。
聪明的读者马上就会想到,既然信号量的语义可以是发送信号,我们用等待信号量来代替自旋锁的循环,不就可以完美解决自旋锁浪费计算资源的问题了?下图给出了使用信号量设计该程序的思路(具体的代码并不难写,可以留作练习)。
信号量在这个问题中具有完全正确的语义,但问题真的被完美解决了吗?如果等待需求再复杂一点,使用信号量就很烧脑了。
图 2 的场景把计算任务从 1 个变成了 3 个,把等待 1 个计算任务完成修改为了等待其中 2 个。各个计算线程相互独立,即它们无法得知其他计算任务是否完成,也无法得知一共有多少计算任务已经完成。
注
这个场景完全是有可能的。假设我为一个计算问题编写了包含三个函数的库,并且这三个函数可以自由地单独调用。考虑到库的复用性,很难让其他函数知道一个函数计算完毕。现在遇到一个实际问题,只需要比较其中任何两个函数的运算结果就可以知道答案,而这三个函数的运算时间与具体问题相关,所以我可以让三个函数同时运算,等到其中两个计算结束后就告诉用户结果,并向没有结束的函数发送
stop_token
,从而保证程序的运行效率最高(当然,前提是处理器核心数要大于等于 3)。
如何使用信号量解决图 2 的同步问题?我想不出来。不过,如果用自旋锁,问题反而简单了,如下图所示。
图 3 中,我们等待的条件是“三个结束变量之和大于等于 2”,而检查这个条件只需要在任何“结束变量”被修改后进行,能不能用像图 2 中的那样,“发送信号”后再检查条件?
图 4 正是条件变量的思路!条件变量将复杂条件抽象为互斥访问某些变量的代码,把线程分为了生产者和消费者两类。消费者(图 4 中的主线程)等待(wait)条件变量的信号,而生产者在修改条件后通过条件变量通知(notify)消费者检查条件。如果消费者发现条件满足,就可以结束等待,反之可以重新调用条件变量的等待方法继续等待。
聪明的读者很快想到,把图 4 中的“等待信号”用信号量实现,不也可以实现吗?但问题再复杂些,如果有多个消费者线程该怎么办?信号量的根本问题在于太难设计了,而即使在多消费者背景下,条件变量也仍然可以用图 4 描述。
前文提到,条件变量将复杂条件抽象为了互斥访问某些变量的代码,其中互斥访问是通过互斥体实现的。不难发现,条件的修改和检查必须是互斥的,因此条件变量必须和互斥体协同工作。对于等待操作,条件变量和互斥体协同工作可以用下图表示。
图 5 中,wait
函数首先将传入的互斥体解锁,这是因为我们在一开始应该先对互斥体上锁以检查条件。如果条件通过,就不需要 wait
了;如果条件不通过,wait
结束后应该再次检查条件,由于 wait
函数执行前后互斥体都被当前消费者线程占有,所以 wait
函数返回后可以继续检查条件。消费者线程判断条件通过后,当前线程应该释放互斥体,表明不再需要读取条件,可以在其他线程执行涉及条件的代码。综上,消费者线程检查条件直到条件满足的流程可以用下图表示。
C++ 标准库为 wait
函数提供了两个重载,分别对应图 6 的两个淡色框。结合对以上流程的理解,可以写出如下消费者线程的代码。注意,需要包含 condition_variable
头文件。
#include
#include
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_one
或 notify_all
函数来向消费者线程发送唤醒消息(它们两者的区别在下一小节介绍)。在发送唤醒消息前,生产者一定会修改条件对应的变量,否则条件不变,也没有必要唤醒消费者了。前文反复提到,这些变量必须是互斥访问的,所以修改时一定要加锁。修改后,调用 notify_one
或 notify_all
函数发送唤醒消息。这里留有一个问题:发送唤醒消息时,生产者是否应当持有锁呢?事实上,答案是都可以,我们将在下一小节解释。
据此,可以写出如下生产者线程的代码。
#include
#include
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
),是不是就不需要互斥体了?碍于函数的形式,消费者线程必须要使用互斥体,那就生产者线程不使用互斥体吧。程序 29 实现了这种想法,但程序 29 以趋于 1 的概率无法正常结束。
#include
#include
#include
#include
#include
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 无法正常退出了,如下图所示。
图 29 的生产者发送信号后,条件变量发现还没有消费者需要等待条件变量,因此就什么都不做。而当消费者开始等待条件变量信号后,却不会再有唤醒信号发送了。可见,将复杂条件抽象为互斥访问某些变量的代码,在保护读写相关变量的线程安全的同时,还保护了条件变量收发信号的线程安全。
程序 30 是程序 29 的修正版。它在写单个变量时上了锁,使得程序可以在大约 4 s 后顺利结束。由于相关变量已被互斥体保护,所以使用条件变量时无需使用原子类型。
#include
#include
#include
#include
#include
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
函数调用过程中双队列的变化。
引入双队列模型后,notify_one
和 notify_all
的区别就不言而喻了。不过需要注意,其中的“队列”有可能不是严格先进先出的,其行为取决于具体实现。
标准规定,条件变量总是应当与一个特定的互斥体绑定使用;如果传入 wait
函数的互斥体不是同一个,则行为是不确定的。所以从条件变量的角度看,只会涉及两个队列,不会涉及更多队列。
condition_variable_any
下面我们继续讨论条件变量在 C++ 语言层面的问题。程序 30 中使用的 condition_variable
与一个 std::mutex
绑定,能否将 std::mutex
替换为 recursive_mutex
,以满足我们嵌套调用的需求呢(见程序 23)?直接使用 conditon_variable
是不行的,可以发现,condition_variable
强制规定了参数类型必须为 unique_lock
。
condition_variable_any
则允许参数为任何*基本可锁定(Basic Lockable)*的类型,例如 recursive_mutex
、recursive_mutex
。消费者使用时,只需编写:
std::recursive_mutex m;
std::condition_variable_any cv;
void consumer()
{
std::unique_lock lock(m);
cv.wait(m, [] {
// ...
});
}
在某些平台上,condition_variable
比 condition_variable_any
的效率更高2,所以一般情况下最好只使用 condition_variable
。
自 C++20 起,condition_variable_any
还允许传入一个 stop_token
作为停止等待的标志3。据此我们可以写出漂亮的按回车键停止发臭的屑程序。
#include
#include
#include
#include
#include
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 是 POSIX thread 的简写,它提供了一套操作系统无关的线程接口4。利用 pthread API,可以实现线程管理和线程同步,因此可以把 pthread API 作为 C++ 线程标准库的一种可能实现。
标准的 pthread 支持互斥体、条件变量、线程私有存储(TLS)、*屏障(barrier)*等;此外,pthread 有一个扩展的信号量库4。可见,C++11 标准的线程同步原语是按 pthread 中最常用的互斥体和条件变量设计的。
自 C++20 起,标准库支持 pthread 中的 barrier,我们将在下一节学习。
Mutex vs Semaphore, GeeksforGeeks. ↩︎
What is the difference between std::condition_variable and std::condition_variable_any? - kennytm, stack overflow. ↩︎
What is the difference between std::condition_variable and std::condition_variable_any? - Anthony Williams, stack overflow. ↩︎
POSIX线程, 百度百科. ↩︎ ↩︎