《C++ Concurrencyin Action》第4章--同步并发操作

14 篇文章 1 订阅

前言

本章主要内容:

1、等待事件
2、带有期望的等待一次性事件
3、在限定时间内等待
4、使用同步操作简化代码

在上一章中,我们看到各种在线程间保护共享数据的方法。当你不仅想要保护数据,还想对单独的线程进行同步。例如,在第一个线程完成前,可能需要等待另一个线程执行完成。通常情况下,线程会等待一个特定事件的发生,或者等待某一条件达成(为true)。这可能需要定期检查“任务完成”标识,或将类似的东西放到共享数据中,但这与理想情况还是差很多。像这种情况就需要在线程中进行同步,C++标准库提供了一些工具可用于同步操作,形式上表现为条件变量(condition variables)和期望(futures)。

在本章,将讨论如何使用条件变量等待事件,以及介绍期望,和如何使用它简化同步操作。

4.1 等待一个事件或其他条件

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

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

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

bool flag;
std::mutex m;

void wait_for_flag()
{
   std::unique_lock<std::mutex> 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)。从概念上来说,一个条件变量会与多个事件或其他条件相关,并且一个或多个线程会等待条件的达成。当某些线程被终止时,为了唤醒等待线程(允许等待线程继续执行)终止的线程将会向等待着的线程广播“条件达成”的信息。

4.1.1 等待条件达成

C++标准库对条件变量有两套实现:std::condition_variable和std::condition_variable_any。这两个实现都包含在<condition_variable>头文件的声明中。两者都需要与一个互斥量一起才能工作(互斥量是为了同步);前者仅限于与std::mutex一起工作,而后者可以和任何满足最低标准的互斥量一起工作,从而加上了*_any*的后缀。因为std::condition_variable_any更加通用,这就可能从体积、性能,以及系统资源的使用方面产生额外的开销,所以std::condition_variable一般作为首选的类型,当对灵活性有硬性要求时,我们才会去考虑std::condition_variable_any。

所以,如何使用std::condition_variable去处理之前提到的情况——当有数据需要处理时,如何唤醒休眠中的线程对其进行处理?以下清单展示了一种使用条件变量做唤醒的方式。

清单4.1 使用std::condition_variable处理数据等待

std::mutex mut;
std::queue<data_chunk> data_queue;  //** 1**
std::condition_variable data_cond;

void data_preparation_thread()
{
  while(more_data_to_prepare())
  {
     data_chunkconst 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_chunkdata=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不为空——那就意味着队列中已经准备好数据了。附录A的A.5节有Lambda函数更多的信息。

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()的调用;它还可以用于有待处理但还未处理的数据⑥。处理数据可能是一个耗时的操作,并且如你在第3章见到的,你就知道持有锁的时间过长是一个多么糟糕的主意。

使用队列在多个线程中转移数据(如清单4.1)是很常见的。做得好的话,同步操作可以限制在队列本身,同步问题和条件竞争出现的概率也会降低。鉴于这些好处,现在从清单4.1中提取出一个通用线程安全的队列。

4.1.2 使用条件变量构建线程安全队列

当你正在设计一个通用队列时,花一些时间想想有哪些操作需要添加到队列实现中去,就如之前在3.2.3节看到的线程安全的栈。可以看一下C++标准库提供的实现,找找灵感;std::queue<>容器的接口展示如下:

清单4.2 std::queue接口

template <class T, class Container =std::deque<T> >
class queue 
{
public:
  explicit queue(const Container&);
  explicit queue(Container&& = Container());
  template <class Alloc> explicitqueue(const Alloc&);
  template<class Alloc> queue(const Container&, const Alloc&);
  template<class Alloc> queue(Container&&, const Alloc&);
  template<class Alloc> queue(queue&&, const Alloc&);
 
  void swap(queue& q);
  bool empty()const;
  size_type size() const;
  T& front();
  const T&front() const;
  T& back();
  const T&back() const;
  void push(const T& x);
  void push(T&& x);
  void pop();
  template<class... Args> void emplace(Args&&... args);
};

当你忽略构造、赋值以及交换操作时,你就剩下了三组操作:1. 对整个队列的状态进行查询(empty()和size());2.查询在队列中的各个元素(front()和back());3.修改队列的操作(push(), pop()和emplace())。这就和3.2.3中的栈一样了,因此你也会遇到在固有接口上的条件竞争。因此,你需要将front()和pop()合并成一个函数调用,就像之前在栈实现时合并top()和pop()一样。与清单4.1中的代码不同的是:当使用队列在多个线程中传递数据时,接收线程通常需要等待数据的压入。这里我们提供pop()函数的两个变种:try_pop()和wait_and_pop()。try_pop() ,尝试从队列中弹出数据,总会直接返回(当有失败时),即使没有指可检索;wait_and_pop(),将会等待有值可检索的时候才返回。当你使用之前栈的方式来实现你的队列,你实现的队列接口就可能会是下面这样:

清单4.3 线程安全队列的接口

#include <memory> // 为了使用std::shared_ptr

template<typename T>
class threadsafe_queue
{
public:
    threadsafe_queue();
    threadsafe_queue(const threadsafe_queue&);
    threadsafe_queue& operator=(const threadsafe_queue&) =delete;  // 不允许简单的赋值
    void push(Tnew_value);
    booltry_pop(T& value);  // 1
    std::shared_ptr<T> try_pop(); // 2
    voidwait_and_pop(T& value);
    std::shared_ptr<T> wait_and_pop();
    bool empty()const;
};

就像之前对栈做的那样,在这里你将很多构造函数剪掉了,并且禁止了对队列的简单赋值。和之前一样,你也需要提供两个版本的try_pop()和wait_for_pop()。第一个重载的try_pop()①在引用变量中存储着检索值,所以它可以用来返回队列中值的状态;当检索到一个变量时,他将返回true,否则将返回false(详见A.2节)。第二个重载②就不能做这样了,因为它是用来直接返回检索值的。当没有值可检索时,这个函数可以返回NULL指针。

那么问题来了,如何将以上这些和清单4.1中的代码相关联呢?好吧,我们现在就来看看怎么去关联。你可以从之前的代码中提取push()和wait_and_pop(),如以下清单所示。

清单4.4 从清单4.1中提取push()和wait_and_pop()

#include <queue>
#include <mutex>
#include <condition_variable>

template<typename T>
class threadsafe_queue
{
private:
  std::mutexmut;
 std::queue<T> data_queue;
 std::condition_variable data_cond;

public:
  void push(Tnew_value)
  {
     std::lock_guard<std::mutex> lk(mut);
     data_queue.push(new_value);
     data_cond.notify_one();
  }

  voidwait_and_pop(T& value)
  {
     std::unique_lock<std::mutex> lk(mut);
     data_cond.wait(lk,[this]{return !data_queue.empty();});
     value=data_queue.front();
     data_queue.pop();
  }
};

threadsafe_queue<data_chunk> data_queue;  // **1**

void data_preparation_thread()
{
  while(more_data_to_prepare())
  {
     data_chunkconst data=prepare_data();
     data_queue.push(data);  // **2**
  }
}

void data_processing_thread()
{
  while(true)
  {
    data_chunkdata;
    data_queue.wait_and_pop(data);  //**3**
    process(data);
   
    if(is_last_chunk(data))
      break;
  }
}

线程队列的实例中包含有互斥量和条件变量,所以独立的变量就不需要了①,并且调用push()也不需要外部同步②。当然,wait_and_pop()还要兼顾条件变量的等待③。

另一个wait_and_pop()函数的重载写起来就很琐碎了,剩下的函数就像从清单3.5实现的栈中一个个的粘过来一样。最终的队列实现如下所示。

清单4.5 使用条件变量的线程安全队列(完整版)

#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>

template<typename T>
class threadsafe_queue
{
private:
  mutablestd::mutex mut;  // **1** 互斥量必须是可变的 
 std::queue<T> data_queue;
 std::condition_variable data_cond;

public:
 threadsafe_queue() {}
 threadsafe_queue(threadsafe_queue const& other)
  {
   std::lock_guard<std::mutex> lk(other.mut);
   data_queue=other.data_queue;
  }

  void push(Tnew_value)
  {
   std::lock_guard<std::mutex> lk(mut);
   data_queue.push(new_value);
   data_cond.notify_one();
  }

  voidwait_and_pop(T& value)
  {
   std::unique_lock<std::mutex> lk(mut);
   data_cond.wait(lk,[this]{return !data_queue.empty();});
   value=data_queue.front();
   data_queue.pop();
  }

 std::shared_ptr<T> wait_and_pop()
  {
   std::unique_lock<std::mutex> lk(mut);
   data_cond.wait(lk,[this]{return !data_queue.empty();});
   std::shared_ptr<T>res(std::make_shared<T>(data_queue.front()));

   data_queue.pop();
    return res;
  }

  bool try_pop(T& value)
  {
   std::lock_guard<std::mutex> lk(mut);
   if(data_queue.empty())
      return false;

   value=data_queue.front();
   data_queue.pop();
    return true;
  }

 std::shared_ptr<T> try_pop()
  {
   std::lock_guard<std::mutex> lk(mut);
   if(data_queue.empty())
      returnstd::shared_ptr<T>();

   std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));

   data_queue.pop();

    return res;

  }

 

  bool empty()const
  {
   std::lock_guard<std::mutex> lk(mut);
    return data_queue.empty();
  }
};

