【C++多线程】1. 基本使用

1. 管理线程

1.1 启动线程

  • 每个C++程序至少有一个线程,即由C++运行时库启动的执行main函数的线程。除此之外,程序中可以以不同函数作为入口点启动其他线程,当指定的入口函数返回后,线程也会退出
  • 创建线程需要引入头文件#include。管理线程的函数和类在中声明,保护共享数据的函数和类在其他头文件中声明
  • 使用C++线程库启动多线程,可以归结为创建std::thread对象。创建一个有参线程对象时,该线程就开始执行,参数顺序依次为:函数名、参数列表std::thread th1(fun1,x1,x2)
  • 最简单的情况下,线程的构造方法中传入普通函数,函数在所属线程上运行,函数返回时线程结束。复杂的情况下,构造方法中传入函数对象,它执行一系列通过系统消息指定的独立操作,只有在系统消息明确通知它结束时线程才会停止
  • 当把函数对象传入到线程构造函数中时,若传递的是一个临时变量,而非命名变量,编译器会将其解析为函数声明,而不是对象的定义。此时可以使用额外的括号,或使用新的花括号统一初始化语法
fun_object f;
std::thread my_thread(f);	// 1
std::thread my_thread((fun_object()));	// 2
std::thread my_thread{fun_object()};	// 3

1.2 线程连接与分离

  • 一旦启动了线程,就需要明确是要等待线程结束(通过连接它join),还是让线程自主运行(通过分离它detach)。如果线程对象销毁之前还没有做出决定,线程就会终止(std::thread的析构函数会调用std::terminate()),因此有必要确保线程即使出现异常的情况下也能正确的连接或分离
  • 只需要在线程对象销毁之前做出连接或分离的决定,此时线程本身可能已经结束了。分离后的线程可能在线程对象销毁之后还在继续运行,它只有在线程函数返回之后才停止运行。如果不等待线程结束,则需要确保线程访问的数据直到线程结束之前都是有效的
  • 调用join()的动作还清理了线程相关的存储,这使得线程对象不再与已经结束的线程有任何关联,这意味着一个线程只能调用一次join(),且调用后的joinable()将返回false
  • 对于要等待结束的线程,需要精心挑选调用join()代码的位置,这意味着如果在线程启动后调用join()前有异常抛出,join()调用会被跳过。为避免应用因为异常抛出而终止,需要在异常处理过程中也调用join(),若在try/catch块中调用join()使得异常处理部分过于冗长
  • 一种可以保证join()一定调用的方式是使用标准的“资源获取即初始化(RAII)”,并且提供一个类,在该类的析构函数中执行join()。当函数执行到尾部时,局部对象要按照构造的逆序销毁,此时析构函数调用,从而调用join()
  • 调用detach()会让线程在后台运行,这意味着没有直接的方法和它通信,也没法等待线程结束,没有线程对象引用它,也不能被连接。分离的线程所有权和控制回传递给C++运行时库,保证和线程相关的资源在线程退出的时候被正确地回收,分离线程常被称为守护线程
  • 从线程对象中分离线程,前提是该线程对象有执行线程,这也是join()的使用条件,因此只有在线程对象执行joinable()返回true时才能使用detach()

1.3 传递参数给线程函数

  • 向线程函数传递参数,只需要将这些参数作为std::thread构造函数的附加参数即可。在默认情况下,这些参数会被拷贝到新线程的内部存储,然后像临时值那样以右值引用传递给调用函数,就算函数的参数期待的是一个引用时仍然是这样
  • 线程函数中发生隐式转换是在新线程中进行的,当函数的参数是指向局部变量的指针时,需要显式转换后传递,否则可能出现类型转换之前当前函数就退出的情况,此时指向局部变量的指针会被回收
void f(std::string const& s);
void m_f(){
	char buffer[10];
	std::thread t(f,std::string(buffer));
	t.detach();
}
  • std::thread的构造函数无视函数期待的参数类型,盲目地拷贝提供地变量,且内部代码会将拷贝的参数以右值地方式进行传递。由于无法传递一个右值到非常量引用的函数参数,所以需要使用std::ref将参数包装成引用的形式
