互斥锁
互斥是为了防止多线程同时访问共享资源而产生的数据竞争,并提供多线程的同步支持。
我们举一个简单的例子:
static int gInt = 1;
int main()
{
std::thread dec( []() { for (int i = 0; i < 1000000; ++i) gInt--; } );
std::thread inc( []() { for (int i = 0; i < 1000000; ++i) gInt++; } );
dec.join();
inc.join();
std::cout << gInt << std::endl;
return 0;
}
我们期望上述程序的输出结果是1,但是因为多线程的执行顺序不定,所以每次运行结果gInt的值都不相同。这对我们的程序来说是一个灾难。
这时互斥锁的作用就出现了,我们对临界区加锁,实现对临界区资源的互斥访问。
static int gInt = 1;
static std::mutex mtx;
int main()
{
std::thread dec( []() {
for (int i = 0; i < 1000000; ++i)
{
mtx.lock();//访问临界区之前:尝试获取锁
gInt--;
mtx.unlock();//退出临界区:解锁
}
} );
std::thread inc([]()
{
for (int i = 0; i < 1000000; ++i)
{
mtx.lock();//访问临界区之前:尝试获取锁
gInt++;
mtx.unlock();//退出临界区:解锁
}
});
dec.join();
inc.join();
std::cout << gInt << std::endl;
return 0;
}
这样,每次的运行gInt的最终的值都是1
实际上,我们这样写是有线程安全隐患的。
想象下述情况:
线程A和线程B并发执行,共享一个互斥锁mtx,每次在临界区进入时加锁,退出时手动解锁
线程A持有锁,在临界区生了异常,在手动解锁前抛出该异常被上层捕获
线程A没有触发解锁的行为,该锁一直被持有,其他线程无法获得锁的所有权
虽然这是小概率情况,但是一旦发生那么后果会非常的严重。
在EffectiveC++中有一个条款是将资源的管理交付给一个资源类,互斥锁是一种非常重要的资源,为此C++设计了一种监管锁资源的类,常见的有lock_guard和unique_lock.
我们看一个lock_guard的例子:
static int gInt = 1;
static std::mutex mtx;
int main()
{
std::thread dec( []() {
for (int i = 0; i < 1000000; ++i)
{
std::lock_guard<std::mutex> lock(mtx);
gInt--;
}
} );
std::thread inc([]()
{
for (int i = 0; i < 1000000; ++i)
{
std::lock_guard<std::mutex> lock(mtx);
gInt++;
}
});
dec.join();
inc.join();
std::cout << gInt << std::endl;
return 0;
}
我们把手动解锁的mtx的生存周期交付给了lock_guard类,你会发现这是一个局部变量,所以它在退出它的作用域时会调用lock_guard的析构函数,在析构函数中会释放锁。
即便发生了异常安全,在退出for循环的某次循环的作用域时lock_guard也会正常析构释放资源。
unique_lock的使用方式同lock_guard,除此之外,它还要一个非常重要的作用就是配合条件变量condition_variable使用。
条件变量
条件变量是一种多线程的同步机制,它能够阻塞线程,直到某一条件满足。条件变量要与互斥量联合使用,以避免出现竞争的情况。(常用在生产者消费者模型当中)
在C++中,标准库封装了一个conditon_variable的类来抽象条件变量的所有操作:
bool condition; //条件变量等待的条件,必须是一个可判断真假的表达式
std::mutex mtx;//互斥量
std::unique_lock<std::mutex> lock(mtx);//独占锁
std::condition_variable cv;//条件变量
cv.wait(lock);//[1]
cv.wait(lock, [&]() { return condition; });//[2]
cv.notify_one();//[3]
cv.notify_all();//[4]
[1] cv.wait(lock) 当前线程置于阻塞状态,直到被notify_xxx()唤醒。wait()函数做的第一件事就是解锁lock,解锁后将当前线程挂起
[2]cv.wait(lock, condition) 如果conditon为false,那么当前线程阻塞被挂起,释放锁。如果conditon为true,那么当前线程继续执行。该函数相当于
while(!condition) cv.wait(lock)
[3]cv.notify_one() 唤醒某一个在等待条件的线程,哪一个不确定。
[4]cv.notify_all() 唤醒所有等待条件的线程,这些线程回去竞争锁的所有权来决定具体让哪一个线程持有锁并运行。
我们可以看一个简单的例子:
std::mutex mtx;
std::condition_variable cv;
std::queue<std::time_t> tasks;
bool full = false;
std::time_t GetTimeStamp()
{
std::chrono::time_point<std::chrono::system_clock, std::chrono::milliseconds> tp = std::chrono::time_point_cast<std::chrono::milliseconds>(std::chrono::system_clock::now());
auto tmp = std::chrono::duration_cast<std::chrono::milliseconds>(tp.time_since_epoch());
return tmp.count();
}
void Producer()
{
while (true)
{
std::this_thread::sleep_for(std::chrono::milliseconds(500));//1
std::unique_lock<std::mutex> lock(mtx);//2
std::time_t x = GetTimeStamp();//3
tasks.push(x);//4
cout << "producer push val : " << x << endl;//5
cv.notify_one();//6
}
}
void Consumer()
{
while (true)
{
std::unique_lock<std::mutex> lock(mtx);//7
cv.wait(lock, [&]() { return !tasks.empty(); });//8
while (!tasks.empty())//9
{
time_t x = tasks.front();//10
tasks.pop();//11
cout << "consumer pid : " << std::this_thread::get_id() << " consume val : " << x << endl;//12
}
}
}
int main()
{
std::thread consumer(Consumer);
std::thread producer(Producer);
consumer.join();
producer.join();
return 0;
}
这个程序会启动两个线程生产者和消费者,生产者会每隔500ms尝试去生产一个task(这里其实是当前的时间戳)放到任务队列当中,消费者去任务队列中取数据。
由于consumer和producer两个线程我们没有办法知道谁先谁后,就只好列出所有的情况分析。
(实际上这对于理解并发是最简单最粗暴的一点,从最简单的demo开始,列出所有的情况逐步分析)
情况1:consumer比producer先执行。
(1)consumer在循环中7首先尝试获取锁,此时锁没有被占用,consumer持有锁
(2)consumer在8处等待条件变量,此时task.empty()为true,所以不会继续执行下去,那么consumer会被挂起,同时consumer持有的锁也被释放(这里被释放是wait函数的作用)
(3)producer在2处尝试获取锁,因为consumer被挂起且锁被释放,所以producer获取锁成功。
(4)producer获取当前的时间戳,加入到tasks,调用cv.notify_one(),告诉某个等待条件变量的线程它等待的条件已经准备好,可以去尝试获取锁。
(5)consumer的8处等待的条件为真,此时wait函数会返回,在wait返回时,cv封装的操作会获取锁。然后consumer继续执行。情况2:producer比consumer先执行。
这种情况下所有事件发生的顺序会顺理成章,consumer不必被挂起。
讨论:更一般情况下的条件变量的用法
一般来说,我们的条件变量cv是和某个条件相联系的。
condition_variable cv;
bool status;
考虑下面几个版本的伪代码,哪一种是正确的用法,以及为什么这样使用是正确的。
//version 1
void wait()
{
mutex.lock();
if (status == false) cv.wait();
mutex.unlock();
}
void signal()
{
status = true;
cv.notify_one();
}
//version 2
void wait()
{
mutex.lock();
while (status == false) cv.wait();
mutex.unlock();
}
void signal()
{
status = true;
cv.notify_one();
}
//version 3
void wait()
{
mutex.lock();
while (status == false) cv.wait();
mutex.unlock();
}
void signal()
{
mutex.lock();
status = true;
cv.notify_one();
mutex.unlock();
}
- Version 1:错误的版本
本版本可能会发生的一个错误称为
spurious wakeup //虚假唤醒
虚假唤醒在网上有很多讨论,可是版本众多,甚至连错误的描述都不统一,这里我试着对出现虚假唤醒的情况以及虚假唤醒的表现谈一谈我的理解。
首先我们应该给虚假唤醒一个定义,什么是虚假唤醒?
虚假唤醒是:
(1)一个notify操作,唤醒了两个或者两个以上的wait()的线程
(2)没有notify操作但是却有线程的wait()函数返回。
考虑这种情况:
线程A是生产者,BC是消费者
BC先于A执行。B先执行,if(status == false ) wait(),在wait中释放锁。C再执行,if(status == false)wait(),在wait中释放锁。
A执行,获取锁,生产一个item,放在队列中,notify_one()。在多个CPU核心的情况下,notify_one()可能唤醒BC两个线程。BC都执行过了status的false判断在wait处阻塞,如果BC都被唤醒,那么BC都认为队列里有东西。
- B先于C执行,拿到了锁的所有权,并且消耗了队列中的item,此时队列为空,置status为false。现在C执行,由于已经在A notify_one()唤醒C时,status是true(实际上由于B消耗了item设置了status为false),那么C会继续执行,从队列中获取item,但是item其实已经被C消费掉了。
- 这就是虚假唤醒的一种表现形式。
我们要做的修改其实很简单,就是多检查一次status,所以要把if改为while。
- Version 2 : 错误的版本,考虑下述线程的指令执行顺序
其实,这里问题的关键在于,signal是有可能发生在wait之前的,如果这种情况确实出现了,那么就有可能会造成信号的丢失(所以我们在notify信号的时候也要加锁)
- Version3 : 正确的版本,鼓励使用的方式。
对条件变量阻塞的那个条件的改变,要加锁,并且把这个状态的改变放在notify之前。