empty()是一个const成员函数,并且传入拷贝构造函数的other形参是一个const引用;因为其他线程可能有这个类型的非const引用对象,并调用变种成员函数,所以这里有必要对互斥量上锁。如果锁住互斥量是一个可变操作,那么这个互斥量对象就会标记为可变的①,之后他就可以在empty()和拷贝构造函数中上锁了。

条件变量在多个线程等待同一个事件时,也是很有用的。当线程用来分解工作负载,并且只有一个线程可以对通知做出反应,与清单4.1中使用的结构完全相同;运行多个数据实例——处理线程(processing thread)。当新的数据准备完成,调用notify_one()将会触发一个正在执行wait()的线程,去检查条件和wait()函数的返回状态(因为你仅是向data_queue添加一个数据项)。 这里不保证线程一定会被通知到,即使只有一个等待线程被通知时,所有处线程也有可能都在处理数据。

另一种可能是,很多线程等待同一事件,对于通知他们都需要做出回应。这会发生在共享数据正在初始化的时候,当处理线程可以使用同一数据时,就要等待数据被初始化(有不错的机制可用来应对;可见第3章,3.3.1节),或等待共享数据的更新,比如,定期重新初始化(periodicreinitialization)。在这些情况下,准备线程准备数据数据时,就会通过条件变量调用notify_all()成员函数,而非直接调用notify_one()函数。顾名思义,这就是全部线程在都去执行wait()(检查他们等待的条件是否满足)的原因。

当等待线程只等待一次,当条件为true时,它就不会再等待条件变量了,所以一个条件变量可能并非同步机制的最好选择。尤其是,条件在等待一组可用的数据块时。在这样的情况下,期望(future)就是一个适合的选择。

4.2 使用期望等待一次性事件

假设你乘飞机去国外度假。当你到达机场,并且办理完各种登机手续后,你还需要等待机场广播通知你登机,可能要等很多个小时。你可能会在候机室里面找一些事情来打发时间,比如:读书,上网,或者来一杯价格不菲的机场咖啡,不过从根本上来说你就在等待一件事情:机场广播能够登机的时间。给定的飞机班次再之后没有可参考性;当你在再次度假的时候,你可能会等待另一班飞机。

C++标准库模型将这种一次性事件称为“期望” (future)。当一个线程需要等待一个特定的一次性事件时,在某种程度上来说它就需要知道这个事件在未来的表现形式。之后,这个线程会周期性(较短的周期)的等待或检查,事件是否触发(检查信息板);在检查期间也会执行其他任务(品尝昂贵的咖啡)。另外,在等待任务期间它可以先执行另外一些任务,直到对应的任务触发,而后等待期望的状态会变为“就绪”(ready)。一个“期望”可能是数据相关的(比如,你的登机口编号),也可能不是。当事件发生时(并且期望状态为就绪),这个“期望”就不能被重置。

在C++标准库中,有两种“期望”,使用两种类型模板实现,声明在头文件中: 唯一期望(unique futures)(std::future<>)和共享期望(shared futures)(std::shared_future<>)。这是仿照std::unique_ptr和std::shared_ptr。std::future的实例只能与一个指定事件相关联,而std::shared_future的实例就能关联多个事件。后者的实现中,所有实例会在同时变为就绪状态,并且他们可以访问与事件相关的任何数据。这种数据关联与模板有关,比如std::unique_ptr和std::shared_ptr的模板参数就是相关联的数据类型。在与数据无关的地方,可以使用std::future与std::shared_future的特化模板。虽然,我希望用于线程间的通讯,但是“期望”对象本身并不提供同步访问。当多个线程需要访问一个独立“期望”对象时,他们必须使用互斥量或类似同步机制对访问进行保护,如在第3章提到的那样。不过,在你将要阅读到的4.2.5节中,多个线程会对一个std::shared_future<>实例的副本进行访问,而不需要期望同步,即使他们是同一个异步结果。

最基本的一次性事件,就是一个后台运行出的计算结果。在第2章中,你已经了解了std::thread 执行的任务不能有返回值,并且我能保证,这个问题将在使用“期望”后解决——现在就来看看是怎么解决的。

4.2.1 带返回值的后台任务

假设,你有一个需要长时间的运算,你需要其能计算出一个有效的值,但是你现在并不迫切需要这个值。可能你已经找到了生命、宇宙,以及万物的答案,就像道格拉斯·亚当斯[1]一样。你可以启动一个新线程来执行这个计算,但是这就意味着你必须关注如何传回计算的结果,因为std::thread并不提供直接接收返回值的机制。这里就需要std::async函数模板(也是在头文件中声明的)了。

当任务的结果你不着急要时,你可以使用std::async启动一个异步任务。与std::thread对象等待运行方式的不同,std::async会返回一个std::future对象,这个对象持有最终计算出来的结果。当你需要这个值时,你只需要调用这个对象的get()成员函数;并且直到“期望”状态为就绪的情况下,线程才会阻塞;之后,返回计算结果。下面清单中代码就是一个简单的例子。

清单4.6 使用std::future从异步任务中获取返回值

#include <future>
#include <iostream>

int find_the_answer_to_ltuae();
void do_other_stuff();

int main()
{
   std::future<int> the_answer=std::async(find_the_answer_to_ltuae);
   do_other_stuff();
   std::cout<<"The answer is"<<the_answer.get()<<std::endl;
}

与std::thread 做的方式一样,std::async允许你通过添加额外的调用参数,向函数传递额外的参数。当第一个参数是一个指向成员函数的指针,第二个参数提供有这个函数成员类的具体对象(不是直接的,就是通过指针,还可以包装在std::ref中),剩余的参数可作为成员函数的参数传入。否则,第二个和随后的参数将作为函数的参数,或作为指定可调用对象的第一个参数。就如std::thread,当参数为右值(rvalues)时,拷贝操作将使用移动的方式转移原始数据。这就允许使用“只移动”类型作为函数对象和参数。来看一下下面的程序清单:

清单4.7 使用std::async向函数传递参数

#include <string>
#include <future> 

struct X
{
  voidfoo(int,std::string const&);
  std::stringbar(std::string const&);
};

X x;

autof1=std::async(&X::foo,&x,42,"hello");  // 调用p->foo(42, "hello"),p是指向x的指针
autof2=std::async(&X::bar,x,"goodbye");  // 调用tmpx.bar("goodbye"), tmpx是x的拷贝副本

struct Y
{
  double operator()(double);
};

Y y;

auto f3=std::async(Y(),3.141);  // 调用tmpy(3.141),tmpy通过Y的移动构造函数得到
auto f4=std::async(std::ref(y),2.718);  // 调用y(2.718)

X baz(X&);

std::async(baz,std::ref(x));  // 调用baz(x)

class move_only
{
public:
  move_only();
  move_only(move_only&&);
  move_only(move_only const&) = delete;
  move_only& operator=(move_only&&);
  move_only& operator=(move_only const&) = delete;
 
  void operator()();
};

auto f5=std::async(move_only());  // 调用tmp(),tmp是通过std::move(move_only())构造得到

在默认情况下,这取决于std::async是否启动一个线程,或是否在期望等待时同步任务。在大多数情况下(估计这就是你想要的结果),但是你也可以在函数调用之前,向std::async传递一个额外参数。这个参数的类型是std::launch,还可以是std::launch::defered,用来表明函数调用被延迟到wait()或get()函数调用时才执行,std::launch::async 表明函数必须在其所在的独立线程上执行,std::launch::deferred | std::launch::async表明实现可以选择这两种方式的一种。最后一个选项是默认的。当函数调用被延迟,它可能不会在运行了。如下所示:

