c++并发编程

一、c++并发世界

什么是并发

  按最简单、最基本的程度理解,并发(concurrency)是两个或多个同时独立进行的活动。并发现象遍布日常生活,我们可以边走路边说话,左右手同时做出不一样的动作,诸如此类

计算机系统中的并发

  若我们谈及计算机系统中的并发,则是指同一个系统中,多个独立活动同时进行,而非依次进行。多年来,多任务操作系统可以凭借任务切换,让同一台计算机同时运行多个应用软件,这早已稀松平常,而高端服务器配备了多处理器,实现了“真并发”。大势所趋,主流计算机现已能够真真正正地并行处理多任务,而不再只是制造并发的表象。
  看起来所有任务都正在同时执行,因此其被称为任务切换。至此,我们谈及的并发都基于这种模式。由于任务飞速切换,我们难以分辨处理器到底在哪一刻暂停某个任务而切换到另一个。任务切换对使用者和应用软件自身都制造出并发的表象。由于是表象,因此对比真正的并发环境,当应用程序在进行任务切换的单一处理器环境下运行时,其行为可能稍微不同。具体而言,如果就内存模型做出不当假设,本来会导致某些问题,但这些问题在上述环境中却有可能不会出现。
  多年来,配备了多处理器的计算机一直被用作服务器,它要承担高性能的计算任务;现今,基于一芯多核处理器(简称多核处理器)的计算机日渐普及,多核处理器也用在台式计算机上。无论是装配多个处理器,还是单个多核处理器,或是多个多核处理器,这些计算机都能真正并行运作多个任务,我们称之为硬件并发(hardware concurrency)。
  下图所示为理想化的情景,计算机有两个任务要处理,将它们进行十等分。在双核机(具有两个处理核)上,两个任务在各自的核上分别执行。另一台单核机则切换任务,交替执行任务小段,但任务小段之间略有间隔。在下图中,单核机的任务小段被灰色小条隔开,它们比双核机的分隔条粗大。为了交替执行,每当系统从某一个任务切换到另一个时,就必须完成一次上下文切换(context switch),于是耗费了时间。若要完成一次上下文切换,则操作系统需保存当前任务的 CPU 状态和指令指针,判定需要切换到哪个任务,并为之重新加载 CPU 状态。接着,CPU 有可能需要将新任务的指令和数据从内存加载到缓存,这或许会妨碍 CPU,令其无法执行任何指令,加剧延迟。

两种并发方式:双核机上的并发执行与单核机上的任务切换

  尽管多处理器或多核系统明显更适合硬件并发,不过有些处理器也能在单核上执行多线程。真正需要注意的关键因素是硬件支持的线程数(hardware threads),也就是硬件自身真正支持同时运行的独立任务的数量。即便是真正支持硬件并发的系统,任务的数量往往容易超过硬件本身可以并行处理的数量,因而在这种情形下任务切换依然有用。譬如,常见的台式计算机能够同时运行数百个任务,在后台进行各种操作,表面上却处于空闲状态。正是由于任务切换,后台任务才得以运作,才容许我们运行许多应用软件,如文字处理软件、编译器、编辑软件,以及浏览器等。下图展示了双核机上 4 个任务的相互切换,这同样是理想化的情形,各个任务都被均匀切分。实践中,许多问题会导致任务切分不均匀或调度不规则。
4个任务在双核机上切换

并发的方式

  设想两位开发者要共同开发一个软件项目。假设他们处于两间独立的办公室,而且各有一份参考手册,则他们可以静心工作,不会彼此干扰。但这令交流颇费周章:他们无法一转身就与对方交谈,遂不得不借助电话或邮件,或是需起身离座走到对方办公室。另外,使用两间办公室有额外开支,还需购买多份参考手册。
  现在,如果安排两位开发者共处一室,他们就能畅谈软件项目的设计,也便于在纸上或壁板上作图,从而有助于交流设计的创意和理念。这样,仅有一间办公室要管理,并且各种资源通常只需一份就足够了。但缺点是,他们恐怕难以集中精神,共享资源也可能出现问题。
   这两种安排开发者的办法示意了并发的两种基本方式。一位开发者代表一个线程,一间办公室代表一个进程。第一种方式采用多个进程,各进程都只含单一线程,情况类似于每位开发者都有自己的办公室;第二种方式只运行单一进程,内含多个线程,正如两位开发者同处一间办公室。

1. 多线程并发
  在应用软件内部,一种并发方式是,将一个应用软件拆分成多个独立进程同时运行,它们都只含单一线程,非常类似于同时运行浏览器和文字处理软件。这些独立进程可以通过所有常规的进程间通信途径相互传递信息(信号、套接字、文件、管道等),如下图所示。这种进程间通信普遍存在短处:或设置复杂,或速度慢,甚至二者兼有,因为操作系统往往要在进程之间提供大量防护措施,以免某进程意外改动另一个进程的数据;还有一个短处是运行多个进程的固定开销大,进程的启动花费时间,操作系统必须调配内部资源来管控进程,等等。

两个进程并发运行并相互通信

  进程间通信并非一无是处:通常,操作系统在进程间提供额外保护和高级通信机制。这就意味着,比起线程,采用进程更容易编写出安全的并发代码。某些编程环境以进程作为基本构建单元,其并发效果确实一流,譬如为 Erlang 编程语言准备的环境。
  运用独立的进程实现并发,还有一个额外优势——通过网络连接,独立的进程能够在不同的计算机上运行。这样做虽然增加了通信开销,可是只要系统设计精良,此法足以低廉而有效地增强并发力度,改进性能。
