多线程编程指南

第 9 章 编程原则

本章提供有关使用线程进行编程的一些建议。大多数建议同时适用于 Solaris 和 POSIX 线程,但效用会有所不同,需要注意其行为。本章着重讲解从单线程思维到多线程思维的转变。本章讨论以下主题:

重新考虑全局变量

以前,大多数代码都是为单线程程序设计的。此代码设计特别适合于大多数从 C 程序调用的库例程。对于单线程代码,进行了以下隐含假设:

以下示例论述了由于这些假设而在多线程程序中引发的一些问题,以及如何处理这些问题。

传统的单线程 C 和 UNIX 通常会处理在系统调用中检测到的错误。系统调用可将任何内容作为函数值返回。例如,write() 返回已传输的字节数。但是,会保留值 -1 以表明出现了错误。因此,当系统调用返回 -1 时,即表明调用失败。


示例 9–1 全局变量和 errno

extern int errno;

...

if (write(file_desc, buffer, size) == -1) {

    /* the system call failed */

    fprintf(stderr, “something went wrong, “

        “error code = %d\n”, errno);

    exit(1);

}

...

错误代码将被置于全局变量 errno 中,而不是返回可能与正常返回值混淆的实际错误代码。系统调用失败时,可以查看 errno 以了解错误所在。

现在,请考虑在多线程环境中,当两个线程几乎同时失败而出现的错误不同时会发生的情况。两个线程都期望在 errno 中找到其错误代码,但 errno 的一个副本不能同时包含两个值。此全局变量方法不适用于多线程程序。

线程可通过概念上的新存储类来解决此问题:线程特定数据。此类存储似于全局存储。可从正在运行的线程的任何过程访问线程特定数据。但是,线程特定数据专门用于线程。当两个线程引用同名称的线程特定数据位置时,这些线程引用的是两个不同的存储区域。

因此,使用线程时,对 errno 的每个引用都是特定于线程的,因为每个线程都具有专用的 errno 副本。在此实现方案中,通过使 errno 成为可扩展到函数调用的宏来实现特定于线程的 errno 调用。

提供静态局部变量

示例 9–2 说明了与 errno 问题类似的问题,但涉及的是静态存储,而不是全局存储。函数 gethostbyname(3NSL) 是将计算机名称作为其参数进行调用的。返回值是一个指向结构的指针,该结构包含通过网络通信联系计算机所需的信息。


示例 9–2 gethostbyname() 问题

struct hostent *gethostbyname(char *name) {

    static struct hostent result;

        /* Lookup name in hosts database */

        /* Put answer in result */

    return(&result);

}

通常情况下,使用返回到局部变量的指针不是一个好办法。在本示例中使用指针有效,是因为变量是静态的。但是,当两个线程同时使用不同的计算机名称调用此变量时,使用静态存储会发生冲突。

errno 问题一样,可以使用线程特定数据来替换静态存储。但是,此替换涉及到动态分配存储,并且会增加调用开支。

处理该问题的更好方法是使 gethostbyname() 的调用方为调用结果提供存储。调用方可通过例程的其他输出参数来提供存储。其他输出参数需要 gethostbyname() 函数的新接口。

在线程中常使用此技术来解决许多问题。在大多数情况下,新接口的名称就是原有名称附加 "_r",如 gethostbyname_r(3NSL)

同步线程

共享数据和进程资源时,应用程序中的线程必须彼此协作并进行同步。

多个线程调用处理同一对象的函数时,会引发问题。在单线程环境中,同步对这类对象的访问不是问题。但是,如示例 9–3 所示,同步对于多线程代码是个问题。请注意,对于多线程程序,可以安全调用 printf(3S) 函数。本示例说明当 printf() 不安全时可能会发生的情况。


示例 9–3 printf() 问题

/* thread 1: */

    printf("go to statement reached");





/* thread 2: */

    printf("hello world");







printed on display:

    go to hello

单线程策略

一个策略是,只要应用程序中的任何线程处于运行状态并在必须阻塞之前被释放,即可获取单个应用程序范围的互斥锁。由于无论何时都只能有一个线程可以访问共享数据,因此每个线程都有一致的内存视图。