auto f6 = std::async(std::launch::async,Y(),1.2);  // 在新线程上执行
auto f7 = std::async(std::launch::deferred,baz,std::ref(x));  // 在wait()或get()调用时执行
auto f8 = std::async(std::launch::deferred |std::launch::async, baz,std::ref(x));  //实现选择执行方式
auto f9 = std::async(baz,std::ref(x));

f7.wait(); //  调用延迟函数

在本章的后面和第8章中,你将会再次看到这段程序,使用std::async会让分割算法到各个任务中变的容易,这样程序就能并发的执行了。不过,这不是让一个std::future与一个任务实例相关联的唯一方式;你也可以将任务包装入一个std::packaged_task<>实例中,或通过编写代码的方式,使用std::promise<>类型模板显示设置值。与std::promise<>对比,std::packaged_task<>具有更高层的抽象,所以我们从“高抽象”的模板说起。

4.2.2 任务与期望

std::packaged_task<>对一个函数或可调用对象,绑定一个期望。当std::packaged_task<> 对象被调用,它就会调用相关函数或可调用对象,将期望状态置为就绪,返回值也会被存储为相关数据。这可以用在构建线程池的建筑块(可见第9章),或用于其他任务的管理,比如在任务所在线程上运行任务,或将它们顺序的运行在一个特殊的后台线程上。当一个粒度较大的操作可以被分解为独立的子任务时,其中每个子任务就可以包含在一个std::packaged_task<>实例中,之后这个实例将传递到任务调度器或线程池中。这就是对任务的细节进行抽象了;调度器仅处理std::packaged_task<>实例,要比处理独立的函数高效的多。

std::packaged_task<>的模板参数是一个函数签名,比如void()就是一个没有参数也没有返回值的函数,或int(std::string&,double*)就是有一个非const引用的std::string和一个指向double类型的指针,并且返回类型是int。当你构造出一个std::packaged_task<>实例时,你必须传入一个函数或可调用对象,这个函数或可调用的对象需要能接收指定的参数和返回可转换为指定返回类型的值。类型可以不完全匹配;你可以用一个int类型的参数和返回一个float类型的函数,来构建std::packaged_task<double(double)>的实例,因为在这里,类型可以隐式转换。

指定函数签名的返回类型可以用来标识,从get_future()返回的std::future<>的类型,不过函数签名的参数列表,可用来指定“打包任务”的函数调用操作符。例如,局部类定义std::packaged_task<std::string(std::vector*,int)>将在下面的代码清代中使用。

清单4.8 std::packaged_task<>的特化——局部类定义

template<>
classpackaged_task<std::string(std::vector<char>*,int)>
{
public:
  template<typename Callable>
  explicitpackaged_task(Callable&& f);
  std::future<std::string> get_future();
  voidoperator()(std::vector<char>*,int);
};

这里的std::packaged_task对象是一个可调用对象,并且它可以包含在一个std::function对象中,传递到std::thread对象中,就可作为线程函数;传递另一个函数中,就作为可调用对象,或可以直接进行调用。当std::packaged_task作为一个函数调用时,可为函数调用操作符提供所需的参数,并且返回值作为异步结果存储在std::future,可通过get_future()获取。你可以把一个任务包含入std::packaged_task,并且在检索期望之前,需要将std::packaged_task对象传入,以便调用时能及时的找到。

当你需要异步任务的返回值时,你可以等待期望的状态变为“就绪”。下面的代码就是这么个情况。

线程间传递任务

很多图形架构需要特定的线程去更新界面,所以当一个线程需要界面的更新时,它需要发出一条信息给正确的线程,让特定的线程来做界面更新。std::packaged_task提供了完成这种功能的一种方法,且不需要发送一条自定义信息给图形界面相关线程。下面来看看代码。

清单4.9 使用std::packaged_task执行一个图形界面线程

#include <deque>
#include <mutex>
#include <future>
#include <thread>
#include <utility>

std::mutex m;
std::deque<std::packaged_task<void()> >tasks;

bool gui_shutdown_message_received();
void get_and_process_gui_message();

void gui_thread() // **1**

{

 while(!gui_shutdown_message_received()) // **2**
  {
   get_and_process_gui_message();  //** 3**
   std::packaged_task<void()> task;
    {
     std::lock_guard<std::mutex> lk(m);
     if(tasks.empty())  // **4**
       continue;

     task=std::move(tasks.front());  //**5**
     tasks.pop_front();
    }
   task();  // **6**
  }
}

 

std::thread gui_bg_thread(gui_thread);

template<typename Func>
std::future<void>post_task_for_gui_thread(Func f)
{
 std::packaged_task<void()> task(f);  // **7**
 std::future<void> res=task.get_future();  // **8**
 std::lock_guard<std::mutex> lk(m); //** 9**
 tasks.push_back(std::move(task)); // **10**
  
  return res;
}

这段代码十分简单:图形界面线程①循环直到收到一条关闭图形界面的信息后关闭②,进行轮询界面消息处理③,例如用户点击,和执行在队列中的任务。当队列中没有任务④,它将再次循环;除非,他能在队列中提取出一个任务⑤,然后释放队列上的锁,并且执行任务⑥。这里,“期望”与任务相关,当任务执行完成时,其状态会被置为“就绪”状态。

将一个任务传入队列,也很简单:提供的函数⑦可以提供一个打包好的任务,可以通过这个任务⑧调用get_future()成员函数获取“期望”对象,并且在任务被推入列表⑨之前,“期望”将返回调用函数⑩。当需要知道线程执行完任务时,向图形界面线程发布消息的代码,会等待“期望”改变状态;否则,则会丢弃这个“期望”。

这个例子使用std::packaged_task<void()>创建任务,其包含了一个无参数无返回值的函数或可调用对象(如果当这个调用有返回值时,返回值会被丢弃)。这可能是最简单的任务,如你之前所见,std::packaged_task也可以用于一些复杂的情况——通过指定一个不同的函数签名作为模板参数,你不仅可以改变其返回类型(因此该类型的数据会存在期望相关的状态中),而且也可以改变函数操作符的参数类型。这个例子可以简单的扩展成允许任务运行在图形界面线程上,且接受传参,还有通过std::future返回值,而不仅仅是完成一个指标。

这些任务能作为一个简单的函数调用来表达吗?还有,这些任务的结果能从很多地方得到吗?这些情况可以使用第三种方法创建“期望”来解决:使用std::promise对值进行显示设置。

4.2.3 使用std::promises

当你有一个应用,需要处理很多网络连接,它会使用不同线程尝试连接每个接口,因为这能使网络尽早联通,尽早执行程序。当连接较少的时候,这样的工作没有问题(也就是线程数量比较少)。不幸的是,随着连接数量的增长,这种方式变的越来越不合适;因为大量的线程会消耗大量的系统资源,还有可能造成上下文频繁切换(当线程数量超出硬件可接受的并发数时),这都会对性能有影响。最极端的例子就是,因为系统资源被创建的线程消耗殆尽,系统连接网络的能力会变的极差。在不同的应用程序中,存在着大量的网络连接,因此不同应用都会拥有一定数量的线程(可能只有一个)来处理网络连接,每个线程处理可同时处理多个连接事件。

考虑一个线程处理多个连接事件,来自不同的端口连接的数据包基本上是以乱序方式进行处理的;同样的,数据包也将以乱序的方式进入队列。在很多情况下,另一些应用不是等待数据成功的发送,就是等待一批(新的)来自指定网络接口的数据接收成功。

std::promise提供设定值的方式(类型为T),这个类型会和后面看到的std::future 对象相关联。一对std::promise/std::future会为这种方式提供一个可行的机制;在期望上可以阻塞等待线程,同时,提供数据的线程可以使用组合中的“承诺”来对相关值进行设置,以及将“期望”的状态置为“就绪”。

可以通过get_future()成员函数来获取与一个给定的std::promise相关的std::future对象,就像是与std::packaged_task相关。当“承诺”的值已经设置完毕(使用set_value()成员函数),对应“期望”的状态变为“就绪”,并且可用于检索已存储的值。当你在设置值之前销毁std::promise,将会存储一个异常。在4.2.4节中,会详细描述异常是如何传送到线程的。

清单4.10中,是单线程处理多接口的实现,如同我们所说的那样。在这个例子中,你可以使用一对std::promise/std::future找出一块传出成功的数据块;与“期望”相关值只是一个简单的“成功/失败”标识。对于传入包,与“期望”相关的数据就是数据包的有效负载。

