跳到主要内容

C/C++并发编程

阅读量

0

阅读人次

0

前言

我与多线程的邂逅是在大学毕业后的第一份工作中。我们正在编写一个数据处理应用程序,不过,需要处理的数据量很大,每条记录都是独立的,并且需要在插入数据库之前,对数据量进行合理分配。为了充分利用我们的10核UltraSPARC CPU(Ultra Scalable Processor ARChitecture,终极可扩充处理器架构(大端))的能力,我们使用了多线程,每个线程需要处理所传入的数据记录。我们使用C++和POSIX线程库完成编码,并犯了相当多的 错误——多线程对我们所有人来说都是一个新的事物——但我们最终还是完成了任务。也是 在这个项目中,我第一次了解到C++标准委员会和最新发布的C++标准。

从那时起,我就对多线程和并发产生了浓厚的兴趣。虽然其他人认为多线程非常困难,复杂,也是问题的来源,而我认为它是一个强大的工具。使用多线程可以使您的代码充分利用硬件资源从而更快地运行。从那以后,我开始学习如何使用多线程来提高应用程序的响应能力和性能,甚至在单核硬件上,通过使用多线程来消除耗时操作(如I/O)的延迟。我也学会了在操作系统级别上使用多线程,以及英特尔CPU如何处理任务切换。

同时,我对C++的兴趣使我接触到了ACCU,之后是BSI(英国标准委员会)中的C++标准委员会,还有Boost。也是因为兴趣的原因,当初期版本已经被开发者们放弃的时候,我抓住了这次机会参与了Boost线程库的初期开发工作。直到现在,我依然是Boost线程库的主要开发者和维护者。

随着c++标准委员会的工作从修复现有标准的缺陷转移到为编写c++ 11标准(新 标准命名为C++0x是希望它能在2009年发布,不过最后因为2011年才发布,所以官方命名为C++11)建议书。我也开始更多地参与BSI的工作中,并且开始起草我自己的提案。当委员会将多线程提上C++标准的日程时,我高兴得差点飞起来,因为我撰写或与其他人共同撰写的多线程和并发相关的草案,将会成为新标准的一部分。在解决C++17的改进时,我继续参与到并发TS小组的工作中,以及对未来的建议。我感到很荣幸有机会以这种方式将我的两个主要计算机相关兴趣——C++和多线程以这种方式结合起来。

本书吸收了我在C++和多线程方面的经验,旨在教其他C++开发人员如何安全有效的使用C++17线程库和并发性,我也希望能把我对这门学科的热情传递给大家。

关于这本书

本书是对C++新标准的并发性和多线程工具的深入指南,从最基本的std::threadstd::mutexstd::async的使用,到复杂的原子操作和内存模型。

路线图

前4章,介绍了标准库提供的各种库工具,展示了使用方法。

第5章,涵盖了底层内存模型和原子操作的实际情况,包括原子操作如何对执行顺序进行限制(这章标志着介绍部分的结束)。

第6、7章,开始讨论高级主题,如何使用基本工具去构建复杂的数据结构——第6章是基于锁的数据结构,第7章是无锁数据结构。

第8章,继续讨论高级主题,包括设计多线程代码的指导原则,其中覆盖了影响性能的问题,以及各种并行算法的示例实现。

第9章,线程管理——线程池,工作队列和中断操作。

第10章,介绍了C++ 17提供的新的并行性支持,它以附加重载的形式出现在许多标准库算法中。

第11章,测试和调试——Bug类型,定位Bug的技巧,以及如何进行测试等等。

附录,包括新的语言特性的简要描述,主要是与多线程相关的特性,以及在第4章中提到的消息传递库的实现细节和C++17线程库的完整的参考。

你好,C++并发世界

本章主要内容

  • 何谓并发和多线程
  • 应用程序为什么要使用并发和多线程
  • C++的并发史
  • 一个简单的C++多线程程序

令C++用户振奋的时刻到了。距初始的C++标准(1998年) 发布13年后,C++标准委员会给语言本身,以及标准库,带来了一次重大的变革。新C++标准(也被称为C++11或C++0x) 在2011年发布,带来一系列的变革让C++编程更加简单和高效。委员会还致力于发布一个新的“train model”,每三年发布一个新的C++标准。到目前为止,我们已经发布了其中两个标准:在2014年发布了C++14标准和2017年的C++ 17标准,以及多项技术规范描述对C++标准的扩展。

其中一个最重要的新特性就是C++11标准中对多线程的支持。C++标准第一次承认多线程在语言中的存在,并在标准库中为多线程提供组件。这意味着使用C++编写与平台无关的多线程程序成为可能,也为可移植性提供了强有力的保证。与此同时,程序员们为提高应用的性能,对并发的关注也是与日俱增,特别在多线程编程方面。C++14和C++17标准建立在这个基础之上,为用C++编写多线程程序提供了进一步的支持,技术规范也是如此。有一个并发扩展的技术规范,还有一个并行性的技术规范,尽管后者已经集成到C++17中。