由于此策略仅对单线程非常有效,因此此策略的使用范围非常小。

可重复执行函数

更好的方法是利用模块化和数据封装的原理。可重复执行函数可以在同时被多个线程调用的情况下正确执行。要编写可重复执行函数,需要大致了解正确操作对此特定函数的意义。

必须使被多个线程调用的函数可重复执行。要使函数可重复执行,可能需要对函数接口或实现进行更改。

访问全局状态(如内存或文件)的函数具有可重复执行问题。这些函数需要借助线程提供的相应同步机制来保护其全局状态的使用。

使模块中的函数可重复执行的两个基本策略是代码锁定和数据锁定。

代码锁定

代码锁定是在函数调用级别执行的,而且可保证函数在锁定保护下完全执行。该假设针对通过函数对数据执行的所有访问。共享数据的函数应该在同一锁定下执行。

某些并行编程语言提供一种构造,称为监视程序。监视程序可以对监视程序范围内定义的函数隐式执行代码锁定。还可以通过互斥锁来实现监视。

可保证受同一互斥锁保护或同一监视程序中的函数相对于监视程序中的其他函数以原子方式执行。

数据锁定

数据锁定可保证一直维护对数据集合的访问。对于数据锁定,代码锁定概念仍然存在,但代码锁定只是对共享(全局)数据的轮流引用。对于互斥锁,在每个数据集合的临界段中只能有一个线程。

另外,在多个读取器、单个写入器协议中,允许每个数据集合或一个写入器具有多个读取器。当多个线程对不同数据集合执行操作时,这些线程可以在单个模块中执行。需要特别指出的是,对于多个读取器、单个写入器协议,这些线程不会在单个集合上发生冲突。因此,数据锁定通常比代码锁定具备的并发性更多。

使用锁定时应使用哪种策略,在程序中实现互斥、条件变量还是信号?是要尝试通过仅在必要时锁定并在不必要时尽快解除锁定来实现最大并行性(这种方法称作“细粒度锁定 (fine-grained locking)”)?还是要长期持有锁定,以使使用和释放锁的开销降到最低程度(这种方法称作“粗粒度锁定 (coarse-grained locking)”)?

锁定的粒度取决于锁定所保护的数据量。粒度非常粗的锁定可能是单一锁定,目的是保护所有数据。划分由适当数目的锁定保护数据的方式非常重要。锁定粒度过细可能会降低性能。当应用程序包含太多锁定时,与获取和释放锁关联的开销可能会变得非常大。

常见的明智之举是先使用粗粒度方法,确定瓶颈,并在必要时添加细粒度锁定来缓解瓶颈。此方法听起来是很合理的建议,但是您应该自行判断如何在最大化并行性与最小化锁定开销之间找到平衡。

不变量和锁定

对于代码锁定和数据锁定,不变量对于控制锁定复杂性非常重要。不变量指始终为真的条件或关系。

对于并发执行,其定义修改如下(在上述定义的基础上稍加修改即可得到此定义):不变量是在设置关联锁定时为真的条件或关系。设置锁定后,不变量可能为假。但是,在释放锁之前,持有锁的代码必须重新建立不变量。

不变量还可以是设置锁定时为真的条件或关系。条件变量可以被认为含有一个不变量,而这个不变量就是这个条件。


示例 9–4 使用 assert(3X) 测试不变量

    mutex_lock(&lock);

    while((condition)==FALSE)

        cond_wait(&cv,&lock);

    assert((condition)==TRUE);

      .

      .

      .

    mutex_unlock(&lock);

assert() 语句用于测试不变量。cond_wait() 函数不保留不变量,这就是在线程返回时必须重新评估不变量的原因所在。

另一个示例就是用于管理双重链接的元素列表的模块。对于该列表中的每一项,良好的不变量是列表中前一项的向前指针。向前指针还应与向前项的向后指针指向同一元素。

