【操做系统—并发】线程

介绍

咱们将介绍为单个运行进程提供的新抽象:线程(thread)。经典观点是一个程序只有一个执行序列(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行序列(多个程序计数器,每一个都用于取指令和执行)。换一个角度来看,每一个线程相似于独立的进程,只有一点区别:它们共享地址空间,从而可以访问相同的数据。安全

所以,单个线程的状态与进程状态很是相似。线程有一个程序计数器,记录程序从哪里获取指令。每一个线程有本身的一组用于计算的寄存器。因此,若是有两个线程运行在一个处理器上,从一个线程切换到另外一个线程时,一定发生上下文切换(context switch)。线程之间的上下文切换相似于进程间的上下文切换。对于进程,咱们将状态保存到进程控制块(ProcessControl Block,PCB)。如今,咱们须要一个或多个线程控制块(Thread Control Block,TCB),保存每一个线程的状态。可是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不须要切换当前使用的页表)。多线程

线程和进程之间的另外一个主要区别在于栈。在简单的传统进程地址空间模型(咱们如今能够称之为单线程(single-threaded)进程)中,只有一个栈,一般位于地址空间的底部(见图左)。函数

然而,在多线程的进程中,每一个线程独立运行,能够调用各类例程来完成正在执行的任何工做。此时地址空间中不仅有一个栈,而是每一个线程都有一个栈。假设有一个多线程的进程,它有两个线程,结果地址空间看起来不一样(见图右)。oop

image.png

之前,堆和栈能够互不影响地增加,直到空间耗尽,多个栈就没有这么简单了。幸运的是,一般栈不会很大(除了大量使用递归的程序)。this

实例:线程建立

假设咱们想运行一个程序,它建立两个线程,每一个线程都作一些独立的工做:分别打印“A”或“B”。代码如图所示。spa

主程序建立了两个线程,分别执行函数mythread(),可是传入不一样的参数。一旦线程建立,可能会当即运行,或者处于就绪状态,等待执行。建立了两个线程后,主程序调用pthread_join(),等待特定线程完成。操作系统

1    #include <stdio.h>
2    #include <assert.h>
3    #include <pthread.h>
4
5    void *mythread(void *arg) {
6        printf("%s\n", (char *) arg);
7        return NULL;
8    }
9
10   int
11   main(int argc, char *argv[]) {
12       pthread_t p1, p2;
13       int rc;
14       printf("main: begin\n");
15       rc = pthread_create(&p1, NULL, mythread, "A"); assert(rc == 0);
16       rc = pthread_create(&p2, NULL, mythread, "B"); assert(rc == 0);
17       // join waits for the threads to finish
18       rc = pthread_join(p1, NULL); assert(rc == 0);
19       rc = pthread_join(p2, NULL); assert(rc == 0);
20       printf("main: end\n");
21       return 0;
22   }

此时,系统中存在有三个线程。代码每次的执行结果都有可能与上次不一样,有不少可能的顺序,这取决于调度程序决定在给定时刻运行哪一个线程。线程

线程建立有点像进行函数调用。然而,并非首先执行函数而后返回给调用者,而是为被调用的例程建立一个新的执行线程,它能够独立于调用者运行,可能在从建立者返回以前运行,但也许会晚得多。指针

共享数据的问题

设想一个简单的例子,有两个线程但愿更新全局共享变量。代码如图所示。code

1    #include <stdio.h>
2    #include <pthread.h>
3    #include "mythreads.h"
4
5    static volatile int counter = 0;
6
7    //
8    // mythread()
9    //
10   // Simply adds 1 to counter repeatedly, in a loop
11   // No, this is not how you would add 10,000,000 to
12   // a counter, but it shows the problem nicely.
13   //
14   void *
15   mythread(void *arg)
16   {
17       printf("%s: begin\n", (char *) arg);
18       int i;
19       for (i = 0; i < 1e7; i++) {
20           counter = counter + 1;
21       }
22       printf("%s: done\n", (char *) arg);
23       return NULL;
24   }
25
26   //
27   // main()
28   //
29   // Just launches two threads (pthread_create)
30   // and then waits for them (pthread_join)
31   //
32   int
33   main(int argc, char *argv[])
34   {
35       pthread_t p1, p2;
36       printf("main: begin (counter = %d)\n", counter);
37       Pthread_create(&p1, NULL, mythread, "A");
38       Pthread_create(&p2, NULL, mythread, "B");
39
40       // join waits for the threads to finish
41       Pthread_join(p1, NULL);
42       Pthread_join(p2, NULL);
43       printf("main: done with both (counter = %d)\n", counter);
44       return 0;
45   }

