线程之间共享数据的问题
从整体上来看,所有线程之间共享数据的问题,都是修改数据导致的。如果所有的共享数据都是只读的,就没有问题,因为一个线程所读取的数据不受另一个线程是否正在读取相同的数据而影响。然而,如果数据是在线程之间共享的,同时一个或多个线程开始修改数据,就可能有很多的麻烦。在这种情况下,你必须要小心确保一切安好。
一个被广泛用来帮助程序员推导代码的概念,就是不变量——对于特定的数据结构总是为真的语句,例如“此变量包含了列表中项目的数量。”这些不变量在更新中经常被打破,尤其是在数据结构比较复杂或是更新需要修改超过一个值时。
考虑一个双向链表,它的每一个节点持有指向表中下一节点和前一节点的指针。其中一个不变量就是如果你跟随从一个节点(A)到另一个节点(B)的“下一个”指针,则那个节点(B)的“前一个”指针指回到前一个节点(A)。为了从表中删除一个节点,两边的节点都必须被更新为指向彼此。一旦其中一个被更新,直到另一侧的节点也被更新前不变量是打破的,当更新完成后,再次持有不变量。
从这样的表中删去一个条目的步骤如图3.1所示。
1)标识要删除的节点(N)。
2)将N的前一节点到N的链接更新为指向N的后一节点。
3)将N的后一节点到N的链接更新为指向N的前一节点。
4)删除节点N。
如你所见,在步骤b和c之间,在一个方向上的链接与在相反的方向上的链接不一致,并且不变量损坏。
修改线程之间共享数据的最简单的潜在问题就是破坏不变量。如果你没有为确保其他情况而做些特别的工作,要是一个线程正在读取双向链表,而另一个线程正在删除一个节点,那么读线程很有可能看到一个节点仅被部分删除了的链表(因为在图3.1的步骤b中,只有其中一个链接被改变了),因此不变量损坏。不变量损坏的后果可能有所不同,如果其他线程只是在图中由左到右读取链表项,它会跳过正被删除的节点。另一方面,如果又一个线程试图删除图中最右边的节点,则可能最终永久性破坏数据结构,并使得程序崩溃。无论结果如何,这是并发代码中错误的最常见诱因之一的例子:竞争条件。
竞争条件
假设你在电影院买票看电影。如果是个大电影院,会有多个收银员收款,所以不止一个人可以同时买票。如果有人在另一个收银台也购买了与你同一部电影的票,这时可供你选择的座位取决于事实上是其他人先订购还是你先订购。如果只剩少量座位,这种差异可能会很关键。字面上可以看作一个比赛,看谁得到最后的电影票。这是一个竞争条件的例子:得到哪个座位(或者甚至是否得到票)取决于两次购买的相对顺序。
在并发性中,竞争条件就是结果取决于两个或更多线程上的操作执行的相对顺序的一切事物。线程竞争执行各自的操作。在大多数情况下,这是比较良性的,因为所有可能的结果都是可以接受的,尽管他们可能会随着不同的相对顺序而改变。例如,如果两个线程都将项目添加到一个队列中等待处理,在保持系统不变量的前提下,哪个项目先被添加一般是不影响的。当竞争条件导致损坏不变量时才会出现问题,以刚才提到的双向链表为例。在谈到并发时,术语竞争条件通常用来表示有问题的竞争条件。良性的竞争条件没什么意思,也不是错误的诱因。C++标准还定义了术语数据竞争,表示因对单个对象的并发修改而产生的特定类型的竞争条件,数据竞争造成可怕的未定义行为。
有问题的竞争条件通常发生在完成操作需要修改两个或多个不同的数据块的地方,就如示例中的两个链表指针。因为该操作必须访问两块独立的数据,这必须在单独的指令中进行修改,而当只有其中一条指令完成时,另一个线程有可能访问此数据结构。竞争条件往往很难找到且难以复制,因为机遇的窗口很小。如果这些修改作为连续的CPU指令来完成,在任意一次运行中显现问题的机会是非常小的,即使数据结构正被另一个线程并发访问。随着系统上负载的升高,以及执行该操作次数的增加,有问题的执行序列出现的机会也增加。这种问题几乎是不可避免地会在最不方便的时间暴露出来。由于竞争条件一般是时间敏感的,它们常常在应用程序运行于调试工具下时完全消失,因为调试工具会影响程序的时间,即使只是轻微地。
如果你正在编写多线程程序,竞争条件会轻易地成为你生活的灾难。编写使用并发的软件中大量的复杂性来源于避免有问题的竞争条件。
避免有问题的竞争条件
有几种方法来处理有问题的竞争条件。最简单的选择是用保护机制封装你的数据结构,以确保只有实际执行修改的线程能够在不变量损坏的地方看到中间数据。从其他访问该数据结构线程的角度看,这种修改要么还没开始要么已完成。C++标准库提供了一些这样的机制,在本章中均有述及。
另一个选择是修改数据结构的设计及其不变量,从而令修改作为一系列不可分割的变更来完成,每个修改均保留其不变量。这通常被称为无锁编程,且难以尽善尽美。如果你工作在这个级别上,内存模型的细微差异和确认哪些线程可能看到哪组值,会变得很复杂。
处理竞争条件的另一种方式是将对数据结构的更新作为一个事务来处理,就如同在一个事务内完成数据库的更新一样。所需的一系列数据修改和读取被存储在一个事务日志中,然后在单个步骤中进行提交。如果该提交因为数据结构已被另一个线程修改而无法进行,该事务将重新启动。这称为软件事务内存(STM),在写作时这是一个活跃的研究领域。这在本书中不会述及,因为在C++中没有对STM的直接支持。然而,私下里做些事情然后在单个步骤中提交的基本思想,我会在后面提到。
由C++标准提供的保护共享数据的最基本机制是互斥元(mutex),那么我们先来看一看。