Posix线程

概述

Posix线程(Pthreads)是C程序中处理线程的一个标准接口,Pthreads定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。

常用函数

这里我们简单介绍几个常用的函数,下面我们通过一个简单的线程程序进行介绍:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void *thread(void *vargp);


int main(int argc, char **argv)
{
	pthread_t tid;				// 存放对等线程的线程ID
	pthread_create(&tid, NULL, thread, NULL);	// 创建一个新的对等线程
	pthread_join(tid, NULL);	// 使主线程等待对等线程终止
	exit(0);
}

// 线程例程
void *thread(void *vargp)
{
	printf("Hello, Pthreads!\n");
	return NULL;
}

线程的代码和本地数据被封装在一个线程例程(thread routine)中,每个线程例程都以一个void作为输入,并返回一个void。如果想要传递多个参数给线程例程,应该将参数放到一个结构体中,并传递一个指向该结构的指针。相似的,想要线程例程返回多个参数,可以返回一个指向一个结构的指针。

创建线程

#include <pthread.h>
typedef void *(func)(void*);

// @tid	: 返回新建线程的ID
// @attr: 改变新建线程的默认属性
// @f	: 在新线程上下文运行的线程例程
// @arg	: 作为线程例程的输入变量
// return:若成功则返回0,若出错则为非零
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);

// return:返回调用者的线程ID
pthread_t pthread_self(void);

终止线程

一个线程可以通过以下四种方式之一来终止:

  • 当顶层的线程例程返回时,线程会隐式地终止
  • 通过调用pthread_exit函数,线程会显式地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,然后在终止主线程和这个进程,返回值为pthread_return
#include <pthread.h>

void pthread_exit(void *thread_return);
  • 每个对等线程调用exit函数,该函数会终止进程以及所有与该进程相关的线程。
  • 另一个对等线程通过以当前线程ID作为参数调用pthread_cancle函数来终止当前线程。
#include <pthread.h>

// @tid: 要终止的线程ID
// return: 若成功则返回0,出错则为非零
int pthread_cancel(pthread_t tid);

回收已终止线程的资源

线程通过调用pthread_join函数等待其他线程终止:

#include <pthread.h>

// @tid: 等待终止的线程ID
// @thread_return: 线程例程返回的(void*)指针赋值为thread_return指向的位置
// return:若成功则返回0,出错返回非零
int pthread_join(pthread_t tid, void **thread_return);

pthread_join函数会阻塞,直到线程tid终止,并将线程例程返回的(void*)指针赋值为thread_return指向的位置,然后回收已终止线程tid占用的所有存储器资源。
值得注意的是:和wait函数不同,pthread_join函数只能等待一个指定的线程终止,没有办法让pthread_join函数等待任意一个线程终止。

分离线程

在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。

  • 一个可结合的线程能够被其他线程回收和杀死。在被其他线程回收之前,它的存储器资源(例如栈)是没有被释放的。
  • 一个分离的线程不能被其他线程回收或杀死。它的存储器资源在它终止时由系统自动释放。
    默认情况下,线程被创建为可结合的。为了避免存储器资源泄漏,每个可结合线程要么被其他线程显式回收,要么通过调用pthread_detach函数被分离。
#include <pthread.h>

// @tid:分离可结合线程ID
// return: 若成功则返回0,若出错则为非零
int pthread_detach(pthread_t tid);

pthread_detach函数可以分离可结合线程tid。线程通过调用以pthread_self()为参数的pthread_detach调用来分离它们自己。
有一些场景可以使用分离的线程。例如,一个高性能Web服务器可能在每次收到Web浏览器的连接请求时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对于服务器而言,就没有必要显式地等待每个对等线程终止。在这种情况下,每个对等线程都应该在它开始处理请求之前分离它自身,这样能够在它终止后有系统回收它的存储器资源。

初始化线程

当需要动态初始化多个线程共享的全局变量时,可以使用pthread_once函数:

