基于线程的并发编程

写在开始:本篇文章基本摘抄自《深入理解计算机系统》的十二章--并发编程中关于线程部分的讲解,本人在看到这个章节对进度图的使用时,有一种醍醐灌顶的感觉。之前关于线程临界区,死锁问题都只是概念上的理解,引入了进度图以后,认识到了从图形化角度认识该问题的威力。摘抄的主要目的是为了做小组分享的文档。

线程就是运行在进程上下文中的逻辑流,由内核自动调度。每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有运行在一个进程里的线程共享该进程的整个虚拟地址空间。

线程执行模型

如图所示,每个进程开始生命周期时都是单一线程,这个线程称为主线程。在某一时刻,主线程创建一个对等线程,从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用,或者因为它被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,以此类推。

并发线程执行.jpg

Posix线程

Posix线程是在C程序中处理线程的一个标准接口。它最早出现在1995年,而且在大多数Unix系统上都可用。Pthreads定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全的共享数据,还可以通知对等线程系统状态的变化。
下面展示了一个简单的Pthreads程序。主线程创建一个对等线程,然后等待它的终止。对等线程输出“Hello,world!\n”并且终止。当主线程检测到对等线程终止后,它就通过调用exit终止该进程。

#include "csapp.h"
void *thread(void *vargp);

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, thread, NULL);
    pthread_join(tid, NULL);
    exit(0);
}

void *thread(void *vargp)
{
    printf("Hello, world!\n");
    return NULL;
}

有以下几个点值得说明一下:
1.线程通过调用pthread_create函数来创建其他线程。

#include 
typedef void *(func)(void *);

int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);

函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新创建线程的默认属性。当pthread_create返回时,参数tid包含新创建线程的ID。新线程可以通过调用pthread_self函数来获得它自己的线程ID。

#include 

pthread_t pthread_self(void);

2.终止线程
一个线程是以下列方式之一来终止的:

  • 当顶层的线程例程返回时,线程会隐式地终止。
  • 通过调用pthread_exit函数,线程会显式地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,然后再终止主线程和整个进程。
#include 

void pthread_exit(void *thread_return);
  • 某个对等线程调用Unix的exit函数,该函数终止进程以及所有与该进程相关的线程。
  • 另一个对等线程通过以当前线程ID为参数调用pthread_cancel函数来终止当前线程。

3.回收已经终止线程的资源
线程通过调用pthread_join函数等待其他线程终止。

#include 

int pthread_join(pthread_t tid, void **thread_return);

pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的(void *)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有存储器资源。

4.分离线程
在任何一个时间点上,线程是可结合的或者是可分离的。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(比如栈)是没有被释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的存储器资源在它终止时由系统自动释放。默认情况下,线程被创建成可结合的。为了避免存储器泄露,每个可结合线程都应该要么被其他线程显示的收回,要么通过调用pthread_detach函数被分离。
pthread_detach函数分离可结合线程tid。线程能够通过以pthread_self()为参数的pthread_detach调用来分离它自己。

#include 

int pthread_detach(pthread_t tid);
多线程程序中的共享变量

从一个程序员的角度来看,线程很有吸引力的一个方面就是多个线程很容易共享相同的程序变量。然而,这种共享也是很棘手的。为了编写正确的线程化程序,我们必须对所谓的共享以及它是如何工作的有很清楚的了解。
为了理解C程序中的一个变量是否是共享的,有一些基本的问题要解答:1)线程的基础存储器模型是什么? 2)根据这个模型,变量实例是如何映射到存储器的? 3)最后,有多少线程引用这些实例?
以下一段代码说明了关于共享的许多细微之处。实例程序由一个创建了两个对等线程的主线程组成。主线程传递一个唯一的ID给每个对等线程,每个对等线程利用这个ID输出一条个性化的信息,以及调用该线程例程的总次数:

#include "csapp.h"
#define N 2
void *thread(void *vargp);