void f(widget_data& data);
void m_f(){
	widget_data data;
	std::thread t(f,std::ref(data));
	t.join();
}
  • std::thread构造函数和std::bind的操作是根据相同的机制定义的,也可以传递一个成员函数指针作为线程函数,此时需要提供一个对象指针作为第一个参数std::thread t(&X::X_fun,&my_x);
  • 若线程函数的参数不能拷贝,只能移动时(如std::unique_ptr同一时间只允许一个实例指向一个对象,且当该实例销毁时,指向的对象也被删除),移动构造函数和移动赋值操作符允许一个对象的所有权在多个实例中转移,转移后原对象会留下空指针。这种类型的对象作为函数的参数或从函数返回时,当原对象是一个临时值时,移动自动完成,当原对象是一个命名变量时,需要调用std::move()来请求转移
void f(std::unique_ptr<m_T>);
std::unique_ptr<m_T> p(new m_T);
std::thread t(f,std::move(p));

1.4 转移线程所有权

  • C++标准库中有很多资源占有类型,比如std::ifstreamstd::unique_ptrstd::thread都是可移动,但是不可拷贝。临时的线程对象直接赋值会隐式调用移动操作,若对象已有关联线程,再将另一个线程的所有权转给该线程时,系统会直接调用std::terminate()终止程序运行
void f1();
void f2();
std::thread t1(f1);
std::thread t2=std::move(t1);
t1=std::thread(f2);
std::thread t3;
t3=std::move(t2);
t1=std::move(t3);	// 程序崩溃
  • std::thread支持移动,意味着线程的所有权可以在函数外进行转移,此时函数的返回值类型为std::thread。同样,若要将线程所有权转移到某个函数中,可以让该函数接受一个按值传递的std::thread实例作为一个参数,注意对命名变量要用std::move()进行传递
  • std::thread支持移动的一个好处是可以构建thread_guard类,让该类拥有线程的所有权,这避免了当thread_guard的生命周期长于它所引用的线程时出现问题,且一旦所有权转移到这个对象后,这个线程不能再被连接或分离。在C++20中,加入了std::jthread类,会在析构函数中自动连接
class thread_guard{
	std::thread t;
public:
	explicit thread_guard(std::thread t_):t(std::move(t_)){
		if(!t.joinable()) throw std::logic_error("No thread");
	}
	~thread_guard(){
		t.join();
	}
	thread_guard(thread_guard const&)=delete;
	thread_guard& operator=(thread_guard const&)=delete;
}

1.5 标识线程

  • C++标准库中的std::thread::hardware_concurrency()返回执行程序真正可以并发的线程数,在一个多核系统中返回值是CPU的核数,当系统信息无法获取时可能返回0,在线程间划分任务时它是一个非常有用的标准
  • 线程标识类型为std::thread::id,有两种方式进行检索:① 可以通过线程对象的成员函数get_id()来获取,如果线程对象没有与任何执行线程相关联,get_id()返回默认构造的std::thread::id对象,表示没有任何线程;② 调用std::this_thread::get_id()可以获得当前线程的标识
  • std::thread::id类型的对象可以随意拷贝和比较,如果两个std::thread::id类型的对象相等,那它们代表了同一线程,或都没有任何线程。std::thread::id也可以作为无序关联容器的键

2. 线程间共享数据

2.1 共享数据面临的问题

  • 共享数据:如果线程间共享数据,需要有规则确定哪些线程在何时可以访问哪个数据位,以及如何将更新传递给关心该数据的其他线程。线程间共享数据的问题全部归咎于修改数据,如果共享数据是只读的就不会出现问题
  • 不变量:指对数据结构中某个量的各种表述方式总是为保持一致,当不变量的表述方式很多且数据结构复杂时,一次对数据结构的更新可能会破坏不变量,更新完成后不变量又恢复

2.2 使用互斥锁(mutex)保护共享数据

  • 在访问共享数据前,锁住和数据关联的互斥锁,在访问结束后,再将该互斥锁解锁。线程库保证,一旦一个线程已经锁住了指定的互斥锁,其他所有线程试图锁住这把锁时必须等待,直到该互斥锁被解锁。这就保证了所有线程都能看到共享数据自我一致的视图,而不破坏不变量
  • C++中通过构建一个std::mutex实例创建互斥锁,通过成员函数lock()对互斥锁上锁,unlock()解锁。实践中不推荐直接调用lock()函数,因为这样就必须在每个函数出口都要调用unlock(),包括发生异常的情况。C++标准库为互斥锁提供了一个RAII语法的std::lock_guard类模板,在构造时锁住提供的互斥锁,在析构时进行结束,从而保证了一个锁住的互斥锁能被正确解锁。std::mutexstd::lock_guard都在头文件中声明
