为什么手动对互斥锁进行加锁会使第二个线程的计数器增加,而lock_guard却做不到这一点?
#include<iostream>
#include<thread>
#include<mutex>
#include<chrono>
using namespace std;
int counter = 0;
mutex mtx;
void incrementCounter(){
lock_guard<mutex> lock(mtx);
// 调用 mtx.lock();
counter += 1;
// 调用 mtx.unlock();
this_thread::sleep_for(chrono::milliseconds(2));
}
int main(){
thread t1(incrementCounter);
cout<<"Thread one released "<<counter<<"\n";
thread t2(incrementCounter);
cout<<"thread two released "<<counter<<"\n";
t1.join();
t2.join();
return 0;
}
当我在互斥锁上使用 lock_guard 时,counter只增加了一次,而当我使用手动加锁(lock() 和 unlock())时,两个线程都对counter进行了自增,因此最终输出为2。
解决方案
通用
首先需要理解的一点是,你的程序有三个线程,而不仅仅是两个。每个程序都有一个初始线程,它进入 main(),而你的程序在此基础上再多创建了两个线程。初始线程在数据竞争防护方面的要求与其他任何线程相同。它们在程序中只有一个线程时虽然不再需要关注,但在多线程程序中你仍需关注它们。
另外需要理解的一点是,不同线程之间执行操作的相对顺序并不可靠地可预测,除非你的程序实现了对其的控制。线程的创建和连接(join)是对这些控制中最强、最粗粒度的方式,但并不是唯一的方法。
第三点你需要理解的是,线程同步以及C++内存模型并不关心时序,而是关注可见性(对内存写入对内存读取的可见性)。引入延迟从来都不是解决线程同步问题的正确方法。
你的程序
你的 lock_guard 使用互斥量 mtx 来保护 incrementCounter() 中对 counter 的访问,但这还不够。初始线程也会访问 counter(为了打印它的值)。这些读取与其他两个线程所进行的更新“冲突”,不同线程之间对这些冲突操作没有任何同步,因此你的程序存在数据竞争。因此它的行为是未定义的。
此外,即使你在 main() 中通过对 mtx 加锁来保护对 counter 的读取,也不足以确保程序的输出像它声称的那样反映操作的时序。也就是说,考虑这个对 main() 的替代版本:
int main(){
thread t1(incrementCounter);
{
lock_guard<mutex> lock(mtx);
cout<<"Thread one released "<<counter<<"\n";
}
thread t2(incrementCounter);
{
lock_guard<mutex> lock(mtx);
cout<<"thread two released "<<counter<<"\n";
}
t1.join();
t2.join();
return 0;
}
这可以消除原程序中的数据竞争,但仍然存在竞争条件,意味着程序对操作顺序的期望不是C++保证会实现的。就C++而言,生成的程序可能输出:
Thread one released 1
thread two released 2
,这被我视为期望输出,但它也可能输出以下任意一种:
Thread one released 0
thread two released 0
或
Thread one released 0
thread two released 1
或
Thread one released 0
thread two released 2
或
Thread one released 1
thread two released 1
或
Thread one released 2
thread two released 2
额外的变体
在你的具体程序中,你可以选择使用 join() 来提供同步与操作顺序。对一个线程调用 join() 会与该线程的所有操作同步,因此你可以考虑如下:
int main(){
thread t1(incrementCounter);
thread t2(incrementCounter);
t1.join();
{
// 仍需要互斥保护,因为还有 t2
lock_guard<mutex> lock(mtx);
cout<<"Thread one released "<<counter<<"\n";
}
t2.join();
// 不再需要互斥保护,因为当前只有主线程在运行
cout<<"thread two released "<<counter<<"\n";
return 0;
}
这样,你的程序仍然可能输出
Thread one released 1
thread two released 2
,但也可能输出:
Thread one released 2
thread two released 2
,因为t1与 t2对 counter的更新在主线程打印t1相关输出时的可见性,可能会产生上述变化。
如果你希望程序只有一个符合规范的输出同时仍然是多线程的,那么这是一个不错的选项:
int main(){
thread t1(incrementCounter);
thread t2(incrementCounter);
t1.join();
t2.join();
// 不再需要互斥保护,因为当前只有一个线程在执行输出
cout<<"Thread one released "<<counter<<"\n";
cout<<"thread two released "<<counter<<"\n";
return 0;
}
,但是它会输出:
Thread one released 2
thread two released 2
如果你希望输出能可靠地、分别地反映每个线程更新后的值,那么更好的做法之一是让执行更新的同一个线程也来输出,尽管这会带来自己的复杂性。如果你决心在任何临界区之外进行输出,那么大致可能像这样:
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
int counter = 0;
std::mutex mtx;
void incrementCounter() {
int result;
{
std::lock_guard<mutex> lock(mtx);
result = ++counter;
}
// cout 本身是线程安全的,但链式输出会带来排序问题
// 还需要注意线程 ID 是“不透明”的
std::string message = std::string("线程 ") + std::this_thread::get_id()
+ " 已释放 " + result + "\n";
std::cout << message;
// 不需要睡眠
}
int main() {
std::thread t1(incrementCounter);
std::thread t2(incrementCounter);
std:thread::id t1_id = t1.get_id();
std:thread::id t2_id = t2.get_id();
t1.join();
t2.join();
std::cout << "线程一是 " << t1_id << '\n';
std::cout << "线程二是 " << t2_id << '\n';
return 0;
}
你也可以考虑对线程函数进行如下变体,在释放互斥锁前报告更新后的counter值:
void incrementCounter() {
std::lock_guard<mutex> lock(mtx);
counter += 1;
// cout 链式输出顺序问题通过互斥锁得到控制
std::string message = std::string("线程 ") << std::this_thread::get_id()
<< " 将 counter 更新为 " << counter << "\n";
}