char **ptr;

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

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

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

虚拟存储器模型.jpg

从实际操作的角度来说,让一个线程去读或者写一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。如果某个线程修改了一个存储器位置,那么其他每个线程都能在它读这个位置的时候发现变化。因此,寄存器是从不共享的,而虚拟存储器总是共享的。
各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且经常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程的指针,那么它就可以读写这个栈的任何部分。我们的实例程序在printf这行展示了这一点,其中对等线程直接通过全局变量ptr间接引用主线程的栈的内容。

  1. 将变量映射到存储器
    线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
  • 全局变量:全局变量是定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。示例中声明的全局变量ptr在虚拟存储器的读/写区域中有一个运行时实例。当一个变量只有一个实例时,我们只用变量名(在这里就是ptr)来表示这个实例。
  • 本地自动变量: 本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。例如,有一个本地变量tid的实例,它保存在主线程的栈中。我们用tid.m来表示这个实例。另外,本地变量myid有两个实例,一个在对等线程0的栈内,一个在对等线程1的栈内。我们将这两个实例分别表示为myid.p0和myid.p1。
  • 本地静态变量:本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。即使示例程序中的每个对等线程都声明了cnt,在运行时,虚拟存储器的读/写区域中也只有一个cnt的实例。每个对等线程都读和写这个实例。

3.共享变量
我们说一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。如示例中的cnt就是共享的,而myid就不是共享的。然而,认识到像msgs这样的本地自动变量也能被共享是很重要的。

用信号量同步线程

共享变量是十分方便的,但是引入了同步错误的可能性。以下代码创建了两个线程,每个线程都对计数器增加了niters次,我们预计它的最终值是2xniters。这看上去简单而直接,但是可能得到错误的答案,而且每次得到的答案都可能不同!

#include "csapp.h"

void *thread(void *vargp);

volatile int cnt = 0;

int main(int argc, char **argv) 
{
    int niters;
    pthread_t tid1, tid2;
    
    if (argc != 2) {
        printf("usage: %s \n", argv[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)) {
        printf("BOOM! cnt=%d\n, cnt");
    } else
        printf("OK cnt=%d\n", cnt);
    exit(0);
}

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

为了深入理解这个问题,我们需要研究for循环的汇编代码,将线程i的循环代码分解成五个部分:

  • H(i): 在循环头部的指令块
  • L(i): 加载共享变量cnt到寄存器%eax
  • U(i): 更新(增加)%eax的指令
  • S(i): 将%eax的更新值存回到共享变量cnt的指令
  • T(i): 循环尾部的指令块

注意头和尾只操作本地栈变量,而L(i)、U(i)和S(i)操作共享计数器变量的内容。
当程序中的两个对等线层在一个单处理器上并发运行时,机器指令以某种顺序一个个地完成。因此,每个并发执行定义了两个线程中的指令的某种全序。不幸的是,这些顺序中的一些将会产生正确的结果,但是其他的则不会。

进度图

进度图将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。每条轴k对应于线程k的进度。每个点代表线程k已经完成了该指令的状态。图的原点对应于没有任何线程完成一条指令的初始状态。

初始进度图.jpg

进度图将指令执行模型化为从一种状态到另一种状态的转换。转化被表示为一条从一点到相邻点的有向边。合法的转换是向右或者向上。两条指令不能在同一时刻完成--对角线转换是不允许的。程序绝不会反向运行,所以向下或者向左的转换也是不合法的。
一个程序的执行历史被模型化为状态空间中的一条轨迹线。下图展示了下面指令顺序对应的轨迹线:
H(1), L(1), U(1), H(2), L(2), S(1), T(1), U(2), S(2), T(2)
对于线程i,操作共享变量cnt内容的指令构成了一个(关于共享变量cnt的)临界区,这个临界区不应该和其他线程的临界区交替执行。换句话说,我们想要确保每个线程在执行它临界区中的指令时,拥有对共享变量的互斥访问。

轨迹线.jpg

在进度图中,两个临界区的交集形成的状态空间区域称为不安全区。下图展示了变量cnt的不安全区。注意,不安全区和与它交界的状态相毗邻,但并不包括这些状态。绕开不安全区的叫安全轨迹线,接触到任何不安全区的叫不安全轨迹线。

写cnt的临界区.jpg

任何安全轨迹线都将正确地更新共享计数器。为了保证任何共享全局数据结构的并发程序的正确执行,我们必须以某种方式同步线程,使他们总是有一条安全轨迹线。一个经典的方法就是基于信号量的思想。

信号量

信号量s是具有非负整数值得全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V:

  • P(s): 如果s是非零的,那么P将s减1,并且立即返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个V操作会重启这个线程。
  • V(s): V操作将s加1。如果有任何线程阻塞在P操作等待s变为非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。

P中的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非零,就会将s减1,不能中断。V中的加1操作也是不可分割的,也就是加载,加1和存储信号量的过程中没有中断。注意,V的定义中没有定义等待线程被重启的顺序。唯一的要求是V必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个线程。
P和V的定义确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不可变性,为控制并发程序的轨迹线提供了强有力的工具。
Posix标准定义了许多操作信号量的函数。

#include 

int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *s);
int sem_post(sem_t *s);