第三章 线程间共享数据

本章主要内容

  • 线程间共享数据带来的问题
  • 使用互斥量保护数据
  • 保护共享数据的替代方案

使用线程进行并发的一个关键好处是可以很容易地实现并且直接在线程之间共享数据,所以现在我们已经讨论过了启动和管理线程,让我们来看看共享数据的相关问题。

想象一下,你和你的朋友合租一个公寓,公寓中只有一个厨房和一个卫生间。当你的朋友在卫生间时,你就会不能使用了(除非你们特别好,好到可以在同时使用一个卫生间)。当你的室友一直霸占着卫生间时,而这时你也需要使用卫生间,你会感到非常不快。同样的,这个问题也会出现在厨房,假如:尽管它也许可以同时做两顿饭,如果你有一个组合烤箱和烤架,如果你们中的一个人想在餐厅里烤香肠,而另一个人正在烤蛋糕,结果肯定不会太好,(香肠味的蛋糕)。此外,在公共空间将一件事做到一半时,发现某些需要的东西被别人借走,或是当离开的一段时间内有些东西被变动了地方,这都会令我们不快。

线程也有着同样的问题。当线程在访问共享数据的时候,必须定一些规矩,用来限定哪些线程可以访问哪些数据位。还有,一个线程更新了共享数据,需要对那些关心这些数据线程进行通知。从易用性的角度看,同一进程中的多个线程进行数据共享,有利有弊。错误的共享数据使用方法是产生并发bug的一个主要原因,其后果要比香肠味的蛋糕严重的多。

本章就以在C++中进行安全的数据共享为主题。避免上述及其他潜在问题的发生的同时,将共享数据的优势发挥到最大。

3.1 共享数据带来的问题

归根结底,线程之间共享数据的问题都是由于修改数据所导致。如果所有共享数据都是只读的,就没有问题,因为一个线程读取数据,与另一个线程是否读取此数据没有影响。 但是,如果数据在线程之间共享,并且一个或多个线程开始修改数据,那么就有很多潜在的问题。在这种情况下,你必须注意确保一切工作正常。

一个被广泛用于帮助程序员分析代码的概念是不变量(invariants)——对于特定的数据结构总是正确的语句,例如“变量包含list中的items数量”。这些不变量经常在更新过程中被破坏,特别是当数据结构非常复杂或者一次更新需要修改多个值。

考虑一个双链表,其中每个node都有一个指向下一个node的node指针和指向前一个node的node指针。其中一个不变量是如果你遵循一个“next”指针从一个node(A)指向另一个node(B),“previous”指针从node(B)向后指向第一个node(A) 。为了从list中删除一个node,每一边的node都必须更新以指向彼此。一旦其中一个更新后,不变量将被破坏,直到另一侧的节点被破坏为止;更新完成后,不变量再次保持不变。

从list中删除item的步骤如图3.1所示:

a. 找到要删除的节点N

b. 更新前一个节点指向N的指针,让这个指针指向N的下一个节点

c. 更新后一个节点指向N的指针,让这个指正指向N的前一个节点

d. 删除节点N

如图3.1所示,在步骤b和步骤c之间,指向一个方向的链接与指向相反方向的链接不一致,不变量被破坏。

修改线程之间共享数据的最简单的潜在问题就是不变量被破坏。

当不采取任何措施来确保在这个过程中不会有其他线程进行访问的话,可能就有线程访问到刚刚删除一边的节点;这样的话,线程就读取到要删除节点的数据(因为只有一边的连接被修改,如图3.1(b)),所以不变量就被破坏。破坏不变量的后果是多样,当其他线程按从左往右的顺序来访问列表时,它将跳过被删除的节点。在一方面,如有第二个线程尝试删除图中右边的节点,那么可能会让数据结构产生永久性的损坏,使程序崩溃。无论结果如何,都是并行代码常见错误:竞争条件*(race condition)*。

图3.1 从双链表中删除一个节点

图3.1 从双链表中删除一个节点

3.1.1 竞争条件

假设你去电影院买电影票。如果去的是一家大电影院,有很多收银台,很多人就可以在同一时间买电影票。当另一个收银台也在卖你想看的这场电影的电影票,那么你的座位选择范围就取决于在之前已预定的座位。当只有少量的座位剩下,这就意味着,这可能是一场抢票比赛,看谁能抢到最后一张票。这就是一个竞争条件的例子:你的座位(或者你的电影票)都取决于两种购买方式的相对顺序。