#include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT;

// 总是返回0
int pthread_once(pthread_once_t *once_control, void (*init_rountine)(void));

once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当第一次使用once_control调用pthread_once函数时,它在函数内部调用init_rountine函数。

多线程程序中的共享变量

和多个进程拥有各自的用户地址空间不同,同一进程的多个对等线程共享进程的地址空间,因此多个线程能够非常容易共享相同的程序变量。但是为了保证能够写出正确的线程化程序,需要了解这种共享的工作模式。下面我们来讨论关于多线程程序的三个问题:

  • 线程的基本存储模型
  • 根据存储模型,变量实例是如何映射到存储器的
  • 有多少个线程引用这些共享变量的实例

为了回答这三个问题,可以通过下面一个简单的例子进行分析:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N 2
void *thread(void *vargp);

char **ptr;

int main()
{   
    int i;
    pthread_t tid;
    char *msgs[N] = {
        "foo",
        "bar"
    };
    
    ptr = msgs;
    for(i = 0; i < N; ++i)
        pthread_create(&tid, NULL, thread, (void*)i);
    pthread_exit(NULL);
}

void *thread(void *vargp)
{
    int id = (int)vargp;
    static int cnt = 0;
    printf("[%d]: %s (cnt = %d)\n", id, ptr[id], ++cnt);
    return NULL;
}

线程存储器模型

一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程共享进程上下文的其余部分,包括整个用户虚拟地址空间,他是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。

  • 寄存器值是线程上下文的私有部分,实际操作中,不可能让一个线程去读或写另一个线程的寄存器值。
  • 任何线程都可以访问共享虚拟内存的任意位置,如果某个线程修改了一个内存位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。
  • 各自独立的线程栈的内存模型不是那么整齐清楚的,也就是说线程栈不对其他线程设防。这些栈被保存在虚拟地址空间的栈区域中,通常是被相应的线程独立地访问,但如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就能够访问这个栈的任何部分。例如,上面的全局指针ptr间接引用主线程的栈的内容。

将变量映射到存储器

多线程的C程序中变量根据它们的存储类型被映射到虚拟内存中:

  • 全局变量:全局变量是定义在函数之外的变量。在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。例如,上面全局变量ptr在虚拟内存的读/写区域中有一个运行时实例。
  • 本地自动变量:本地自动变量是定义在函数内部的non-static变量。在运行时,每个线程的栈都包含自己的本地自动变量的实例。例如,main函数中的tid,它保存在主线程的栈中。线程例程中的id,保存在对等线程的栈中。
  • 本地静态变量:本地静态变量是定义在函数内部的static变量。与全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。例如,线程例程中的cnt变量,在运行时,虚拟内存的读/写区域中只有一个cnt的实例,每个对等线程都读写这个实例。

共享变量

当且仅当变量的一个实例能够被一个以上的线程引用时,我们称这个变量是共享的(shared)。

  • thread()中的cnt是共享的,它只有一个运行时实例,并且这个实例被两个对等线程引用
  • main()中的msgs也是共享的,它只有一个运行时实例,并且这个实例也被两个对等线程引用

使用信号量同步线程

有了共享变量是非常方便的,不过它们也引入了同步错误(synchronization error)的可能。下面通过一个例子来讨论多线程读写共享变量带来的同步问题:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void *thread(void *vargp);

volatile int cnt = 0;

int main(int argc, char **argv)
{
	int niters;
	pthread_t tid1, tid2;
	
	if(argc != 2)
	{
		fprintf(stderr, "Usage : %s <niters>\n", argv[0]);
		exit(0);
	}

	niters = atoi(argv[1]);
	
	pthread_create(&tid1, NULL, thread, &niters);
	pthread_create(&tid2, NULL, thread, &niters);
	pthread_join(tid1, NULL);
	pthread_join(tid2, NULL);
	
	if(cnt != 2 * niters)
		fprintf(stderr, "BOOM! cnt = %d\n", cnt);
	else
		fprintf(stdout, "OK! cnt = %d\n", cnt);
	
	exit(0);
}