std::mutex m;	//互斥锁
std::lock_guard<std::mutex> lk(m);	//在构造函数中上锁,析构函数中解锁

C++17中添加了类模板参数推导的新特性,使得像std::lock_guard这样的简单类模板,可以省略模板参数列表

  • 某些情况下,即使使用了互斥锁,也无法正确保护互斥锁所在函数内的数据:① 当互斥锁所在函数返回的是被保护数据的指针或引用;② 把保护数据的指针或引用传递给其他函数进行调用

2.3 死锁

  • 若一对线程中的每一个都需要锁住一对互斥锁来执行某些操作,其中每个线程都有一个互斥锁,且都在等待另一个,这样没有线程能够继续进行,这种情况就是死锁
  • 一种简单避免死锁的方法是:让两个互斥锁总是以相同的顺序上锁,这种方法在不同互斥锁用于不同目的时有效。但是若两个互斥锁作用相同,只是作用在不同位置的参数上,这样当参数位置变化时反而会引起死锁
  • C++标准库中的std::lock函数可以一次性锁住两个或更多互斥锁,且没有死锁风险。首先使用std::lock函数锁住两个互斥锁,然后创建两个std::lock_guard实例接过互斥锁的所有权,且提供额外参数std::adopt_lock告诉std::lock_guard实例互斥锁已经上过锁了,不用再在构造函数里上锁
std::lock(m_1,m_2);
std::lock_guard<std::mutex> lk_1(m_1,std::adopt_lock);
std::lock_guard<std::mutex> lk_2(m_2,std::adopt_lock);
  • std::lock给互斥锁上锁时提供了要么全做要么不做的语义。当std::lock成功地获取了一个互斥锁上的锁,并且尝试从另一个互斥锁上获取锁时,如果有异常抛出,第一个锁会自动释放
  • C++17中提供了一种新的RAII模板std::scoped_lock<>,它的功能与std::lock_guard<>完全等价,但是模板参数个数可变,其构造函数提供的互斥锁使用与std::lock相同的算法上锁
std::scoped_lock<std::mutex,std::mutex> lk(m_1,m_2);
  • 虽然std::lockstd::scoped_lock<>可以在需要一起获取多个锁的情况下避免死锁,但是没有办法分别获取锁。并且死锁不仅仅发生在锁上,尽管这是最常见的原因。基于以上情况,需要通过开发的规则来写出无死锁代码
  • 避免死锁的更多指南:① 避免嵌套锁:如果已经持有一把锁不要再去获取一把,当需要获取多个锁时,使用std::lock来避免死锁;② 避免在持有锁时调用用户提供的代码;③ 使用固定顺序获得锁;④ 使用锁的层次结构:将互斥锁划分为多个层级,若一个线程已拥有锁,则只能获取更低层级的锁,否则抛出异常,其中当前线程所拥有的层级值为thread_local修饰的静态变量,在每个线程中只有一份

2.4 更灵活的互斥锁

  • C++标准库提供了更加灵活的std::unique_lock模板,与std::lock_guard一样,也是互斥锁类型参数化的类模板,也提供了相同的RAII风格的锁管理。但std::unique_lock实例可以不拥有与其关联的互斥锁(构造函数后保持解锁状态),为了存储这个是否拥有锁的信息,std::unique_lock实例相比于std::lock_guard会占用更多的空间,且性能更低
  • 生成std::unique_lock实例时,若将std::adopt_lock作为第二个参数传递给构造函数,表示传递的互斥锁已经上锁,仅把互斥锁对象传递给该实例(与std::lock_gurad一样)。若将std::defer_lock作为第二个参数传递给构造函数,表示互斥锁在构造时保持解锁状态,可通过调用std::unique_lock对象的lock()成员函数,或将std::unique_lock对象传递给std::lock来获取锁
  • 因为std::unique_lock实例不一定要拥有与之关联的互斥锁,互斥锁的所有权可以通过移动操作在不同实例中进行转移。std::unique_lock可移动但不可赋值,若源值是一个右值(某种临时值),所有权的转移是自动的,若源值为左值(实际值或引用),则必须显式调用std::move()实现转移
  • 实际上,std::unique_lock支持与互斥锁相同的用于上锁和解锁的基本函数集,可以使用unlock()成员函数来放弃拥有的锁,还可以与std::lock泛型函数一起使用。若函数中明显不再需要该锁,可在std::unique_lock实例被销毁前提前释放锁

2.5 保护共享数据的替代措施

