线程相关(一)——线程基础

1.  线程

        线程的概念就不赘述了,直接记录在Linux系统上的操作。(本系列文章示例均使用g++编译通过。)

1.1  线程的创建

        在传统的UNIX进程模型中,每个进程只有一个控制线程。从概念上讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在POSIX线程(pthread)的情况下,程序开始运行时,它也是以单进程中的单个控制线程启动的,在创建多个控制线程以前,程序的行为与传统的进程并没有什么区别,新增的线程可以通过调用pthread_create函数创建。函数原型如下:

#include <pthread.h>

int pthread_create (pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg);

        返回值:若成功则返回0,否则返回错误编号。

        当pthread_create成功返回时,由tidp指向的内存单元被设置为新创建线程的线程ID。att参数用于定制各种不同的线程属性,后续会讨论线程属性的问题,目前暂时将其设置为NULL,创建默认属性的线程。

       新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无类型指针参数arg,如果需要向start_rtn函数传递的参数不止一个,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg参数传入。

       线程创建时并不能保证哪个线程会先运行:是新创建的线程还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的未决信号集被清除。

       注意pthread函数在调用失败时通常会返回错误码,它们并不像其他的POSIX函数一样设置errno。每个线程都提供errno的副本,这只是为了与使用errno的现有函数兼容。在线程中,从函数中返回错误码更为清晰整洁,不需要依赖那些随着函数执行不断变化的全局状态,因而可以把错误的范围限制在引起出错的函数中。

       接下来演示一下创建线程,这里将创建线程并且打印进程ID、新线程的线程ID以及初始线程的线程ID。

#include <pthread.h>
#include <cstdio>
#include <cstdlib>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

pthread_t ntid;

void printids(const char *s)
{
	pid_t pid;
	pthread_t tid;

	pid = getpid();
	tid = pthread_self();
	printf("%s pid %u tid %u (0x%x)\n", s, (unsigned int)pid, (unsigned int)tid, (unsigned int)tid);
}

void *thr_fn(void *arg)
{
	printids("new thread: ");
	return ((void *)0);
}

int main(void)
{
	int err;
	err = pthread_create(&ntid, NULL, thr_fn, NULL);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	printids("main thread: ");
	sleep(1);
	exit(0);
}
        注意,编译的时侯需要指定函数库,即加上-lpthread,编译并运行:

$ ./pth_create
main thread:  pid 4352 tid 1845016448 (0x6df8b780)
new thread:  pid 4352 tid 1828210432 (0x6cf84700)

<注意,这里有1秒的停顿>
$

        这个实例有两个特别之处,需要处理主线程和新线程之间的竞争。首先是主线程需要休眠,如果主线程不休眠,它就可能退出,这样在新线程有机会运行之前整个进程可能就已经终止了。这种行为特征依赖于操作系统中的线程实现和调度算法。

        第二个特别之处在于新线程是通过调用pthread_self函数获取自己的线程ID,而不是从共享内存中读出或者从线程的启动例程中以参数的形式接收到。回忆pthread_create函数,它会通过第一个参数(tidp)返回新建线程的线程ID。在本例中,主线程把新线程ID存放在ntid中,但是新建的线程并不能安全地使用它,如果新线程在主线程调用pthread_create返回之前就运行了,那么新线程看到的是未经初始化的ntid内容,这个内容并不是正确的线程ID。

        可以从结果看出,两个线程有相同的进程ID,但是各自的线程ID则不相同。

1.2  线程的终止

        如果进程中的任一线程调用了exit,_Exit或者是_exit,那么整个进程就会终止。与此类似,如果信号的默认动作是终止进程,那么,把该信号发送到线程会终止整个进程。

        单个线程可以通过下列三种方式退出,在不终止整个进程的情况下停滞它的控制流。

(1) 线程只是从启动例程中返回,返回值是线程的退出码。

(2) 线程可以被同一进程中的其他线程取消。