2. 多线程并发
  另一种并发方式是在单一进程内运行多线程。线程非常像轻量级进程,每个线程都独立运行,并能各自执行不同的指令序列。不过,同一进程内的所有线程都共用相同的地址空间,且所有线程都能直接访问大部分数据。全局变量依然全局可见,指向对象或数据的指针和引用能在线程间传递。尽管进程间共享内存通常可行,但这种做法设置复杂,往往难以驾驭,原因是同一数据的地址在不同进程中不一定相同。
单一进程内的两个线程借共享内存通信

上图展示了单一进程内的两个线程借共享内存通信。
  我们可以启用多个单线程的进程并在进程间通信,也可以在单一进程内发动多个线程而在线程间通信,后者的额外开销更低。因此,即使共享内存带来隐患,主流语言大都青睐以多线程的方式实现并发功能。提到多线程代码,还常常用到一个词——并行。接下来,我们来厘清并发与并行的区别。

并发与并行

  就多线程代码而言,并发与并行(parallel)的含义很大程度上相互重叠。确实,在多数人看来,它们就是相同的。并发和并行的差别甚小,主要是着眼点和使用意图不同。两个术语都是指使用可调配的硬件资源同时运行多个任务,但并行更强调性能。当人们谈及并行时,主要关心的是利用可调配的硬件资源提升大规模数据处理的性能;当谈及并发时,主要关心的是分离关注点或响应能力。

为什么使用并发

  应用程序使用并发技术的主要原因有两个:分离关注点与性能提升。据我所知,实际上这几乎是仅有的用到并发技术的原因。如果追根究底,任何其他原因都能归结为二者之一,也可能兼得(除非你说为了中华之崛起而并发。我就随口说说。别当真)。

1. 为分离关注点而并发
  一直以来,编写软件时,分离关注点(separation of concerns)几乎总是不错的构思:归类相关代码,隔离无关代码,使程序更易于理解和测试,因此所含缺陷很可能更少。并发技术可以用于隔离不同领域的操作,即便这些不同领域的操作需同时进行;若不直接使用并发技术,我们将不得不编写框架做任务切换,或者不得不在某个操作步骤中,频繁调用无关领域的代码。
  考虑一个带有用户界面的应用软件,需要由 CPU 密集处理,如台式计算机上的 DVD 播放软件。本质上,这个应用软件肩负两大职责:既要从碟片读取数据,解码声音影像,并将其及时传送给图形硬件和音效硬件,让 DVD 顺畅放映,又要接收用户的操作输入,譬如用户按“暂停”、“返回选项单”、“退出”等键。假若采取单一线程,则该应用软件在播放过程中,不得不定时检查用户输入,结果会混杂播放 DVD 的代码与用户界面的代码。改用多线程就可以分离上述两个关注点,一个线程只负责用户界面管理,另一个线程只负责播放 DVD,用户界面的代码和播放 DVD 的代码遂可避免紧密纠缠。两个线程之间还会保留必要的交互,例如按“暂停”键,不过这些交互仅仅与需要立即处理的事件直接关联。
  如果用户发送了操作请求,而播放 DVD 线程正忙,无法马上处理,那么在请求被传送到该线程的同时,代码通常能令用户界面线程立刻做出响应,即便只是显示光标或提示“请稍候”。这种方法使得应用软件看起来响应及时。类似地,某些必须在后台持续工作的任务,则常常交由独立线程负责运行,例如,让桌面搜索应用软件监控文件系统变动。此法基本能大幅简化各线程的内部逻辑,原因是线程间交互得以限定于代码中可明确辨识的切入点,而无须将不同任务的逻辑交错散置。这样,线程的实际数量便与 CPU 既有的内核数量无关,因为用线程分离关注点的依据是设计理念,不以增加运算吞吐量为目的。