2.5.1 初始化期间保护共享数据

  • 一个极端但常见的情况是,共享数据只在初始化时才需要并发访问的保护,之后就不需要显式同步了(如数据一旦创建就是只读的)。这种情况下,若使用互斥锁只是为了保护初始化,会对性能造成不必要的影响
  • C++标准库提供了std::once_flagstd::call_once来处理这种情况。不同于锁住互斥锁并显式地检查指针是否初始化,std::call_once可以在每个线程中使用,当std::call_once返回时,指针已经被某个线程初始化了。必要的同步数据存储在std::once_flag实例中,每个实例对应一个不同的初始化。使用std::call_once比显式使用互斥锁消耗的资源更少,特别是在初始化完成后
std::shared_ptr<X> x;
std::once_flag init_flag;
void init_x(){
	x.init(new X);
}
void fun(){
	std::call_once(init_flag,init_x);
	x->do_something();
}
  • std::call_once中第一个参数为std::once_flag实例,第二个参数为只调用一次的函数或仿函数。常见情况下,std::once_flag和用于待初始化的资源作为同一个类的成员出现,std::once_flag实例和std::mutex实例一样不能拷贝和移动
  • 当一个局部变量被声明为static类型时,该变量的初始化被定义在第一次通过声明时发生,对于调用该函数的多个线程,谁第一次声明是潜在的竞争条件。C++11之前的编译器上,这样的竞争条件是有问题的,在C++11的标准中解决了这些问题。在只需要一个全局实例的情况下,提供了一个std::call_once的替代方案
class X;
X& get_instance(){
	static X x;
	return x;
}

2.5.2 保护很少更新的数据

  • 仅在初始化时保护数据是一种非常特殊的情况,更泛化一点的情况是,数据结构在大多数情况下可以并发读取,但在很少情况下需要更新,更新期间的数据需要得到适当的保护。使用std::mutex来保护数据显得用力过猛,因为在没有发生修改时,也会无法并发读取。需要使用另一种不同的互斥锁,被称为“读者-写者”锁(读写锁),他允许两种不同的用法:① 一个写者线程独占访问;② 多个读者线程并发访问
  • C++14标准库中提供了互斥锁std::shared_timed_mutex,C++17又提供了std::shared_mutex,但在C++11中什么都没有,需要使用Boost库中的互斥锁。这两种互斥锁中,std::shared_timed_mutex支持更多的操作方式,而std::shared_mutex可提供更高的性能
  • 对于更新操作,可以使用std::lock_guardstd::unique_lock上锁,保证独占访问。对于不需要更新的操作,可以使用std::shared_lock上锁,获得共享访问。如果任一线程拥有共享锁,试图获取独占锁的线程将阻塞

2.5.3 递归锁

  • 对于std::mutex,一个线程试图锁住它已经拥有的互斥锁会发生错误,但在某些情况中,一个线程需要在没有释放互斥锁的情况下多次获取它。为此,C++标准库提供了一种互斥锁std::recursive_mutex(递归锁),在递归锁被另一个线程锁定之前,必须释放所有的锁,使用std::lock_guradstd::unique_lock会自动处理
  • 不变量通常在持有锁时被破坏,递归锁可能导致在不变量被破坏时第二个函数仍要工作。大多数情况下,如果想要一个递归互斥,可能需要改变原有的设计

3. 同步并发操作

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

  • 如果线程A正在等待线程B完成一个任务,它有几个选择:① 在互斥锁保护下,线程A不断检查共享数据中的标志,让线程B在完成任务时设置该标志,这样会浪费大量的时间和资源;② 让线程A在检查的间隙用std::this_thread::sleep_for()函数休眠很短的时间,休眠期间将互斥锁解锁,休眠结束后重新上锁,这样很难确定正确的休眠时间;③ 使用C++标准库中的条件变量(condition variable)
  • 条件变量是等待一个线程触发事件的最基本机制,条件变量与事件或其他条件相关联,一个或多个线程可以等待该条件满足,当一个线程确定满足条件时,它可以通知一个或多个等待条件变量的线程,以唤醒它们并允许它们继续处理
  • C++标准库对条件变量有两套实现:std::condition_variablestd::condition_variable_any,这两个实现都包含在库中,都需要与一个互斥锁一起才能工作。前者仅限于使用std::mutex,而后者可以使用任何满足类似于互斥锁的最低标准的对象,但有额外的成本,除非需要额外的灵活性,否则首选std::condition_variable
