UNIX线程同步
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图,这就需要对这些线程进行同步。
同步就是要保证每次各线程对数据的操作总是以顺序一致的方式出现的。
竞争的原因
1、计算机体系结构的因素
例如变量的递增操作:
① 从内存单元读入寄存器
② 在寄存器中进行变量值的增加
③ 把新的值写回内存单元
如果两个线程试图在几乎同一时间对同一个变量做增量操作而不进行同步的话,结果就可能出现不一致。
在现代计算机系统中,存储器访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以无法保证数据是顺序一致的。(指令级的交叉)
2、程序使用变量的方式
例如,可能会对某个变量加1,然后基于这个数值做出某种决定。
增量操作这一步和做出决定这一步两者的组合并非原子操作,因而给不一致的情况提供了可能。(语句级的交叉)
UNIX线程同步方式
互斥量(mutex)
互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。
对互斥量进行加锁以后,任何其他试图再次对其加锁的进程将会被阻塞直到当前线程释放该互斥锁。 如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
(可以这样想:要想独占这个资源必须先上一把锁把别人挡在外面。而当我想上锁时,发现这上面已经有了一把锁,我们只有等这把锁开锁,才能再次上锁。)
(锁这个名称很形象的描述了此种资源访问的规则,我们也可以把它起名为令牌只是不如锁包含的意思丰富罢了)
【注意:我们要规定所有的线程必须遵守相同的数据访问规则,只有这样,互斥机制才能正常工作】
锁只是一种约定的机制:在访问共享资源时要先获得锁(得到许可),它并没有强制要求这么做,若某线程不获得锁,也可以对共享数据操作,只是这样可能会出现数据不一致问题。遵守约定,机制才会有效。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t * mutex , const pthread_mutexattr_t * attr) ;
int pthread_mutex_destroy(pthread_mutex * mutex) ;
互斥变量用pthread_mutex_t数据类型来表示(系统要用这个互斥变量来实现锁的机制),在使用互斥变量前,必须首先对它进行初始化,①可以把它置为常量PTHREAD_MUTEX_INITIALIZER(只对静态分配的互斥量)②通过调用pthread_mutex_init函数进行初始化。
如果动态地分配互斥量(例如通过调用malloc函数),那么在释放内存前需要调用pthread_mutex_destroy。
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t * mutex) ; //对互斥量加锁
int pthread_mutex_trylock(pthread_mutex * mutex) ; //尝试加锁
int pthread_mutex_unlock(pthread_mutex * mutex) ; //对互斥量解锁
如果线程不希望被阻塞,它可以使用pthread_mutex_trylock尝试对互斥量进行加锁。若此时互斥量处于未锁住状态,那么pthread_mutex_trylock将锁住互斥量,不会出现阻塞并返回0,否则其就会失败,不能锁住互斥量,而返回EBUSY。
避免死锁
多个互斥量使用不当可能会发生死锁。
我们可以小心地控制互斥量加锁的顺序来避免死锁的发生,但是有时候对互斥量加锁进行排序是很困难的。
可以先释放占有的锁,然后过一段时间再试。
这种情况可以使用pthread_mutex_trylock接口避免死锁。如果线程已经占有某些锁而且pthread_mutex_trylock接口返回成功,那么就可以前进,但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间重新尝试。
锁的粒度
//互斥量(mutex)的使用举例
//对于哈希表的操作,把结构体的地址存放在hash表中
//用链表法解决地址冲突
//
//着重观察加锁、解锁的顺序
//
#include <pthread.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#define NHASH 29 //哈希表的大小
#define HASH(fp) (((unsigned long)fp)%NHASH) //哈希函数
struct foo
{
int f_count ; //引用此结构体的次数
struct foo* f_next ; //指向链表中下个结构体
pthread_mutex_t f_lock ; //
int f_id ;
/* more stuff */
} ;
struct foo* fh[NHASH] ;
pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER ;
//我们规定:
//哈希互斥量hashlock保护的是哈希表fh
//结构互斥量f_lock保护的是结构体中的元素
struct foo*
foo_alloc(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) ;
//把新malloc的结构体地址放入哈希表中
pthread_mutex_lock(&hashlock) ;
fp->f_next = fh[idx] ;
fh[idx] = fp ;
pthread_mutex_lock(&fp->f_lock) ; //注意加锁解锁的顺序:要在哈希锁解锁之前给结构锁上锁,这样可以保证
pthread_mutex_unlock(&hashlock) ; //下面的结构体其他变量的初始化,和上面的代码都是由同一个线程执行的
//哈希锁解锁之后 其它线程若要对结构体初始化 但此时结构体锁已上锁,
//其他线程只有阻塞等待。
//那为什么不让哈希锁一直等到结构体初始化完毕之后才解锁呢?若此,则
//临界区的长度太大,程序的并发度低。
//当哈希锁解锁时,其他线程可被调度(虽然它们不能访问结构体变量)
//当对临界变量的访问结束后,要及时解锁,让临界区的长度尽可能短。
/* 继续初始化其他结构体中的变量 */
pthread_mutex_unlock(&fp->f_lock) ;
}
return fp ;
}
//增加一次对结构体的引用
void
foo_hold(struct foo* fp)
{
pthread_mutex_lock(&fp->f_lock) ;
fp->f_count++ ;
pthread_mutex_unlock(&fp->f_lock) ;
}
//在哈希表中查找一个存在的结构体
//参数id为标识结构体的ID
struct foo*
foo_find(int id)
{
struct foo* fp ;
int idx ;
idx = HASH(fp) ;
pthread_mutex_lock(&hashlock) ;
//在链表中查找
for (fp = fh[idx]; fp != NULL; fp = fp->f_next)
{
if (fp->f_id == id)
{
foo_hold(fp) ;
break ;
}
}
pthread_mutex_unlock(&hashlock) ;
return fp ;
}
//释放一个结构体的引用
void
foo_release1(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) ;
pthread_mutex_lock(&hashlock) ;
pthread_mutex_lock(&fp->f_lock) ;
//重新检查条件--因为if (fp->f_count == 1)之后就释放了结构体锁,故其他线程就有可能改变f_count的值
//那为什么不在最后再释放结构体锁呢?还是为了多个线程调度点 让其他等待访问结构体的线程有机会运行
if (fp->f_count != 1)
{
fp->f_count-- ;
pthread_mutex_unlock(&fp->f_lock) ;
pthread_mutex_unlock(&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) ; //注意释放锁的顺序,先释放哈希锁,允许别的线程对哈希表访问
pthread_mutex_unlock(&fp->f_lock) ;//(等待在哈希表上的线程可能比较多)再释放结构锁,访问此结构体的线程少
//让其再等一下也无妨。
free(fp) ;
}
else
{
fp->f_count-- ;
pthread_mutex_unlock(&fp->f_lock) ;
}
}
//----------2.0版---------------
//我们规定:
//哈希互斥量hashlock保护的是哈希表fh、结构中的f_count、f_next
//结构互斥量f_lock保护的是结构体中除f_count、f_next其他的元素
//
//由于上面foo_release1中对锁的设置太复杂了,为什么这么复杂?因为我们遵循一个好的习惯
//在对临界变量访问结束后 马上就释放了它的锁,为了线程更高的并发度。
//若我们在整段代码中都把结构体锁f_lock锁住,固然可以不那么复杂,但连累了无辜的其他结构体变量
//其他变量这这段代码中为被访问,但它们却也被锁,使得访问这些变量的线程 无端被阻塞!
//
//那么我们想既减少复杂性,又想遵循这个好的习惯,只有考虑改变锁的粒度(锁的管辖资源)
//我们观察发现这个函数中,对哈希表的访问总是伴随着对结构体中f_count和f_next的访问
//故我们可以考虑 把这三个数据资源归一个锁来管辖,即哈希锁hashlock
//而结构体中剩余的变量可由结构体锁f_lock管辖。
//
//这样我们增大了锁的粒度,减少了复杂性,但线程并行度减小了
//
//设计锁的粒度(即其管辖的资源)是一门艺术
//
//释放一个结构体的引用 2.0版
void
foo_release2(struct foo* fp)
{
struct foo* tfp ;
int idx ;
pthread_mutex_lock(&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) ;
pthread_mutex_destroy(&fp->f_lock) ;
free(fp) ;
}
else
{
pthread_mutex_unlock(&hashlock) ;
}
}
多线程的软件设计经常要考虑这样的折中处理方案:
如果锁的粒度太粗,就会出现很多线程阻塞等待相同的锁,源自并发性的改善微乎其微。
如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,而且代码变得相当复杂。作为一个程序员,需要在满足锁需求的情况下,在代码复杂度和优化性能之间找到好的平衡点。
【后记】
我的一些想法
锁就像是一个信号灯指示着它管辖的资源是否可以访问。
pthread_mutex_lock(mutex)的意思是我申请访问互斥量mutex管辖的资源(这是我们自定义 约定的)
pthread_mutex_unlock(mutex)的意思是我对互斥量mutex管辖的资源的访问结束。
锁的释放点就是线程调度的时机点,对这个锁对应数据的访问的线程就可以运行了。
作为一个通用的变成原则,我们总是应该努力减少由一个互斥锁锁住的代码量。
锁机制的成功是靠程序员对心中约定的遵守(在访问资源前先向相应的mutex申请 获得许可再访问)
读写锁(rwlock)
读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。
读写锁可以有三种状态:
①读模式下加锁状态②写模式下加锁状态 ③不加锁状态
一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
【注意】当读写锁处于读模式状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。(防止写饥饿)
读写锁非常适合于对数据结构读的次数远大于写的情况。(可并行读)
与互斥量一样,读写锁在使用之前必须初始化,在释放它们底层的内存前必须销毁。
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t * rwlock,
pthread_rwlockattr_t * attr) ;
int pthread_rwlock_destroy(pthread_rwlock_t * rwlock) ;
int pthread_rwlock_rdlock(pthread_rwlock_t * rwlock) ; //在读模式下锁定读写锁
int pthread_rwlock_wrlock(pthread_rwlock_t * rwlock) ; //在写模式下锁定读写锁
int pthread_rwlock_unlock(pthread_rwlock_t * rwlock) ; //读写模式都可用此解锁
注意要检查pthread_rwlock_rdlock的返回值,因为系统可能会对读锁的数量进行限制。
int pthread_rwlock_tryrdlock(pthread_rwlock_t * rwlock) ;
int pthread_rwlock_trywrlock(pthread_rwlock_t * rwlock) ;
可以获取锁时,函数返回0;否则返回错误EBUSY
条件变量(cond)
当我们遇到期待的条件尚未准备好时,我们应该怎么做?我们可以一次次的循环判断条件是否成立,每次给互斥锁解锁又上锁。这称为轮询(polling),是一种对CPU时间的浪费。
我们也许可以睡眠很短的一段时间,但是不知道该睡眠多久。
我们所需的是另一种类型的同步,它允许一个线程(或进程)睡眠到发生某个时间为止。
互斥量用于上锁,条件变量则用于等待。则两种不同类型的同步都是需要的。
条件变量是与互斥量一起使用的,因为条件本身是由互斥量保护的,线程在改变条件状态前必须首先锁住互斥量。
条件变量是类型为pthread_cond_t类型的变量,其在使用之前必须进行初始化,可以把常量PTHREAD_COND_INITIALIZER赋给静态分配的条件变量,可以使用pthread_cond_init函数对动态分配的条件变量初始化。
#include <pthread.h>
int pthread_cond_init(pthread_cond_t* cond, pthread_condattr_t* attr) ;
int pthread_cond_destroy(pthread_cond_t* cond) ;
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t mutex) ;
int pthread_cond_timewait(pthread_cond_t* cond,
pthread_mutex_t* mutex,
const struct timespec* timeout) ; //等待时间
使用pthread_cond_wait等待条件变为真,如果在给定的时间条件不能满足,那么会生成一个代表出错码的返回变量。
传递给pthread_cond_wait的互斥量对条件进行保护,调用者把锁住的互斥量传给函数。函数把调用线程放到等待条件的线程列表上,然后对互斥量解锁。pthread_cond_wait返回时,互斥量再次被锁住。
【注意:避免虚假唤醒】当pthread_cond_wait返回时,我们应该再次测试相应条件是否成立,因为可能发生虚假唤醒:期待的条件尚不成立时的唤醒。
示范代码:
pthread_mutex_lock(&var.mutex) ;
while (条件为假)
pthread_cond_wait(&var.cond, &var.mutex) ;
修改条件
pthread_mutex_unlock(&var.mutex) ;
通知线程条件已满足:
int pthread_cond_signal (pthread_cond_t* cond) ;
//唤醒等待条件的某个线程
int pthread_cond_broadcast (pthread_cond_t* cond) ;
//唤醒等待该条件的所有线程
(条件是我们自己定义的,谁改变的条件,谁负责通知唤醒阻塞在条件变量上的线程。而不是由OS监控条件的改变。条件变量就像一个拴马桩,把挂起等待条件的线程拴在了一起。当我们改变条件时,向条件变量发送信号(不是signal),唤醒挂在其上的某个线程)
(我们还可以用条件变量来实现线程间信号的传递。^_^)
【注意:避免上锁冲突】
若pthread_cond_signal由当前锁住某个互斥锁的线程调用,而该互斥锁是与本函数将它发送信号的的条件变量相关联的。我们可以设想下最坏情况,当该条件变量被发送信号后,系统立即调度等待在其上的线程,该线程开始运行,但立即终止,因为它没能获取相应的互斥锁。为避免上锁冲突可把代码改为:
int dosignal ;
pthread_mutex_lock(&nready.mutex) ;
dosignal = (nready.nready == 0) ;
nready.nready++ ;
pthread_mutex_unlock(&nready.mutex) ;
if (dosignal)
pthread_cond_signal(&nready.mutex) ;
【示例】
//此为条件变量的示例代码
//
//背景为:生产者--消费者模型
//有多个生产者、单个消费者。消费者从生产者刚放入的位置处取产品。
//生产者向位置写入正整数(从1开始累计) 表示把产品放入了该位置
//消费者把该位置置0 表示消耗了该产品
//
#include <pthread.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#define MAX_ITEMS 1024
#define MAX_THREADS 20
int g_nitem = 30 ; //设定的缓冲区的大小
int g_buf[MAX_ITEMS] ; //生产者的缓冲池
//尽量把共享数据和它们的同步变量收集到一个结构中,这是一个很好的编程技巧。
struct
{
pthread_mutex_t mutex ; //用于生产者线程间的互斥(也保护g_nitems、g_buf)
int putidx ; //即将放入的产品的位置
int val ; //即将放入的产品值
} put = {
PTHREAD_MUTEX_INITIALIZER
} ;
struct
{
pthread_cond_t cond ;
pthread_mutex_t mutex ;
int nready ; //为消费者准备好的产品数(即当前缓冲区中的产品数)此值即为判断条件
} nready = {
PTHREAD_COND_INITIALIZER ,
PTHREAD_MUTEX_INITIALIZER
} ;
void * produce(void * arg);
void * consume(void* arg) ;
int
main(void)
{
int i = 0 ;
int prodthreadnum = 5 ; //生产者的线程数
pthread_t tid_produce[MAX_THREADS], tid_consume ;
//创建所有的生产者线程和一个消费者线程
for(i = 0; i < prodthreadnum; i++)
{
pthread_create(&tid_produce[i], NULL, produce, NULL) ;
}
pthread_create(&tid_consume,NULL, consume, NULL) ;
//等待所有的生产者线程和一个消费者线程
for(i = 0; i < prodthreadnum; i++)
{
pthread_join(tid_produce[i], NULL) ;
}
pthread_join(tid_consume, NULL) ;
return 0 ;
}
void *
produce(void * arg)
{
for(;;)
{
pthread_mutex_lock(&put.mutex) ;
//缓冲区已满
if (put.putidx > g_nitem)
{
pthread_mutex_unlock(&put.mutex) ;
return NULL ;
}
//把产品放入相应的位置
g_buf[put.putidx] = ++put.val ;
++put.putidx ;
pthread_mutex_unlock(&put.mutex) ;
//不好的代码---此处可能有上锁冲突
pthread_mutex_lock(&put.mutex) ;
//若之前缓冲区是空的(条件是:缓冲区由空变非空 则发出通知)
if (nready.nready == 0)
pthread_cond_signal(&nready.cond) ;
nready.nready++ ;
pthread_mutex_unlock(&put.mutex) ;
}
}
void *
consume(void* arg)
{
int i ;
for (i = 0; i < g_nitem; i++)
{
pthread_mutex_lock(&nready.mutex) ;
while (nready.nready == 0)
pthread_cond_wait(&nready.cond, &nready.mutex) ;
//消耗一个产品
pthread_mutex_lock(&put.mutex) ;
g_buf[--put.putidx] = 0 ;
pthread_mutex_unlock(&put.mutex) ;
nready.nready-- ;
pthread_mutex_unlock(&nready.mutex) ;
}
return NULL ;
}

本文深入探讨UNIX线程同步的必要性和实现方法,包括互斥量、读写锁和条件变量的应用,以及如何避免死锁和优化锁的粒度。

1833

被折叠的 条评论
为什么被折叠?