2. 为性能而并发:任务并行与数据并行
  多处理器系统已存在数十年,不过一直以来它们大都只见于巨型计算机、大型计算机和大型服务器系统。但是,芯片厂家日益倾向设计多核芯片,在单一芯片上集成 2 个、4 个、16 个或更多处理器,从而使其性能优于单核芯片。于是,多核台式计算机日渐流行,甚至多核嵌入式设备亦然。不断增强的算力并非得益于单个任务的加速运行,而是来自多任务并行运作。从前,处理器更新换代,程序自然而然随之加速,程序员可以“坐享其成,不劳而获”。但现在,正如 Herb Sutter 指出的“免费午餐没有了!”,软件若要利用增强的这部分算力,就必须设计成并发运行任务。所以程序员必须警觉,特别是那些踌躇不前、忽视并发技术的同业,有必要注意熟练掌握并发技术,储备技能。
  增强性能的并行方式有两种,第一种,最直观地,将单一任务分解成多个部分,各自并行运作,从而节省总运行耗时。此方式即为任务并行。尽管听起来浅白、直接,但这却有可能涉及相当复杂的处理过程,因为任务各部分之间也许存在纷繁的依赖。任务分解可以针对处理过程,调度某线程运行同一算法的某部分,另一线程则运行其他部分;也可以针对数据,线程分别对数据的不同部分执行同样的操作,这被称为数据并行。
  易于采用上述并行方式的算法常常被称为尴尬并行算法。其含义是,将算法的代码并行化实在简单,甚至简单得会让我们尴尬,实际上这是好事。我还遇见过用其他术语描述这类算法,叫“天然并行”(naturally parallel)与“方便并发”(conveniently concurrent)。尴尬并行算法具备的优良特性是可按规模伸缩——只要硬件支持的线程数目增加,算法的并行程度就能相应提升。这种算法是成语“众擎易举”的完美体现。算法中除尴尬并行以外的部分,可以另外划分成一类,其并行任务的数目固定(所以不可按规模伸缩)。
  第二种增强性能的并行方式是利用并行资源解决规模更大的问题。例如,只要条件适合,便同时处理 2 个文件,或者 10 个,甚至 20 个,而不是每次 1 个。同时对多组数据执行一样的操作,实际上是采用了数据并行,其着眼点有别于任务并行。采用这种方式处理单一数据所需的时间依旧不变,而同等时间内能处理的数据相对更多。这种方式明显存在局限,虽然并非任何情形都会因此受益,但数据吞吐量却有所增加,进而带来突破。例如,若能并行处理视频影像中不同的区域,就会提升视频处理的解析度。

并发与多线程

  以标准化形式借多线程支持并发是C++的新特性。C++11标准发布后,我们才不再依靠平台专属的扩展,可以用原生C++直接编写多线程代码。标准C++线程库的成型历经种种取舍,若要掌握其设计逻辑,则知晓其历史渊源颇为重要。

开始多线程

下面,我们从一个学习任何编程语言入门必学的程序入手。以此循序渐进。

#include<iostream>
int main()
{
	std::cout << "Hello World " << std::endl;
	return 0;
}

main函数,学习过编程语言的都清楚,是程序的入口。也是主线程。接着我们用独立于主线程的线程来打印输出。

#include<iostream>
#include<thread>
void hello()
{
	std::cout << "Hello World " << std::endl;
}
int main()
{
	std::thread t(hello);
	t.join();
	return 0;
}

当我们信心满满以为程序没有问题时,殊不知在点击生成后嘴角的笑容也戛然而止。于是乎看到编译器报如下图错误-_-
在这里插入图片描述
不要担心,谁的编程之路不踩坑。那是我们程序依赖thread库。打开项目属性,找到链接器/输入,在库依赖项添加pthread,如下图所示
在这里插入图片描述
  注意,头文件包含<thread>,c++标准库引入很多头文件,他们包含支持多线程的相关说明:管控线程的函数和类在<thread>中声明。在程序中,我们看到一个hello()函数,这是因为每个线程都需要一个起始函数,新线程从这个函数开始执行。就应用程序而言,起始函数就是main()。对于其他线程,其起始函数需要在std::thread对象的构造函数中指明。如本程序中变量为t的std::thread对象以新引入的hello()作为起始函数。如此说来,程序就有两个线程存在,起始线程从main()开始执行,新线程从hello()开始执行。
  新线程启动后,起始函数继续执行。如果起始线程不等待新线程结束,就会一路执行,直到main()函数结束,甚至直到整个程序运行结束。导致新线程没有机会运行,所以才要调用join()的原因。命令作用是让主线程等待子线程。前者负责执行main()函数,后者则与std::thread对象关联。即变量t。
  尽管这是多线程编程最简单的程序。可是我们也需要稳扎稳打。一步一个脚印。这样才能在多线程的编程道路上走得更远,更稳。让我们一起进步哈。

二、线程管控

线程的基本管控

  每个C++程序都含有至少一个线程,即运行mainO的线程,它由C++运行时(C++runtime)系统启动。随后,程序就可以发起更多线程,它们以别的函数作为入口(entry point)。这些新线程连同起始线程并发运行。当mai()返回时,程序就会退出;同样,当入口函数返回时,对应的线程随之终结。我们会看到,如果借std::thread对象管控线程,即可选择等它自然结束。不过,首先要让线程启动,所以我们来学习发起线程。

发起线程

   线程是通过构造std::thread对象启动,该对象指明要运行的任务。任务可以是一个普通的函数,也可以是一个可调用对象。只要是通过C++标准库启动线程,归根到底,代码总会构造一个std::thread对象。下面列出构建线程的几种方式。

1. 普通函数

#include<iostream>
#include<thread>
void do_some_work()
{
	std::cout << "do_some_work" << std::endl;
}
int main()
{
	std::thread my_thread(do_some_work);
	my_thread.join();
	return 0;
}
  1. 可调用对象
#include<iostream>
#include<thread>
class background_task
{
public:
	void operator()() const
	{
		std::cout << "function call operator" << std::endl;
	}
};
int main()
{
	background_task f;
	std::thread my_thread(f);
	my_thread.join();
	return 0;
}

   在使用函数对象构造线程时,需要注意“C++最麻烦的解释”问题。即在构造std::thread对象时,传递临时对象,而不是具名变量,那么调用构造函数的语法可能与函数声明相同,产生二义性。例如:

std::thread my_thread(background_task());