std::mutex mut;
std::queue<Data> data_q;
std::condition_variable data_c;

void data_push(){
	Data const data=prepare_data();
	{
		std::lock_guard<std::mutex> lk(mut);
		data_q.push(data);
	}
	data_c.notify_one();
}

void data_pop(){
	while(true){
		std::unique_lock<std::mutex> lk(mut);
		data_c.wait(lk,[]{
			return !data_q.empty();
		});
		if(!data_q.empty()){
			Data data=data_q.front();
			data_q.pop();
			lk.unlock();
			process(data);
		}
	}
}
  • 有一个用来在两个线程之间传递数据的队列,在线程B中,当数据准备好时,使用std::lock_guard来保护队列,并把数据推入队列中,在解锁之后条件变量调用notify_one()成员函数通知等待线程,这样使得线程A醒来之后不用再被阻塞等待互斥锁。在线程A中,首先用std::unique_lock锁住互斥锁,然后再条件变量上调用wait()成员函数,并传入锁对象和等待条件
  • wait()函数会检查等待条件,当条件为true时返回,当条件为false时解锁互斥锁,并将这个线程置于阻塞或等待状态。当线程B调用notify_one()通知条件变量时,线程A从睡眠状态中醒来,加锁互斥锁,并再次检查条件是否满足。当线程A重新获得互斥锁并检查条件时,如果它不是直接响应来自线程B的通知,则称为伪唤醒(spurious wakeup)

3.2 使用期望(future)等待一次性事件

3.2.1 从后台任务返回值

  • C++标准库将一次性事件建模为期望,如果一个线程需要等待一个特定的一次性事件,它会以某种方式获得一个表示该事件的期望。然后线程可以周期性地等待很短地一段时间,以检查事件是否已经发生,同时在检查地间隙执行其他任务。或者,它可以执行另一个任务,直到它等待的事件发生,此时期望变成就绪(ready),变为就绪的期望不能再被重置。期望可以有与之相关联的数据
  • C++标准库中有两种期望,都声明在库头文件中:① 唯一期望std::future<>;② 共享期望std::share_future<>。一个事件只能有一个std::future<>实例引用,但可以有多个std::share_future<>实例引用。尽管期望用于线程间通信,但期望对象本身不提供同步访问,如果多个线程访问一个期望对象,需要通过互斥锁或其他同步机制保护访问
  • 最基本的一次性事件是在后台运行的计算结果,std::thread并没有提供一种简单方法从这样的任务中返回一个值,此时需要使用头文件中的std::async函数模板。std::async返回一个std::future对象,它将最终持有函数的返回值,当你需要该值时,只需调用get()成员函数,此时线程就会阻塞,直到期望就绪后返回该值。std::async的传参方式与std::thread相同
std::future<int> x=std::async(int_fun);
do_something_other();
std::out<<x.get()<<std::endl;
  • std::async的附加参数中可以指定是启动要给新线程,还是同步执行任务,该参数的类型是std::launch,当参数值为std::launch::defered时,表明函数调用被推迟到wait()get()调用时才执行,参数值为std::launch::async时,表明函数必须在它自己的线程上运行。默认情况下,会让具体实现来选择使用哪种方式
  • 使用std::async并不是将std::future与任务联系起来的唯一方法,还可以通过将任务包装到std::packaged_task<>类模板的实例中,或通过编写代码使用std::promise<>类模板显式地设置值来实现

3.2.2 将任务封装为与期望关联的对象

  • std::packaged_task<>将期望绑定到函数或可调用对象,当调用std::packaged_task<>对象时,它调用关联的仿函数,并让期望就绪,返回值存储为关联的数据。std::packaged_task<>类模板的模板参数是个函数签名,构建实例时,传递的仿函数类型不必精确匹配,支持隐式转换,实例调用get_future()成员函数返回期望
std::mutex m;
std::deque<std::packaged_task<void()>> tasks;
bool if_shotdown();
void deal_message();

void q_pop(){
	while(!if_shotdown()){
		deal_message();
		std::packaged_task<void()> task;
		{
			std::lock_guard<std::mutex> lk(m);
			if(tasks.empty()) continue;
			task=std::move(tasks.front());
			tasks.pop_front();
		}
		task();
	}
}
std::thread q_pop_thread(q_pop);

//向q_pop_thread线程发送任务,根据返回的期望判断任务是否完成
template<typename Func>
std::future<void> q_push(Func f){
	std::package_task<void()> task(f);
	std::future<void> res=task.get_future();
	std::lock_guard<std::mutex> lk(m);
	tasks.push_back(std::move(task));
	return res;
}

