From:《linux多线程编程》

Sun Microsystems, Inc.

4150 Network Circle

Santa Clara, CA95054

U.S.A.

 

1、重新考虑全局变量

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

    1、写入全局变量,随后又从该变量中读取时,读取的内容就是写入的内容。

    2、写入非全局静态存储,随后又从变量中读取时,所读取的内容恰好就是写入的内容。

    3、不需要进行同步,因为不会调用对变量的并发访问。

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

    传统的单线程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调用。

 

2、提供静态局部变量

    示例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)。

3、同步线程

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

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

    示例9-3printf()问题

    /* thread 1: */

    printf("go to statement reached");

    /* thread2 */

    printf("hello world");

    

    printed on display:

    go to hello

    

    1)单线程策略

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

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

    

    2)可重复执行函数

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

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

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

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

    

    3)代码锁定

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

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

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

    

    4)数据锁定

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

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

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

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

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

 

    5)不变量和锁定

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

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

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

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

    mutex_lock(&lock);

    while((condition) == FALSE)

        cond_wait(&cv, &lock);

    assert((condition) == TRUE);

    mutex_unlock(&lock);

    

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

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

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