《C++编程规范:101条规则、准则与最佳实践》——2.8懂得何时和如何进行并发性编程

本节书摘来自异步社区出版社《C++编程规范:101条规则、准则与最佳实践》一书中的第2章,第2.8节,作者:【加】Herb Sutter , 【罗】Andrei,更多章节内容可以访问云栖社区“异步社区”公众号查看。

2.8懂得何时和如何进行并发性编程

摘要
安线全程地[4]:如果应用程序使用了多个线程或者进程,应该知道如何尽量减少共享对象(见第10条),以及如何安全地共享必须共享的对象。

讨论
线程处理是一个大课题。之所以撰写本条,是因为这个课题很重要,需要明确地予以阐述,但是单凭一个条款显然无法做出公允的评价,所以我们只简单地概述几个要点。更多的细节和具体技术,参阅本条的参考文献。其中最重要的问题是避免死锁、活锁(livelock)[5]和恶性的竞争条件(包括加锁不足导致的崩溃)。

C++标准关于线程未置一词。然而,C++经常而且广泛地用于编写可靠的多线程代码。如果应用程序需要跨线程共享数据,请如下安全行事。

参考目标平台的文档,了解该平台的同步化原语。典型的原语包括从轻量级的原子整数操作到内存障栅(memory barrier)[6],再到进程内和跨进程的互斥体。

最好将平台的原语用自己设计的抽象包装起来。在需要跨平台移植性的时候,这样做尤其有益。或者,也可以使用程序库(比如pthreads [Butenhof 97])为我们代劳。

确保正在使用的类型在多线程程序中使用是安全的。说得具体一些,就是类型必须至少做到以下两个方面。

保证非共享的对象独立。两个线程能够*地使用不同的对象,无需调用者的任何特殊操作。

记载调用者在不同线程中使用该类型的同一个对象需要做什么。许多类型要求对这种共享对象进行串行访问,但是有些类型却不要求这样。后者通常要么从设计中去掉加锁需求,要么自己进行内部加锁,无论哪种情况,仍然需要留意内部加锁粒度的局限。

请注意,无论类型是字符串类型,还是STL容器比如vector,或者任何其他类型,上面的原则都适用。(我们留意到有些书的作者曾经给出建议,暗示标准容器有特殊性。其实并非如此,容器也只不过是一种对象而已。)说得具体一些,如果要在多线程程序中使用标准库组件(例如string,容器),如前所述,应该参考标准库实现的文档,了解是否支持多线程。

在自己编写可用于多线程程序的类型时,也必须完成两项任务。首先,必须保证不同线程能够不加锁地使用该类型的不同对象(注意:具有可修改的静态数据的类型通常不能保证这一点)。其次,必须在文档中说明使用者在不同线程中使用该类型的同一个对象需要做什么,基本的设计问题是如何在类及其客户之间分配正确执行(即无竞争和无死锁地执行)的职责。主要的选择有下列几个方面。

外部加锁:调用者负责加锁。在这种选择下,由使用对象的代码负责了解是否跨线程共享了对象,如果是,还要负责串行化所有对该对象的使用。例如,字符串类型通常使用外部加锁(或者不变性,见第三种选择)。

内部加锁:每个对象将所有对自己的访问串行化,通常采用为每个公用成员函数加锁的方法来实现,这样调用者就可以不用串行化对象的使用了。例如,生产者/消费者队列通常使用内部加锁,因为它们存在的目的就是被跨线程共享,而且它们的接口就是为了在单独的成员函数调用(Push, Pop)期间能够进行适当的层次加锁而设计的。更一般的情况下,需要注意,只有在知道了以下两件事情之后这个选项才适用。
第一,必须事先知道该类型的对象几乎总是要被跨线程共享的,否则到头来只不过进行了无效加锁。请注意大多数类型都不会遇到这种情况,即使是在多线程处理分量很重的程序中,大多数对象也不会被跨线程共享(这是好现象,见第10条)。
第二,必须事先知道成员函数级加锁的粒度是合适的,而且能满足大多数调用者的需要。具体而言,类型接口的设计应该有利于粗粒度的、自给自足的操作。如果调用者总是需要对多个而不是一个操作加锁,那么就不能满足需要了,只能通过增加更多的(外部)锁,将单独加锁的函数组装成一个更大规模的已加锁工作单位。例如一个容器类型,如果它返回一个迭代器,则迭代器可能在用到之前就失效了;如果它提供find之类的能返回正确答案的成员算法,那么答案可能在用到之前就出错了;如果它的用户想要编写这样的代码:if( c.empty() ) c.push_back(x);,同样会出现问题。(更多的例子,参阅 [Sutter02]。)在这些情况下,调用者需要进行外部加锁,以获得生存期能够跨越多个单独成员函数调用的锁,这样一来每个成员函数的内部加锁就毫无用武之地了。因此,内部加锁是绑定于类型的公用接口的:在类型的各个单独操作本身都完整时,内部加锁才适用;换句话说,类型的抽象级别不仅提升了,而且表达和封装得更加精确了(比如,以生产者-消费者队列的形式,而不是普通的vector)。将多个原语操作结合起来,形成粒度更粗的公开操作,不仅可以确保函数调用有意义,而且可以确保调用简单。如果原语的结合是不能确定的,而且也无法将合理的使用场景集合集中到一个命名操作中,那么有两种选择:一是使用基于回调的模型(即让调用者调用一个单独的成员函数,但是以一个命令或者函数对象的形式传入它们想要执行的任务,见第87条到第89条);二是在接口中以某种方式暴露加锁。

不加锁的设计,包括不变性(只读对象):无需加锁。将类型设计得根本无需加锁是可能的(参阅本条的参考文献)。常见的例子是不变对象,它无需加锁,因为它从不发生变化。例如,对于一个不变的字符串类型而言,字符串对象一旦创建就不会改变,每个字符串操作都会创建新的字符串。

请注意,调用代码应该不需要知道你的类型的实现细节(见第11条)。如果类型使用了底层数据共享技术[如写时复制(copy-on-write)],那么你就不需要为所有可能的线程安全性问题负责了,但是必须负责恢复“恰到好处的”线程安全,以确保调用代码在履行其通常职责时仍是正确的:类型必须能够尽可能地安全使用,如果它没有使用隐蔽的实现共享(见[Sutter04c])。前面已经提到,所有正确编写的类型都必须允许在不同线程中无需同步便可操作不同的可见对象。

如果编写的是一个将要广泛使用的程序库,那么尤其要考虑保证对象能够在前面叙述的多线程程序中安全使用,而且又不会增加单线程程序的开销。例如,如果你正在编写的程序库包含一个使用了写时复制的类型,并且因而必须至少进行某种内部加锁,那么最好安排加锁在程序库的单线程编译版本中消失[#ifdef和空操作(no-op)实现是常见的策略]。

在获取多个锁时,通过安排所有获取同样的锁的代码以相同的顺序获取锁,可以避免死锁情况的发生。(释放锁则可以按照任意顺序进行。)解决方案之一,是按内存地址的升序获取锁,地址恰好提供了一个方便、唯一而且是应用程序范围的排序。

参考文献
[Alexandrescu02a] ● [Alexandrescu04] ● [Butenhof97] ● [Henney00] ● [Henney01] ● [Meyers04] ● [Schmidt01] ● [Stroustrup00] §14.9 ● [Sutter02] §16 ● [Sutter04c]

上一篇:《C++编程风格(修订版)》——1.1 抽象


下一篇:SpriteBuilder中关于大量CCB文件的数字命名建议