假设此模块使用基于代码的锁定,进而受到单个全局互斥锁的保护。删除或添加项时,将获取互斥锁,正确处理指针,而且会释放互斥锁。显然,在处理指针的某一时间点,不变量为假,但在释放互斥锁之前,需要重新建立不变量。

避免死锁

死锁是指永久阻塞一组争用一组资源的线程。仅因为某个线程可以继续执行,并不表示不会在某个其他位置发生死锁。

导致死锁的最常见错误是自死锁递归死锁。在自死锁或递归死锁中,线程尝试获取已被其持有的锁。递归死锁是在编程时很容易犯的错误。

例如,假设代码监视程序让每个模块函数在调用期间都获取互斥锁。随后,由互斥锁保护的模块内函数间的任何调用都会立即死锁。函数调用模块外的代码时,如果迂回调入任何受同一互斥锁保护的方法,则该函数也会发生死锁。

这种死锁的解决方案就是避免调用模块外可能通过某一路径依赖此模块的函数。需要特别指出的是,应避免在未重新建立不变量的情况下调用回调入模块的函数,而且在执行调用之前不要删除所有的模块锁。当然,在调用完成和重新获取锁定之后,必须验证状态,以确保预期的操作仍然有效。

另一种死锁的示例就是当两个线程(线程 1 和线程 2)分别获取互斥锁 A 和 B 时的情况。假设,线程 1 尝试获取互斥锁 B,线程 2 尝试获取互斥锁 A。如果线程 1 在等待互斥锁 B 时受到阻塞,而无法继续执行。线程 2 在等待互斥锁 A 时受到阻塞也无法继续执行。任何情况都不会发生变化。因此,在这种情况下,将永久阻塞线程,即出现死锁现象。

通过建立获取锁定的顺序(锁定分层结构),可以避免这种死锁。当所有线程始终按指定的顺序获取锁定时,即可避免这种死锁。

遵循严格的锁定获取顺序并不总是非常理想。例如,线程 2 具有许多有关在持有互斥锁 B 时模块状态的假设。放弃互斥锁 B 以获取互斥锁 A,然后按相应的顺序重新获取互斥锁 B 将导致线程放弃其假设。必须重新评估模块的状态。

阻塞同步元语通常具有变体,这些变体将尝试获取锁定,并在无法获取锁定时失败。例如 mutex_trylock()。元语变体的这种行为允许线程在不出现争用时破坏锁定分层结构。出现争用时,通常必须放弃持有的锁定,并按顺序重新获取锁定。

与调用相关的死锁

由于不能保证获取锁定的顺序,因此如果特定线程永远不能获取锁定就会出现问题。

持有锁的线程释放锁,一小段时间后重新获取锁定时,通常会出现此问题。由于锁被释放,因此其他线程似乎理应可以获取锁。但是,持有锁的线程未被阻塞。因此,从线程释放锁到重新获取锁定的时间内,该线程将持续运行。这样,就不会运行其他线程。

通常,通过在进行重新获取锁定的调用前调用 thr_yield(3C),可以解决此类型的问题。thr_yield() 允许其他线程运行并获取锁定。

由于应用程序的时间片要求是可变的,因此系统不会强加任何要求。可通过调用 thr_yield() 来使线程根据需要进行分时操作。

锁定原则

请遵循以下的简单锁定原则。

线程代码的一些基本原则

创建和使用线程

线程软件包会对线程数据结构和栈进行高速缓存,以使重复创建线程的代价较为合理。 但是,与管理等待独立工作的线程池相比,在需要线程时创建和销毁线程的代价通常会更高。RPC 服务器就是一个很好的示例,该服务器可以为每个请求创建一个线程,并在传送回复时销毁该线程。

创建线程的开销比创建进程的开销要少。但是,与创建几个指令的成本相比,创建线程并不是最经济的。请在至少持续处理数千个计算机指令时创建线程。

使用多处理器

借助多线程,可以充分利用多处理器,主要是通过并行性和可伸缩性。程序员应该了解多处理器与单处理器的内存模块之间的差异。

内存一致性与询问内存的处理器直接相关。对于单处理器,内存显然是一致的,因为只有一个处理器查看内存。

