当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。当某个线程可以修改变量,而其他线程也可以读取或修改这个变量的时候,就需要对这些线程进行同步,以确保它们在访问变量的存储内容时不会访问到无效的数据。
当一个线程修改变量,其他线程在读取这个变量的值时就可能会看到不一致的数据。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种潜在的不一致性就会出现。
可以通过使用pthread的互斥接口保护数据,确保同一时间只有一个线程访问数据。互斥量从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其他线程将会看到互斥锁依然被锁住,只能再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
在设计时需要规定所有的线程都必须遵守相同的数据访问规则,只有这样,互斥机制才能正常工作。
互斥变量用pthread_mutex_t数据类型来表示,在使用互斥变量以前,必须首先对它进行初始化,可以把它置为常量PTHREAD_MUTEX_INITIALIZER(只对静态分配的互斥量),也可以通过调用pthread_mutex_init函数进行初始化。如果动态分配互斥量,那么在释放内存前需要调用pthread_mutex_destroy。
#include <pthread.h> int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); int pthread_mutex_destroy(pthread_mutex_t *mutex); // 返回值:若成功则返回0,否则返回错误编号
要用默认的属性初始化互斥量,只把attr设置为NULL。
对互斥量进行加锁,需要调用pthread_mutex_lock,如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用pthread_mutex_unlock。
#include <pthread.h> int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); // 返回值:若成功则返回0,否则返回错误编号
如果线程不希望被阻塞,它可以使用pthread_mutex_trylock尝试对互斥量进行加锁。如果调用pthread_mutex_trylock时互斥量处于未锁住状态,那么pthread_mutex_trylock将锁住互斥量,不会出现阻塞并返回0,否则pthread_mutex_trylock将会失败,不能锁住互斥量,而返回EBUSY。
程序清单11-5描述了用于保护某个数据结构的互斥量。当多个线程需要访问动态分配的对象时,可以在对象中嵌入引用计数,确保在所有使用该对象的线程完成数据访问之前,该对象内存空间不会被释放。
《UNIX环境高级编程》P300:程序清单11-5 使用互斥量保护数据结构
#include <stdlib.h> #include <pthread.h> struct foo { int f_count; // 计数器 pthread_mutex_t f_lock; // 互斥量 }; // 分配空间 struct foo * foo_allco(void) { struct foo *fp; if ((fp = malloc(sizeof(struct foo))) != NULL) { fp->f_count = 1; // 初始化互斥变量 if (pthread_mutex_init(&fp->f_lock, NULL) != 0) { free(fp); return NULL; } } return fp; } // 增加引用次数 void foo_hold(struct foo *fp) { pthread_mutex_lock(&fp->f_lock); // 加锁 fp->f_count++; pthread_mutex_unlock(&fp->f_lock); // 解锁 } // 减少引用次数 void foo_rele(struct foo *fp) { pthread_mutex_lock(&fp->f_lock); // 加锁 if (--fp->f_count == 0) { // 最后一次引用 pthread_mutex_unlock(&fp->f_lock); // 解锁 pthread_mutex_destroy(&fp->f_lock); // 释放锁资源 free(fp); // 释放内存空间 } else { pthread_mutex_unlock(&fp->f_lock); // 解锁 } }
在对引用计数加1、减1以及检查引用计数是否为0这些操作之前需要锁住互斥量。在使用对象前,线程需要对这个对象的引用计数加1,对象使用完毕时,需要对引用计数减1。当最后一个引用被释放时,对象所占的内存空间就被释放。
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,使用互斥量时,还有其他更不明显的方式也能产生死锁。例如,两个线程都在相互请求另一个线程拥有的资源,这两个线程都无法向前运行,于是就产生死锁。
可以通过小心地控制互斥量加锁的顺序来避免死锁的发生。只有在一个线程试图以与另一个线程相反的顺序锁住互斥量时,才可能出现死锁。
有时候应用程序的结构使得对互斥量加锁进行排序是很困难的,如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。可以先释放占有的锁,然后过一段时间再试。这种情况可以使用pthread_mutex_trylock接口避免死锁。
《UNIX环境高级编程》P302:程序清单11-6 使用两个变量
#include <stdlib.h> #include <pthread.h> #define NHASH 29 #define HASH(fp) (((unsigned long)fp)%NHASH) struct foo *fh[NHASH]; pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER; struct foo { int f_count; // 计数器 pthread_mutex_t f_lock; // 互斥量 struct foo *f_next; // 由hashlock保护 int f_id; }; // 分配空间 struct foo * foo_allco(void) { struct foo *fp; int idx; if ((fp = malloc(sizeof(struct foo))) != NULL) { fp->f_count = 1; // 初始化互斥变量 if (pthread_mutex_init(&fp->f_lock, NULL) != 0) { free(fp); return NULL; } idx = HASH(fp); pthread_mutex_lock(&hashlock); // hashlock 加锁 fp->f_next = fh[idx]; fh[idx] = fp; pthread_mutex_lock(&fp->f_lock); // f_lock 加锁 pthread_mutex_unlock(&hashlock); // hashlock 解锁 pthread_mutex_unlock(&fp->f_lock); // f_lock 解锁 } return fp; } // 增加引用次数 void foo_hold(struct foo *fp) { pthread_mutex_lock(&fp->f_lock); // f_lock 加锁 fp->f_count++; pthread_mutex_unlock(&fp->f_lock); // f_lock 解锁 } // 查找一个存在的对象 struct foo * foo_find(int id) { struct foo *fp; int idx; idx = HASH(fp); pthread_mutex_lock(&hashlock); // hashlock 加锁 for (fp = fh[idx]; fp != NULL; fp = fp->f_next) { if (fp->f_id == id) { foo_hold(fp); break; } } pthread_mutex_unlock(&hashlock); // hashlock 解锁 return fp; } // 减少引用次数 void foo_rele(struct foo *fp) { struct foo *tfp; int idx; pthread_mutex_lock(&fp->f_lock); // 加锁 if (fp->f_count == 1) { // 最后一次引用 pthread_mutex_unlock(&fp->f_lock); // f_lock 解锁 // 此处存在竞争,因此在之后还需要检查fp->count的值 pthread_mutex_lock(&hashlock); // hashlock 加锁 pthread_mutex_lock(&fp->f_lock); // f_lock 加锁 if (fp->f_count != 1) { fp->f_count--; pthread_mutex_unlock(&fp->f_lock); // f_lock 解锁 pthread_mutex_unlock(&hashlock); // hashlock 解锁 return; } // 从列表中移出 idx = HASH(fp); tfp = fh[idx]; if (tfp == fp) { fh[idx] = fp->f_next; } else { while (tfp->f_next != fp) tfp = tfp->f_next; tfp->f_next = fp->f_next; } pthread_mutex_unlock(&hashlock); // hashlock 解锁 pthread_mutex_unlock(&fp->f_lock); // f_lock 解锁 pthread_mutex_destroy(&fp->f_lock); // 释放锁资源 free(fp); // 释放内存空间 } else { fp->f_count--; pthread_mutex_unlock(&fp->f_lock); // 解锁 } }
当同时需要两个互斥量时,总是让它们以相同的顺序加锁,以避免死锁。hashlock互斥量保护foo数据结构中的fh散列表和f_next散列链字段。foo结构中的 f_lock互斥量保护对foo结构中的其它字段的访问。
分配函数锁住散列列表锁,把新的结构添加到散列列表存储桶中,在对散列列表的解锁之前,先锁住新结构中的互斥量。因为新的结构是放在全局列表中,其他线程可以找到它,所以在完成初始化之前,需要阻塞其他试图访问新结构的线程。
foo_find函数锁住散列列表锁,然后搜索被请求的结构,如果找到了,就增加其引用计数并返回指向该结构的指针。注意,加锁的顺序是先在foo_find函数中锁定散列列表锁,然后再在foo_hold函数中锁定foo结构中的f_lock互斥量。
现在有了两个锁以后,foo_rele函数变得更加复杂。如果这是最后一个引用,因为将需要从散列列表中删除这个结构,就要先对这个结构互斥量进行解锁,才可以获取散列列表锁。然后重新获取结构互斥量。从上一次获得结构互斥量以来可能处于被阻塞状态,所以需要重新检查条件,判断是否还需要释放这个结构。如果其他线程在我们为满足锁顺序而阻塞时发现了这个结构并对其引用计数加1,那么只需要简单地对引用计数减1,对所有的东西解锁然后返回。
如此加、解锁太复杂,所以需要重新审计原来的设计。也可以使用散列列表锁来保护结构引用计数,使事情大大简化,结构互斥量可以用于保护foo结构中的其他任何东西。程序清单11-7反映了这种变化
《UNIX环境高级编程》P304:程序清单11-7 简化的加、解锁
#include <stdlib.h> #include <pthread.h> #define NHASH 29 #define HASH(fp) (((unsigned long)fp)%NHASH) struct foo *fh[NHASH]; pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER; struct foo { int f_count; // 由hashlock保护 pthread_mutex_t f_lock; // struct foo *f_next; // 由hashlock保护 int f_id; }; // 分配空间 struct foo * foo_allco(void) { struct foo *fp; int idx; if ((fp = malloc(sizeof(struct foo))) != NULL) { fp->f_count = 1; // 初始化互斥变量 if (pthread_mutex_init(&fp->f_lock, NULL) != 0) { free(fp); return NULL; } idx = HASH(fp); pthread_mutex_lock(&hashlock); // hashlock 加锁 fp->f_next = fh[idx]; fh[idx] = fp; pthread_mutex_unlock(&hashlock); // hashlock 解锁 } return fp; } // 增加引用次数 void foo_hold(struct foo *fp) { pthread_mutex_lock(&hashlock); // hashlock 加锁 fp->f_count++; pthread_mutex_unlock(&hashlock); // hashlock 解锁 } // 查找一个存在的对象 struct foo * foo_find(int id) { struct foo *fp; int idx; idx = HASH(fp); pthread_mutex_lock(&hashlock); // hashlock 加锁 for (fp = fh[idx]; fp != NULL; fp = fp->f_next) { if (fp->f_id == id) { fp->f_count++; break; } } pthread_mutex_unlock(&hashlock); // hashlock 解锁 return fp; } // 减少引用次数 void foo_rele(struct foo *fp) { struct foo *tfp; int idx; pthread_mutex_lock(&hashlock); // hashlock 加锁 if (--fp->f_count == 0) { // 最后一次引用 // 从列表中移出 idx = HASH(fp); tfp = fh[idx]; if (tfp == fp) { fh[idx] = fp->f_next; } else { while (tfp->f_next != fp) tfp = tfp->f_next; tfp->f_next = fp->f_next; } pthread_mutex_unlock(&hashlock); // hashlock 解锁 pthread_mutex_destroy(&fp->f_lock); // 释放锁资源 free(fp); // 释放内存空间 } else { pthread_mutex_unlock(&hashlock); // hashlock 解锁 } }
与程序清单11-6的程序相比,程序清单11-7中的程序简单不少。多线程的软件设计经常要考虑这类折中处理方案。如果锁的粒度太粗,就会出现很多线程阻塞等待相同的锁,源自并发性的改善微乎其微。如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,而且代码变得相当复杂。