(3) 线程调用pthread_exit。

        函数原型如下:

#include <pthread.h>

void pthread_exit (void *rval_ptr);

        rval_ptr是一个无类型指针,与传给启动例程的单个参数类似。进程中的其他线程可以通过调用pthread_join函数访问到这个指针,其函数原型如下:

#include <pthread.h>

int pthread_join (pthread_t thread, void **rval_ptr);

        返回值:若成功则返回0,否则返回错误编号。

        调用线程将一直阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消。如果线程只是从它的启动例程返回,rval_ptr将包含返回码。如果线程被取消,由rval_ptr指定的内存单元就置为PTHREAD_CANCELED。

        可以通过调用pthread_join自动把线程置于分离状态(即将讨论到),这样资源就可以恢复。如果线程已经处于分离状态,pthread_join调用就会失效,返回EINVAL。

        如果对线程的返回值并不感兴趣,可以把rval_ptr置为NULL。在这种情况下,调用pthread_join函数将等待指定的线程终止,但并不获取线程的终止状态。

        接下来演示获取已终止的线程的退出码。

#include <cstdio>
#include <pthread.h>
#include <cstdlib>
#include <cstring>

void *thr_fn1(void *arg)
{
	printf("thread 1 returning\n");
	return ((void *)1);
}

void *thr_fn2(void *arg)
{
	printf("thread 2 exiting\n");
	pthread_exit((void *)2);
}