这段代码的意图很简单:两个线程分别将共享变量的计数器加一,并在循环中执行1000万次。所以,预期的最终结果是20000000。

遗憾的是,即便是在单处理器上运行这段代码,也不必定能得到预期结果。有时会这样:

prompt> ./main
main: begin (counter = 0) 
A: begin
B: begin 
A: done 
B: done
main: done with both (counter = 19345221)

并且,每次运行不但会产生错误的结果,甚至结果都不尽相同。那么不由要问了:为何会出现这种状况?

核心:不可控的调度

为了理解为何会发生这种状况,咱们必须了解编译器为更新计数器生成的代码序列。在这个例子中,咱们只是想给counter加上一个数字。所以,作这件事的代码序列可能看起来像这样:

mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c

这个例子假定,变量counter位于地址0x8049a1c。在这3条指令中,先用mov指令从内存地址处取出值,放入eax。而后,给eax寄存器的值加1。最后,eax的值被存回内存中相同的地址。

设想线程1进入这个代码区域,它将counter的值(假设它这时是50)加载到寄存器eax中。而后它向寄存器加1,所以eax = 51。如今,一件不幸的事情发生了:时钟中断发生。操做系统将当前正在运行的线程(它的程序计数器、寄存器,包括eax等)的状态保存到线程的TCB。

而后线程2被调度运行,并进入同一段代码。它也执行了第一条指令,获取计数器的值并将其放入eax中。此时counter的值仍为50,所以eax = 50。线程2继续执行接下来的两条指令,将eax递增1(此时eax = 51),而后将eax的内容保存到counter中。所以,全局变量counter如今的值是51。

最后,又发生一次上下文切换,线程1恢复运行。它已经执行过mov和add指令,如今准备执行最后一条mov指令。回忆一下,如今eax=51。最后的mov指令执行,将值保存到内存,counter再次被设置为51。

发生的状况是:增长counter的代码被执行两次,初始值为50,可是结果为51。

这里展现的状况称为竞态条件(race condition):结果取决于代码的时间执行。因为执行过程当中发生的上下文切换,咱们获得了错误的结果。事实上,可能每次都会获得不一样的结果。

因为执行这段代码的多个线程可能致使竞争状态,所以咱们将此段代码称为临界区(criticalsection)。临界区是访问共享变量(或更通常地说,共享资源)的代码片断,必定不能由多个线程同时执行。

咱们真正想要的代码就是所谓的互斥(mutual exclusion)。这个属性保证了若是一个线程在临界区内执行,其余线程将被阻止进入临界区。

原子执行

解决这个问题的一种途径是拥有更强大的指令,只需一步就能完成要作的事,从而消除不合时宜的中断的可能性。好比,若是有一条超级指令原子地支持对内存变量的自增操做,上面的程序就能够获得正确的结果。

但在通常状况下,不会有这样的指令。所以,咱们要作的是要求硬件提供一些有用的指令,而后在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。经过使用这些硬件同步原语,加上操做系统的一些帮助,咱们将可以构建多线程代码,以同步和受控的方式访问临界区,从而可靠地产生正确的结果。

等待另外一个线程

事实证实,线程之间还有另外一种常见的交互,即一个线程在继续以前必须等待另外一个线程完成某些操做。例如,当进程执行磁盘I/O并进入睡眠状态时,会产生这种交互。当I/O完成时,该进程须要从睡眠中唤醒,以便继续进行。

所以,咱们不只要研究如何构建同步原语来支持原子性,还要研究支持在多线程程序中常见的睡眠/唤醒交互的机制。

线程API

建立线程

编写多线程程序的第一步就是建立新线程,在POSIX中以下:

#include <pthread.h>
int pthread_create(pthread_t * thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);

该函数有4个参数:thread、attr、start_routine和arg。第一个参数thread是指向pthread_t结构类型的指针,咱们将利用这个结构与该线程交互,所以须要将它传入pthread_create(),以便将它初始化。

