Unix环境高级编程学习笔记(八) 线程同步

从上一篇学习笔记中,我们了解到线程的特性,以及该如何创建线程,终止线程,设置线程属性等,今天我们将来看一看多线程模式下的资源竞争问题。

互斥量

当某个资源,存在多个线程对它进行访问时,为了维护数据的一致性,我们可以对它加锁,使得同一时间只有一个线程在访问该资源。其中,最常见的锁是互斥量phtread_mutext_t。该结构的初始化有两种方式,分为静态初始化和动态初始化。当该类型的变量被声明为静态全局变量时,我们应该使用宏PTHREAD_MUTEX_INITIALIZER在变量定义的时候对它进行初始化,而如果是利用malloc动态分配的变量,则须利用如下函数进行初始化和销毁(和内存分配没关系):

int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
	const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);

在初始化时,当第二个参数attr赋空时,将使用默认的属性对互斥量进行初始化。锁的属性等会儿再研究,我们先来看默认情况下可以对锁执行的操作及其作用:

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函数获取将与该资源相关的互斥量上锁,如果该锁已被其他线程获得,该调用将被阻塞,知道其他线程调用unlock函数释放锁,该线程才会被唤醒,从而重新尝试获得锁,如果成功将该互斥量上锁,函数正常返回,线程可以访问该资源,在访问完成之后,应该立即释放调用锁,以保证进程最大的并发性。如果因为一些原因不希望被阻塞住,可以调用trylock函数进行上锁,然后通过返回值来判断是否上锁成功。可以看一个简单的例子,以下例子来自环高,其中f_count就是需要保护的共享资源:

#include <stdlib.h>
#include <pthread.h>
struct foo {
	int f_count;
	pthread_mutex_t f_lock;
	/* ... more stuff here ... */
};
struct foo *
foo_alloc(void) /* allocate the object */
{
	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);
		}
		/* ... continue initialization ... */
	}
	return(fp);
}
void
foo_hold(struct foo *fp) /* add a reference to the object */
{
	pthread_mutex_lock(&fp->f_lock);
	fp->f_count++;
	pthread_mutex_unlock(&fp->f_lock);
}
void
foo_rele(struct foo *fp) /* release a reference to the object */
{
	pthread_mutex_lock(&fp->f_lock);
	if (--fp->f_count == 0) { /* last reference */
		pthread_mutex_unlock(&fp->f_lock);
		pthread_mutex_destroy(&fp->f_lock);
		free(fp);
	} else {
		pthread_mutex_unlock(&fp->f_lock);
	}
}

然后,我们来了解一下互斥量的属性thread_mutexattr_t,对该结构体的初始化和销毁函数如下:

int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);

进程间是可以有共享内存区的,我们可以设置该互斥量成为进程间共享的锁。参看以下两个函数:

int pthread_mutexattr_getpshared(const pthread_mutexattr_t *restrict attr,
	int *restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr, int pshared);

这两个函数可以用于获得或设置互斥量的共享属性,该属性可设置为PTHREAD_PROCESS_PRIVATE或是PTHREAD_PROCESS_SHARED。前者是默认值,如果设置为后者,则表示该互斥量是进程间共享的,进程间共享的互斥量可以用在进程间的同步问题上。

然后是锁的类型属性,设置和获取的函数如下:

int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, 
	int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);

互斥量的类型属性可以是一下几种常量:

1. PTHREAD_MUTEX_NORMAL 这是一个标准的互斥量,它不提供任何错误检查或死锁检验。

2. PTHREAD_MUTEX_ERRORCHECK 提供错误检查。

3. PTHREAD_MUTEX_RECURSIVE 这个互斥量类型允许同一个线程对同一个互斥量进行多次加锁,一个递归的互斥量内部维护者一个加锁的次数值,需要注意的是,要想释放一个递归的锁,必须unlock和加锁同样多的次数才行。

4. PTHREAD_MUTEX_DEFAUL 锁类型的默认语义,该类型允许不同的实现自由的将它映射到前面三种类型上。例如,对于linux,该类型被映射到了PTHREAD_MUTEX_NORMAL类型上。