清单4.10 使用“承诺”解决单线程多连接问题

#include <future>

void process_connections(connection_set&connections)
{
 while(!done(connections))  // **1**
  {
   for(connection_iterator connection=connections.begin(),end=connections.end();
         connection!=end;++connection) //**2**
    {
     if(connection->has_incoming_data()) // **3**
      {
       data_packet data=connection->incoming();
       std::promise<payload_type>&p=connection->get_promise(data.id); // **4**
       p.set_value(data.payload);
      }

     if(connection->has_outgoing_data()) // **5**
      {
       outgoing_packet data=connection->top_of_outgoing_queue();
       connection->send(data.payload);
       data.promise.set_value(true);  // **6**
      }
    }
  }
}

函数process_connections()中,直到done()返回true①为止。每一次循环,程序都会依次的检查每一个连接②,检索是否有数据③或正在发送已入队的传出数据⑤。这里假设输入数据包是具有ID和有效负载的(有实际的数在其中)。一个ID映射到一个std::promise(可能是在相关容器中进行的依次查找)④,并且值是设置在包的有效负载中的。对于传出包,包是从传出队列中进行检索的,实际上从接口直接发送出去。当发送完成,与传出数据相关的“承诺”将置为true,来表明传输成功⑥。这是否能映射到实际网络协议上,取决于网络所用协议;这里的“承诺/期望”组合方式可能会在特殊的情况下无法工作,但是它与一些操作系统支持的异步输入/输出结构类似。

上面的代码完全不理会异常,它可能在想象的世界中,一切工作都会很好的执行,但是这有悖常理。有时候磁盘满载,有时候你会找不到东西,有时候网络会断,还有时候数据库会奔溃。当你需要某个操作的结果时,你就需要在对应的线程上执行这个操作,因为代码可以通过一个异常来报告错误;不过使用std::packaged_task或std::promise,就会带来一些不必要的限制(在所有工作都正常的情况下)。因此,C++标准库提供了一种在以上情况下清理异常的方法,并且允许他们将异常存储为相关结果的一部分。

4.2.4 期望存储异常

看完下面短小的代码段,思考一下,当你传递-1到square_root()中时,它将抛出一个异常,并且这个异常将会被调用者看到:

double square_root(double x)
{
  if(x<0)
  {
    throwstd::out_of_range(“x<0);
  }
  
  return sqrt(x);
}

假设调用square_root()函数不是当前线程,

double y=square_root(-1);

你将这样的调用改为异步调用:

std::future<double>f=std::async(square_root,-1);

double y=f.get();

如果行为是完全相同的时候,其结果是理想的;在任何情况下,y获得函数掉用的结果,当线程调用f.get()时,就能再看到异常了,即使在一个单线程例子中。

好吧,事实的确如此:函数作为std::async的一部分时,当在调用时抛出一个异常,那么这个异常就会存储到“期望”的结果数据中,之后“期望”的状态被置为“就绪”,之后调用get()会抛出这个存储的异常。(注意:标准级别没有指定重新抛出的这个异常是原始的异常对象,还是一个拷贝;不同的编译器和库将会在这方面做出不同的选择)。当你将函数打包入std::packaged_task任务包中后,在这个任务被调用时,同样的事情也会发生;当打包函数抛出一个异常,这个异常将被存储在“期望”的结果中,准备在调用get()再次抛出。

当然,通过函数的显式调用,std::promise也能提供同样的功能。当你希望存入的是一个异常而非一个数值时,你就需要调用set_exception()成员函数,而非set_value()。这通常是用在一个catch块中,并作为算法的一部分,为了捕获异常,使用异常填充“承诺”:

extern std::promise<double> some_promise;

try
{
   some_promise.set_value(calculate_value());
}
catch(...)
{
   some_promise.set_exception(std::current_exception());
}

这里使用了std::current_exception()来检索抛出的异常;可用std::copy_exception()作为一个替换方案,std::copy_exception()会直接存储一个新的异常而不抛出:

some_promise.set_exception(std::copy_exception(std::logic_error("foo")));

这就比使用try/catch块更加清晰,当异常类型是已知的,它就应该优先被使用;不是因为代码实现简单,而是它给编译器提供了极大的代码优化空间。

另一种向“期望”中存储异常的方式是,在没有调用“承诺”上的任何设置函数前,或正在调用包装好的任务时,销毁与std::promise或std::packaged_task相关的“期望”对象。在这任何情况下,当“期望”的状态还不是“就绪”时,调用std::promise或std::packaged_task的析构函数,将会存储一个与std::future_errc::broken_promise错误状态相关的std::future_error异常;通过创建一个“期望”,你可以构造一个“承诺”为其提供值或异常;你可以通过销毁值和异常源,去违背“承诺”。在这种情况下,编译器没有在“期望”中存储任何东西,等待线程可能会永远的等下去。

直到现在,所有例子都在用std::future。不过,std::future也有局限性,在很多线程在等待的时候,只有一个线程能获取等待结果。当多个线程需要等待相同的事件的结果,你就需要使用std::shared_future来替代std::future了。

4.2.5 多个线程的等待

虽然std::future可以处理所有在线程间数据转移的必要同步,但是调用某一特殊std::future对象的成员函数,就会让这个线程的数据和其他线程的数据不同步。当多线程在没有额外同步的情况下,访问一个独立的std::future对象时,就会有数据竞争和未定义的行为。这是因为:std::future模型独享同步结果的所有权,并且通过调用get()函数,一次性的获取数据,这就让并发访问变的毫无意义——只有一个线程可以获取结果值,因为在第一次调用get()后,就没有值可以再获取了。

如果你的并行代码没有办法让多个线程等待同一个事件,先别太失落;std::shared_future可以来帮你解决。因为std::future是只移动的,所以其所有权可以在不同的实例中互相传递,但是只有一个实例可以获得特定的同步结果;而std::shared_future实例是可拷贝的,所以多个对象可以引用同一关联“期望”的结果。

在每一个std::shared_future的独立对象上成员函数调用返回的结果还是不同步的,所以为了在多个线程访问一个独立对象时,避免数据竞争,必须使用锁来对访问进行保护。优先使用的办法:为了替代只有一个拷贝对象的情况,可以让每个线程都拥有自己对应的拷贝对象。这样,当每个线程都通过自己拥有的std::shared_future对象获取结果,那么多个线程访问共享同步结果就是安全的。可见图4.1。
在这里插入图片描述

图4.1 使用多个std::shared_future对象来避免数据竞争

有可能会使用std::shared_future的地方,例如,实现类似于复杂的电子表格的并行执行;每一个单元格有单一的终值,这个终值可能是有其他单元格中的数据通过公式计算得到的。公式计算得到的结果依赖于其他单元格,然后可以使用一个std::shared_future对象引用第一个单元格的数据。当每个单元格内的所有公式并行执行后,这些任务会以期望的方式完成工作;不过,当其中有计算需要依赖其他单元格的值,那么它就会被阻塞,直到依赖单元格的数据准备就绪。这将让系统在最大程度上使用可用的硬件并发。

std::shared_future的实例同步std::future实例的状态。当std::future对象没有与其他对象共享同步状态所有权,那么所有权必须使用std::move将所有权传递到std::shared_future,其默认构造函数如下:

std::promise<int> p;
std::future<int> f(p.get_future());
assert(f.valid()); // **1** "期望" f 是合法的
std::shared_future<int> sf(std::move(f));
assert(!f.valid()); // **2** "期望" f 现在是不合法的
assert(sf.valid()); //** 3** sf 现在是合法的

这里,“期望”f开始是合法的①,因为它引用的是“承诺”p的同步状态,但是在转移sf的状态后,f就不合法了②,而sf就是合法的了③。

如其他可移动对象一样,转移所有权是对右值的隐式操作,所以你可以通过std::promise对象的成员函数get_future()的返回值,直接构造一个std::shared_future对象,例如:

std::promise<std::string> p;
std::shared_future<std::string>sf(p.get_future());  // **1 **隐式转移所有权

这里转移所有权是隐式的;用一个右值构造std::shared_future<>,得到std::futurestd::string类型的实例①。

std::future的这种特性,可促进std::shared_future的使用,容器可以自动的对类型进行推断,从而初始化这个类型的变量(详见附录A,A.6节)。std::future有一个share()成员函数,可用来创建新的std::shared_future ,并且可以直接转移“期望”的所有权。这样也就能保存很多类型,并且使得代码易于修改:

