多线程编程---同步并发操作

来源:互联网 发布:詹姆斯选秀体测数据 编辑:程序博客网 时间:2024/05/21 06:56

线程同步, 简单的说,就是在第一个线程完成前,需要等待另一个线程执行完成。通常情况下,线程会等待一个特定事件发生,或者等待某一条件达成(true).

等待一个事件或者其他条件
假设你在旅游,而且正在一辆在夜间运行的火车上。在夜间,如何在正确的站点下车呢?一种方法是整晚都要醒着,然后注意到了哪一站。这样,你就不会错过你要到达的站点,但是这样会让你感到很疲倦。另外,你可以看一下时间表,估计一下火车到达目的地的时间,然后在一个稍早的时间点上设置闹铃,然后你就可以安心的睡会了。这个方法听起来也很不错,也没有错过你要下车的站点,但是当火车晚点的时候,你就要被过早的叫醒了。当然,闹钟的电池也可能会没电了,并导致你睡过站。理想的方式是,无论是早或晚,只要当火车到站的时候,有人或其他东西能把你唤醒,就好了。

这和线程有什么关系呢?好吧,让我们来联系一下。当一个线程等待另一个线程完成任务时,它会有很多选择。第一,它可以持续的检查共享数据标志(用于做保护工作的互斥量),直到另一线程完成工作时对这个标志进行重设。不过,就是一种浪费:线程消耗宝贵的执行时间持续的检查对应标志,并且当互斥量被等待线程上锁后,其他线程就没有办法获取锁,这样线程就会持续等待。因为以上方式对等待线程限制资源,并且在完成时阻碍对标识的设置。这种情况类似与,保持清醒状态和列车驾驶员聊了一晚上:驾驶员不得不缓慢驾驶,因为你分散了他的注意力,所以火车需要更长的时间,才能到站。同样的,等待的线程会等待更长的时间,这些线程也在消耗着系统资源。+

第二个选择是在等待线程在检查间隙,使用std::this_thread::sleep_for()进行周期性的间歇(详见4.3节):
bool flag;
std::mutex m;

void wait_for_flag()
{
std::unique_lock lk(m);
while(!flag)
{
lk.unlock(); // 1 解锁互斥量
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠100ms
lk.lock(); // 3 再锁互斥量
}
}
在这个循环中,在休眠前②,函数对互斥量进行解锁①,并且在休眠结束后再对互斥量进行上锁,所以另外的线程就有机会获取锁并设置标识。
这个实现就进步很多,因为当线程休眠时,线程没有浪费执行时间,但是很难确定正确的休眠时间。太短的休眠和没有休眠一样,都会浪费执行时间;太长的休眠时间,可能会让任务等待线程醒来。休眠时间过长是很少见的情况,因为这会直接影响到程序的行为,当在高节奏游戏(fast-paced game)中,它意味着丢帧,或在一个实时应用中超越了一个时间片。
第三个选择(也是优先的选择)是,使用C++标准库提供的工具去等待事件的发生通过另一线程触发等待事件的机制是最基本的唤醒方式(例如:流水线上存在额外的任务时),这种机制就称为“条件变量”(condition variable)。从概念上来说,一个条件变量会与多个事件或其他条件相关,并且一个或多个线程会等待条件的达成。当某些线程被终止时,为了唤醒等待线程(允许等待线程继续执行)终止的线程将会向等待着的线程广播“条件达成”的信息。

条件变量和互斥量配合使用。

考虑有两个线程,线程1 准备数据, 线程2 处理数据,
当有数据需要处理时候,如何唤醒休眠中的线程对其进行处理?

下面给出一种使用条件变量做唤醒的方式。

线程1 中,准备数据
基本步骤
1. 对互斥量加锁
2. 改变互斥量保护的条件
3. 向等待条件的线程2 发送信号
4. 对互斥量解锁