我们本意是发起新线程,却被解释成函数声明。函数名为my_thread,只接收一个参数,返回std::thread对象,接收参数是一个函数指针,所指向的函数没有参数传入,返回background_task类型对象,出现如下图所示错误:
在这里插入图片描述
为了解决这个问题,我们可以通过以下三种方式解决:

	1 std::thread my_thread((background_task())); //多用一对圆括号
	2 std::thread my_thread{ (background_task()) };//列表初始化
	3 std::thread my_thread([]   //lambda表达式
	{
		do_something();
	});

等待与分离

  一旦std::thread对象被构建后,线程就可能会被执行,因此,我们需要明确指定是等待该线程结束,或是任由它独自运行。假设等到std::thread对象销毁时还未决定好,那么std::thread对象的析构函数将调用std::terminate函数终止整个程序。因此,创建线程后,必须调用如下函数中的一个:

1 th.join();      //等待
2 th.detach();     //分离

1. 等待
  若需等待线程完成,那么可以在与之关联的std::thread实例上,通过调用成员函数join()实现。该函数会阻塞当前线程,并等待线程示例结束后join()函数才返回。

 void do_some_work()
 2 {
 3     std::cout << "do_some_work start" << std::endl;
 4     std::this_thread::sleep_for(std::chrono::seconds(3));
 6 }
 7 
 8 int main()
 9 {
10     std::thread th(do_some_work);
12     th.join();    //会等待子线程入口函数返回后该函数才返回
14 }

  如果子线程中处理的数据量特别大,那么join()函数可能等到天荒地老。也有可能在调用join()函数时,该线程已经结束,那么join()将立即返回。如果只想等待特定事件,可以通过条件变量或者future完成,会在后续章节中介绍。join()函数只能调用一次;只要线程对象曾经调用过join()函数,该线程就不可再汇合,可以通过joinable()成员函数判断当前线程是否可汇合,当线程已经调用过join()函数,那么joinable()函数将返回false。
  如果选择等待线程结束,则需要选择合适的位置来调用join()函数。最重要一个原因是线程启动后有异常抛出,而join()尚未执行,则该join()调用会被略过,从而导致应用程序终止。可以运用RAII手法,构造一个thread_guard类,在析构函数中调用join()。代码如下:

class thread_guard
{
public:
	explicit thread_guard(std::thread& th) : m_th(th) {}
	~thread_guard()
	{
		if (m_th.joinable())  //避免多次调用join
			m_th.join();
	}
	thread_guard(const thread_guard&) = delete;
	thread_guard& operator=(const thread_guard&) = delete;
private:
	std::thread& m_th;
};

用法如下:

void threadEntry(int* pData)
{
	std::cout << "threadEntry()" << std::endl;
}
void func()
{
	int nLocalData = 0;
	std::thread th(threadEntry(&nLocalData));
	thread_guard(th);
    do_something_in_current_thread();
}

即使在do_something_in_current_thread()函数中发生了异常行为,线程对象同样也能正确调用join函数汇合。
2. 分离
  若不需要等待线程结束,可以调用detach成员函数将线程分离,从而避免异常引发的安全问题。分离操作会切断线程和std::thread对象间的关联,后续无法通过std::thread对象再操作该线程。被分离后的线程会在后台运行,由c++运行时库托管,当该线程运行完毕,与之对应的资源将被回收。
  如果要把std::thread对象和线程分离,就必须存在与其关联的执行线程。若没有与其关联的执行线程,便不能在std::thread对象上凭空调用detach()。可以用joinable()函数检测。仅当joinable()函数返回true时,才能调用detach函数。
  以下做法极不可取:意图在函数中创建线程,并让线程访问函数的局部变量。除非线程肯定会在该函数退出前结束,否则切勿这么做。代码如下:

class ThreadOperator
{
 public:
	 ThreadOperator(int& nData) : m_nData(nData) {}
	 void operator()()
     {
		         do_some_work(m_nData);  //对象的引用,可能该对象已经被销毁
	 }
 private:
	     int& m_nData;
};

void func()
{
	int nLocalData = 0;
	ThreadOperator op(nLocalData);
	std::thread  th(op);
	th.detach();                //不等待线程结束
}

   能func函数已经结束,而线程还在运行,此时访问nLocalData将会出现崩溃。 上述情形的处理方法通常是:令线程函数完全自含(self-contained),即将数据复制到新线程内部,而不是共享数据。

向线程函数传递参数