要提高多处理器性能,应放宽内存一致性。不应始终假设由一个处理器对内存所做的更改立即反映在该内存的其他处理器视图中。

使用共享变量或全局变量时,可借助同步变量来避免这种复杂性。

屏障同步有时是控制多处理器上并行性的一种有效方式。可以在附录 B,Solaris 线程示例: barrier.c 中找到屏障示例。

另一个多处理器问题是在线程必须等到所有线程都到达执行的共同点时进行有效同步。


注 –

始终使用线程同步元语访问共享内存位置时,此处讨论的问题并不重要。


基础体系结构

线程使用线程同步例程来同步对共享存储位置的访问。使用线程同步时,在共享内存多处理器上运行程序与在单处理器上运行程序的效果相同。

但是,在许多情况下,程序员可能很想利用多处理器,并使用“技巧”来避免同步例程。正如示例 9–5示例 9–6 所示,这类技巧可能是危险的。

了解常见多处理器体系结构支持的内存模块有助于了解这类危险。

主要的多处理器组件为:

在简单的传统模型中,多处理器的行为方式就像将处理器直接连接到内存一样:当一个处理器存储在一个位置,而另一个处理器从同一位置直接装入时,第二个处理器将装入第一个处理器存储的内容。

可以使用高速缓存来加快平均内存访问速度。当该高速缓存与其他高速缓存保持一致时,即可实现所需的语义。

该简单方法存在的问题是,必须经常延迟处理器以确定是否已实现所需的语义。许多现代的多处理器使用各种技术来防止这类延迟,遗憾的是,这会更改内存模型的语义。

下面的两个示例说明了这两种方法及其效果。

“共享内存”多处理器

请考虑示例 9–5 中显示的对生成方和使用者问题的假设解决方案。

尽管此程序是在当前基于 SPARC 的多处理器上工作,但该程序假设所有的多处理器都有强秩序存储器 (strongly ordered memory)。因此,此程序不是可移植的。


示例 9–5 生成方和使用者问题:共享内存多处理器

                    char buffer[BSIZE];

                    unsigned int in = 0;

                    unsigned int out = 0;



void                             char

producer(char item) {              consumer(void) {

                                    char item;

    do                               

        ;/* nothing */                do  

    while                                 ;/* nothing */

        (in - out == BSIZE);           while

                                         (in - out == 0);

    buffer[in%BSIZE] = item;            item = buffer[out%BSIZE];

    in++;                             out++;

}                                }

当此程序确实具有一个生成方和一个使用者,而且在共享内存多处理器上运行时,该程序看上去是正确的。inout 之间的差异就是缓冲区中的项数目。

生成方通过重复计算此差异一直等待,直到缓冲区中有可用空间来存放新项为止。使用者一直等到缓冲区中存在项为止。

严格排序的内存可以对一个处理器上可供其他处理器直接使用的内存进行修改。对于强秩序存储器,该解决方案是正确的,即使考虑到 inout 最终会溢出也是如此。只要 BSIZE 小于以单个词表示的最大整数,就会发生溢出。

共享内存多处理器不一定具有强秩序存储器。由一个处理器对内存所做的更改不一定可直接用于其他处理器。请了解一个处理器对不同内存位置进行两次更改时发生的具体情况。其他处理器不一定会按照更改顺序检测更改,因为不会立即修改内存。

首先,会将更改存储在对于高速缓存不可见的存储缓冲区中。

处理器将检查这些存储缓冲区,以确保程序具有一致的视图。但是,由于存储缓冲区对于其他处理器是不可见的,因此在某个处理器写入高速缓存之前,该处理器写入的内容不可见。

同步元语使用刷新存储缓冲区的特殊指令来执行高速缓存。请参见第 4 章,用同步对象编程。因此,对共享数据使用锁定可确保内存的一致性。

如果内存排序非常宽松,则示例 9–5 存在问题。使用者可能发现,在其查看对相应缓冲槽位所做的更改之前生成方已经增大了 in