在后面我们会提到条件变量这个东东,由于要检查或改变一个条件变量的值的线程必须持有某个特定的锁,因此,在这种情况下最好不要使用recursive互斥量,如果该互斥量已经被多次上锁了,当调用pthread_cond_wait(后面会提到)去等待条件满足时,它将永远阻塞下去,因为能改变条件变量的线程将用于无法将与该条件变量相关的互斥量上锁。

和线程属性类似,一个互斥量属性可以被用来初始化多个互斥量,并且一旦初始化完毕后,对该属性对象做出的任何改变甚至是销毁都不会再影响到前面那些已经被初始化过的互斥量了。

条件变量(condition varible)

条件变量的类型是pthread_cond_t,它也有静态初始化与动态初始化两种方式,当使用静态全局变量声明时,应当使用宏PTHREAD_COND_INITIALIZER来初始化变量,当使用malloc动态分配内存时,则应使用如下函数对变量进行初始化和销毁:

int pthread_cond_init(pthread_cond_t *restrict cond,
	pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);

如果第二个参数attr赋值为空,即代表使用默认的属性。

我们使用条件变量去等待某个条件的发生,调用如下函数:

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 timeout);

先说第一个函数,传递给函数的互斥量是用于保护该条件变量的,当传递的时候,该锁应该以经被锁住,若等待的条件没有被满足,该线程将自动被放置到等待条件的列表上,并释放锁且陷入沉睡,这些都是在一个原子操作中发生的,当条件满足时,函数返回,且锁已经被再次锁上。第二个函数与前者的区别在与有一个超时限制,当指定的时间过去了,条件还没有被满足时,函数同样会返回,且获得锁。

那么当条件满足后,线程该如何醒来呢,当然是由改变条件的其他线程来通知咯,通知函数如下:

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);


第一个函数用于将等待该条件变量的某个线程唤醒,而第二个则是将等待这个条件变量的所有线程唤醒。但是为了使第一个函数的实现变得简单,POSIX规格说明书允许它唤醒多个线程。

为了更好的理解条件变量的使用逻辑,我们还是来看一个简单的实例吧,该例子来自于环高:

#include <pthread.h>
struct msg {
	struct msg *m_next;
	/* ... more stuff here ... */
};
struct msg *workq;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;
void
process_msg(void)
{
	struct msg *mp;
	for (;;) {
		pthread_mutex_lock(&qlock);
		while (workq == NULL)
			pthread_cond_wait(&qready, &qlock);
		mp = workq;
		workq = mp->m_next;
		pthread_mutex_unlock(&qlock);
		/* now process the message mp */
	}
}
void
enqueue_msg(struct msg *mp)
{
	pthread_mutex_lock(&qlock);
	mp->m_next = workq;
	workq = mp;
	pthread_mutex_unlock(&qlock);
	pthread_cond_signal(&qready);
}

前面阐述的关于条件变量的这些都是其默认设置,我们也可以设置其属性,属性的初始化函数如下:

int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);

对于条件变量的属性,只有一个,就是进程间的共享属性,默认是私有的,其设置方式和mutext相同,所使用函数如下:
int pthread_condattr_getpshared(const pthread_condattr_t *restrict attr,
	int *restrict pshared);
int pthread_condattr_setpshared(pthread_condattr_t *attr, int pshared);

读写锁

如果一个资源,它的读操作多于写操作,我们这时为了提供程序的并发性,可以使用读写锁pthread_rwlock_t,其初始化和销毁函数如下:

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
	const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

如果第二个参数attr赋值为空,即代表使用默认的属性初始化读写锁,读写锁有三种模式,读上锁模式,读写上锁模式,以及未上锁模式,其操作函数如下:

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

这些函数的理解比较简单,就不再赘述了。当读写锁以读写模式上锁后,其他线程无论是想要一读模式上锁也好,读写模式上锁也罢,都将上锁失败,使用前两个函数将被阻塞住,使用后两个函数则失败返回。若读写锁以只读模式被上锁后,那么后来的线程,如果以读写模式上锁,则会失败,但以只读模式上锁则会成功。也就是说读写锁允许被多个线程同时上读锁,但只允许一个线程上读写锁,且上读写锁时,只读锁也不能上锁成功。所以读写锁又成为共享——排斥锁。