1. 普通函数
   向线程函数传递參数在构造线程对象时就可以完毕。不过请务必牢记,默认情况下是把參数复制到线程内部,即使在函数中使用的是引用,例如:

	void f(int i, std::string const& s);
	std::thread t(f, 3, "hello");

  这两行代码借由构造对象t新创建一个执行线程,它们互相关联,并在新线程上调用f(3,”hello")。请注意,尽管函数 f()的第二个参数属于std::string类型,但字符串的字面内容仍以指针char const*的形式传入,进入新线程的上下文环境以后,才转换为std:string 类型。如果参数是指针,并且指向自动变量(automatic variable),那么这一过程会产生非常重大的影响,举例说明:

	void f(int i, std::string const& s);
	void oops(int some_param)
	{
		char buffer[1024];
		sprintf(buffer, "%i", some_param);
		std::thread t(f, 3, buffer);
		t.detach();
	}

  在这样的情况下,指针指向局部变量buffer,然后传递到新创建的线程。在此完成之前,极有可能会发生函数oops已经终止。可是buffer还没有转换为std::string。buffer就已经销毁。解决办法就是在传递之前就转换:

	void f(int i, std::string const& s);
	void oops(int some_param)
	{
		char buffer[1024];
		sprintf(buffer, "%i", some_param);
		std::thread t(f, 3, std::string(buffer));
		t.detach();
	}

   与oops函数相反的情形是,我们真正想要的是非const引用(non-const reference)而整个对象却被完全复制。但这不可能发生,因为编译根本无法通过。可尝试按用的方式传入一个数据结构,以验证线程能否对其进行更新,例如:

	void update_data_for_widget(widget_id w, widget_data& data);
	void oops_again(widget_id w)
	{
		widget_data data;
		std::thread t(update_data_for_widget, w, data);
		display_status();
		t.join();
		process_widget_data(data);
	}

  根据update_data_for_widget()函数声明,第二个參数用引用传递,可是std::thread的构造函数不知道,这时函数的參数会被拷贝。当调用update_data_for_widget时,会传递拷贝data的引用,而不是data的引用。当线程终止,线程内部的拷贝析构,可是函数process_widget_data传递的是未更新的data。对于熟悉std:bind的人来说,立即就能想到解决的办法:你须要使用std::ref包装參数。须要更改线程创建形式为:

std::thread t(update_data_for_widget, w, std::ref(data));

  由于std::thread的构造函数和std::bind都是使用相同的原理。这也就是说,你能够传递成员函数的指针作为函数參数,假如你使用对象指针作为第一个參数。
2. 类成员函数
   若要将某个类的成员函数设定为线程函数,我们则应传入一个函数指针,指向该成员函数。此外,我们还要给出合适的对象指针,作为该函数的第一个参数:

class X
{
public:
	void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_length_work, &my_x);

  上述对象指针由my_x的地址充当,这段代码将它传递给std::thread的构造函数,因此新线程会调用my_x.do lengthy_work()。我们还能为成员函数提供参数:若给 std::thread的构造函数增添第3个参数,则它会传入成员函数作为第1个参数,以此类推。
3. C++11 智能指针作为参数
  函数參数对象不能拷贝,仅仅能转移其全部权(比如STL中的auto_ptr指针)。std::unique_ptr就是这种一个样例。std::unique指针一次仅仅能指向一个对象。当指针析构时。对象也就被析构了。在赋值时是转移全部权(像auto_ptr)。在使用时,当对象是暂时对象时,会自己主动调用move,当是个变量时必须调用move。下面介绍std::move()函数的用法,我们借助它向线程转移动态对象的归属权:

void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object, std::move(p));

  在调用std::thread的构造函数时,依据std::move§所指定的操作,big_object对象的归属权会发生转移,先进入新创建的线程的内部存储空间,再转移给process_big_object()函数。

移交线程归属权

  假设要写一个在后台启动线程的函数,想通过新线程返回的所有权去调用这个函数,而不是等待线程结束再去调用;或完全与之相反的想法:创建一个线程,并在函数中转移所有权,都必须要等待线程结束。总之,新线程的所有权都需要转移。
  这就是移动引入std::thread的原因,C++标准库中有很多资源占有(resource-owning)类型,比如std::ifstream,std::unique_ptr还有std::thread都是可移动,但不可拷贝。这就说明执行线程的所有权可以在std::thread实例中移动,下面将展示一个例子。例子中,创建了两个执行线程,并且在std::thread实例之间(t1,t2和t3)转移所有权:

void some_function();
void some_other_function();
std::thread t1(some_function);             // #1
std::thread t2 = std::move(t1);            // #2
t1 = std::thread(some_other_function);     // #3
std::thread t3;                            // #4
t3 = std::move(t2);                        // #5
t1 = std::move(t3);

  当显式使用std::move()创建t2后#2,t1的所有权就转移给了t2。之后,t1和执行线程已经没有关联了;执行some_function的函数现在与t2关联。
  然后,与一个临时std::thread对象相关的线程启动了#3。为什么不显式调用std::move()转移所有权呢?因为,所有者是一个临时对象——移动操作将会隐式的调用。
  t3使用默认构造方式创建#4,与任何执行线程都没有关联。调用std::move()将与t2关联线程的所有权转移到t3中#5。因为t2是一个命名对象,需要显式的调用std::move()。移动操作#5完成后,t1与执行some_other_function的线程相关联,t2与任何线程都无关联,t3与执行some_function的线程相关联。
  最后一个移动操作,将some_function线程的所有权转移#6给t1。不过,t1已经有了一个关联的线程(执行some_other_function的线程),所以这里系统直接调用std::terminate()终止程序继续运行。这样做是为了保证与std::thread的析构函数的行为一致。
  std::thread支持移动,就意味着线程的所有权可以在函数外进行转移,就如下面程序一样。

std::thread f()
{
	void some_function();
	return std::thread(some_function);
}
std::thread g()
{
	void some_other_function(int);
	std::thread t(some_other_function, 42);
	return t;
}

  当所有权可以在函数内部传递,就允许std::thread实例可作为参数进行传递,代码如下:

