18.4 线程同步化与Concurrency(并发)问题
使用多线程几乎总是会伴随“数据的并发访问”(concurrent data access)。多个线程彼此毫无关系是很罕见的。线程有可能提供数据给其他线程处理,或是备妥必要的先决条件(precondition)用以启动其他进程(process)。
这就是线程的棘手的原因。许多事情有可能往错误的方向走,或换个角度来说,许多事情也许和新手甚至经验丰富的程序员的预期不同。
在讨论同步化线程(synchronized thread)和并发数据处理的技术之前,必须先了解问题所在。然后在讨论线程同步化技术:
- Mutex和lock(互斥体和锁)
- Condition variable(条件变量)
- Atomic(原子操作)
18.4.1 当心Concurrency(并发)
若只学一条“如何面对多线程”机制,那应该是:
多个线程并发处理相同的数据而又不曾同步化(synchronization),那么唯一安全的情况是:所有线程只读取数据。
所谓“相同的数据”,是指使用相同内存区(same memory location)。若不同线程并发处理它们手上不同的变量或对象或成员,不会有问题,因为自C++11起,每个变量都保证拥有自己的内存区,唯一例外是bitfield;不同的bitfield有可能共享同一块内存区。因此,访问不同的bitfield其实意味着彼此分享对同一块数据的访问。
位域是C/C++中常用的数据结构。 在某些情况下合理的使用位域可以节省存储空间,提高运行效率并提高程序的可读行。通常以下情况下会优先考虑使用位域。
1、有很多的状态标记,需要集中存储,比如tcp链接的状态 2、协议栈相关的数据结构,尤其是底层通信协议中很多情况使用一个或者几个bit来表示某种状态,数据长度等等。这时候就会使用到位域。
struct SNField{
unsigned char seq:7 ; // frame sequnce
unsigned char startbit:1 ; // indicate if it's starting frame 1 for yes.
};
struct STField{
unsigned int flag_tt:20 ; // frame sequnce
unsigned int flag_ee:12 ; // indicate if it's starting frame 1 for yes.
};
这里要注意的位域为单位的成员和其他的struct的成员,都是按照从低地址到高地址的方式分配内存的。
然后,当两个或更多线程并发处理相同的变量或对象或成员,而且至少其中一个线程改动了它,而你又不曾同步后该处理动作,你就可能有了深深的麻烦。这就是C++所谓的data race。C++11标准中定义的data race是“不同线程中的两个互相冲突的动作,其中至少一个动作不是atomic(不可切割的),而且无一个动作发生在另一个动作之前。” Data race总是导致不可预期的行为。
就像处于data race情况一样,问题在于,代码也许经常能够如你所愿,但却不是永远如此。这正是编程时面对的最难缠的问题之一。或进入生产模式,或换到另一个平台,或你的程序拓然就完蛋了。所以,如使用多线程,要特别小心concurrent data access。
18.4.2 concurrent data access为什么造成问题
我们必须了解当我们使用concurrency时,C++给了什么保证。
注意:一个编程语言如C++,总是个抽象层,用以支持不同的平台和硬件,后者根据其体系结构和目的提供不同的能力和接口。因此,一个像C++这样的标准具体描述了语句和操作的影响,但并非等同于其所产生的汇编码。标准描述的是what而不是how。
事实上,行为甚至有可能不被明确定义,例如:函数调用时其实参核值(argument evalution)次序就没有具体说明。
重要问题:语言给了什么保证?
c++ - “as-if”规则究竟是什么?究竟什么是“假设”规则?一个典型的答案是:
允许任何和所有代码转换不会改变程序的可观察行为的规则。
事实上,关于所谓的as-if规则:每个编译器都可以将代码无限优化,只要程序行为外观上相同。因此,被生成的代码是个黑盒子,是可以变化的,只要可观测行为保持稳定。以下摘自C++ standard:
任何实现(implementation)可以自由忽略国际标准的任何规定,只要最终成果貌似遵守了那些规定——这可以有程序的可观测行为加以判断。例如:一个现实实现(actual implementation)不需要核算表达式的某一部分——如果它可以推演而知其值未被使用且又不至于影响程序所产生的可观测行为。
未定义行为之所以存在,是为了给予编译器和硬件厂商自由度和能力去生成最佳代码,不论他们的“最佳”标准在哪里。是的,它适用于两端:编译器有可能展开循环,重新安排语句,去除无用代码,预先获取数据,而在现代体系结构中,一个硬件实现的buffer有可能重新安排(reorder)load或store。
重新安排次序(reordering)对于改善程序速度也许有帮助,但它们亦有可能产生破坏行为。为了受益于“最快的速度”,“安全性”也许不在默认考虑范围内。因此,特别针对concurrent data access,我们必须了解我们手上有些什么保证。
18.4.3 什么情况下可能出错
事实上,在C++中我们可能会遭遇到以下问题:
-
Unsynchronized data access(未同步化的数据访问):并行运行的两个线程读和写同一笔数据,不知道哪个语句先来;
-
Half-written data(写至半途的数据):某个线程正在读数据,另一个线程改动它,于是读取中的线程甚至可能读到改了一半的数据,读到一个半新半旧值。
-
Reordered statement(重新安排的语句):语句和操作有可能被重新安排次序(reordered)。也许对于每一个单线程正确,但对于多个线程的组合却破坏了预期的行为。
- 请牢记以下文字 *
除非另 有说明,C++标准库提供的函数通常不支持“写或读”动作与另一个“写”动作(写至同一笔数据)并发执行。
也就说,除非另有说明,来自多线程“对同一object的多次调用”会导致不可预期的行为。
然而C++标准库对于线程安全还是挺了若干保证,如:
-
并发处理同一容器内的不同元素是可以的(vector
例外)。因此,不同的线程可以并发读和/或写同一容器的不同元素。例如:每个线程可以处理某些事,然后将结果存储于一个共享的vector内专属该线程的元素。 -
并发处理string stream、file stream或stream buffer会导致不可预期的行为。但格式化输入自和输出至某个标准stream是可以的,虽然这可能导致插叙的字符。
load是取后面地址单元的内容,放到前面地址单元里面去。
store是把前面地址的内容存储到后面地址单元里面去。
一前一后。
即使是基本数据类型如int或bool,标准也不保证读或写是atomic(不可切割的),意指独占而不可被打断(exclusive noninterruptable)。Data race不是那么有可能发生,但如果想完全消除其可能性,就必须采取手段。
相同情况也适用于复杂的数据结构,即使由C++标准库提供,如:std::list<>,程序员有权决定是否确保“当某个线程正在安插或删除元素时,容器不会被另一个线程改动”。否则,其它线程便有可能用到这个list的不一致状态。如:“前向指针”已修改但“后向指针”尚未被改。
long data;
bool readyFlag = false;
一种天真做法是,将“某线程中对data的设定”和“另一线程中对data的消费”同步化。于是,供应端这么调用:
data = 42;
readyFlag = true;
而消费端这么调用:
while(!readyFlag) {
;
}
foo(data);
在不知任何细节的情况下,几乎每个程序员一开始都会认为第二线程必是在data有值42之后,才调用foo()。他们认为“多foo()的调用”只有在readyFlag是true的前提下才能触及,而那又唯有发生在第一线程将42赋值给data之后,因此赋值之后才令readyFlag变成true。
但其实这并非必要。事实上,第二线程的输出有可能是data“在第一线程赋值42之前”的旧值(甚至任何值,因为42赋值动作有可能只做了一半)【假设赋值操作两次store】
也就是说,编译器和/或硬件有可能重新安排语句,使得实际执行以下动作:
readyFlag = true;
data = 42;
一般而言,基于C++规则,这样的重新安排(reordering)是允许的。因为C++只要求编译所得的代码在单一线程内的可观测行为(observable behavior inside a thread)正确。
对于第一线程,并不在意改变readyFlag还是data,从这个线程的角度看,两个语句毫不相干。因此,重新安排语句是被允许的,只要单一线程的可视效果相同。
再强调一次,允许如此更改,原因是默认情况下C++编译器应该有能力生成高度优化代码,而某些优化行为可能需要重新排列语句。默认情况下,这些优化并未被要求在意“是否存在其他线程”,这样能让优化更容易些,因为这种情况下,只需要局部分析(local analysis)便足够。
18.4.4 解决问题所需要的性质(Feature)
为了解决concurrent data access的三个主要问题,我们需要先建立以下概念:
-
Atomicity(不可切割性):这意味着写或读一个变量,或是一连串语句,其行为时独占的,排他的,无任何打断,因此一个线程不可能读到“因另一个线程而造成的”中间状态。
-
Order(次序):我们需要一些方法保证“具体指定之语句”的次序。
C++标准库提供了多种方法来处理这个概念,让程序在concurrent access方面获得额外的保证:
- 可以使用future和promise,它们都保证atomicity和order:一定是在形成成果(返回值或异常)之后才设定shared state,这意味着读和写不会并发发生
2.可使用mutex和lock来处理critical section或protected zone,借此得以授予独占权利,使得(例如)一个“检查动作”和一个“依赖该检查结果的操作”之间不会发生任何事。Lock提供atomicity,它会阻塞(blocking)所有“使用second lock”的处理行为,直到作用于相同资源身上的first lock被释放。更精确得说,被某个线程获得的lock object,它“被另一线程获得”之前必须先被成功释放。然而,如果两个线程使用lock来处理数据,每次运行的次序都有可能发生变化。
-
可以使用condition variable有效地令某线程等待若干“被另一个线程控制的”判断式(predicate)成为true。这有助于应付多线程间的次序,允许一或多个线程处理其他一或多个线程所提供的数据或状态。
-
可以使用atomic data type,确保每次对变量或对象的访问动作都是不可切割的(atomic)——只要atomic type上的操作次序保持稳定(stable)
-
可以使用atomic data type的底层接口,它允许专家放宽(relex)atomic语句的次序或针对内存访问使用手制藩篱(manual barrier,所谓fence)。
原则上,这份清单由高级排列到底层。高级特征如future何promise或mutex和lock很容易使用,风险较低。
底层特性如atomic和(特别是)其底层接口,也许能够提供较佳效能,因为它们有较低的潜在因素并因此有较高的可伸缩性(scalability),但也大幅增加了误用的风险。尽管如此,底层特性有时候可以为某些特定的高级问题提供简单解法。
有了atomic,我们得以进入lock-free(免锁)编程,而那是专家偶尔也会出错的领域。以下文字摘自Herb Sutter的【Sutter:LockFree】“lock-free code即使对专家都很困难。我们很容易写出似乎可运行的lock-free code,但很难写出不但正确而且运行良好的lock-free code。甚至优秀的杂志和期刊都曾刊出大量lock-free code而实际上却在微妙处失败。”
volatile和concurrency
注意,我并没有说volatile是个“用来解决concurrent data access问题”的性质(feature),虽然你可能因为以下原因而有那样的期盼:
- volatile是C++关键字,用来阻止“过度优化”
- 在java中,volatile对于atomicity和order提供了某些保证
在c++中,volatile * “只” * 具体表示对外部资源(像是共享内存)的访问不该被优化掉。如果没有volatile,编译器也许会消除对同一块共享内存区看似多余的load,只因它在整个程序中看不到这个区域的任何改变。但是在C++,volatile既不提供atomicity也不提供特别的order。因此,volatile的语义在C++和Java之间如今有些差异。
探讨“当mutex被用来在一个循环(loop)内读取数据”时,为什么通常不要求使用volatile?
18.5 Mutex和Lock
Mutex全名mutual exclusion(互斥体),是个object,用来协助采取独占排他(exclusive)方式控制“对资源的并发访问”。这里所谓的“资源”可能是个object,或多个object的组合。为了获得独占式的资源访问能力,相应的线程必须锁定(lock)mutex,这样可以防止其他线程也锁定mutex,直到第一个线程解锁(unlock)mutex。
18.5.1 使用Mutex和Lock
有一点很重要:凡是可能发生concurrent access的地方都该使用同一个mutex,不论读或写皆如此。
这个简单的办法,有可能演变得十分复杂。举例:你应该确保异常——它会终止独占——也解除(unlock)相应的mutex,否则资源就有可能被永远锁住。此外,也可能出现deadlock情景:两个线程在释放它们自己的lock之前,彼此等待对方的lock。
C++标准库试图处理这些问题(但目前仍无法从概念上根本解决)。举例:面对异常,你不该自己lock/unlock mutex,应该使用RAII守则(Resource Acquisition Is Initialization),循此,构造函数将获得资源,而析构函数——甚至当“异常造成生命结束”它也总是会被调用——则负责为我们释放资源。为了这个目的,C++标准库提供了class std::lock_guard,如:
int val;
std::mutext valMutex;
{
std::lock_guardstd::mutex lg(valMutext);
++val;
} // ensure that lock gets released here
注意:这样的lock应该被限制在可能之最短周期内,因为它们会阻塞(block)其他代码的并行运行机会。由于析构函数会释放这个lock,你或许会想明确安插大括号,令lock在更进一步语句被处理前先被释放。
这只是第一个例子,但可以看出,整个主题很容易变得很繁复。一如既往,程序员应该知道在并发模式(concurrent mode)下他们的所有行为。此外,C++存在着不同的mutex和lock,稍后讨论。
所谓将输出同步化,就是令每次对print()的调用都独占地写出所有字符,为此引入mutex给print使用,以及一个lock guardy用来锁定被保护区。
lock guard的构造函数会调用mutex的lock(),如果资源(亦即mutex)已被取走,它会block(阻塞),直到“对保护区的访问”再次获得允许。然后,lock的次序仍旧不明确,因此,print的三行输出有可能以任何次序出现。
递归的(Recursive)Lock
有时候,递归锁定(to lock recursively)是必要的。典型例子是:active object或monitor,它们在每个public函数内放一个mutex并取得其lock,用以防止data race腐蚀对象的内部状态。例如:一个数据库接口可以像这样:
每一个public都含有