std::promise< std::map< SomeIndexType,SomeDataType, SomeComparator, SomeAllocator>::iterator> p;
auto sf=p.get_future().share();

在这个例子中,sf的类型推到为std::shared_future<std::map<SomeIndexType, SomeDataType,SomeComparator, SomeAllocator>::iterator>,一口气还真的很难念完。当比较器或分配器有所改动,你只需要对“承诺”的类型进行修改即可;“期望”的类型会自动更新,与“承诺”的修改进行匹配。

有时候你需要限定等待一个事件的时间,不论是因为你在时间上有硬性规定(一段指定的代码需要在某段时间内完成),还是因为在事件没有很快的触发时,有其他必要的工作需要特定线程来完成。为了处理这种情况,很多等待函数具有用于指定超时的变量。

4.3 限定等待时间

之前介绍过的所有阻塞调用,将会阻塞一段不确定的时间,将线程挂起直到等待的事件发生。在很多情况下,这样的方式很不错,但是在其他一些情况下,你就需要限制一下线程等待的时间了。这允许你发送一些类似“我还存活”的信息,无论是对交互式用户,或是其他进程,亦或当用户放弃等待,你可以按下“取消”键直接终止等待。

介绍两种可能是你希望指定的超时方式:一种是“时延”的超时方式,另一种是“绝对”超时方式。第一种方式,需要指定一段时间(例如,30毫秒);第二种方式,就是指定一个时间点(例如,协调世界时[UTC]17:30:15.045987023,2011年11月30日)。多数等待函数提供变量,对两种超时方式进行处理。处理持续时间的变量以“_for”作为后缀,处理绝对时间的变量以"_until"作为后缀。

所以,当std::condition_variable的两个成员函数wait_for()和wait_until()成员函数分别有两个负载,这两个负载都与wait()成员函数的负载相关——其中一个负载只是等待信号触发,或时间超期,亦或是一个虚假的唤醒,并且醒来时,会检查锁提供的谓词,并且只有在检查为true时才会返回(这时条件变量的条件达成),或直接而超时。

在我们观察使用超时函数的细节前,让我们来检查一下时间在C++中指定的方式,就从时钟开始吧!

4.3.1 时钟

对于C++标准库来说,时钟就是时间信息源。特别是,时钟是一个类,提供了四种不同的信息:

· 现在时间

· 时间类型

· 时钟节拍

· 通过时钟节拍的分布,判断时钟是否稳定

时钟的当前时间可以通过调用静态成员函数now()从时钟类中获取;例如,std::chrono::system_clock::now()是将返回系统时钟的当前时间。特定的时间点类型可以通过time_point的数据typedef成员来指定,所以some_clock::now()的类型就是some_clock::time_point。

时钟节拍被指定为1/x(x在不同硬件上有不同的值)秒,这是由时间周期所决定——一个时钟一秒有25个节拍,因此一个周期为std::ratio<1, 25>,当一个时钟的时钟节拍每2.5秒一次,周期就可以表示为std::ratio<5, 2>。当时钟节拍直到运行时都无法知晓,可以使用一个给定的应用程序运行多次,周期可以用执行的平均时间求出,其中最短的时间可能就是时钟节拍,或者是直接写在手册当中。这就不保证在给定应用中观察到的节拍周期与指定的时钟周期相匹配。

当时钟节拍均匀分布(无论是否与周期匹配),并且不可调整,这种时钟就称为稳定时钟。当is_steady静态数据成员为true时,表明这个时钟就是稳定的,否则,就是不稳定的。通常情况下,std::chrono::system_clock是不稳定的,因为时钟是可调的,即是这种是完全自动适应本地账户的调节。这种调节可能造成的是,首次调用now()返回的时间要早于上次调用now()所返回的时间,这就违反了节拍频率的均匀分布。稳定闹钟对于超时的计算很重要,所以C++标准库提供一个稳定时钟std::chrono::steady_clock。C++标准库提供的其他时钟可表示为std::chrono::system_clock(在上面已经提到过),它代表了系统时钟的“实际时间”,并且提供了函数可将时间点转化为time_t类型的值;std::chrono::high_resolution_clock 可能是标准库中提供的具有最小节拍周期(因此具有最高的精度[分辨率])的时钟。它实际上是typedef的另一种时钟,这些时钟和其他与时间相关的工具,都被定义在库头文件中。

我们马上来看一下时间点是如何表示的,但在这之前,我们先看一下持续时间是怎么表示的。

4.3.2 时延

时延是时间部分最简单的;std::chrono::duration<>函数模板能够对时延进行处理(线程库使用到的所有C++时间处理工具,都在std::chrono命名空间内)。第一个模板参数是一个类型表示(比如,int,long或double),第二个模板参数是制定部分,表示每一个单元所用秒数。例如,当几分钟的时间要存在short类型中时,可以写成std::chrono::duration<short, std::ratio<60, 1>>,因为60秒是才是1分钟,所以第二个参数写成std::ratio<60, 1>。另一方面,当需要将毫秒级计数存在double类型中时,可以写成std::chrono::duration<double, std::ratio<1, 1000>>,因为1秒等于1000毫秒。

标准库在std::chrono命名空间内,为延时变量提供一系列预定义类型:nanoseconds[纳秒] , microseconds[微秒] , milliseconds[毫秒] , seconds[秒] , minutes[分]和hours[时]。比如,你要在一个合适的单元表示一段超过500年的时延,预定义类型可充分利用了大整型,来表示所要表示的时间类型。当然,这里也定义了一些国际单位制(SI, [法]le Système international d’unités)分数,可从std::atto(10(-18))到std::exa(10(18))(题外话:当你的平台支持128位整型);也可以指定自定义时延类型,例如,std::duration<double, std::centi>,就可以使用一个double类型的变量表示1/100。

当不要求截断值的情况下(时转换成秒是没问题,但是秒转换成时就不行)时延的转换是隐式的。显示转换可以由std::chrono::duration_cast<>来完成。

std::chrono::milliseconds ms(54802);
std::chrono::secondss=std::chrono::duration_cast<std::chrono::seconds>(ms);

这里的结果就是截断的,而不是进行了舍入,所以s最后的值将为54。

延迟支持计算,所以你能够对两个时延变量进行加减,或者是对一个时延变量乘除一个常数(模板的第一个参数)来获得一个新延迟变量。例如,5*seconds(1)与seconds(5)或minutes(1)-seconds(55)一样。在时延中可以通过count()成员函数获得单位时间的数量。例如,std::chrono::milliseconds(1234).count()就是1234。

基于时延的等待可由std::chrono::duration<>来完成。例如,你等待一个“期望”状态变为就绪已经35毫秒:

std::future<int> f=std::async(some_task);
if(f.wait_for(std::chrono::milliseconds(35))==std::future_status::ready)
   do_something_with(f.get());

等待函数会返回一个状态值,来表示等待是超时,还是继续等待。在这种情况下,你可以等待一个“期望”,所以当函数等待超时时,会返回std::future_status::timeout;当“期望”状态改变,函数会返回std::future_status::ready;当“期望”的任务延迟了,函数会返回std::future_status::deferred。基于时延的等待是使用内部库提供的稳定时钟,来进行计时的;所以,即使系统时钟在等待时被调整(向前或向后),35毫秒的时延在这里意味着,的确耗时35毫秒。当然,难以预料的系统调度和不同操作系统的时钟精度都意味着:在线程中,从调用到返回的实际时间可能要比35毫秒长。

时延中没有特别好的办法来处理以上情况,所以我们暂且停下对时延的讨论。现在,我们就要来看看“时间点”是怎么样工作的。

4.3.3 时间点

时钟的时间点可以用std::chrono::time_point<>的类型模板实例来表示,实例的第一个参数用来指定所要使用的时钟,第二个函数参数用来表示时间的计量单位(特化的std::chrono::duration<>)。一个时间点的值就是时间的长度(在指定时间的倍数内),例如,指定“unix时间戳”(epoch)为一个时间点。时间戳是时钟的一个基本属性,但是不可以直接查询,或在C++标准中已经指定。通常,unix时间戳表示1970年1月1日 00:00,即计算机启动应用程序时。时钟可能共享一个时间戳,或具有独立的时间戳。当两个时钟共享一个时间戳时,其中一个time_point类型可以与另一个时钟类型中的time_point相关联。这里,虽然你无法知道unix时间戳是什么,但是你可以通过对指定time_point类型使用time_since_epoch()来获取时间戳。这个成员函数会返回一个时延值,这个时延值是指定时间点到时钟的unix时间戳锁用时。

