线程同步
线程由于共享同一个进程的内存空间,所以资源的访问也应当如同操作系统一样受到限制,一个线程在读取的时候其他线程不能写入,这种限制被称为同步措施。
在学习操作系统原理的时候应当都听过锁的使用。一个资源,如果想要被多个进程访问,最好使用锁机制来确保一致性,不会出现访问冲突。线程也是一样,对于这个情况最简单的想法就是简单的加上一个锁,同一时刻只允许一个线程访问资源。
资源的访问实际上是竞争的访问,如果说所有的操作都是原子性的,那么线程就不存在资源冲突,但是很可惜,目前做不到这点,所以我们还是不得不忍受着线程同步的繁琐。
顺便一提,目前现有的现代化操作系统,几乎都是按照线程分配CPU的,而不是按照进程分配的,如果CPU大于线程数,甚至可以让一个线程占据一个CPU资源,不过这种情况很少见罢了。
互斥量
pthread模型提供了互斥的锁访问,也就是上面讲过的同一时刻只有一个线程访问资源。当设置了互斥量以后,任何视图对互斥量加锁的线程都会被阻塞直到当前线程释放互斥锁。
互斥变量是用pthread_mutex_t
数据类型声明。并且在使用前,必须进行初始化,我们也可以将其设置为常量PTHREAD_MUTEX_INITIALIZER
,也可以使用pthread_mutex_init
函数初始化。如果是动态的分配变量,则在释放内存前需要使用pthread_mutex_destroy
。
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_init函数创建一个新的互斥量,并且使用attr参数初始化,如果attr为null,则使用默认的属性参数。pthread_mutex_destroy释放分配给mutex参数的资源。
下面是加锁函数
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
这三个函数都很简单,lock函数会阻塞调用进程,trylock函数则尝试锁住资源,如果失败则不会阻塞。
下面讲述原著中一个例子,关于C语言引用计数的线程同步。
#include <stdlib.h>
#include <pthread.h>
struct foo {
int f_count;
pthread_mutex_t f_lock;
int f_id;
/* more stuff here */
}
struct foo *foo_alloc(int id);
{
struct foo *fp;
if ((fp = malloc(sizeof(struct foo))) != NULL) {
fp->f_count = 1;
fp->f_id = id;
if (pthread_mutex_init(&fp->f_lock, NULL) != 0) {
free(fp);
return(NULL);
}
/* continue initialization */
}
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_lock(&fp->f_lock);
pthread_mutex_destroy(&fp->lock);
free(fp);
} else {
pthread_mutex_unlock(&fp->f_lock);
}
}
引用计数是一种古老的内存管理计数,很简单,但是非常有效,性能也很高,上面就是一种C语言下的引用计数,当然,我们可以将函数以函数指针的形式放置在结构体中,这里就不弄了。
可以看到,结构体非常简单,就一个引用计数成员、互斥量和数据成员,使用foo_alloc函数分配空间,并且在其中初始化互斥量,在foo_alloc函数中我们并没有使用互斥量,因为初始化完毕前分配线程是唯一的能使用的线程。但是在这里例子中,如果一个线程调用foo_rele释放引用,但是在此期间另一个线程使用foo_hold阻塞了,等第一个线程调用完毕,引用变为0,并且内存被回收,然后就会导致崩溃。
避免死锁
死锁也是个很常见的情况,一个线程试图对同一个互斥量加锁两次,那么它自身会陷入死锁状态,再比如两个线程都在等待对方已经占有的资源,也会导致死锁,死锁一直是新手的大忌,所以应当仔细控制加锁来避免。pthread_mutex_trylock就是一个很好的方法用来防止死锁产生。
#include <stdlib.h>
#include <pthread.h>
#define NHASH 29
#define HASH(id) (((unsigned long)id)%NHASH)
struct foo *fh[NHASH];
pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER;
struct foo {
int f_count;
pthread_mutex_t f_lock;
int f_id;
struct foo *f_next;
/* more stuff here */
}
struct foo *foo_alloc(int id);
{
struct foo *fp;
int idx;
if ((fp = malloc(sizeof(struct foo))) != NULL) {
fp->f_count = 1;
fp->f_id = id;
if (pthread_mutex_init(&fp->f_lock, NULL) != 0) {
free(fp);
return(NULL);
}
idx = HASH(id);
pthread_mutex_lock(&hashlock);
fp->f_next = fh[idx];
fh[idx] = fp;
pthread_mutex_lock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
/* continue initialization */
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);
}
struct foo *foo_find(int id)
{
struct foo *fp;
pthread_mutex_lock(&hashlock);
for (fp = fh[HASH(id)]; fp != NULL; fp = fp->f_next) {
if (fp->f_id == id) {
foo_hold(fp);
break;
}
}
pthread_mutex_unlock(&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);
pthread_mutex_lock(&hashlock);
pthread_mutex_lock(&fp->f_lock);
if (fp->f_count != 1) {
--fp->f_cound;
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
return;
}
idx = HASH(fp->f_id);
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);
pthread_mutex_destroy(&fp->lock);
free(fp);
} else {
--fp->f_count;
pthread_mutex_unlock(&fp->f_lock);
}
}
这是对之前一个例程的改进,程序很容易理解,这里维护了两个互斥量,一个全局互斥量,一个结构体内部的互斥量,或者换言之,全局互斥量是哈希表自身的互斥量,每次的时候,先加锁全局,然后再加锁结构体内部,就能避免死锁。其实上面添加的那么多东西,实际上就是哈希散列公式,然后利用哈希散列公式存取内部的结构体变量。
看了那么久了,可能大家也对其中实现细节很好奇,这里就稍微贴一下代码
struct _opaque_pthread_mutex_t {
long __sig;
char __opaque[__PTHREAD_MUTEX_SIZE__];
};
typedef struct _opaque_pthread_mutex_t __darwin_pthread_mutex_t;
typedef __darwin_pthread_mutex_t pthread_mutex_t;
#define PTHREAD_MUTEX_INITIALIZER {_PTHREAD_MUTEX_SIG_init, {0}}
相信看了上面的代码,各位对互斥量实现细节应该也有自己的看法了。
pthread_mutex_timedlock
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct timespec *restrict tsptr);
这个函数其实没什么好说的,只是在原先的锁定阻塞函数上多了一个超时机制,但是感觉没什么用,在实际使用的时候也很少出现,而且很悲伤的是,苹果系统尚未支持这个函数,虽然FreeBSD函数支持。
读写锁
读写锁是一个更加灵活的锁机制,互斥锁只有两种状态,锁定、不锁定,而读写锁可以有三种状态,读锁定、写锁定、不加锁状态。并且在读锁定情况下,可以有很多线程锁定,而写锁定下,只有一个线程能锁定。其实非常好理解,读取可以有多个,但是写入只能有一个,当写入的时候就不能读取,读取的时候不能写入。在很多教科书上,读写锁也被称为共享互斥锁。
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
这两个函数跟之前互斥锁的初始化函数差不多,系统也提供了PTHREAD_RWLOCK_INITIALIZER。就像前面提到的pthread各种类型的实现一样,由于这些变量本质上是一个结构体,也是会分配内存空间的,如果我们在调用上面函数销毁回收空间之前就使用free函数回收内存,则会导致资源丢失,也就是内存泄露。
int pthread_rwlock_rdlock(pthread_rwlock_t *lock);
int pthread_rwlock_wrlock(pthread_rwlock_t *lock);
int pthread_rwlock_unlock(pthread_rwlock_t *lock);
这三个函数实际上也就是三种读写锁的状态,而且这些函数实际上也有条件版本。
int pthread_rwlock_tryrdlock(pthread_rwlock_t *lock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *lock);
带有超时的读写锁
就像互斥锁一样,Unix标准还规定了带有超时的读写锁,但是非常遗憾,苹果系统同样不存在这个函数。所以这里就不讲解了
int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock, const struct *timespec *restrict tsptr);
int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock, const struct *timespec *restrict tsptr);
条件变量
条件变量是第三种同步机制。条件变量让线程挂起,直到共享数据上的某些条件得到满足才触发启动。但是在正常情况下需要和互斥量一起使用,来防止出现条件竞争。
就像是其他的pthread函数一样,我们必须先对其进行初始化后才能使用,同样也存在PTHREAD_COND_INITIALIZER常量对其进行初始化。
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
创建完成以后,需要使用函数等待条件变为真
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
令人惊讶的是,苹果系统居然提供了超时函数。当然,就像是锁定函数一样,这两个函数都是阻塞的。pthread_cond_wait
函数自动解锁mutex参数指向的互斥量,并使当前线程阻塞在cond参数指向的条件变量上,被阻塞的线程可以被pthread_cond_signal
函数函数或者pthread_cond_broadcast
唤醒,当函数返回的时候,互斥量将被再次锁住。
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
这两个函数很好理解,就是发送了一个信号给cond参数指向的条件。
自旋锁
自旋锁是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。实际上自旋锁和互斥锁在接口形式和行为方面都非常相似,可以很容易的从一个到另外一个,不过,苹果系统并没有提供自旋锁,所以这里也就不讲述了。
屏障
屏障是用户协调多个线程并行工作,并且直到所有线程都到达一点以后继续执行,pthread_join函数就是一种屏障。Unix系统还提供了同游的函数用于开发。
int pthread_barrier_init(pthread_barrier_t *restrict barrier, const pthread_barrierattr_t *restrict attr, unsigned int count);
int pthread_barrier_destroy(pthread_barrier_t *restrict barrier);
int pthread_barrier_wait(pthread_barrier_t *barrier)
其实这些API经过之前的学习,基本上都能看懂了,就是和互斥锁一样,只不过更加的广泛而已。但是非常遗憾的是,苹果系统还是没有提供这些API,或者是苹果认为这些API并没有想象中那么好用,并且在实际使用中也是更少见,所以就将其depreciation。