C++并发cheating Paper

keywords: RAII, daemon threads, race condition

注意:这篇博客是对《C++并发编程实战》的整理,在原文的基础上重新梳理逻辑关系和要点分析,无任何原创内容。

1. 线程管理

1.1 线程的初始化

每一个线程都需要有一个初始函数(一个函数指针),std::thread可以和任何可调用类型(callable,通俗的说就是后面可以接括号的)一起使用,即可以把一个实例传递给std::thread的构造函数。注意实例被复制到新线程的栈里,并从这个新的储存器中调用;另外,如果传递一个临时的且未命名的函数对象(函数对象是重载了函数调用符的对象,优点是可以有自己的状态,并且有自己的类型,可以用于初始化C++的stl模板),那么编译器会将这个线程的初始化编译为返回一个std::thread 对象的函数的声明,此时的初始化方法是使用std::thread my_thread{callable_object()}来避免这种情况;另一种避免临时的,未命名的函数对象的编译问题的方法是使用lambda表达式。

1.2 线程等待和分离

在线程初始化结束后,我们需要显示地确定主线程是等待新线程(join)还是让这个线程继续运行(detaching)。只有在线程joinable属性为True的时候,我们才能进行join或者detach。

1.2.1 RAII技巧

为了防止任务在主线程发生异常的情况下没有及时join新线程,导致主线程没有等待新线程结束而提前终止的情况,我们应使用RAII(资源获取即初始化)的方法。这种技巧主要依靠构造函数的逆序销毁特性,在新线程初始化后,在主线程中新建一个thread_guard类,并在这个thread_guard()析构的时候判断新线程是否是joinable的, True则调用join。注意这个判断joinable是很重要的一步,防止在异常发生时新线程已经被等待,此时如果继续调用join则会报错。另外构造RAII时的细节是一定要把thread_guard的复制构造函数和赋值操作符给去掉(在c++11中使用 “= delete“更加优雅可读些),防止这个thread_guard在新线程的生命周期之外继续存续。在互斥锁中,为了保证unlocking被顺利调用,也会用到类似的技巧。

1.2.1 守护线程及其应用场景

如果我们将线程分离(detaching),那么将没有办法继续与这个线程通信或者继续调用这个线程的join。此时这个线程的控制权会交給C++运行时库,以保证线程相关的资源在线程推出后被正常回收。被分离的线程也别叫做守护线程(daemon thread, 参考守护进程的概念)。这种线程一般在整个应用程序的周期都存续,并在后台运行。常见的应用场景包括监控文件系统,清除对象缓存中未使用的对象或者优化数据结构等。

1.3 给线程传递参数

如果将主线程中的数据移交给新线程,那么这个数据就会被copy。即使我们在新线程调用的函数中将这个数据声明为引用,我们也仅仅是在新线程内部调用这个数据的时候使用引用机制。总之,在新线程中使用主线程的数据肯定是要深拷贝的,不要抱有不切实际的幻想。

1.3.1 将指针直接传入新线程中的后果:悬浮指针

不要使用指针传递参数到新线程里,否则主线程结束后,新线程中就无法找到这个指针指向的对象,产生悬浮指针。一个可能的解决方法是强制转换为原子类型(如果可以的话,例如char数组转化为string)。

1.3.2 在新线程直接传输数据(数据对象而非引用或者指针)的后果和解决方法

结果是:在线程中对数据进行的更改将在新线程结束后一起被销毁,即没有任何在新线程中的操作结果被保留。解决方法是使用std:ref。这其实是一个reference wrapper,可以通过一个右值初始化,其.get()方法返回的就是正常的引用。使用ref的另一个好处是,在新线程析构时,其栈中的内存也会被释放,因此被复制的数据是不会被保留的,如果多线程计算最终是为了返回数据,记得加ref。

1.3.3 move 语义在多线程上的优化

如果给函数或者新线程传递一个右值,那么这个临时变量的所有权会自动转移到新函数或者新线程中,原scope中的改变量会被释放。如果我们确定某个很大动态数据结构不会再在原空间内使用,那么我们可以强行把左值move到右值,从而节约内存开销。

2. 线程间共享数据和竞争条件

竞争条件是指运行结果依赖于线程相对操作顺序的一些事物。一般race condition指恶性的情况,即可能发生未定义的事宜。避免竞争条件在并发中的影响的方式主要有优化数据结构中的不变量(无锁编程, lock free programming)以及使用互斥锁(mutex,mutual exclusion)保护数据。

2.1 互斥元保护共享数据

互斥锁是跟随数据资源的。在共享数据之前,锁定于该数据相关的互斥元,访问完成后,解锁互斥元。C++的线程库保证了一个线程在访问一个互斥锁的时候另外一个线程必须等待,直到该数据的互斥元被解锁。在设计互斥元的时候应该避免通过指针过引用传出被互斥元保护的数据。这些数据一旦被传出,那么无论是在主线程还是在其他调用该数据的线程中都可能与其他线程中的调用形成竞争条件。一旦脱离mutex_guard的作用域,数据就不再被mutex保护。所以在多线程的共享数据的时候我们有时不得不返回一个动态结构的副本,以内存开销为代价,所以在设计接口的时候记得避免。

在特定函数中的互斥锁不能保证一个函数调用序列的安全,在设计接口中应该考虑避免这样的情况发生。

2.2 同步结构防死锁

如果一个函数中需要调用两个被互斥锁保护的数据块,可能出现的情况就是一个线程锁住了其中一个数据块,而另一个线程锁住了另一个数据块,此时两个线程就会互相等待对方释放互斥元,形成死锁。一个理所当然的处理方法就是在一个函数调用两个被互斥元保护的数据前,把这两个互斥元提前锁定(如果这两个互斥元不能被同时锁定,threading库就会等待直到能够同时锁住为止)。

一些防止死锁的原则和技巧

这种描述本身其实也体现了防止死锁最基础的原则:在一个函数中只有一个锁(或者把多个互斥元同时锁定,从行为上看只等于有一个锁);上一个原则衍生出来的另一个准则就是不要在函数内调用其他用户代码,因为这些函数中就可能有锁,从而违背第一原则;如果必需使用多个锁,那么必须用相同的顺序调用(反证法,自证不难)。

在使用lock的RAII时,应该注意unique_lock的性能稍弱于lock_guard, 但是unique_lock有一个非常强力的特性,那就是可以安全地在scope之外转移锁, 例如,某个带锁定的互斥元取决于某个程序的运行状态,而我们又懒得使用conditional variable时。

2.3 优化技巧和锁粒度

首先不要给IO bounded的任务上互斥锁,如果不加会出错,就老老实实改API。另外,养成在代码不需要调用共享数据块的时候unlock(注意为了不造成可能的死锁,不要在这种时候放两个lock)的好习惯。

HDF5-Notes Admissibility and consistency of informed search

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×