int main(void)
{
	int err;
	pthread_t tid1, tid2;
	void *tret;

	err = pthread_create(&tid1, NULL, thr_fn1, NULL);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread 1: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}

	err = pthread_create(&tid2, NULL, thr_fn2, NULL);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread 2: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}

	err = pthread_join(tid1, &tret);
	if (err != 0)
	{
		fprintf(stderr, "can't join with thread 1: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	printf("thread 1 exit code %ld\n", (long)tret);  //这里需要注意,在32位操作系统中指针是4字节,64位系统中指针是8字节。如果是32位操作系统,则强制转换为int类型。

	err = pthread_join(tid2, &tret);
	if (err != 0)
	{
		fprintf(stderr, "can't join with thread 2: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	printf("thread 2 exit code %ld\n", (long)tret);

	exit(0);
}

        编译并运行:

$ ./pth_join
thread 1 returning
thread 2 exiting
thread 1 exit code 1
thread 2 exit code 2
$

        可以看出,当一个线程通过调用pthread_exit退出或者简单地从启动例程中返回时,进程中的其他线程可以通过调用pthread_join函数获得该线程的退出状态。

        pthread_create和pthread_exit函数的无类型指针参数能传递的数值可以不止一个,该指针可以传递包含更复杂信息的结构的地址,但是注意这个结构所使用的内存在调用者完成调用以后必须仍然是有效的,否则就会出现无效或非法内存访问。例如,在调用线程的栈上分配了该结构,那么其他的线程在使用这个结构时内存内容可能已经改变了。又如,线程在自己的栈上分配了一个结构然后把指向这个结构的指针传给pthread_exit,那么当调用pthread_join的线程试图使用该指针结构时,这个栈可能已经被撤销,这块内存也已另作他用。

#include <cstdio>
#include <pthread.h>
#include <cstdlib>
#include <cstring>
#include <unistd.h>

struct foo {
	int a, b, c, d;
};

void printfoo(const char *s, const struct foo *fp)
{
	printf("%s", s);
	printf("  structure at %p\n", fp);
	printf("  foo.a = %d\n", fp->a);
	printf("  foo.b = %d\n", fp->b);
	printf("  foo.c = %d\n", fp->c);
	printf("  foo.d = %d\n", fp->d);
}

void *thr_fn1(void *arg)
{
	struct foo foo = {1, 2, 3, 4};
	printfoo("thread 1:\n", &foo);
	pthread_exit((void *)&foo);
}

void *thr_fn2(void *arg)
{
	printf("thread 2: ID is %ld\n", pthread_self());
	pthread_exit((void *)0);
}

int main()
{
	int err;
	pthread_t tid1, tid2;
	void *fp;

	err = pthread_create(&tid1, NULL, thr_fn1, NULL);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread 1: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}

	err = pthread_join(tid1, &fp);
	if (err != 0)
	{
		fprintf(stderr, "can't join with thread 1: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	sleep(1);

	printf("parent starting second thread\n");
	err = pthread_create(&tid2, NULL, thr_fn2, NULL);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread 2: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	sleep(1);
	printfoo("parent:\n", (struct foo *)fp);
	exit(0);
}
        编译并运行:

$ ./pth_exitthread 1:
  structure at 0x7f8da8d12f00
  foo.a = 1
  foo.b = 2
  foo.c = 3
  foo.d = 4

<注意,这里暂停一秒>
parent starting second thread
thread 2: ID is 140246399399680

<注意,这里暂停一秒>
parent:
  structure at 0x7f8da8d12f00
  foo.a = -1462683904
  foo.b = 32653
  foo.c = -1462683904
  foo.d = 32653
$

        可以看出,当主线程访问这个结构时,结构的内容(在线程tid1的栈上分配)已经改变。注意第二个线程(tid2)的栈是如何覆盖第一个线程的栈的。萎了解决这个问题,可以使用全局结构,或者用malloc的函数分配结构。

        线程可以通过调用pthread_cancel函数来请求取消同一进程中的其他线程。

#include <pthread.h>

int pthread_cancel (pthread_t tid);

        返回值:若成功则返回0,都则返回错误编号。

        在默认情况下,pthread_cancel函数会使得由tid标识的线程的行为表现为如同调用了参数为PTHREAD_CANCELED的pthread_exit函数,但是,线程可以选择忽略取消方式或是控制取消方式。注意pthread_cancel并不等待线程终止,它仅仅是提出请求。

        线程可以安排它退出时需要调用的函数,这与进程可以用atexit函数安排进程退出时需要调用的函数是类似的。这样的函数称为线程清理处理程序(thread cleanup handler)。线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说它们的执行顺序与它们的注册顺序相反。

#include <pthread.h>

void pthread_cleanup_push (void (*rtn)(void *), void *arg);

void pthread_cleanup_pop (int execute);

        当线程执行以下动作时调用清理函数,调用参数为arg,清理函数rtn的调用顺序是由pthread_cleanup_push函数来安排的。

  • 调用pthread_exit时。
  • 相应取消请求时。
  • 用非零execute参数调用pthread_cleanup_pop时。

        如果execute参数置为0,清理函数将不被调用。无论哪种情况,pthread_cleanup_pop都将删除上次pthread_cleanup_push调用建立的清理处理程序。

        这些函数有一个限制,由于它们可以实现宏,所以必须在与线程相同的作用域中以匹配对的形式使用,pthread_cleanup_push的宏定义可包含字符{,在这种情况下对应的匹配字符}就要在pthread_cleanup_pop定义中出现。

        接下来演示如何使用线程清理处理程序。

#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <pthread.h>

void cleanup(void *arg)
{
	printf("cleanup: %s\n", (char *)arg);
}

void *thr_fn1(void *arg)
{
	printf("thread 1 start\n");
	const char *msg1 = "thread 1 first handler";
	const char *msg2 = "thread 1 second handler";
	pthread_cleanup_push(cleanup, (void *)msg1);
	pthread_cleanup_push(cleanup, (void *)msg2);
	printf("thread 1 push complete\n");
	if (arg)
		return ((void *)1);
	pthread_cleanup_pop(0);
	pthread_cleanup_pop(0);
	return ((void *)1);
}

void *thr_fn2(void *arg)
{
	printf("thread 2 start\n");
	const char *msg1 = "thread 2 first handler";
	const char *msg2 = "thread 2 second handler";
	pthread_cleanup_push(cleanup, (void *)msg1);
	pthread_cleanup_push(cleanup, (void *)msg2);
	printf("thread 2 push complete\n");
	if (arg)
		pthread_exit((void *)2);
	pthread_cleanup_pop(0);
	pthread_cleanup_pop(0);
	pthread_exit((void *)2);
}

int main()
{
	int err;
	pthread_t tid1, tid2;
	void *tret;

	err = pthread_create(&tid1, NULL, thr_fn1, (void *)1);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread 1: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}

	err = pthread_create(&tid2, NULL, thr_fn2, (void *)1);
	if (err != 0)
	{
		fprintf(stderr, "can't create thread 2: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}

	err = pthread_join(tid1, &tret);
	if (err != 0)
	{
		fprintf(stderr, "can't join with thread 1: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	printf("thread 1 exit code %ld\n", (long)tret);

	err = pthread_join(tid2, &tret);
	if (err != 0)
	{
		fprintf(stderr, "can't join with thread 2: %s\n", strerror(err));
		exit(EXIT_FAILURE);
	}
	printf("thread 2 exit code %ld\n", (long)tret);

	exit(0);
}
        编译并运行:

$ ./pth_clean
thread 1 start
thread 1 push complete
thread 2 start
thread 2 push complete
thread 1 exit code 1
cleanup: thread 2 second handler
cleanup: thread 2 first handlerthread 1 exit code 1
thread 2 exit code 2
$

        从输出结果可以看出,两个线程都正确地启动和退出了,但是只调用了第二个线程的清理处理程序,所以如果线程是通过从它的启动例程中返回而终止的话,那么它的清理处理程序就不会被调用,还要注意清理处理程序是按照与它们安装时相反的顺序被调用的。

        现在可以开始看出线程函数和进程函数之间的相似之处。下表对此进行了总结。

表1 进程原语和线程原语的比较
进程原语线程原语描述
forkpthread_create创建新的控制流
exitpthread_exit从现有的控制流中退出
waitpidpthread_join从控制流中得到退出状态
atexitpthread_cancle_push注册在退出控制流时调用的函数
getpidphtread_self获取控制流的ID
abortpthread_cancel请求控制流的非正常退出

       在默认情况下,线程的终止状态会保存到对该线程调用pthread_join,如果线程已经处于分离状态,线程的底层存储资源可以在线程终止时立即被回收。当线程被分离时,并不能用pthread_join函数等待它的终止状态。对分离状态的线程进行pthread_join的调用会产生失败,返回EINVAL。pthread_detach调用可以用于使线程进入分离状态。

#include <pthread.h>

int pthread_detach (pthread_t tid);

        返回值:若成功则返回0,否则返回错误编号。

1.3  线程同步

        相关的线程同步的缘由在此就不过多赘述,也就是共享资源的保护问题。

1.3.1  互斥量

        可以通过使用pthread的互斥接口保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上来说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥锁加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。

        在设计时需要规定所有的线程必须遵守相同的数据访问规则,只有这样,互斥机制才能正常工作。操作系统并不会做数据访问的串行话。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都获取了锁,也还是会出现数据不一致的问题。

        互斥变量用pthread_mutex_t数据类型来表示,在使用互斥变量以前,必须首先对它进行初始化,可以把它置为常量PTHREAD_MUTEX_INITIALIZER(只对静态分配的互斥量),也可以通过调用pthread_mutex_init函数进行初始化。如果动态地分配互斥量(例如通过调用malloc函数),那么在释放内存前需要调用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。

        接下来演示互斥量的使用,当多个线程需要访问动态分配的对象时,可以在对象中嵌入引用计数,确保在所有使用该对象的线程完成数据访问之前,该对象内存空间不会被释放。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>

struct foo {
	int f_count;
	pthread_mutex_t f_lock;
	/* ... more stuff here ... */
};

struct foo *foo_alloc(void)
{
	struct foo *fp;
	if ((fp = (struct foo *)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)
{
	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这些操作之前需要锁住互斥量。在foo_alloc函数将引用计数初始化为1时没必要加锁,因为在这个操作之前分配线程是唯一引用该对象的线程。但是在这之后如果要将该对象放到一个列表中,那么它就有可能被别的线程发现,因此需要首先对它加锁。

        在使用该对象前,线程需要对这个对象的引用计数加1,当对象使用完毕时,需要对引用计数减1。当最后一个引用被释放时,对象所占的内存空间就被释放。

1.3.2  避免死锁

        如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,使用互斥量时,还有其他更不明显的方式也能产生死锁。例如,程序中使用多个互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量,这时就会发生死锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。

        可以通过小心地控制互斥量加锁的顺序来避免死锁的发生。例如,假设需要对两个互斥量A和B同时加锁,如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量不会产生死锁(当然在其他的资源上仍可能出现死锁);类似地,如果所有的线程总是在锁住互斥量A之前锁住互斥量B,那么也不会发生死锁。只有在一个线程试图以与另一个线程相反的顺序锁住互斥量时,才可能出现死锁。

        有时候应用程序的结构使得对互斥量加锁今次嗯排序是很困难的,如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。可以先释放占有的锁,然后过一段时间再试。这种情况可以使用pthread_mutex_trylock接口避免死锁。如果已经占有某些锁而且pthread_mutex_trylock接口返回成功,那么就可以前进;但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间重新尝试。

        接下来修改前一个程序清单,用以描述两个互斥量的使用方法。当同时需要两个互斥量时,总是让它们以相同的顺序加锁,以避免死锁。第二个互斥量维护着一个用于跟踪foo数据结构的散列列表。这样hashlock互斥量保护foo数据结构中的fh散列表和f_next散列链字段。foo结构中的f_lock互斥量保护对foo结构中的其他字段的访问。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>

#define NHASH 29
#define HASH(fpt) (((unsigned long)fpt) % 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;
    int f_id;
    /* ... more stuff here ... */
};

struct foo *foo_alloc(void)
{
    struct foo *fp;
    int idx;

    if ((fp = (struct foo*)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);
        fp->f_next = fh[idx];
        fh[idx] = fp->f_next;
        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;
    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_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);
        /* need to recheck the condition */
        if (fp->f_count != 1)
        {
            fp->f_count--;
            pthread_mutex_unlock(&fp->f_lock);
            pthread_mutex_unlock(&hashlock);
            return;
        }
        /* remove from list */
        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);
        pthread_mutex_destroy(&fp->f_lock);
        free(fp);
    }
    else
    {
        fp->f_count--;
        pthread_mutex_unlock(&fp->f_lock);
    }
}
        与之前的程序清单比较,可以看出分配函数现在锁住散列列表锁,把新的结构添加到散列存储桶中,在对散列列表的锁解锁之前,先锁住新结构中的互斥量。因为新的结构是放在全局列表中的,其他线程可以找到它,所以在完成初始化之前,需要阻塞其他试图访问新结构的线程。

        foo_find函数锁住散列列表锁然后搜索被请求的结构。如果找到了,就增加其引用计数并返回指向该结构的指针。注意加锁的顺序是先在foo_find函数中锁定散列列表锁,然后再在foo_hold函数中锁定foo结构中的f_lock互斥量。

        现在有了两个锁以后,foo_rele函数变得更加复杂。如果这是最后一个引用,因为将需要从散列列表中删除这个结构,就要先对这个结构互斥量进行解锁,才可以获取散列列表锁。然后重新获取结构互斥量。从上一次获得结构互斥量以来可能处于被阻塞状态,所以需要重新检查条件,判断是否还需要释放这个结构。如果其他线程在我们为满足锁顺序而阻塞时发现了这个结构并对其引用计数加1,那么只需要简单地对引用计数减1,对所有的东西解锁然后返回。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>

#define NHASH 29
#define HASH(fpt) (((unsigned long)fpt) % 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;
    int f_id;
    /* ... more stuff here ... */
};

struct foo *foo_alloc(void)
{
    struct foo *fp;
    int idx;

    if ((fp = (struct foo*)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);
        fp->f_next = fh[idx];
        fh[idx] = fp->f_next;
        pthread_mutex_lock(&fp->f_lock);
        pthread_mutex_unlock(&hashlock);
        /* ... continue initialization ... */
    }
    return (fp);
}

void foo_hold(struct foo *fp)
{
    pthread_mutex_lock(&hashlock);
    fp->f_count++;
    pthread_mutex_unlock(&hashlock);
}

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)
        {
            fp->f_count++;
            break;
        }
    }
    pthread_mutex_unlock(&hashlock);
    return (fp);
}

void foo_rele(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);
    }
}
        与前一个程序清单相比,这里的程序简单得多。两种用途使用相同的锁时,围绕散列列表和引用计数的锁的排序问题就随之不见了。多线程的软件设计经常要考虑这类折中处理方案。如果锁的粒度太粗,就会出现很多线程阻塞等待相同的锁,源自并发性的改善微乎其微。如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,而且代码变得相当复杂。所以需要在满足锁需求的情况下,在代码复杂性和优化性能之间找好平衡点。

1.3.3  读写锁

        读写锁与互斥量类似,不过读写锁允许更高的并发性。互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有三种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

        当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁,它必须阻塞直到所有的线程释放读锁。虽然读写锁的实现各不相同,但当读写锁处于读模式锁住状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随口的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。

        读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为当前只有一个线程可以在写模式下拥有者个锁。当读写锁在读模式下时,只要线程获取了读模式下的读写锁,该锁所保护的数据结构可以被多个获得读模式锁的线程读取。

        读写锁也叫做共享-独占锁,当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。

        与互斥量一样,读写锁在使用之前必须初始化,在释放它们底层的内存前必须撤销。

#include <pthread.h>

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

int pthread_rwlock_destroy (pthread_rwlock_t *rwlock);

        两者的返回值都是:若成功则返回0,否则返回错误编号。

        读写锁通过调用pthread_rwlock_init进行初始化。如果希望读写锁有默认的属性,可以传一个空指针给attr,读写锁的属性将在后续介绍。

        在释放读写锁占用的内存之前,需要调用pthread_rwlock_destroy做清理工作。如果pthread_rwlock_init为读写锁分配了资源,pthread_rwlock_destroy将释放这些资源。如果在调用pthread_rwlock_destroy之前就释放了读写锁占用的内存空间,那么分配给这个锁的资源就丢失了。

        要在读模式下锁定读写锁,需要调用pthread_rwlock_rdlock;要在写模式下锁定读写锁,需要调用pthread_rwlock_wrlock。不管以何种方式锁住读写锁,都可以调用pthread_rwlock_unlock进行解锁。

#include <pthread.h>

int pthread_rwlock_rdlock (pthread_rwlock_t *rwlock);

int pthread_rwlock_wrlock (pthread_rwlock_t *rwlock);

int pthread_rwlock_unlock (pthread_rwlock_t *rwlock);

        所有的返回值都是:若成功则返回0,否则返回错误编号。

        在实现读写锁的时侯可能会对共享模式下可获取的锁的数量进行限制,所以需要检查pthread_rwlock_rdlock的返回值。即使pthread_rwlock_wrlock和pthread_rwlock_unlock有错误的返回值,如果锁设计合理的话,也不需要检查其返回值。错误返回值的定义只是针对不正确地使用读写锁的情况,例如未经初始化的锁,或者试图获取已拥有的锁从而可能产生死锁这样的错误返回等。

        Single UNIX Specification同样定义了有条件的读写锁原语的版本。

#include <pthread.h>

int pthread_rwlock_tryrdlock (pthread_rwlock_t *rwlock);

int pthread_rwlock_trywrlock (pthread_rwlock_t *rwlock);

        两者的返回值都是:若成功则返回0,否则返回错误编号。

        可以获取锁时,函数返回0,否则返回错误EBUSY。这些函数可以用于前面讨论的遵守某种锁层次但还不能完全避免死锁的情况。

        接下来演示读写锁的使用。

#include <cstdlib>
#include <pthread.h>

struct job {
    struct job *j_next;
    struct job *j_prev;
    pthread_t j_id;
    /* ... more stuff here ... */
};

struct queue {
    struct job *q_head;
    struct job *q_tail;
    pthread_rwlock_t q_lock;
};

int queue_init(struct queue *qp)
{
    int err;

    qp->q_head = NULL;
    qp->q_tail = NULL;
    err = pthread_rwlock_init(&qp->q_lock, NULL);
    if (err != 0)
        return (err);

    /* ... continue initialization ... */

    return (0);
}

void job_insert(struct queue *qp, struct job *jp)
{
    pthread_rwlock_wrlock(&qp->q_lock);
    jp->j_next = qp->q_head;
    jp->j_prev = NULL;
    if (qp->q_head != NULL)
        qp->q_head->j_prev = jp;
    else
        qp->q_tail = jp;
    qp->q_head = jp;
    pthread_rwlock_unlock(&qp->q_lock);
}

void job_append(struct queue *qp, struct job *jp)
{
    pthread_rwlock_wrlock(&qp->q_lock);
    jp->j_next = NULL;
    jp->j_prev = qp->q_tail;
    if (qp->q_tail != NULL)
        qp->q_tail->j_next = jp;
    else
        qp->q_head = jp;
    qp->q_tail = jp;
    pthread_rwlock_unlock(&qp->q_lock);
}

void job_remove(struct queue *qp, struct job *jp)
{
    pthread_rwlock_wrlock(&qp->q_lock);
    if (jp == qp->q_head)
    {
        qp->q_head = jp->j_next;
        if (qp->q_tail == jp)
            qp->q_tail = NULL;
    }
    else if (jp == qp->q_tail)
    {
        qp->q_tail = jp->j_prev;
        if (qp->q_head == jp)
            qp->q_head = NULL;
    }
    else
    {
        jp->j_prev->j_next = jp->j_next;
        jp->j_next->j_prev = jp->j_prev;
    }
    pthread_rwlock_unlock(&qp->q_lock);
}

struct job *job_find(struct queue *qp, pthread_t id)
{
    struct job *jp;
    if (pthread_rwlock_rdlock(&qp->q_lock) != 0)
        return (NULL);

    for (jp = qp->q_head; jp != NULL; jp = jp->j_next)
        if (pthread_equal(jp->j_id, id))
            break;

    pthread_rwlock_unlock(&qp->q_lock);
    return (jp);
}
        在这个例子中,不管什么时侯需要增加一个作业到队列中或者从队列中删除作业,都用写模式锁住队列的读写锁。不管何时搜索队列,首先需要获取读模式下的锁,允许所有的工作线程并发地搜索队列。在这种情况下,只有线程搜索队列的频率远远高于增加或删除作业时,使用读写锁才可能改善性能。

        工作线程只能从队列中读取与它们的线程ID匹配的作业。既然作业结构同一时间只能由一个线程使用,所以不需要额外加锁。

1.3.4  条件变量

        条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。

        条件本身是由互斥量保护的。线程在改变条件状态前必须首先锁住互斥量,其他线程在获得互斥量之前不会察觉到这种改变,因为必须锁定互斥量以后才能计算条件。

        条件变量使用之前必须首先进行初始化,pthread_cond_t数据类型代表的条件变量可以用两种方式初始化,可以把常量PTHREAD_COND_INITIALIZER赋给静态分配的条件变量,但是如果条件变量是动态分配的,可以使用pthread_cond_init函数进行初始化。

        在释放底层的内存空间之前,可以使用pthread_cond_destroy函数对条件变量进行去除初始化(deinitialize)。

#include <pthread.h>

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

int pthread_cond_destroy (pthread_cond_t *cond);

        两者的返回值都是:若成功则返回0,否则返回错误编号。

        除非需要创建一个非默认属性的条件变量,否则pthread_cond_init函数的attr参数可以设置为NULL,条件变量属性将在后续讨论。

        使用pthread_cond_wait等待条件变为真,如果在给定的时间内条件不能满足,那么会生成一个代表出错码的返回变量。

#include <pthread.h>

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

        两者的返回值都是:若成功则返回0,否则返回错误编号。

        传递给pthread_cond_wait的互斥量对条件进行保护,调用者把锁住的互斥量传递给函数。函数把调用线程放到等待条件的线程列表上,然后对互斥量解锁,这两个操作是原子操作。这样就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait返回时,互斥量再次被锁住。

        pthread_cond_timedwait函数的工作方式与pthread_cond_wait函数相似,只是多了一个timeout。timeout指定了等待的时间,它是通过timespec结构指定。时间值用秒数或者分秒数来表示,分秒数的单位是纳秒。

struct timespec {
    time_t tv_sec;          /* seconds */
    long tv_nsec;           /* nanoseconds */
};

        使用这个结构时,需要指定愿意等待多长时间,时间值是一个绝对数而不是相对数。例如,如果能等待3分钟,就需要把当前时间加上3分钟再转换到timespec结构,而不是把3分钟转换成timespec结构。

        可以使用gettimeofday获取用timeval结构表示的当前时间,然后把这个时间转换成timespec结构。要得到timeout值的绝对时间,可以使用下面的函数:

void maketimeout(struct timespec *tsp, long minutes)
{
    struct timeval now;
    
    /* get the current time */
    gettimeofday(&now);
    tsp->tv_sec = now.tv_sec;
    tsp->tv_nsec = now.tv_usec * 1000;
    tsp->tv_sec += minutes * 60;
}
        如果时间值到了但是条件还是没有出现,pthread_cond_timedwait将重新获取互斥量然后返回错误ETIMEDOUT。从pthread_cond_wait或者pthread_cond_timedwait调用成功返回时,线程需要重新计算条件,因为其他的线程可能已经在运行并改变了条件。

        有两个函数可以用于通知线程条件已经满足。pthread_cond_signal函数将唤醒等待该条件的某个线程,而pthread_cond_broadcast函数将唤醒等待该条件的所有线程。

#include <pthread.h>

int pthread_cond_signal (pthread_cond_t *cond);

int pthread_cond_broadcast (pthread_cond_t *cond);

        两者的返回值都是:若成功则返回0,否则返回错误编号。

        调用pthread_cond_signal或者pthread_cond_broadcast,也称为向线程或条件发送信号。必须注意一定要在改变条件状态以后再给线程发送信号。

        接下来演示如何结合使用条件变量和互斥量对线程进行同步。

#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()
{
    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 = wrokq;
    workq = mp;
    pthread_mutex_unlock(&qlock);
    pthread_cond_signal(*qready);
}
        条件是工作队列的状态。用互斥量保护条件,在while循环中判断条件。把消息放到工作队列时,需要占有互斥量,但向等待线程发送信号时并不需要占有互斥量。主要线程可以在调用cond_signal之前把消息从队列中拖出,就可以在释放互斥量以后再完成这部分工作。因为是在while循环中检查条件,所以不会存在问题:线程醒来,发现队列仍为空,然后返回继续等待。如果代码不能容忍这种竞争,就需要在向线程发送信号的时侯占有互斥量。


——整理自《UNIX环境高级编程(第二版)》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值