此排序称为弱排序,因为由一个处理器执行的存储在由另一个处理器执行时顺序可能会被打乱。但是,内存在同一个处理器中始终是一致的。要解决此不一致性,代码应使用互斥来刷新高速缓存。

由于趋势朝着放宽内存顺序方向发展,因此程序员在对所有的全局数据或共享数据使用锁定时变得越来越谨慎。

示例 9–5示例 9–6 所示,锁定是最基本的。

Peterson 算法

示例 9–6 中的代码是 Peterson 算法的实现,该算法可以处理两个线程之间的互斥。此代码尝试保证临界段中只有一个线程。当线程调用 mut_excl() 时,线程会在某一时间“快速”进入临界段。

此处假设在进入临界段后线程很快就退出了。


示例 9–6 两个线程是否互斥?

void mut_excl(int me /* 0 or 1 */) {

    static int loser;

    static int interested[2] = {0, 0};

    int other; /* local variable */

   

    other = 1 - me;

    interested[me] = 1;

    loser = me;

    while (loser == me && interested[other])

        ;



    /* critical section */

    interested[me] = 0;

}

如果多处理器具有强秩序存储器,则此算法可工作一段时间。

某些多处理器(包括某些基于 SPARC 的多处理器)具有存储缓冲区。线程发出存储指令时,数据即被放入存储缓冲区中。缓冲区内容最终会被发送到高速缓存,但不一定会立即发送。每个处理器上的高速缓存都可以维护一致的内存视图,但修改的数据不会立即到达高速缓存。

在存储到多个内存位置时,更改将按正确顺序到达高速缓存和内存,但可能会在一定延迟后到达。具备此属性的基于 SPARC 的多处理器被称为具有全存储序顺序 (total store order, TSO)。

假设您面临的情况是,一个处理器存储到位置 A 并从位置 B 装入。另一个处理器存储到位置 B 并从位置 A 装入。第一个处理器将从位置 B 提取新修改的值,或第二个处理器将从位置 A 提取新修改的值,或者同时发生这两种情况。但是,两个处理器装入原有值的情况不会发生。

此外,由于装入和存储缓冲区导致的延迟,可能会发生“不可能的情况”。

使用 Peterson 算法时可能发生的情况是在不同处理器上运行的两个线程可以同时进入临界段。每个线程都可以存储到其各自的特定数组槽中,然后从其他槽装入。两个线程可以读取原有的值 (0),每个线程都假设对方不存在,而且都可以进入临界段。测试程序时可能不会检测出这种问题,这种问题只会在稍后发生。

要避免此问题,请使用线程同步元语,其实现会发出特殊指令,从而强制将存储缓冲区写入高速缓存。

在共享内存并行计算机上并行化循环

在许多应用程序(特别是数值应用程序)中,一部分算法可以并行化,而其他部分却具有固有的顺序性,如下表所示。

表 9–1  

顺序执行 

并行执行 

 

Thread 1

 

Thread 2 through Thread n

 

while(many_iterations) {



    sequential_computation

    --- Barrier ---

    parallel_computation

}

 

while(many_iterations) {





    --- Barrier ---

    parallel_computation

}

例如,您可能会使用严格的线性计算产生一组矩阵,并对使用并行算法的矩阵执行操作。随后,可以使用这些操作的结果来产生另一组矩阵,并行在这些矩阵上执行操作等。

这类计算的并行算法特征是计算期间很少需要执行同步。但是,为确保在并行计算开始之前完成顺序计算,需要对所有线程进行同步。

屏障将强制所有执行并行计算的线程一直等待,直到所有涉及到的线程都达到屏障为止。所有线程到达屏障后,即释放线程并同时开始计算。

线程程序示例

本指南介绍了各种重要线程编程问题。附录 A,样例应用程序:多线程 grep 提供了使用许多已论述功能和样式的 pthread 程序示例。附录 B,Solaris 线程示例: barrier.c 提供了使用 Solaris 线程的程序示例。

需要进一步阅读的内容

有关多线程的更详细信息,请参见 Steve Kleiman、Devang Shah 和 Bart Smaalders 合编的《Programming with Threads》(Prentice-Hall 出版,1995)。