例如,你i可能指定了一个时间点std::chrono::time_point<std::chrono::system_clock,std::chrono::minutes>。这就与系统时钟有关,且实际中的一分钟与系统时钟精度应该不相同(通常差几秒)。

你可以通过std::chrono::time_point<>实例来加/减时延,来获得一个新的时间点,所以std::chrono::hight_resolution_clock::now() + std::chrono::nanoseconds(500)将得到500纳秒后的时间。当你知道一块代码的最大时延时,这对于计算绝对时间的超时是一个好消息,当等待时间内,等待函数进行多次调用;或,非等待函数且占用了等待函数时延中的时间。

你也可以减去一个时间点(二者需要共享同一个时钟)。结果是两个时间点的时间差。这对于代码块的计时是很有用的,例如:

auto start=std::chrono::high_resolution_clock::now();
do_something();
auto stop=std::chrono::high_resolution_clock::now();
std::cout<<do_something() took “
 <<std::chrono::duration<double,std::chrono::seconds>(stop-start).count()<<”seconds”<<std::endl;

std::chrono::time_point<>实例的时钟参数可不仅是能够指定unix时间戳的。当你想一个等待函数(绝对时间超时的方式)传递时间点时,时间点的时钟参数就被用来测量时间。当时钟变更时,会产生严重的后果,因为等待轨迹随着时钟的改变而改变,并且知道调用时钟的now()成员函数时,才能返回一个超过超时时间的值。当时钟向前调整,这就有可能减小等待时间的总长度(与稳定时钟的测量相比);当时钟向后调整,就有可能增加等待时间的总长度。

如你期望的那样,后缀为_unitl的(等待函数的)变量会使用时间点。通常是使用某些时钟的::now()(程序中一个固定的时间点)作为偏移,虽然时间点与系统时钟有关,可以使用std::chrono::system_clock::to_time_point() 静态成员函数,在用户可视时间点上进行调度操作。例如,当你有一个对多等待500毫秒的,且与条件变量相关的事件,你可以参考如下代码:

清单4.11 等待一个条件变量——有超时功能

#include <condition_variable>
#include <mutex>
#include <chrono> 

std::condition_variable cv;
bool done;
std::mutex m;

bool wait_loop()
{
  auto consttimeout= std::chrono::steady_clock::now()+std::chrono::milliseconds(500);
 std::unique_lock<std::mutex> lk(m);

  while(!done)
  {
     if(cv.wait_until(lk,timeout)==std::cv_status::timeout)
        break;
  }

  return done;
}

这种方式是我们推荐的,当你没有什么事情可以等待时,可在一定时限中等待条件变量。在这种方式中,循环的整体长度是有限的。如你在4.1.1节中所见,当使用条件变量(且无事可待)时,你就需要使用循环,这是为了处理假唤醒。当你在循环中使用wait_for()时,你可能在等待了足够长的时间后结束等待(在假唤醒之前),且下一次等待又开始了。这可能重复很多次,使得等待时间无边无际。

到此,有关时间点超时的基本知识你已经了解了。现在,让我们来了解一下如何在函数中使用超时。

4.3.4 具有超时功能的函数

使用超时的最简单方式就是,对一个特定线程添加一个延迟处理;当这个线程无所事事时,就不会占用可供其他线程处理的时间。你在4.1节中看过一个例子,你循环检查“done”标志。两个处理函数分别是std::this_thread::sleep_for()和std::this_thread::sleep_until()。他们的工作就像一个简单的闹钟:当线程因为指定时延而进入睡眠时,可使用sleep_for()唤醒;或因指定时间点睡眠的,可使用sleep_until唤醒。sleep_for()的使用如同在4.1节中的例子,有些事必须在指定时间范围内完成,所以耗时在这里就很重要。另一方面,sleep_until()允许在某个特定时间点将调度线程唤醒。这有可能在晚间备份,或在早上6:00打印工资条时使用,亦或挂起线程直到下一帧刷新时进行视频播放。

当然,休眠只是超时处理的一种形式;你已经看到了,超时可以配合条件变量和“期望”一起使用。超时甚至可以在尝试获取一个互斥锁时(当互斥量支持超时时)使用。std::mutex和std::recursive_mutex都不支持超时锁,但是std::timed_mutex和std::recursive_timed_mutex支持。这两种类型也有try_lock_for()和try_lock_until()成员函数,可以在一段时期内尝试,或在指定时间点前获取互斥锁。表4.1展示了C++标准库中支持超时的函数。参数列表为“延时”(duration)必须是std::duration<>的实例,并且列出为“时间点”(time_point)必须是std::time_point<>的实例。

表4.1 可接受超时的函数

类型/命名空间函数返回值
std::this_thread[namespace]sleep_for(duration)N/A
sleep_until(time_point)
std::condition_variable 或 std::condition_variable_anywait_for(lock, duration)std::cv_status::time_out 或 std::cv_status::no_timeout
wait_until(lock, time_point)
wait_for(lock, duration, predicate)bool —— 当唤醒时,返回谓词的结果
wait_until(lock, duration, predicate)
std::timed_mutex 或 std::recursive_timed_mutextry_lock_for(duration)bool —— 获取锁时返回true,否则返回fasle
try_lock_until(time_point)
std::unique_lockunique_lock(lockable, duration)N/A —— 对新构建的对象调用owns_lock();
unique_lock(lockable, time_point)当获取锁时返回true,否则返回false
try_lock_for(duration)bool —— 当获取锁时返回true,否则返回false
try_lock_until(time_point)
std::future或std::shared_futurewait_for(duration)当等待超时,返回std::future_status::timeout
wait_until(time_point)当“期望”准备就绪时,返回std::future_status::ready
当“期望”持有一个为启动的延迟函数,返回std::future_status::deferred

现在,我们讨论的机制有:条件变量、“期望”、“承诺”还有打包的任务。是时候从更高的角度去看待这些机制,怎么样使用这些机制,简化线程的同步操作。

4.4 使用同步操作简化代码

同步工具的使用在本章称为构建块,你可以之关注那些需要同步的操作,而非具体使用的机制。当需要为程序的并发时,这是一种可以帮助你简化你的代码的方式,提供更多的函数化(是函数化编程的意思(functional programming))方法。比起在多个线程间直接共享数据,每个任务拥有自己的数据会应该会更好,并且结果可以对其他线程进行广播,这就需要使用“期望”来完成了。

4.4.1使用"期望"的函数化编程

术语“函数化编程”(functional programming( FP ))引用于一种编程方式,这种方式中的函数结果只依赖于传入函数的参数,并不依赖外部状态。当一个函数与数学概念相关时,当你使用相同的函数调用这个函数两次,这两次的结果会完全相同。在C++标准库中很多与数学相关的函数都有这个特性,例如, sin(正弦), cos(余弦)和sqrt(平方根);当然,还有基本类型间的简单运算,例如, 3+3, 6*9,或1.3/4.7。一个纯粹的函数不会改变任何外部状态,并且这种特性完全限制了函数的返回值。

很容易想象这是一种什么样的情况,特别是当并行发生时,因为在第三章时我们讨论过,很多问题发生在共享数据上。当共享数据没有被修改,那么就不存在条件竞争,并且没有必要使用互斥量去保护共享数据。这可对编程进行极大的简化,例如Haskell语言[2],在Haskell中函数默认就是这么的“纯粹”;这种纯粹对的方式,在并发编程系统中越来越受欢迎。因为大多数函数都是纯粹的,那么非纯粹的函数对共享数据的修改就显得更为突出,所以其很容易适应应用的整体结构。

函数化编程的好处,并不限于那些将“纯粹”作为默认方式(范型)的语言。C++是一个多范型的语言,其也可以写出FP类型的程序。在C++11中这种方式要比C++98简单许多,因为C++11支持lambda表达式(详见附录A,A.6节),还加入了BoostTR1中的std::bind,以及自动可以自行推断类型的自动变量(详见附录A,A.7节)。“期望”作为拼图的最后一块,它使得函数化编程模式并发化(FP-style concurrency)在C++中成为可能;一个“期望”对象可以在线程间互相传递,并允许其中一个计算结果依赖于另外一个的结果,而非对共享数据的显式访问。

快速排序** FP****模式版**