//为了简明,我们更喜欢用等价的P和V的包装函数

#include "csapp.h"

void P(sem_t *s);
void V(sem_t *s);
使用信号量实现互斥

信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量与一个信号量s(初始化为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫二元信号量,因为它的值总是0或者1.以提供互斥为目的的二元信号量常常也称为互斥锁。在一个互斥锁上执行P操作称为对互斥锁加锁,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。
下图展示了我们如何利用二元信号量来正确地同步我们的计数器程序示例。每个状态都标出了信号量s的值。关键思想是这种P和V操作的结合创建了一组状态,叫做禁止区,其中s<0。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。

信号量进度图.jpg

为了用信号量正确同步计数器,我们先在主线程声明并初始化一个信号量:

volatile int cnt = 0;
sem_t mutex;
sem_init(&mutex, 0, 1);

然后将for循环体用P和V操作包围起来:

for (i = 0; i < niters; i++) {
    P(&mutex);
    cnt++;
    V(&mutex);
}

现在运行该程序,每次都能得到正确的结果。

使用信号量调度共享资源

除了提供互斥访问之外,信号量的另一个重要作用是调度对共享资源的访问。在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件为真了。两个经典而有用的例子是生产者 - 消费者和读者 - 写者问题。
生产者 - 消费者问题:
下图给出了该问题,生产者和消费者线程共享一个有n个槽的有限缓冲区。生产者线程反复地生成新的项目,并把他们插入到缓冲区中。消费者线程不断地从缓冲区中取出这些项目,然后消费它们。也可能有多个生产者和消费者的变种。

生产消费图.jpg

因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的互斥访问,同时需要调度对缓冲区的访问。如果缓冲区是满的,那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的,那么消费者必须等待直到有一个项目变为可用。我们以开发一个简单的叫SBUF的包,来构造生产者 - 消费者程序。SBUF操作类型为sbuf_t的有限缓冲区,项目存放在一个动态分配的n项整数数组中。front和rear索引值记录该数组中的第一项和最后一项。三个信号量同步对缓冲区的访问。mutex信号量提供互斥的缓冲区访问,slots和items信号量分别记录空槽位和可用项目的数量。

typefef struct {
    int *buf;
    int n;
    int front;
    int rear;
    sem_t mutex;
    sem_t slots;
    sem_t items;
} sbuf_t;

以下代码给出SBUF的实现。sbuf_init函数为缓冲区分配堆存储器,设置front和rear表示一个空的缓冲区,并为三个信号量赋初始值。这个函数在调用其他三个函数中的任何一个之前调用一次。sbuf_deinit函数是当应用程序使用完缓冲区时,释放缓冲区存储的。sbuf_insert函数等待一个可用的槽位,对互斥锁加锁,添加项目,对互斥锁解锁,然后宣布有一个新项目可用。sbuf_remove函数是与sbuf_insert函数对称的。在等待一个可用的缓冲区项目之后,对互斥锁加锁,从缓冲区的前面取出该项目,对互斥锁解锁,然后发信号通知一个新的槽位可用。

#include "csapp.h"
#include "sbuf.h"

void subf_init(sbuf_t *sp, int n) 
{
    sp->buf = calloc(n, sizeof(int));
    sp->n = n;
    sp->front = sp->rear = 0;
    sem_init(sp->mutex, 0, 1);
    sem_init(sp->slots, 0, n);
    sem_init(sp->items, 0, 0);
}

void sbuf_deinit(sbuf_t *sp)
{
    free(sp->buf);
}

void sbuf_insert(sbuf_t *sp, int item) 
{
    P(&sp->slots);
    P(&sp->mutex);
    sp->buf[(++sp->rear)%(sp->n)] = item;
    V(&sp->mutex);
    V(&sp->items);
}

void sbuf_remove(sbuf_t *sp)
{
    int item;
    P(&sp->items);
    P(&sp->mutex);
    item = sp->buf[(++sp->front)%(sp->n)];
    V(&sp->mutex);
    V(&sp->slots);
    return item;
}

从实现代码中可以看到,信号量用作互斥锁时,P,V操作都是成对出现的,用作访问调度时则相反。下面思考一个问题:
设p表示生产者数量,c表示消费者数量,而n表示以项目为单位的缓冲区大小。对于以下场景,指出sbuf_insert 和 sbuf_remove中的互斥锁信号量是否是必需的。

  • p = 1,c = 1,n > 1
  • p = 1,c = 1,n = 1
  • p > 1,c > 1,n = 1

答案: 是,不是,不是。缓冲区大小为1时,非空的缓冲区就等于满的缓冲区。当缓冲区包含一个项目时,生产者就被阻塞了。当缓冲区为空时,消费者就被阻塞了。所以任意时刻只有一个线程可以访问缓冲区,因此不用互斥锁也能保证互斥。

读者 - 写者问题:
是互斥问题的一个概括。一组并发的线程要访问一个共享的对象,例如修改一个主存中的数据结构,或者磁盘上的一个数据库。有些线程只读对象(读者),而其他的线程只修改对象(写者)。写者必须拥有对对象的独占访问,而读者可以和无限多个其他的读者共享对象。一般来说,有无限多个并发的读者和写者。
读者 - 写者问题在现实系统中很常见。例如,一个在线航空预定系统中,允许有无效多个客户同时查看座位分配,但是正在预定座位的客户必须拥有对数据库的独占的访问。再比如,一个多线程缓存Web代理中,无限多个线程可以从共享页面缓存中取出已有的页面,但是任何向缓存中写入一个新页面的线程必须拥有独占的访问。
读者 - 写者问题有几个变种,每个都是基于读者和写者的优先级的。第一类,读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者在等待而等待。第二类,写者优先,要求一旦一个写者准备好写,它就会尽可能快地完成它的写操作。即在一个写者后到达的读者必须等待,即使这个写者也是在等待。
以下代码给出了读者优先问题的解答。同许多同步问题的解答一样,这个解答很微妙,极具欺骗性的简单。信号量w控制对访问共享变量的临界区的访问。信号量mutex保护对共享变量readcnt的访问,readcnt统计当前在临界区中的读者数量。每当一个写者进入临界区时,它对互斥锁w加锁,离开时解锁。这就保证了任意时刻临界区中最多只有一个写者。另一方面,只有第一个进入临界区的读者对w加锁,而只有最后一个离开临界区的读者对w解锁。这就意味着只要还有一个读者占用互斥锁,无限多个读者可以没有障碍地进入临界区。
两种读者 - 写者问题的正确解答可能导致饥饿,也就是一个线程无限期的阻塞,无法进展。以下代码就有可能导致写者饥饿,如果不断有读者到达,写者就可能无限期地等待。

int readcnt
sem_t mutex, w

void reader(void) 
{
    while (1) {
        P(&mutex);
        readcnt++;
        if (readcnt == 1)
            P(&w);
        V(mutex);
        
        /* 临界区 */
        /* 读操作 */
        
        P(mutex);
        readcnt--;
        if (readcnt == 0)
            V(&W);
        V(&mutex);
    }
}

void writer(void)
{
    while (1) {
        P(&w);
        
        /* 临界区 */
        /* 写操作 */
        
        V(&w);
    }
}
并发问题
线程安全

当用线程编写程序时,我们必须小心地编写那些具有称为线程安全性属性的函数。一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。我们能够定义出四个(不相交的)线程不安全函数类:

  • 1.不保护共享变量的函数。在前面计数器的示例中已经遇到过这样的问题,该函数对一个未受保护的全局计数器变量加1。将这类线程不安全函数变成线程安全的,相对而言比较简单:利用像P和V操作这样的同步操作来保护共享的变量。这个方法的优点是在调用程序中不需要做任何修改。缺点是同步操作将减慢程序的执行时间。
  • 2.保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。如下的伪随机数生成器代码,rand函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。当调用srand为rand设置了一个种子后,我们从一个单线程中反复地调用rand,能够预期得到一个可重复的随机数字序列。然而,如果多线程调用rand函数,这种假设就不再成立了。使得像rand这样的函数线程安全的唯一方式是重写它,使得它不再使用任何static数据,而是依靠调用者在参数中传递状态信息。这样做的缺点是,程序员现在还要被迫修改调用程序中的代码。在一个大的程序中,可能有成千上万个不同的调用位置,做这样的修改将是非常麻烦的,而且容易出错。
//线程不安全的伪随机数生成器
unsigned int next = 1;

int rand(void)
{
    next = next*1103515245 + 12345;
    return (unsigned int)(next/65536) % 32768;
}

void srand(unsigned int seed)
{
    next = seed;
}
  • 3.返回指向静态变量的指针的函数。某些函数,例如ctime和gethostbyname,将计算结果放在一个static变量中,然后返回一个指向这个变量的指针。如果我们从并发编程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。如果线程不安全函数是难以修改或不可能修改的,那么另外一种选择就是使用加锁-拷贝技术。基本思想是将线程不安全函数与互斥锁联系起来。在每一个调用位置,对互斥锁加锁,调用线程不安全函数,将函数返回的结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。为了尽可能减少对调用者的修改,你应该定义一个一个线程安全的包装函数,它执行加锁-拷贝,然后通过调用这个包装函数来取代所有对线程不安全函数的调用。以下代码给出了ctime的一个线程安全的版本,利用的就是加锁-拷贝技术。
char *ctime_ts(const time_t *timep, char *privatep)
{
    char *sharedp;
    P(&mutex);
    sharedp = ctime(timep);
    strcpy(privatep, sharedp);
    V(mutex);
    return privatep;
}
  • 4.调用线程不安全函数的函数。如果函数f调用线程不安全函数g,那么f就是线程不安全的吗?不一定。如果g是第二类函数,即已依赖于跨越多次调用的状态,那么f也是线程不安全的,而且除了重写g以外,没有什么办法。然而,如果g是第1类或者第3类函数,那么只要你用一个互斥锁保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。上面的代码我们看到了一个这种情况很好的示例,其中我们用加锁-拷贝编写了一个线程安全函数,它调用了一个线程不安全的函数。
可重入性

有一类重要的线程安全函数,叫做可重入函数,其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。所有函数被划分成不相交的线程安全和线程不安全函数。可重入函数是线程安全函数的一个真子集。
可重入函数通常要比不可重入的线程安全函数高效一些,因为他们不需要同步操作。更进一步说,将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之成为可重入的。以下代码展示了rand函数的一个可重入的版本。关键思想是我们用一个调用者传递进来的指针取代了静态的next变量。

int rand_r(unsigned int *nextp)
{
    *nextp = *nextp * 1103515245 + 12345;
    return (unsigned int)(*next / 65536) % 32768;
}
竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流的x点时,就会发生竞争。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。

include "csapp.h"
#define N 4

void *thread(void *vargp);

int main()
{
    pthread_t tid[N];
    int i;
    
    for (i = 0; i < N; i++)
        pthread_create(&tid[i], NULL, thread, &i);
    for (i = 0; i < N; i++)
        pthread_join(tid[i], NULL);
    exit(0);
}

void *thread(void *vargp)
{
    int myid = *((int *)vargp);
    printf("Hello from thread %d\n", myid);
    return NULL;
}

以上的代码展示了竞争发生可能性,主线程创建了四个对等线程,并传递一个指向唯一整数ID的指针到每个线程。每个对等线程拷贝它的参数中传递的ID到一个局部变量中,然后输出一个包含这个ID的信息。它看上去足够简单,但是在系统上运行这个程序时,我们得到不正确的结果:

Hello from thread 1
Hello from thread 3
Hello from thread 2
Hello from thread 3

问题是由每个对等线程和主线程之间的竞争引起的。当主线程创建对等线程时,它传递了一个指向本地栈变量i的指针。在此时,竞争实际上是发生在i++和子线程中对vargap的间接引用和赋值之间。如果对等线程在主线程执行下一次创建对等线程的操作前就执行了间接引用和赋值的话,myid就能得到正确的ID,否则,它包含的计时其他线程的ID。令人惊慌的是,我们是否得到正确的答案依赖于内核是如何调度线程的执行的。
为了消除竞争,我们可以动态地为每个证书ID分配一个独立的块,并且传递给线程例程一个指向这个块的指针。

#inclde "csapp.h"
#define N 4

void *thread(void *vargp);

int main()
{
    pthread_t tid[N];
    int i, *ptr;
    
    for (i = 0; i < N; i++) {
        ptr = malloc(sizeof(int));
        *ptr = i;
        pthread_create(&tid[i], NULL, thread, ptr);
    }
    for (i = 0; i < N; i++) {
        pthread_join(tid[i], NULL);
    }
    exit(0);
}

void *thread(void *vargp)
{
    int myid = *((int *)vargp);
    free(vargp);
    printf("Hello from thread %d\n", myid);
    return NULL;
}
死锁

信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁,它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工具。下图展示了一对用两个信号量来实现互斥的线程的进度图。

死锁图.jpg

从图中我们能够得到一些关于死锁的重要知识:

  • 使用P和V操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹线碰巧到达了死锁状态d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。
  • 死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。对于程序员来说,这其中隐含的着实令人惊慌。你可以运行一个程序1000次不出任何问题,但是下一次它就死锁了。或者程序在一台机器上可能运行得很好,但是在另外的机器上就会死锁。最糟糕的是,错误常常是不可重复的,因为不同的执行有不同的轨迹线。

程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来实现互斥时,可以应用下面简单而有效的规则来避免死锁:
互斥锁加锁顺序规则:如果对于程序中每对互斥锁(s, t),给所有的锁分配一个全序,每个线程按照这个顺序来请求锁,并且按照逆序来释放,那么这个程序就是无死锁的
例如,我们可以通过这样的方法来解决上图中的死锁问题:在每个线程中先对s加锁,然后再对t加锁。下图展示了得到的进度图。

无死锁图.jpg

你可能感兴趣的:(基于线程的并发编程)