第二个参数attr用于指定该线程可能具备的任何属性。一些例子包括设置栈大小,或关于该线程调度优先级的信息。每一个属性经过单独调用pthread_attr_init()来初始化,在大多数状况下,默认值就行。在这个例子中,咱们只需传入NULL。

第三个参数最复杂,但它实际上只是问:这个线程应该在哪一个函数中运行?在C中,咱们把它称为一个函数指针(function pointer),这个指针告诉咱们须要如下内容:一个函数名称(start_routine),它被传入一个类型为void *的参数,而且它返回一个void *类型的值(即一个void指针)。

注:在C中,将void指针做为函数的参数,容许咱们传入任何类型的参数,将它做为返回值,容许函数返回任何类型的结果。

最后,第四个参数arg就是要传递给线程开始执行的函数的参数。

下面是建立线程的一个程序实例:

1    #include <pthread.h>
2
3    typedef struct  myarg_t {
4        int a;
5        int b;
6    } myarg_t;
7
8    void *mythread(void *arg) {
9        myarg_t *m = (myarg_t *) arg;
10       printf("%d %d\n", m->a, m->b);
11       return NULL;
12   }
13
14   int
15   main(int argc, char *argv[]) {
16       pthread_t p;
17       int rc;
18
19       myarg_t args;
20       args.a = 10;
21       args.b = 20;
22       rc = pthread_create(&p, NULL, mythread, &args);
23       ...
24   }

等待线程完成

若是想等待线程完成,你必须调用函数pthread_join()。该函数有两个参数,第一个是pthread_t类型,用于指定要等待的线程。这个变量是由线程建立函数初始化的(当你将一个指针做为参数传递给pthread_create()时),若是你保留了它,就能够用它来等待该线程终止。

第二个参数是一个指针,指向你但愿获得的返回值。函数能够返回任何东西,因此它被定义为返回一个指向void的指针。由于pthread_join()函数改变了传入参数的值,因此你须要传入一个指向该值的指针,而不仅是该值自己。

下面是一个程序实例:

1    #include <stdio.h>
2    #include <pthread.h>
3    #include <assert.h>
4    #include <stdlib.h>
5
6    typedef struct  myarg_t {
7        int a;
8        int b;
9    } myarg_t;
10
11   typedef struct  myret_t {
12       int x;
13       int y;
14   } myret_t;
15
16   void *mythread(void *arg) {
17       myarg_t *m = (myarg_t *) arg;
18       printf("%d %d\n", m->a, m->b);
19       myret_t *r = Malloc(sizeof(myret_t));
20       r->x = 1;
21       r->y = 2;
22       return (void *) r;
23   }
24
25   int
26   main(int argc, char *argv[]) {
27       int rc;
28       pthread_t p;
29       myret_t *m;
30
31       myarg_t args;
32       args.a = 10;
33       args.b = 20;
34       Pthread_create(&p, NULL, mythread, &args);
35       Pthread_join(p, (void **) &m);
36       printf("returned %d %d\n", m->x, m->y);
37       return 0;
38   }

除了线程建立和join以外,POSIX线程库提供的最有用的函数集,多是经过锁(lock)来提供互斥进入临界区的那些函数。这方面最基本的一对函数是:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

若是在调用pthread_mutex_lock()时没有其余线程持有锁,线程将获取该锁并进入临界区。若是另外一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到得到该锁(意味着持有该锁的线程经过解锁调用释放该锁)。在给定的时间内,许多线程可能会卡住,在获取锁的函数内部等待。然而,只有得到锁的线程才应该调用解锁。

不过还有几点须要注意。首先,在使用这些函数以前,必须确保全部的锁被正确地初始化,以保证它们具备正确的值,在锁和解锁被调用时按照须要工做。

对于POSIX线程,有两种方法来初始化锁。一种方法是使用宏PTHREAD_MUTEX_INITIALIZER,这样作会将锁设置为默认值。另外一种是调用pthread_mutex_init(),此函数的第一个参数是锁自己的地址,而第二个参数是一组可选属性,传入NULL就是使用默认值。不管哪一种方式都有效,但咱们一般使用后者。