为了描述在函数化(PF)并发中使用“期望”,让我们来看看一个简单的实现——快速排序算法。该算法的基本思想很简单:给定一个数据列表,然后选取其中一个数为“中间”值,之后将列表中的其他数值分成两组——一组比中间值大,另一组比中间值小。之后对小于“中间”值的组进行排序,并返回排序好的列表;再返回“中间”值;再对比“中间”值大的组进行排序,并返回排序的列表。图4.2中展示了10个整数在这种方式下进行排序的过程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oloEkrtx-1657528141109)(file:///C:/Users/sunx/AppData/Local/Packages/oice_16_974fa576_32c1d314_2708/AC/Temp/msohtmlclip1/01/clip_image004.jpg)]

图4.2 FP-模式的递归排序

下面清单中的代码是FP-模式的顺序实现,它需要传入列表,并且返回一个列表,而非与std::sort()做同样的事情。 (译者:std::sort()是无返回值的,因为参数接收的是迭代器,所以其可以对原始列表直进行修改与排序。可参考sort())

清单4.12 快速排序——顺序实现版

template<typename T>
std::list<T>sequential_quick_sort(std::list<T> input)
{
  if(input.empty())
  {
    return input;
  }

  std::list<T> result;
  result.splice(result.begin(),input,input.begin());  //** 1**
  T const&pivot=*result.begin();  //** 2**
  auto divide_point=std::partition(input.begin(),input.end(), [&](T const&t){return t<pivot;});  // **3**

  std::list<T> lower_part;
  lower_part.splice(lower_part.end(),input,input.begin(),divide_point);  //** 4**
  autonew_lower(sequential_quick_sort(std::move(lower_part)));  // **5**
  autonew_higher(sequential_quick_sort(std::move(input)));  // **6**

  result.splice(result.end(),new_higher);  //** 7**
  result.splice(result.begin(),new_lower); // **8**
  return result;
}

虽然接口的形式是FP模式的,但当你使用FP模式时,你需要做大量的拷贝操作,所以在内部你会使用“普通”的命令模式。你选择第一个数为“中间”值,使用splice()①将输入的首个元素(中间值)放入结果列表中。虽然这种方式产生的结果可能不是最优的(会有大量的比较和交换操作),但是对std::list做任何事都需要花费较长的时间,因为链表是遍历访问的。你知道你想要什么样的结果,所以你可以直接将要使用的“中间”值提前进行拼接。现在你还需要使用“中间”值进行比较,所以这里使用了一个引用②,为了避免过多的拷贝。之后,你可以使用std::partition将序列中的值分成小于“中间”值的组和大于“中间”值的组③。最简单的方法就是使用lambda函数指定区分的标准;使用已获取的引用避免对“中间”值的拷贝(详见附录A,A.5节,更多有关lambda函数的信息)。

std::partition()对列表进行重置,并返回一个指向首元素(小于“中间”值)的迭代器。迭代器的类型全称可能会很长(long-winded),所以你可以使用auto类型说明符,让编译器帮助你定义迭代器类型的变量(详见附录A,A.7节)。

现在,你已经选择了FP模式的接口;所以,当你要使用递归对两部分排序是,你将需要创建两个列表。你可以用splice()函数来做这件事,将input列表小于divided_point的值移动到新列表lower_part④中。其他数继续留在input列表中。而后,你可以使用递归调用⑤⑥的方式,对两个列表进行排序。这里显式使用std::move()将列表传递到类函数中,这种方式还是为了避免大量的拷贝操作。最终,你可以再次使用splice(),将result中的结果以正确的顺序进行拼接。new_higher指向的值放在“中间”值的后面⑦,new_lower指向的值放在“中间”值的前面⑧。

快速排序** FP****模式线程强化版**

因为还是使用函数化模式,所以使用“期望”很容易将其转化为并行的版本,如下面的程序清单所示。其中的操作与前面相同,不同的是它们现在并行运行。

清单4.13 快速排序——“期望”并行版

template<typename T>
std::list<T>parallel_quick_sort(std::list<T> input)
{
   if(input.empty())
   {
     return input;
   }

   std::list<T> result;
   result.splice(result.begin(),input,input.begin());
   T const&pivot=*result.begin();

   autodivide_point=std::partition(input.begin(),input.end(), [&](T const&t){return t<pivot;});

   std::list<T> lower_part;
   lower_part.splice(lower_part.end(),input,input.begin(),divide_point);


   std::future<std::list<T>> new_lower(std::async(&parallel_quick_sort<T>,std::move(lower_part)));// **1** 

   autonew_higher(parallel_quick_sort(std::move(input)));  // **2**

   result.splice(result.end(),new_higher); // **3**
   result.splice(result.begin(),new_lower.get());  //** 4**
   
   return result;
}

这里最大的变化是,当前线程不对小于“中间”值部分的列表进行排序,使用std::async()①在另一线程对其进行排序。大于部分列表,如同之前一样,使用递归的方式进行排序②。通过递归调用parallel_quick_sort(),你就可以利用可用的硬件并发了。std::async()会启动一个新线程,这样当你递归三次时,就会有八个线程在运行了;当你递归十次(对于大约有1000个元素的列表),如果硬件能处理这十次递归调用,你将会创建1024个执行线程。当运行库认为这样做产生了太多的任务时(也许是因为数量超过了硬件并发的最大值),运行库可能会同步的切换新产生的任务。当任务过多时(已影响性能),这些任务应该在使用get()函数获取的线程上运行,而不是在新线程上运行,这样就能避免任务向线程传递的开销。值的注意的是,这完全符合std::async的实现,为每一个任务启动一个线程(甚至在任务超额时;在std::launch::deferred没有明确规定的情况下);或为了同步执行所有任务(在std::launch::async有明确规定的情况下)。当你依赖运行库的自动缩放,建议你去查看一下你的实现文档,了解一下将会有怎么样的行为表现。

比起使用std::async(),你可以写一个spawn_task()函数对std::packaged_task和std::thread做简单的包装,如清单4.14中的代码所示;你需要为函数结果创建一个std::packaged_task对象, 可以从这个对象中获取“期望”,或在线程中执行它,返回“期望”。其本身并不提供太多的好处(并且事实上会造成大规模的超额任务),但是它会为转型成一个更复杂的实现铺平道路,将会实现向一个队列添加任务,而后使用线程池的方式来运行它们。我们将在第9章再讨论线程池。使用std::async更适合于当你知道你在干什么,并且要完全控制在线程池中构建或执行过任务的线程。

清单4.14 spawn_task的简单实现

template<typename F,typename A>
std::future<std::result_of<F(A&&)>::type>spawn_task(F&& f,A&& a)
{
   typedefstd::result_of<F(A&&)>::type result_type;
   std::packaged_task<result_type(A&&)> task(std::move(f)));
   std::future<result_type> res(task.get_future());
   std::threadt(std::move(task),std::move(a));
   t.detach();

   return res;
}

其他先不管,回到parallel_quick_sort函数。因为你只是直接递归去获取new_higher列表,你可以如之前一样对new_higher进行拼接③。但是,new_lower列表是std::future<std::list>的实例,而非是一个简单的列表,所以你需要调用get()成员函数在调用splice()④之前去检索数值。在这之后,等待后台任务完成,并且将结果移入splice()调用中;get()返回一个包含结果的右值引用,所以这个结果是可以移出的(详见附录A,A.1.1节,有更多有关右值引用和移动语义的信息)。

即使假设,使用std::async()是对可用硬件并发最好的选择,但是这样的并行实现对于快速排序来说,依然不是最理想的。其中,std::partition做了很多工作,即使做了依旧是顺序调用,但就现在的情况来说,已经足够好了。如果你对实现最快并行的可能性感兴趣的话,你可以去查阅一些学术文献。

因为避开了共享易变数据,函数化编程可算作是并发编程的范型;并且也是CSP(Communicating Sequential Processer[3],通讯顺序进程)的范型,这里线程理论上是完全分开的,也就是没有共享数据,但是有通讯通道允许信息在不同线程间进行传递。这种范型被Erlang语言所采纳,并且在MPI(Message PassingInterface,消息传递接口)上常用来做C和C++的高性能运算。现在你应该不会在对学习它们而感到惊奇了吧,因为只需遵守一些约定,C++就能支持它们;在接下来的一节中,我们会讨论实现这种方式。

**4.4.2 **使用消息传递的同步操作****

CSP的概念十分简单:当没有共享数据,每个线程就可以进行独立思考,其行为纯粹基于其所接收到的信息。每个线程就都有一个状态机:当线程收到一条信息,它将会以某种方式更新其状态,并且可能向其他线程发出一条或多条信息,对于消息的处理依赖于线程的初始化状态。这是一种正式写入这些线程的方式,并且以有限状态机的模式实现,但是这不是唯一的方案;状态机可以在应用程序中隐式实现。这种方法咋任何给定的情况下,都更加依赖于特定情形下明确的行为要求和编程团队的专业知识。无论你选择用什么方式去实现每个线程,任务都会分成独立的处理部分,这样会消除潜在的混乱(数据共享并发),这样就让编程变的更加简单,且拥有低错误率。