并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。大多数情况下,即使改变执行顺序,也是良性竞争,其结果可以接受。例如,有两个线程同时向一个queue中添加需要处理的items,只要系统提供的不变量保持不变,所以谁先谁后都不会有什么影响。当不变量遭到破坏时,才会产生条件竞争,比如双向链表的例子。并发中的竞争条件通常表示为恶性竞争条件,我们对不产生问题的良性条件竞争不感兴趣。 C++ 标准中也定义了*数据竞争(data race)*这个术语,一种特殊的条件竞争:并发的去修改单个对象(参见5.1.2节),数据竞争会导致可怕的未定义行为

恶性条件竞争通常发生于对多个的不同数据块进行修改时,例如,对两个连接指针的修改(如图3.1)。因为操作要访问两个单独的数据块,这些必须在单独的指令中修改,可能当其中只有一个数据块处理完成时,另一个线程就开始访问数据。由于出现的概率太低,条件竞争很难查找和复现。如果修改是作为连续的CPU指令执行的,即使数据结构可以让其他并 发线程访问,在任何一次运行中出现问题的几率也很小。当系统负载增加时,随着执行数量的增加,执行序列的问题复现的概率也在增加,这样的问题只可能会出现在负载比较大的情况下。竞争条件通常是时间敏感的,所以程序以调试模式运行时,它们常会完全消失,因为调试模式会影响程序的执行时间(即使影响不多)。

如果您正在编写多线程程序,竞争条件很容易就会成为你的梦魇 。在并发编程时,很大程度上的复杂性都来自于如何避免恶性的竞争条件。

3.1.2 避免恶性竞争条件

Linux C 可重入API

在Ubuntu终端输入man 7 pthreads可以查看POSIX.1多Linux多线程的规范说明。

里面明确说明了从POSIX.1-2001POSIX.1-2008开始,除了以下91个函数,其他标准函数都必须为线程安全函数。

asctime()
basename()
catgets()
crypt()
ctermid() if passed a non-NULL argument
ctime()
dbm_clearerr()
dbm_close()
dbm_delete()
dbm_error()
dbm_fetch()
dbm_firstkey()
dbm_nextkey()
dbm_open()
dbm_store()
dirname()
dlerror()
drand48()
ecvt() [POSIX.1-2001 only (function removed in POSIX.1-2008)]
encrypt()
endgrent()
endpwent()
endutxent()
fcvt() [POSIX.1-2001 only (function removed in POSIX.1-2008)]
ftw()
gcvt() [POSIX.1-2001 only (function removed in POSIX.1-2008)]
getc_unlocked()
getchar_unlocked()
getdate()
getenv()
getgrent()
getgrgid()
getgrnam()
gethostbyaddr() [POSIX.1-2001 only (function removed in POSIX.1-2008)]
gethostbyname() [POSIX.1-2001 only (function removed in POSIX.1-2008)]
gethostent()
getlogin()
getnetbyaddr()
getnetbyname()
getnetent()
getopt()
getprotobyname()
getprotobynumber()
getprotoent()
getpwent()
getpwnam()
getpwuid()
getservbyname()
getservbyport()
getservent()
getutxent()
getutxid()
getutxline()
gmtime()
hcreate()
hdestroy()
hsearch()
inet_ntoa()
l64a()
lgamma()
lgammaf()
lgammal()
localeconv()
localtime()
lrand48()
mrand48()
nftw()
nl_langinfo()
ptsname()
putc_unlocked()
putchar_unlocked()
putenv()
pututxline()
rand()
readdir()
setenv()
setgrent()
setkey()
setpwent()
setutxent()
strerror()
strsignal() [Added in POSIX.1-2008]
strtok()
system() [Added in POSIX.1-2008]
tmpnam() if passed a non-NULL argument
ttyname()
unsetenv()
wcrtomb() if its final argument is NULL
wcsrtombs() if its final argument is NULL
wcstombs()
wctomb()

对于以上91个非线程安全函数,大部分原因都是其使用了全局变量,Linux也提供了部分对应的线程安全函数,一般以_r作为后缀(reentrant),例如localtime()的线程安全函数为localtime_r()

同时,随着C运行时库(例如glibc)的更新,有些接口也已经实现了线程安全性,例如readdir()函数,而readdir_r()函数被标记了废弃。在man手册man readdir_r可以找到上述描述。类似的函数还有system()wcstombs(),均可以在man手册中ATTRIBUTES下找到说明。

锁定共享资源

使用静态数据或任何其他共享资源(例如文件或终端)的函数必须通过锁序列化对这些资源的访问,以保证线程安全。 例如,以下函数是线程不安全的:

int increment_counter() {	// 非线程安全函数
static int counter = 0;

counter++;
return counter;
}

为了线程安全,静态变量counter必须受到静态锁的保护,如下例所示:

int increment_counter() {	// 伪代码线程安全函数
static int counter = 0;
static lock_type counter_lock = LOCK_INITIALIZER;

pthread_mutex_lock(counter_lock);
counter++;
pthread_mutex_unlock(counter_lock);
return counter;
}