注:当用完锁时,还应该相应地调用pthread_mutex_destroy()来释放资源。

其次,在调用获取锁和释放锁时还须要有检查错误代码。就像UNIX系统中调用的任何库函数同样,这些函数也可能会失败!若是你的代码没有正确地检查错误代码,失败将会静静地发生,可能会容许多个线程进入临界区。所以咱们至少要使用包装的函数,对函数成功加上断言。

获取锁和释放锁函数不是pthread与锁进行交互的仅有的函数。还有两个可能会用到的函数:

int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex, struct timespec *abs_timeout);

这两个函数用于获取锁。若是锁已被占用,则trylock函数将失败;timedlock函数会在超时或获取锁后返回,以先发生者为准。所以,将超时时间设置为0的timedlock将退化为trylock。一般应避免使用这两个函数,但有些状况下,好比死锁时,避免卡在获取锁的函数中会颇有用。

条件变量

全部线程库还有一个主要组件,就是存在一个条件变量(condition variable)。当线程之间必须发生某种信号时,若是一个线程在等待另外一个线程继续执行某些操做,条件变量就颇有用。相关函数主要有以下两个:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

要使用条件变量,必须另外有一个与此条件相关的锁。在调用上述任何一个函数时,应该持有这个锁。

第一个函数pthread_cond_wait()使调用线程进入休眠状态,所以等待其余线程发出信号,一般当程序中的某些内容发生变化时,唤醒如今正在休眠的线程。典型的用法以下所示:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Pthread_mutex_lock(&lock); 
while (ready == 0)
    Pthread_cond_wait(&cond, &lock); 
Pthread_mutex_unlock(&lock);

在这段代码中,在初始化相关的锁和条件变量以后,一个线程检查变量ready是否已经被设置为零之外的值。若是没有,那么线程只是简单地调用等待函数以便休眠,直到其余线程唤醒它。唤醒线程的代码运行在另外某个线程中,像下面这样:

Pthread_mutex_lock(&lock);
ready = 1; 
Pthread_cond_signal(&cond); 
Pthread_mutex_unlock(&lock);

关于这段代码有一些注意事项。首先,在发出信号时(以及修改全局变量ready时),始终确保持有锁。这确保咱们不会在代码中意外引入竞态条件。

其次,你可能会注意到等待调用将锁做为其第二个参数,而信号调用仅须要一个条件。形成这种差别的缘由在于,等待调用除了使调用线程进入睡眠状态外,还会让调用者睡眠时释放锁。想象一下,若是不是这样:其余线程如何得到锁并将其唤醒?可是,在被唤醒以后返回以前,pthread_cond_wait()会从新获取该锁,从而确保等待线程在等待序列开始时获取锁与结束时释放锁之间运行的任什么时候间,它持有锁。

最后一点须要注意:等待线程在while循环中从新检查条件,而不是简单的if语句。一般使用while循环是一件简单而安全的事情,虽然它从新检查了这种状况(可能会增长一点开销),但有一些pthread实现可能会错误地唤醒等待的线程。在这种状况下,没有从新检查,等待的线程会继续认为条件已经改变。所以,将唤醒视为某种事物可能已经发生变化的暗示,而不是绝对的事实,这样更安全。

一些参考建议

当构建多线程程序时,这里有一些简单而重要的建议:

  • 保持简洁。线程之间的锁和信号的代码应该尽量简洁,复杂的线程交互容易产生缺陷。
  • 让线程交互减到最少。每次交互都应该想清楚,并用验证过的、正确的方法来实现。
  • 初始化锁和条件变量。未初始化的代码有时工做正常,有时失败,可能会产生奇怪的结果。
  • 检查返回值。任何C和UNIX的程序,都应该检查返回值,这里也是同样。
  • 注意传给线程的参数和返回值。举例来讲,若是传递在栈上分配的变量的引用,就会出错。
  • 每一个线程都有本身的栈。线程局部变量应该是线程私有的,其余线程不该该访问。线程之间共享数据,值要在堆或者其余全局可访问的位置。
  • 线程之间老是经过条件变量发送信号。切记不要用标记变量来同步。
  • 多查手册。尤为是Linux的pthread手册,有更多的细节、更丰富的内容。
相关文章
相关标签/搜索