3.2.3 使用承诺设置复杂任务的期望

  • 对于不能表示为简单函数调用的任务,或者结果可能来自多个地方的任务,需要通过std::promise<>显式设置值来创建期望(前面两种方式都是在函数执行完成之后期望就绪,当前方式是手动设置期望就绪)。std::promise实例通过get_future()成员函数可以获得期望对象,通过set_value()成员函数设置类型为T的承诺(promise)值,该函数调用后期望就绪

3.2.4 用期望存储异常

  • 在使用std::async调用函数时,或将函数包装在std::packaged_task中调用时,如果调用的函数抛出异常,那么该异常将存储在期望中,并会在调用get()时抛出。std::promise可以调用set_exception()成员函数来使期望存储异常,若异常类型未知,可将该函数放在catch块中,传入std::current_exception()作为参数检索抛出的异常,若异常类型已知,可用该函数提早存储新的异常而不用抛出
extern std::promise<double> my_promise;
try{
	my_promise.set_value(get_double());
}catch(...){
	my_promise.set_exception(std::current_exception());	//检索抛出的异常
}
//提早存储新的异常
my_promise.set_exception(std::copy_exception(std::logic_error("foo")));

3.2.5 多个线程访问同一个期望

  • std::future建模了异步结果的唯一所有权,使得只能有一个线程可以检索值,因为在第一次调用get()之后就没有值可以检索了。如果并发代码要求多个线程可以等待同一事件,需要使用std::shared_futurestd::future所有权只能在实例之间移动,但std::shared_future实例是可以拷贝的
  • 对于单个std::shared_future对象,其成员函数仍然是不同步的,为了避免多个线程访问它时出现数据竞争,必须用锁保护访问。常见的方法时将std::shared_future对象的副本传递给每个线程,这样多个线程访问共享的异步状态是安全的
  • 使用某个异步状态的std::shared_future实例是由引用该状态的std::future实例构造的,必须使用std::move转移所有权,或者使用share()成员函数创建一个新的std::shared_future并直接将所有权转移给它
std::promise<int> p;
std::future<int> f(p.get_future());
std::shared_future<int> sf(std::move(f));
std::shared_future<int> sf(p.get_future());	//隐式转移所有权
auto sf=p.get_future().share();

3.3 有时间限制的等待

  • 一般情况下会使用两种类型的超时:① 基于时长的超时,等待指定长度的时间;② 绝对超时,等待到指定的时间点。大多数等待函数都提供了处理这两种超时形式的变体,处理前者加_for后缀,处理后者变体加_until后缀。线程库中使用的所有C++时间处理设施,都在std::chrono命名空间中
  • 举例来说,std::condition_variable变量有两个重载的wait_for()成员函数和两个重载的wait_until()成员函数,对应wait()的两个重载:① 第一个重载只是等待,直到被信号通知,或超时过期,或一个伪唤醒发生;② 另一个重载在唤醒时检查所提供的谓词,在谓词为真时或超时过期时返回
  • 在C++标准库中,时钟是时间信息的来源。具体来说,时钟是一个类,它提供了4种不同的信息:① 当前时间:通过调用时钟类的静态成员函数now()获取;② 该时钟类返回时间的值类型:通过typedef定义为该类的time_point类型;③ 时钟计次周期:通过typedef定义为该类的period类型;④ 时钟是否稳定:当时钟不可调且计次速度同一,则为稳定时钟,如std::chrono::system_clock不稳定,std::chrono::steady_clock稳定
  • 时长由std::chrono::duration<>类模板负责,第一个模板参数是表示的类型,第二个模板参数是个分数,指定时长单位是多少秒,它的类型是std::ration<>,例如chrono::duration> d(2)表示该时长是2个1/3秒。标准库中提供了一系列预定义的时长:nanosecondsmicrosecondsmillisecondssecondsminuteshours

C++14中引入的std::chrono_literals命名空间中有许多预定义的用于时长的字面值后缀操作符,可以简化使用硬编码时长值得代码,如24h15min30ms等。当与整型字面值一起使用时,这些后缀等同于预定义得时长类型定义,但是与浮点字面值一起使用时,会自动进行缩放而无法保证精度

  • 在不需要截断值得情况下,时长之间得转换是隐式的(如小时转换为秒),显式转换需要用std::chrono::duration_cast<>。时长支持算术运算,可以通过加减获得新的时长,可以通过成员函数count()获得时长内的单位数量的计数