void f(std::thread t);
void g()
{
	void some_function();
	f(std::thread(some_function));
	std::thread t(some_function);
	f(std::move(t));
}

  std::thread支持移动的好处是可以创建thread_guard类的实例,并且拥有其线程的所有权。当thread_guard对象所持有的线程已经被引用,移动操作就可以避免很多不必要的麻烦;这意味着当某个对象转移了线程的所有权后,它就不能对线程进行加入或分离。为了确保线程程序退出前完成,下面的代码里定义了scoped_thread类

class scoped_thread
{
	std::thread t;
public:
	explicit scoped_thread(std::thread t_) :                 // #1
		t(std::move(t_))
	{
		if (!t.joinable())                                     // #2
			throw std::logic_error(“No thread”);
	}
	~scoped_thread()
	{
		t.join();                                            // #3
	}
	scoped_thread(scoped_thread const&) = delete;
	scoped_thread& operator=(scoped_thread const&) = delete;
};

struct func
{
	int& i;
	func(int& i_) : i(i_) {}
	void operator() ()
	{
		for (unsigned j = 0; j < 1000000; ++j)
		{
			do_something(i);           // 潜在访问隐患:悬空引用
		}
	}
};

void f()
{
	int some_local_state;
	scoped_thread t(std::thread(func(some_local_state)));    // #4
	do_something_in_current_thread();
}                                                        // #5

  这里新线程是直接传递到scoped_thread中#4,而非创建一个独立的命名变量。当主线程到达f()函数的末尾时,scoped_thread对象将会销毁,然后加入#3到的构造函数#1创建的线程对象中去。要在析构的时候检查线程是否"可加入"。这里把检查放在了构造函数中#2,并且当线程不可加入时,抛出异常。
  std::thread对象的容器,如果这个容器是移动敏感的(比如,标准中的std::vector<>),那么移动操作同样适用于这些容器。了解这些后,我们就可以量产了一些线程,并且等待它们结束。如下所示:

void do_work(unsigned id);
void f()
{
	std::vector<std::thread> threads;
	for (unsigned i = 0; i < 20; ++i)
	{
		threads.push_back(std::thread(do_work, i)); // 产生线程
	}
	std::for_each(threads.begin(), threads.end(),
		std::mem_fn(&std::thread::join)); // 对每个线程调用join()
}

  我们经常需要线程去分割一个算法的总工作量,所以在算法结束的之前,所有的线程必须结束。并且做的工作都是独立的,结果是会受到共享数据的影响。如果f()有返回值,这个返回值就依赖于线程得到的结果。
  将std::thread放入std::vector是向线程自动化管理迈出的第一步:并非为这些线程创建独立的变量,并且将他们直接加入,可以把它们当做一个组。创建一组线程(数量在运行时确定),可使得这一步迈的更大。

运行时选择线程数量

  std::thread::hardware_concurrency()在新版C++标准库中是一个很有用的函数。这个函数将返回能同时并发在一个程序中的线程数量。例如,多核系统中,返回值可以是CPU核芯的数量。返回值也仅仅是一个提示,当系统信息无法获取时,函数也会返回0。但是,这也无法掩盖这个函数对启动线程数量的帮助。
  下面实现了一个并行版的std::accumulate。代码中将整体工作拆分成小任务交给每个线程去做,其中设置最小任务数,是为了避免产生太多的线程。程序可能会在操作数量为0的时候抛出异常。比如,std::thread构造函数无法启动一个执行线程,就会抛出一个异常。

template<typename Iterator,typename T>
struct accumulate_block
{
  void operator()(Iterator first,Iterator last,T& result)
  {
    result=std::accumulate(first,last,result);
  }
};

template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
  unsigned long const length=std::distance(first,last);

  if(!length) // #1
    return init;

  unsigned long const min_per_thread=25;
  unsigned long const max_threads=
      (length+min_per_thread-1)/min_per_thread; // #2

  unsigned long const hardware_threads=
      std::thread::hardware_concurrency();

  unsigned long const num_threads=  // #3
      std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);

  unsigned long const block_size=length/num_threads; // #4

  std::vector<T> results(num_threads);
  std::vector<std::thread> threads(num_threads-1);  // #5

  Iterator block_start=first;
  for(unsigned long i=0; i < (num_threads-1); ++i)
  {
    Iterator block_end=block_start;
    std::advance(block_end,block_size);  // #6
    threads[i]=std::thread(     // #7
        accumulate_block<Iterator,T>(),
        block_start,block_end,std::ref(results[i]));
    block_start=block_end;  // #8
  }
  accumulate_block<Iterator,T>()(
      block_start,last,results[num_threads-1]); // #9
  std::for_each(threads.begin(),threads.end(),
       std::mem_fn(&std::thread::join));  // #10

  return std::accumulate(results.begin(),results.end(),init); // #11
}

  其中。如果输入的范围为空#1,就会得到init的值。反之,如果范围内多于一个元素时,都需要用范围内元素的总数量除以线程(块)中最小任务数,从而确定启动线程的最大数量#2,这样能避免无谓的计算资源的浪费。比如,一台32芯的机器上,只有5个数需要计算,却启动了32个线程。
  计算量的最大值和硬件支持线程数中,较小的值为启动线程的数量#3。因为上下文频繁的切换会降低线程的性能,所以你肯定不想启动的线程数多于硬件支持的线程数量。当std::thread::hardware_concurrency()返回0,你可以选择一个合适的数作为你的选择;在这里,我选择了"2"。你也不想在一台单核机器上启动太多的线程,因为这样反而会降低性能,有可能让你没入门放弃使用并发。
  每个线程中处理的元素数量,是范围中元素的总量除以线程的个数得出的#4。对于分配是否得当,我们会在后面讨论。
  现在,确定了线程个数,通过创建一个std::vector容器存放中间结果,并为线程创建一个std::vectorstd::thread容器#5。这里需要注意的是,启动的线程数必须比num_threads少1个,因为在启动之前已经有了一个线程(主线程)。