同条件变量类似,读写锁的属性也只支持进程共享,其方式这里就不在赘述了,只把其初始化和操作函数罗列如下:、

int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *restrict attr, int *restrict pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int pshared);

多线程中的重入函数

前面在讲信号机制时,提到过在信号机制下的可重入函数。所谓多线程下的可重入函数实际上就是指线程安全的意思,例如malloc函数,尽管它在信号机制中属于不可重入的函数,但在多线程下它是属于可重入函数。大部分POSIX定义的函数都是线程安全的也就是在多线程下是可重入的,例如标准IO库。但当我们访问多个文件时,我们也可以根据自己的需要FILE对象进行加锁,其函数如下:

int ftrylockfile(FILE *fp);
void flockfile(FILE *fp);
void funlockfile(FILE *fp);

这三个函数的一意义显而易见,这里就不再解释了,唯一值得注意的是,这里使用的是recursive互斥量。

只要涉及到同步,就肯定会有心能劣势的。有时为了性能,我们可能也需要不加锁的标准IO库版本:

int getchar_unlocked(void);
int getc_unlocked(FILE *fp);
int putchar_unlocked(int c);
int putc_unlocked(int c, FILE *fp);

不过为了保证他们在多线程环境下能够安全的执行,必须将他们置于文件锁机制的保护之下。


特定线程的数据(thread-specific data)

有时候,我们需要定义这样一些变量,使他们在每一个线程中访问的都是一个单独的副本,实际上,就可以说这些变量对与线程来说是私有的,我们已知的变量errno就是这样一个变量,每个线程都有单独的一份(实际上,在linux中,errno被定义为一个返回指向出错变量的一个指针的函数,并对其返回值做解引用运算)。要了解如何定义thread-specific data,先来看如下的两个函数:

int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void *));
int pthread_key_delete(pthread_key_t *key);// break the association of a key with the thread-specific data values for all threads

实际上,每一个线程私有数据都有一个key值与其相关联。这第一个函数就是用于创建这么一个key值的,第二个参数destructor是与该key值相关联的私有数据的析构函数。第一个参数是O类型参数,当key被创建成功后,就被存放在这个指针所指向的位置,而后它可以被该进程内部的所有线程使用,不过每个线程使用该key值将关联着一个指向不同私有数据的指针。当该key被创建时,每个线程与此key想关联的指向私有数据的指针都被赋为空值,如果该指针是被设置为非空值,那么,当线程正常结束时(调用pthread_exit, 或从入口函数处返回),destructor将被调用用于释放资源,并且该指针将作为该函数的唯一参数。如果线程非正常退出,或是调用exit系列函数结束整个进程,该函数将不会被调用。

而第二个函数则用来删除key值的,他将删除该key与所有线程中与该key想关联的数据值。

那么私有数据该如何同key值想关联呢?可以使用如下的函数获取与key值想关联的私有数据或是设置该私有数据:

void *pthread_getspecific(pthread_key_t key);
int pthread_setspecific(pthread_key_t key, const void *value);

在多线程环境下,许多时候我们都希望某些函数只被调用一次,例如key值的创建函数,我们可以四i用以下函数来保证这一点:

pthread_once_t initflag = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *initflag, void (*initfn)(void));

该函数用来保证后者只被调用一次,一般用于多线程环境下对全局变量的初始化,其中第一个参数目前只能被设置为PTHREAD_ONCE_INIT宏。

线程和fork函数

对于多线程的程序,在frok之后,子进程中只会包含其fork函数的调用线程,这对于锁机制来说往往会出问题,如果某个锁已经被别的线程锁住了,那么在fork之后,子进程由于并不包含持有锁的线程,那么子进程将永远没有得到锁的机会。所以我们可以通过以下函数注册一些处理函数来应对这种情况:

int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));

通过该函数注册三个函数,第一个函数将在fork发生之前被调用,用于获取(锁住)所有需要的锁,后两个函数则在fork执行完毕后,但在其返回之前分别被父进程和子进程调用,用来对相关锁进行解锁。该注册函数也可以多次调用,当多次调用后,在进行fork时,prepare的执行顺序为逆序,后两者都是正序。




你可能感兴趣的:(多线程,编程,unix,struct,FP,reference)