void *thread(void *vargp)
{
	int i, niters = *((int*)vargp);
	
	for(i = 0; i < niters; ++i)
		cnt++;
		
	return NULL;
}

这个例子的目的是计算输入量niters的2倍值,并保存全局变量cnt中。我们创建了两个线程,每个线程对共享变量cnt加1,因为每个线程都对计数器增加niters次,所以预计cnt的值为2*niters。我们运行一个这个程序的结果:

$ gcc -pthread badCounter.c -o badCounter

$ ./badCounter 100000000
BOOM! cnt = 177953661

$ ./badCounter 100000000
BOOM! cnt = 185993828

$ ./badCounter 100000000
BOOM! cnt = 176414058

我们输入较大的值,运行三次的结果都不相同!并且都不是期待的200000000。
问题出现在两个线程并发对全局变量cnt进行访问的代码上,即cnt++操作。该操作的汇编指令如下,分为三步:

mov1 cnt(%rip), %eax    # L:加载全局变量cnt到寄存器%eax
incl %eax                   # U:对%eax进行递增操作
movl %eax, cnt(%rip)    # S:将%eax的更新值存回到共享变量cnt

我们无法预测操作系统调度线程的顺序,上面的cnt++操作通过三条分离的指令完成,在逻辑流执行的过程中随时可能被中断。下面通过进度图(progress graph)对指令的执行顺序进行阐述:

  • 正确的指令顺序:每个线程加载(L)、更新(U)、存储(S)的过程均未被中断,最终cnt在寄存器中的值是2,是我们所期待的。
  • 不正确的指令顺序:线程2在第5步加载cnt,是在第2步线程1加载cnt之后,而在第6步线程1存储它的更新值之前。因此,每个线程最终都会存储一个值为1的更新后的计数器值。

信号量

Edsger Dijkstra提出了信号量(semaphore)概念,用于解决不同执行线程的同步问题。信号量是具有非负整数值的全局变量,只能够由两种特殊的操作来处理——P和V:

  • P(s):如果s是非零的,那么P将s减1,并且立即返回。如果s为0,那么就立即挂起这个线程,直到s变为非零,而一个V操作会重启这个线程。在重启之后,P操作得以继续进行,对s减1,并将控制返回给调用者。
  • V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变为非零,那么V操作会重启这些线程中的一个,然后线程将s减1,完成它的P操作。

值得注意的是:

  • P中的测试和减1操作是原子性的,即不可分割。一旦预测信号量s变为非零,就会将s减1,不能有中断。
  • V中的加1操作也是原子性的,即加载(L)、更新(U)、存储(S)的过程没有中断。
  • V操作并没有定义重启等待线程的顺序,唯一的要求是V必须只能重启一个正在等待的线程。当有多个线程在等待同一个信号量时,不能预测V操作要重启哪个线程
  • P和V操作的定义确保了正确运行的程序的信号量永远是非负值,这个属性称为信号量不变性(semaphore invariant)。

Posix标准定义了许多操作信号量的函数,下面简单介绍几个常用的函数

#include <semaphore.h>

// 初始化信号量
// @sem     : 指向信号量结构体的指针
// @pshared : 指出信号量在一个进程中多个线程共享,还是在多个进程之间共享
// @value   : 信号量的初始值
// return   : 若成功则为0,错误则为-1
int sem_init(sem_t *sem, int pshared, unsigned int value);

// P操作
// return   : 若成功则为0,错误则为-1
int sem_wait(sem_t *s);

// V操作
// return   : 若成功则为0,错误则为-1
int sem_post(sem_t *s);

在这里,我们将sem_wait()和sem_post分别封装成P操作和V操作,具体如下:

// PV.h
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>