&emsp 使用简单的循环来启动线程:block_end迭代器指向当前块的末尾#6,并启动一个新线程为当前块累加结果#7。当迭代器指向当前块的末尾时,启动下一个块#8。
  启动所有线程后,#9中的线程会处理最终块的结果。对于分配不均,因为知道最终块是哪一个,那么这个块中有多少个元素就无所谓了。
  当累加最终块的结果后,可以等待std::for_each#10创建线程的完成,之后使用std::accumulate将所有结果进行累加#11。
  在看这个例子之前,需要明确:T类型的加法运算不满足结合律(比如,对于float型或double型,在进行加法操作时,系统很可能会做截断操作),因为对范围中元素的分组,会导致parallel_accumulate得到的结果可能与std::accumulate得到的结果不同。同样的,这里对迭代器的要求更加严格:必须都是向前迭代器,而std::accumulate可以在只传入迭代器的情况下工作。对于创建出results容器,需要保证T有默认构造函数。对于算法并行,通常都要这样的修改;不过,需要根据算法本身的特性,选择不同的并行方式。需要注意的:因为不能直接从一个线程中返回一个值,所以需要传递results容器的引用到线程中去。另一个办法,通过地址来获取线程执行的结果;
  当线程运行时,所有必要的信息都需要传入到线程中去,包括存储计算结果的位置。不过,并非总需如此:有时候这是识别线程的可行方案,可以传递一个标识数。不过,当需要标识的函数在调用栈的深层,同时其他线程也可调用该函数,那么标识数就会变的捉襟见肘。好消息是在设计C++的线程库时,就有预见了这种情况,在之后的实现中就给每个线程附加了唯一标识符。

识别线程

  线程标识类型是std::thread::id,可以通过两种方式进行检索。第一种,可以通过调用std::thread对象的成员函数get_id()来直接获取。如果std::thread对象没有与任何执行线程相关联,get_id()将返回std::thread::type默认构造值,这个值表示“没有线程”。第二种,当前线程中调用std::this_thread::get_id()(这个函数定义在头文件中)也可以获得线程标识。
  std::thread::id对象可以自由的拷贝和对比,因为标识符就可以复用。如果两个对象的std::thread::id相等,那它们就是同一个线程,或者都“没有线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。
  线程库不会限制你去检查线程标识是否一样,std::thread::id类型对象提供相当丰富的对比操作;比如,提供为不同的值进行排序。这意味着允许程序员将其当做为容器的键值,做排序,或做其他方式的比较。按默认顺序比较不同值的std::thread::id,所以这个行为可预见的:当a<b,b<c时,得a<c,等等。标准库也提供std::hashstd::thread::id容器,所以std::thread::id也可以作为无序容器的键值。
  std::thread::id实例常用作检测线程是否需要进行一些操作,比如:当用线程来分割一项工作,主线程可能要做一些与其他线程不同的工作。这种情况下,启动其他线程前,它可以将自己的线程ID通过std::this_thread::get_id()得到,并进行存储。就是算法核心部分(所有线程都一样的),每个线程都要检查一下,其拥有的线程ID是否与初始线程的ID相同。

std::thread::id master_thread;
void some_core_part_of_algorithm()
{
  if(std::this_thread::get_id()==master_thread)
  {
    do_master_thread_work();
  }
  do_common_work();
}

  另外,当前线程的std::thread::id将存储到一个数据结构中。之后在这个结构体中对当前线程的ID与存储的线程ID做对比,来决定操作是被“允许”,还是“需要”(permitted/required)。
  同样,作为线程和本地存储不适配的替代方案,线程ID在容器中可作为键值。例如,容器可以存储其掌控下每个线程的信息,或在多个线程中互传信息。
  std::thread::id可以作为一个线程的通用标识符,当标识符只与语义相关(比如,数组的索引)时,就需要这个方案了。也可以使用输出流(std::cout)来记录一个std::thread::id对象的值。

std::cout<<std::this_thread::get_id();

  具体的输出结果是严格依赖于具体实现的,C++标准的唯一要求就是要保证ID比较结果相等的线程,必须有相同的输出。

三、在线程间共享数据

线程间共享数据的问题

用互斥保护共享数据

保护共享数据的其他工具

九、高级线程管理

线程池

线程池,顾名思义,就是有一堆已经创建好的线程集合。初始都处于空闲状态。当有新任务来的时候。就从线程的集合中取一个空闲的线程来处理任务。当任务处理结束后。就把该线程放回池中,如果池子中的线程都处于忙碌状态。那么此时就需要创建新的线程加入到池中。或者通知任务当前线程池中所有线程都处于繁忙状态,等待片刻再进行尝试。下面编写一个简单的线程池。
threadpool.h代码编写