线程2 中,处理数据
1.对互斥量加锁
2.等待条件满足信号
如果条件不满足,wait() 函数将解锁互斥量,并将这个线程(处理数据的线程)置于阻塞或者等待状态,并且重新开始等待条件满足条件信号
如果条件满足,从wait() 返回并继续持有锁,线程下面的动作
3. 取出互斥量保护的条件数据
4. 对互斥量解锁
5. 处理数据

清单4.1 使用std::condition_variable处理数据等待std::mutex mut;std::queue<data_chunk> data_queue;  // 1std::condition_variable data_cond;void data_preparation_thread(){  while(more_data_to_prepare())  {    data_chunk const data=prepare_data();    std::lock_guard<std::mutex> lk(mut);    data_queue.push(data);  // 2    data_cond.notify_one();  // 3  }}void data_processing_thread(){  while(true)  {    std::unique_lock<std::mutex> lk(mut);  // 4    data_cond.wait(         lk,[]{return !data_queue.empty();});  // 5    data_chunk data=data_queue.front();    data_queue.pop();    lk.unlock();  // 6    process(data);    if(is_last_chunk(data))      break;  }}

首先,你拥有一个用来在两个线程之间传递数据的队列①。当数据准备好时,使用std::lock_guard对队列上锁,将准备好的数据压入队列中②,之后线程会对队列中的数据上锁。然后调用std::condition_variable的notify_one()成员函数,对等待的线程(如果有等待线程)进行通知③。
在另外一侧,你有一个正在处理数据的线程,这个线程首先对互斥量上锁,但在这里std::unique_lock要比std::lock_guard④更加合适——且听我细细道来。线程之后会调用std::condition_variable的成员函数wait(),传递一个锁和一个lambda函数表达式(作为等待的条件⑤)。Lambda函数是C++11添加的新特性,它可以让一个匿名函数作为其他表达式的一部分,并且非常合适作为标准函数的谓词,例如wait()函数。在这个例子中,简单的lambda函数[]{return !data_queue.empty();}会去检查data_queue是否不为空,当data_queue不为空——那就意味着队列中已经准备好数据了。
wait()会去检查这些条件(通过调用所提供的lambda函数),当条件满足(lambda函数返回true)时返回。如果条件不满足(lambda函数返回false),wait()函数将解锁互斥量,并且将这个线程(上段提到的处理数据的线程)置于阻塞或等待状态。当准备数据的线程调用notify_one()通知条件变量时,处理数据的线程从睡眠状态中苏醒,重新获取互斥锁,并且对条件再次检查,在条件满足的情况下,从wait()返回并继续持有锁。当条件不满足时,线程将对互斥量解锁,并且重新开始等待。这就是为什么用std::unique_lock而不使用std::lock_guard——等待中的线程必须在等待期间解锁互斥量,并在这这之后对互斥量再次上锁,而std::lock_guard没有这么灵活。如果互斥量在线程休眠期间保持锁住状态,准备数据的线程将无法锁住互斥量,也无法添加数据到队列中;同样的,等待线程也永远不会知道条件何时满足。
清单4.1使用了一个简单的lambda函数用于等待⑤,这个函数用于检查队列何时不为空,不过任意的函数和可调用对象都可以传入wait()。当你已经写好了一个函数去做检查条件(或许比清单中简单检查要复杂很多),那就可以直接将这个函数传入wait();不一定非要放在一个lambda表达式中。在调用wait()的过程中,一个条件变量可能会去检查给定条件若干次;然而,它总是在互斥量被锁定时这样做,当且仅当提供测试条件的函数返回true时,它就会立即返回。当等待线程重新获取互斥量并检查条件时,如果它并非直接响应另一个线程的通知,这就是所谓的“伪唤醒”(spurious wakeup)。因为任何伪唤醒的数量和频率都是不确定的,这里不建议使用一个有副作用的函数做条件检查。当你这样做了,就必须做好多次产生副作用的心理准备。
解锁std::unique_lock的灵活性,不仅适用于对wait()的调用;它还可以用于有待处理但还未处理的数据⑥。处理数据可能是一个耗时的操作,持有锁的时间过长是一个糟糕的主意。

0 0
原创粉丝点击