/* *nix风格的错误报告函数 */
void unix_error(char *msg) 
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

/* P操作:包装sem_wait函数 */
void P(sem_t *s)
{
    if (sem_wait(sem) < 0)
	    unix_error("P error");
}

/* V操作:包装sem_post函数 */
void V(sem_t *s)
{
    if (sem_post(sem) < 0)
	    unix_error("V error");
}

使用信号量实现互斥

如果将信号量初始化为1,这时候我们称该信号量为二元信号量(binary semaphore),提供互斥访问临界区的功能。接下来通过信号量来解决上面badCounter中的同步问题,只需要做出一些简单的修改:

// goodCounter.c

#include <pthread.h>
#include "PV.h"
void *thread(void *vargp);

volatile int cnt = 0;
sem_t mutex;        // 全局信号量

int main(int argc, char **argv)
{
	int niters;
	pthread_t tid1, tid2;
	
	if(argc != 2)
	{
		fprintf(stderr, "Usage : %s <niters>\n", argv[0]);
		exit(0);
	}

	niters = atoi(argv[1]);
	
	// 将信号量初始化为1
	sem_init(&mutex, 0, 1);
	
	pthread_create(&tid1, NULL, thread, &niters);
	pthread_create(&tid2, NULL, thread, &niters);
	pthread_join(tid1, NULL);
	pthread_join(tid2, NULL);
	
	if(cnt != 2 * niters)
		fprintf(stderr, "BOOM! cnt = %d\n", cnt);
	else
		fprintf(stdout, "OK! cnt = %d\n", cnt);
	
	exit(0);
}

void *thread(void *vargp)
{
	int i, niters = *((int*)vargp);
	
	for(i = 0; i < niters; ++i)
	{
	    // 对全局变量的更新包围P和V操作
	    P(&mutex); 
	    cnt++;
	    V(&mutex);
	}
		
	return NULL;
}

我们在主线程中将信号量初始化为1,并且通过P和V操作包围对全局变量cnt的更新,实现了线程1和线程2对cnt的互斥访问。执行这个新版本,可以发现现在能够得出正确结果:

$ gcc -pthread goodCounter.c -o goodCounter

$ ./goodCounter 100000000
OK! cnt = 200000000

$ ./goodCounter 100000000
OK! cnt = 200000000

$ ./goodCounter 100000000
OK! cnt = 200000000

使用信号量调度共享资源

信号量除了提供互斥外,另一个更重要的作用是调度对共享资源的访问。在这种场景中,一个线程用信号量操作来通知另一个线程程序状态中的某个条件已经为真。下面以两个典型的例子展开讨论——生产者-消费者问题读写者问题

生产者-消费者问题

生产者和消费者线程共享一个有n个槽的有限缓冲区。二者对缓冲区的操作如下:

  • 生产者线程反复地生成新的项目,并将其插入到缓冲区中。如果缓冲区是满的,那么生产者必须等待,直到有一个槽位变为可用。
  • 消费者线程不断的从缓冲区中取出项目,然后消费它们。如果缓冲区是空的,那么消费者必须等待,直到有一个项目变为可用。

生产者-消费者的相互作用在现实系统中是很普遍的:

  • 在一个多媒体系统中,生产者编码视频帧,而消费者解码并在屏幕上呈现出来。缓冲区的目的是为了减少视频流的抖动,而这种抖动是由各个帧的编码和解码时与数据相关的差异引起的。缓冲区生产者提供一个槽位池,而为消费者提供一个已编码的帧池。
  • 设计图形用户接口时,生产者检测到鼠标和键盘事件,并将它们插入到缓冲区中。消费者以某种基于优先级的方式从缓冲区取出这些事件,进行处理,然后显示在屏幕上。

下面给出生产者-消费者的主要思路:

const int n = MAXSLOTS; // 缓冲区槽的数量
int buf[n];             // 缓冲区,大小为n
int front = 0;          // front%n 是第一个产品所在槽的位置
int rear = 0;           // rear%n 是后一个产品所在槽的位置
sem_t mutex;            // 初始化为1,提供互斥的缓冲区访问
sem_t slots;            // 初始化为n,记录空槽的信号量
sem_t itmes;            // 初始化为0,记录可用产品的信号量

void product(int item)
{
    P(&slots);          // 等待空槽
    P(&mutex);          // 访问共享缓冲区前需要获得mutex锁
    rear++;
    buf[rear%n] = item; // 将产品放入空槽中
    V(&mutex);          // 释放mutex锁
    V(&items);          // 宣布有新产品可用
}

int consumer(void)
{
    int item;
    P(&items);          // 等待新产品
    P(&mutex);          // 访问共享缓冲区前需要获得mutex锁
    item = buf[front%n];// 获得产品
    front++;
    V(&mutex);          // 释放mutex锁
    V(&slots);          // 宣布有空槽可用
    
    return item;
}

有三个点需要注意:

  • 生产者在等待一个可用的空槽之后,对互斥锁加锁,添加产品,对互斥锁解锁,然后宣布有一个新的产品可用
  • 消费者在等待一个可用的缓冲区之后,对互斥锁加锁,从缓冲区前面取出新产品,对互斥锁解锁,然后通知生产者有一个新的槽位可以使用。
  • 等待空槽(或新产品)与对互斥锁加锁的顺序不能互换,否则会出现死锁现象。

读写者问题

一组并发的线程要访问一个共享对象,例如一个主存中的数据结构或一个磁盘上的数据库。有些线程只读对象,而其他线程只修改对象。

  • 修改对象的线程叫做写者(writer),只读对象的线程叫做读者(reader)。
  • 写者必须拥有对对象的独占访问,而读者可以和无限多个其他的读者共享对象。

读者-写者交互在现实系统中也是很常见的:

  • 一个机票预订系统中,允许有多个客户(读者)同时查询航班座位,但是正在预订座位的客户(写者)必须拥有对数据库的独占访问
  • 在一个多线程缓存Web代理中,多个线程可以从共享页面缓存中取出已有的页面,但是任何向缓存中写入一个新页面的线程必须拥有独占的访问。

常见的有三类读者-写者问题:

  • 第一类:读者优先,即要求不让读者等待,除非已经把使用对象的权限赋予一个写者。读者不会因为一个写者在等待而等待。
  • 第二类:写者优先,即要求一旦一个写者准备好可以写,它就会尽可能快地完成它的写操作。在一个写者后面到达的读者必须等待,即使这个写者也在等待。
  • 第三类:公平竞争,即读者和写者的优先级相同,遵循先到先服务原则。

读者优先

int readCnt = 0;// 统计当前在临界区中的读者数量
sem_t rdMutex;	// 初始化为1,保护共享变量readCnt的访问
sem_t fMutex;	// 初始化为1,控制对访问共享对象的临界区的访问

void reader()
{
	while(1) {
		P(&rdMutex);
		if(readCnt == 0)
			P(&fMutex);     // 第一个读者提出读申请,对访问临界区的锁加锁
		readCnt++;
		V(&rdMutex);
		
		/*Critical section*/
		/*Reading operation*/
		
		P(&rdMutex);
		readCnt--;
		if(readCnt == 0)
			V(&fMutex);     // 最后一个读者离开,释放访问临界区的锁
		V(&rdMutex);
	}
}

void writer()
{
	while(1) {
		P(&fMutex);
		
		/*Critical section*/
		/*Writing operation*/
		
		V(&fMutex);
	}
}

以上就是读者优先的模式,从中我们可以看到,读者只要发现有其他读者正在访问共享对象的临界区,那么它就可以继续进入临界区进行读操作;写者必须等待所有读者都离开临界区之后才能够进行写操作,即使写者可能比某些后到的读者先提出写申请。值得注意的是,在读者访问非常频繁的场景中,可能造成写者饥饿的现象。