#pragma once
#include <vector>
#include <string>
#include <pthread.h>
using namespace std;
/*执行任务的类:设置任务数据并执行*/
class CTask {
protected:
	string m_strTaskName;   //任务的名称
	void* m_ptrData;    //要执行的任务的具体数据

public:
	CTask() = default;
	CTask(string &taskName)
		: m_strTaskName(taskName)
		, m_ptrData(NULL) {}
	virtual int Run() = 0;
	void setData(void* data);   //设置任务数据

	virtual ~CTask() {}

};
class threadpool
{
private:
	static vector<CTask*> m_vecTaskList;    //任务列表
	static bool shutdown;   //线程退出标志
	int m_iThreadNum;   //线程池中启动的线程数
	pthread_t *pthread_id;

	static pthread_mutex_t m_pthreadMutex;  //线程同步锁
	static pthread_cond_t m_pthreadCond;    //线程同步条件变量
protected:
	static void* ThreadFunc(void *threadData);  //新线程的线程回调函数
	 int Create();   //创建线程池中的线程

public:
	threadpool(int threadNum);
	int AddTask(CTask *task);   //把任务添加到任务队列中
	int StopAll();  //使线程池中的所有线程退出
	int getTaskSize();  //获取当前任务队列中的任务数
};

threadpool.cpp代码编写

#include "threadpool.h"
void CTask::setData(void* data) {
	m_ptrData = data;
}

//静态成员初始化
vector<CTask*> threadpool::m_vecTaskList;
bool threadpool::shutdown = false;
pthread_mutex_t threadpool::m_pthreadMutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t threadpool::m_pthreadCond = PTHREAD_COND_INITIALIZER;

void* threadpool::ThreadFunc(void * threadData)
{
	pthread_t tid = pthread_self();
	while (1)
	{
		pthread_mutex_lock(&m_pthreadMutex);
		//如果队列为空,等待新任务进入任务队列
		while (m_vecTaskList.size() == 0 && !shutdown)
			pthread_cond_wait(&m_pthreadCond, &m_pthreadMutex);

		//关闭线程
		if (shutdown)
		{
			pthread_mutex_unlock(&m_pthreadMutex);
			printf("[tid: %lu]\texit\n", pthread_self());
			pthread_exit(NULL);
		}

		printf("[tid: %lu]\trun: ", tid);
		vector<CTask*>::iterator iter = m_vecTaskList.begin();
		//取出一个任务并处理之
		CTask* task = *iter;
		if (iter != m_vecTaskList.end())
		{
			task = *iter;
			m_vecTaskList.erase(iter);
		}

		pthread_mutex_unlock(&m_pthreadMutex);

		task->Run();    //执行任务
		printf("[tid: %lu]\tidle\n", tid);

	}
	return (void*)0;
}

int threadpool::Create()
{
	pthread_id = new pthread_t[m_iThreadNum];
	for (int i = 0; i < m_iThreadNum; i++)
		pthread_create(&pthread_id[i], NULL, ThreadFunc, NULL);

	return 0;
}

threadpool::threadpool(int threadNum)
{
	this->m_iThreadNum = threadNum;
	printf("I will create %d threads.\n", threadNum);
	Create();
}

int threadpool::AddTask(CTask * task)
{
	pthread_mutex_lock(&m_pthreadMutex);
	m_vecTaskList.push_back(task);
	pthread_mutex_unlock(&m_pthreadMutex);
	pthread_cond_signal(&m_pthreadCond);
	return 0;
}

int threadpool::StopAll()
{
	//避免重复调用
	if (shutdown)
		return -1;
	printf("Now I will end all threads!\n\n");

	//唤醒所有等待进程,线程池也要销毁了
	shutdown = true;
	pthread_cond_broadcast(&m_pthreadCond);

	//清除僵尸
	for (int i = 0; i < m_iThreadNum; i++)
		pthread_join(pthread_id[i], NULL);

	delete[] pthread_id;
	pthread_id = NULL;

	//销毁互斥量和条件变量
	pthread_mutex_destroy(&m_pthreadMutex);
	pthread_cond_destroy(&m_pthreadCond);

	return 0;
}
int threadpool::getTaskSize()
{
	return m_vecTaskList.size();
}

测试程序编写

#include"threadpool.h"
#include<cstdio>
#include<stdlib.h>
#include<thread>

class CMyTask :public CTask
{
public:
	CMyTask() = default;
	int Run()
	{
		printf("%s\n", (char*)m_ptrData);
		int num = rand() % 4 + 1;
		this_thread::sleep_for(std::chrono::seconds(num));
		return 0;
	}
	~CMyTask()
	{
	}
};
int main()
{
	CMyTask taskObj;
	char szTmp[] = "hello, thread";
	taskObj.setData((void*)szTmp);
	threadpool threadpool(5); //线程池大小为5
	for (int i = 0; i < 10; i++)
	{
		threadpool.AddTask(&taskObj);
	}
	while (1)
	{
		//任务队列没有任务
		if (threadpool.getTaskSize() == 0)
		{
			//清除线程池
			if (threadpool.StopAll() == -1)
			{
				printf("Thread pool clear,exit.\n");
				exit(0);
			}
		}
		this_thread::sleep_for(std::chrono::seconds(3));
		printf("Wait 3 seconds \n");
	}
	return 0;
}

运行结果:
在这里插入图片描述

  • 8
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值