std::chrono::milliseconds ms(54832)
std::chrono::seconds s=std::chrono::duration_cast<std::chrono::seconds>(ms);	//截断后结果为54
  • 等待函数都返回一个状态,以指示等待是超时了还是等待事件发生了。等待一个期望,若等待超时,函数返回std::future_status::timeout,若期望就绪,返回std::future_status::ready,若期望对应的任务推迟,返回std::future_status::deferred。库内部基于时长的等待会使用稳定的时钟来度量时间
std::future<int> f=std::async(my_task);
if(f.wait_for(std::chrono::milliseconds(30))==std::future_status::ready) do_something(f.get());
  • 时钟的时间点可以用std::chrono::time_point<>类模板的实例来表示,实例的第一个参数用来指定所要使用的时钟,第二个参数用来表示计量单位。一个时间点的值是时钟从该纪元(如1970年1月1日00时)以来的时间长度,纪元是一个时钟的基本属性。可以给时间点实例增减时长得到一个新的时间点,也可以从同一个时钟的一个时间点中减去另一个时间点得到一个时长

3.4 使用操作的同步来简化代码

3.4.1 使用期望的函数式编程

  • 函数式编程(FP)指的是一种编程风格,其中函数调用的结果仅依赖于该函数的参数,而不依赖于任何外部状态。在涉及到并发时,不对共享数据进行修改,而是通过期望在线程间进行数据传递,这样就不存在竞争条件,也不需要使用互斥锁来保护共享数据。

3.4.2 使用消息传递来同步操作

  • FP并不是唯一能避免共享可变数据的并发编程范式,另一个范式是通信顺序进程(CSP),在这里线程没有共享数据,但有通信通道允许消息在它们之间传递
  • CSP中由于没有共享数据,可以完全独立地思考每个线程,纯粹基于它对接收到的消息的响应方式。因此每个线程实际上都是一个状态机,当它接收一条消息时,会以某种方式更新自己的状态。由于没有共享数据,所有通信都要通过消息队列传递,线程只接收消息队列中自己感兴趣的消息(指定消息类型)

3.4.3 延续风格的并发

  • 并发技术规范在std::experiment命名空间中提供了新版本的std::promisestd::packaged_task,其返回实例类型为std::experimental::future,该类型拥有新特性——延续
  • 对于一个正在运行的任务,它将产生一个结果,期望会在该结果可用时持有该结果,然后运行一些代码来处理结果。对于std::future,必须等待期望准备就绪,或者使用完全阻塞的wait()成员函数。而延续可以使得数据准备好了就进行处理,用于向期望添加延续的成员函数为then()
  • std::future一样,std::experimental::future存储的值也只能被检索一次,如果那个值被一个延续消费了,别的代码就不能再访问它。因此,当一个延续用future.then()被添加时,原来的期望就失效了,该方法的调用会返回一个新的期望来保存延续调用的结果
std::experimental::packaged_task<int()> fun_1;
std::string fun_2(std::experimental::future<int> future_1);
auto future_1=fun_1();
auto future_2=future_1.then(fun_2);
  • fun_2延续函数计划在初始期望继续时在未指定的线程上运行,使得可以通过经验更好地指定线程的选择。延续函数的参数已经由库定义为一个就绪的期望,其中包含触发延续的结果。延续链接的期望可能最终持有一个值或一个异常,从延续逃出的异常将存储在保持延续结果的期望中
  • 并发技术规范中并没有指定std::async的等价函数,可以手动编写一个扩展函数exper_async,通过std::experimental::promise获得一个期望,然后生成一个新线程运行lambda,该线程承诺的值设置为所提供函数的返回值
template<typename Func>
std::experimental::future<decltype(std::declval<Func>()())> exper_async(Func&& func){
	std::experimental::promise<decltype(std::declval<Fun>()())> p;
	auto res=p.get_future();
	std::thread t(
		[p=std::move(p),f=std::decay_t<Func>(func)]()mutable{
			try{
				p.set_value_at_thread_exit(f());
			}catch(...){
				p.set_exception_at_thread_exit(std::current_exception());
			}
		}
	);
	t.detach();
	return res;
}
  • 对于一系列耗时的任务,希望异步地执行它们,且每一步地运行依赖于前一步的结果,可以使用延续分解这一系列任务,使每个任务都链接到前一个任务上作为延续