写者优先

为了解决上面写者饥饿的问题,我们需要提高写者的优先级。这里需要增加一个排队信号量:queue,读者和写者在访问临界区之前都要在此信号量上排队;另外增加一个writeCnt来记录提出写申请和正在进行写操作的写者数量:

int readCnt = 0;    // 统计当前在临界区中的读者数量
int writeCnt = 0;   // 统计提出申请的和正在临界区中的写者数量
sem_t rdMutex;	// 初始化为1
sem_t wtMutex;	// 初始化为1
sem_t fMutex;	// 初始化为1
sem_t queue;	// 初始化为1

void reader()
{
	while(1) {
		P(&queue);        // 检测是否有读者/写者在排队
		P(&rdMutex);
		if(readCnt == 0)
			P(&fMutex);         // 第一个读者提出读申请,对访问临界区的锁加锁
		readCnt++;
		V(&rdMutex);
		V(&queue);
		
		/*Critical section*/
		/*Reading operation*/
		
		P(&rdMutex);
		readCnt--;
		if(readCnt == 0)
			V(&fMutex);         // 最后一个读者离开,释放访问临界区的锁
		V(&rdMutex);
	}
}

void writer()
{
	while(1) {
		P(&wtMutex);
		writeCnt++;
		if(writeCnt == 1)
			P(&queue);          // 第一个写者提出写申请,检测是否有读者/写者在排队
		V(&wtMutex);
		
		P(&fMutex);
		/*Critical section*/
		/*Writing operation*/
		V(&fMutex);
		
		P(&wtMutex)
		writeCnt--;
		if(writeCnt == 0)       // 最后一个写者离开,释放排队锁
			V(&queue);
		V(&wtMutex);
	}
}

以上就是写者优先模式,有两要点:

  • 对于每个读者:在最开始都需要申请queue信号量,之后在真正进行读操作前让出queue,这样可以让写者能够随时申请到queue。
  • 对于写者:只有第一个写者需要申请queue信号量,之后就一直占着不放,直到所有写者都完成操作后才让出queue。
    这样只要有写者提出申请,后面的读者就不可以排队,而后面的写者可以排队,这实际上就是提高了写者的优先级。与读者优先类似,在写者访问非常频繁的场景下,可能造成读者饥饿的现象。

公平竞争

为了消除饥饿的不公平现象,我们可以让每个写者都申请queue信号量:

int readCnt = 0;
sem_t rdMutex;	// 初始化为1
sem_t fMutex;	// 初始化为1
sem_t queue;	// 初始化为1

void reader()
{
	while(1) {
		P(&queue);        // 检测是否有读者/写者在排队
		P(&rdMutex);
		if(readCnt == 0)
			P(&fMutex);         // 第一个读者提出读申请,对访问临界区的锁加锁
		readCnt++;
		V(&rdMutex);
		V(&queue);
		
		/*Critical section*/
		/*Reading operation*/
		
		P(&rdMutex);
		readCnt--;
		if(readCnt == 0)
			V(&fMutex);         // 最后一个读者离开,释放访问临界区的锁
		V(&rdMutex);
	}
}

void writer()
{
	while(1) {
		P(&queue);          // 每个写者都在queue上排队
		P(&fMutex);         
		V(&queue);          // 获得临界区的访问权后,立即释放queue,保证其他读者/写者能够获得queue
		/*Critical section*/
		/*Writing operation*/
		V(&fMutex);

	}
}

这样对于每个读者和每个写者,在提出申请进入临界区前都必须在queue排队,从而避免了读者或写者饥饿的现象。

参考资料

  • Randal E.Bryant, DavidR.O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
  • 汤小丹, 梁红兵等. 计算机操作系统[M]. 西安电子科技大学出版社, 2014.
posted @ 2017-05-16 20:30  west000  阅读(352)  评论(0编辑  收藏  举报