多线程编程指南

使用多处理器

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

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

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

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

屏障同步有时是控制多处理器上并行性的一种有效方式。可以在附录 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

}

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

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

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