3.4.4 等待多个期望

  • 对于多个独立的并行任务,如果需要等待收集所有任务的结果进行处理,可以依次等待每个期望,然后收集结果,这样会在每个结果可用时都将线程调度器唤醒,若还有另一个结果未准备好就会再次返回休眠状态,该过程中的线程等待和上下文切换会产生大量开销
  • 使用std::experimental::when_all可以避免上述等待和切换,将等待的期望集合传递给when_all,当集合中所有的期望都准备好时,就会返回一个新的处于就绪状态的期望,该期望可以与延续一起使用来安排额外的工作
std::experimental::future<FinalResult> process_data(std::vector<MyData>& vec){
	size_t const chunk_size=whatever;
	std::vector<std::experimental::future<ChunkResult>> results;
	for(auto begin=vec.begin(),end=vec.end();begin!=end){
		size_t const remaining_size=end-begin;
		size_t const this_chunk_size=std::min(remaining_size,chunk_size);
		result.push_back(spawn_async(process_chunk,begin,begin+this_chunk_size));
		begin+=this_chunk_size;
	}
	return std::experimental::when_all(results.begin(),results.end()).then(
		[](std::future<std::vector<std::experimental::future<ChunkResult>>> ready_results){
			std::vector<std::experimental::future<ChunkResult>> all_results=ready_results.get();
			std::vector<ChunkResult> v;
			v.reserve(all_results.size());
			for(auto& f:all_results) v.push_back(f.get());
			return gather_results(v);
		}
	);
}
  • 使用std::experimental::when_any可以在集合中任何一个期望准备好时,创建一个新的期望,且该期望相较于when_all创建的期望增加了一层,它将集合与一个索引值组合进std::experimental::when_any_result类模板的一个实例,这个索引值指示哪个期望触发了组合的期望变成就绪状态

3.4.5 锁存器和屏障

  • 锁存器(latch)是一个同步对象,当它的计数器减到零时,它就准备好了,一旦它准备好了,它就会一直保持就绪状态,直到它被销毁,锁存器并不关心哪个线程减少计数器,因此锁存器是等待一系列事件发生的轻量级工具
  • 屏障(barrier)是一个可重用的同步组件,用于一组线程之间的内部同步,每个线程每个周期只能到达屏障一次,当线程到达屏障时,它们就会阻塞,直到所有的线程到达屏障,此时它们都被释放,这时屏障可以被重用,然后线程可以再次到达屏障,等待所有线程进入下一个周期
  • std::experimental::latch是基本的锁存器类型,来自于头文件,其构造函数的唯一参数为初始计数器的值,当等待的事件发生时,在锁存器对象上调用count_down(),当计数器为0时锁存器就准备好了,在锁存器对象上调用wait()来等待锁存器准备好,调用is_ready()检查它是否准备好,调用count_down_and_wait()在递减计数器的同时等待计数器达到零
void fun(){
	unsigned const thread_count=10;
	std::experimental::latch done(thread_count);
	my_data data[thread_count];
	std::vector<std::future<void>> threads;
	for(unsigned i=0;i<thread_count;++i){
		threads.push_back(std::async(std::launch::async,[&,i]{
			data[i]=make_data(i);
			done.count_down();
			do_more_stuff();
		}));
	}
	done.wait();
	process_data(data,thread_count);
}	//在std::future析构函数在函数末尾运行之前,不能保证线程已经全部完成
  • 头文件中提供了两种类型的屏障:std::experimental::barrierstd::experimental::flex_barrier,前者更基础,潜在的开销更低,后者更灵活,潜在的开销更大
  • std::experimental::barrier使用同步组中线程数量构造一个屏障,当每个线程完成处理后会到达屏障,通过在屏障对象上调用arrive_and_wait()来等待组中剩余的线程,当组中最后一个线程到达时,所有线程被释放,屏障被重置。只有组内的线程可以等待屏障就绪(同步),通过在屏障上调用arrive_and_drop()可以将线程退出组,且下一个周期必须到达屏障的线程数量减一
  • std::experimental::flex_barrier有一个额外的构造函数,需要传入线程数量和结束函数,当所有线程都到达屏障时,这个函数将只在其中一个线程上运行,函数体提供了串行运行的代码块,函数的返回值指定了下一周期必须到达屏障的线程数量,使用这个函数的程序员需要保证下一次到达屏障时,线程数量是正确的

你可能感兴趣的:(C++,c++,多线程,并发编程)