真正通讯顺序处理是没有共享数据的,所有消息都是通过消息队列传递,但是因为C++线程共享一块地址空间,所以达不到真正通讯顺序处理的要求。这里就需要有一些约定了:作为一款应用或者是一个库的作者,我们有责任确保在我们的实现中,线程不存在共享数据。当然,为了线程间的通信,消息队列是必须要共享的,具体的细节可以包含在库中。

试想,有一天你要为实现ATM(自动取款机)写一段代码。这段代码需要处理,人们尝试取钱时和银行之间的交互情况,以及控制物理器械接受用户的卡片,显示适当的信息,处理按钮事件,吐出现金,还有退还用户的卡。

一种处理所有事情的方法是让代码将所有事情分配到三个独立线程上去:一个线程去处理物理机械,一个去处理ATM机的逻辑,还有一个用来与银行通讯。这些线程可以通过信息进行纯粹的通讯,而非共享任何数据。比如,当有人在ATM机上插入了卡片或者按下按钮,处理物理机械的线程将会发送一条信息到逻辑线程上,并且逻辑线程将会发送一条消息到机械线程,告诉机械线程可以分配多少钱,等等。

一种为ATM机逻辑建模的方式是将其当做一个状态机。线程的每一个状态都会等待一条可接受的信息,这条信息包含需要处理的内容。这可能会让线程过渡到一个新的状态,并且循环继续。在图4.3中将展示,有状态参与的一个简单是实现。在这个简化实现中,系统在等待一张卡插入。当有卡插入时,系统将会等待用户输入它的PIN(类似身份码的东西),每次输入一个数字。用户可以将最后输入的数字删除。当数字输入完成,PIN就需要验证。当验证有问题,你的程序就需要终止,就需要为用户退出卡,并且继续等待其他人将卡插入到机器中。当PIN验证通过,你的程序要等待用户取消交易或选择取款。当用户选择取消交易,你的程序就可以结束,并返还卡片。当用户选择取出一定量的现金,你的程序就要在吐出现金和返还卡片前等待银行方面的确认,或显示“余额不足”的信息,并返还卡片。很明显,一个真正的ATM机要考虑的东西更多、更复杂,但是我们来说这样描述已经足够了。
在这里插入图片描述

图4.3 一台ATM机的状态机模型(简化)

我们已经为你的ATM机逻辑设计了一个状态机,你可以使用一个类实现它,这个类中有一个成员函数可以代表每一个状态。每一个成员函数可以等待从指定集合中传入的信息,以及当他们到达时进行处理,这就有可能触发原始状态向另一个状态的转化。每种不同的信息类型由一个独立的struct表示。清单4.15展示了ATM逻辑部分的简单实现(在以上描述的系统中,有主循环和对第一状态的实现),并且一直在等待卡片插入。

如你所见,所有信息传递所需的的同步,完全包含在“信息传递”库中(基本实现在附录C中,是清单4.15代码的完整版)

清单4.15 ATM逻辑类的简单实现

struct card_inserted
{
    std::stringaccount;
};

class atm
{
    messaging::receiver incoming;
    messaging::sender bank;
    messaging::sender interface_hardware;
    void(atm::*state)();
    std::string account;
    std::string pin;
  
    void waiting_for_card()  // **1**
    {
      interface_hardware.send(display_enter_card());  // **2**
      incoming.wait().  // **3**
      handle<card_inserted>(
     [&](card_inserted const& msg) //** 4**
      {
        account=msg.account;
        pin="";
        interface_hardware.send(display_enter_pin());
        state=&atm::getting_pin;
      }
    );
  }

  void getting_pin();

public:
  voidrun()  // **5**
  {
     state=&atm::waiting_for_card; // **6**
      try
      {
        for(;;)
        {
           (this->*state)();  // **7**
        }
      }
     catch(messaging::close_queue const&)
    {
    }
  }
};

如之前提到的,这个实现对于实际ATM机的逻辑来说是非常简单的,但是他能让你感受到信息传递编程的方式。这里无需考虑同步和并发问题,只需要考虑什么时候接收信息和发送信息即可。为ATM逻辑所设的状态机运行在独立的线程上,与系统的其他部分一起,比如与银行通讯的接口,以及运行在独立线程上的终端接口。这种程序设计的方式被称为参与者模式(Actor model)——在系统中有很多独立的(运行在一个独立的线程上)参与者,这些参与者会互相发送信息,去执行手头上的任务,并且它们不会共享状态,除非是通过信息直接传入的。

运行从run()成员函数开始⑤,其将会初始化waiting_for_card⑥的状态,然后反复执行当前状态的成员函数(无论这个状态时怎么样的)⑦。状态函数是简易atm类的成员函数。wait_for_card函数①依旧很简单:它发送一条信息到接口,让终端显示“等待卡片”的信息②,之后就等待传入一条消息进行处理③。这里处理的消息类型只能是card_inserted类的,这里使用一个lambda函数④对其进行处理。当然,你可以传递任何函数或函数对象,去处理函数,但对于一个简单的例子来说,使用lambda表达式是最简单的方式。注意handle()函数调用是连接到wait()函数上的;当收到的信息类型与处理类型不匹配,收到的信息会被丢弃,并且线程继续等待,直到接收到一条类型匹配的消息。

lambda函数自身,只是将用户的账号信息缓存到一个成员变量中去,并且清除PIN信息,再发送一条消息到硬件接口,让显示界面提示用户输入PIN,然后将线程状态改为“获取PIN”。当消息处理程序结束,状态函数就会返回,然后主循环会调用新的状态函数⑦。

如图4.3,getting_pin状态函数会负载一些,因为其要处理三个不同的信息类型。具体代码展示如下:

清单4.16 简单ATM实现中的getting_pin状态函数

void atm::getting_pin()
{
 incoming.wait()
   .handle<digit_pressed>(  //** 1**
     [&](digit_pressed const& msg)
      {
         unsigned const pin_length=4;
         pin+=msg.digit;
         if(pin.length()==pin_length)
         {
             bank.send(verify_pin(account,pin,incoming));
             state=&atm::verifying_pin;
          }
      })
   .handle<clear_last_pressed>( // **2**
     [&](clear_last_pressed const& msg)
      {
       if(!pin.empty())
        {
           pin.resize(pin.length()-1);
        }
      })
   .handle<cancel_pressed>(  //** 3**
     [&](cancel_pressed const& msg)
      {
         state=&atm::done_processing;
      });
}

这次需要处理三种消息类型,所以wait()函数后面接了三个handle()函数调用①②③。每个handle()都有对应的消息类型作为模板参数,并且将消息传入一个lambda函数中(其获取消息类型作为一个参数)。因为这里的调用都被连接在了一起,wait()的实现知道它是等待一条digit_pressed消息,或是一条clear_last_pressed肖息,亦或是一条cancel_pressed消息,其他的消息类型将会被丢弃。

这次当你获取一条消息时,无需再去改变状态。比如,当你获取一条digit_pressed消息时,你仅需要将其添加到pin中,除非那些数字是最终的输入。(清单4.15中)主循环⑦将会再次调用getting_pin()去等待下一个数字(或清除数字,或取消交易)。

这里对应的动作如图4.3所示。每个状态盒的实现都由一个不同的成员函数构成,等待相关信息并适当的更新状态。

如你所见,在一个并发系统中这种编程方式可以极大的简化任务的设计,因为每一个线程都完全被独立对待。因此,在使用多线程去分离关注点时,需要你明确如何分配线程之间的任务。

4.5 总结

同步操作对于使用并发编写一款多线程应用来说,是很重要的一部分:如果没有同步,线程基本上就是独立的,也可写成单独的应用,因其任务之间的相关性,它们可作为一个群体直接执行。本章,我们讨论了各式各样的同步操作,从基本的条件变量,到“期望”、“承诺”,再到打包任务。我们也讨论了替代同步的解决方案:函数化模式编程,完全独立执行的函数,不会受到外部环境的影响;还有,消息传递模式,以消息子系统为中介,向线程异步的发送消息。

我们已经讨论了很多C++中的高层工具,现在我们来看一下底层工具是如何让一切都工作的:C++内存模型和原子操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值