操作系统导论——第27章 插叙:线程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 *的参数(start_routine 后面的括号表明了这一点 ),并且它返回一个void *类型的值(即一个void指针)。
如果这个函数需要一个整数参数,而不是一个 void 指针,那么声明看起来像:
int pthread_create(..., // first two args are the same
void * (*start_routine)(int),
int arg);
如果函数接受 void 指针作为参数,但返回一个整数,函数声明会变成:
int pthread_create(..., // first two args are the same
int (*start_routine)(void *),
void * arg);
第四个参数 arg 就是要传递给线程开始执行的函数的参数。你可能会问:为什么我们需要这些 void 指针?好吧,答案很简单:将void指针作为函数的参数start_routine,允许我们传入任何类型的参数,将它作为返回值,允许线程返回任何类型的结果。
看图 27.1 中的例子。这里我们只是创建了一个线程,传入两个参数,它们被打包成一个我们自己定义的类型(myarg_t)。该线程一旦创建,可以简单地将其参数转换为它所期望的类型,从而根据需要将参数解包。
二、线程完成
如果想等待线程完成,会发生什么情况?需要做一些特别的事情来等待完成。具体来说,必须调用函数 phtread_join()。
该函数有两个参数。第一个是 pthread_t 类型,用于指定要等待的线程。这个变量是由线程创建函数初始化的(当你将一个指针作为参数传递给 pthread_create()时)。如果你保留了它,就可以用它来等待该线程终止。
第二个参数是一个指针,指向你希望得到的返回值。因为函数可以返回任何东西,所以它被定义为返回一个指向 void 的指针。因为 pthread_join() 函数改变了传入参数的值,所以你需要传入一个指向该值的指针,而不只是该值本身。
看另一个例子(见图27.2)。在代码中,再次创建单个线程,并通过 myarg_t 结构传递一些参数。对于返回值,使用 myret_t 型。当线程完成运行时,主线程已经在 pthread_join() 函数内等待了。然后会返回,我们可以访问线程返回的值,即在 myret_t 中的内容。
有几点需要说明。首先,我们常常不需要这样痛苦地打包、解包参数。如果我们不需要参数,创建线程时传入NULL即可。类似的,如果不需要返回值,那么 pthread_join() 调用也可以传入NULL。
其次,如果我们只传入一个值(例如,一个int),也不必将它打包为一个参数。图27.3展示了一个例子。在这种情况下,更简单一些,因为我们不必在结构中打包参数和返回值。
再次,我们应该注意,必须非常小心如何从线程返回值。特别是,永远不要返回一个指针,并让它指向线程调用栈上分配的东西。如果这样做,你认为会发生什么?(想一想!) 下面是一段危险的代码示例,对图27.2中的示例做了修改。
void *mythread(void *arg) { myarg_t *m = (myarg_t *) arg; printf("%d %d\n", m->a, m->b); myret_t r; // ALLOCATED ON STACK: BAD! r.x = 1; r.y = 2; return (void *) &r;
}
在这个例子中,变量 r 被分配在 mythread 的栈上。但是,当它返回时,该值会自动释放(这就是栈使用起来很简单的原因!),因此,将指针传回现在已释放的变量将导致各种不好的结果。
最后,使用 pthread_create() 创建线程,然后立即调用 pthread_join(), 这是创建线程的一种非常奇怪的方式。事实上,有一个更简单的方法来完成这个任务,它被称为过程调用。显然,我们通常会创建不止一个线程并等待它完成,否则根本没有太多的用途。
三、锁
除了 线程创建 和 join 之外,POSIX 线程库提供的最有用的函数集,可能是通过锁(lock) 来提供互斥进入临界区的那些函数。这方面最基本的一对函数是:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
如果你意识到有一段代码是一个临界区,就需要通过锁来保护,以便像需要的那样运行。你大概可以想象代码的样子:
pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1; // or whatever your critical section is
pthread_mutex_unlock(&lock);
意思是: 如果在调用 pthread_mutex_lock() 时没有其他线程持有锁,线程将获取该锁并进入临界区。如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到获得该锁(意味着持有该锁的线程通过解锁调用释放该锁)。当然,在给定的时间内,许多线程可能会卡住,在获取锁的函数内部等待。然而,只有获得锁的线程才应该调用解锁。
然而,这段代码有两个重要的问题。第一个问题是缺乏正确的初始化(lack of proper initialization)。所有锁必须正确初始化,以确保它们具有正确的值,并在锁和解锁被调用时按照需要工作。
对于 POSIX 线程,有两种方法来初始化锁。 一种方法是使用 PTHREAD_MUTEX_ INITIALIZER,如下所示:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
这样做会将锁设置为默认值,从而使锁可用。初始化的动态方法(即在运行时)是调用pthread_mutex_init(),如下所示:
int rc=pthread_mutex_init(&lock,NULL);
assert(rc==0); // always check success
此函数的第一个参数是锁本身的地址,而第二个参数是一组可选属性。传入 NULL 就是使用默认值。无论哪种方式都有效,但我们通常使用动态(后者)方法。请注意,当你用完锁时,还应该相应地调用 pthread_mutex_destroy()。
上述代码的第二个问题是在调用获取锁和释放锁时没有检查错误代码。就像 UNIX 系统中调用的任何库函数一样,这些函数也可能会失败!如果你的代码没有正确地检查错误代码,失败将会静静地发生,在这种情况下,可能会允许多个线程进入临界区。至少要使用包装的函数,它对函数成功加上断言(见图27.4)。更复杂的(非玩具)程序,在出现问题时不能简单地退出,应该检查失败并在获取锁或释放锁未成功时执行适当的操作。
获取锁 和 释放锁 函数不是 pthread 与锁进行交互的仅有的函数。
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex,
struct timespec *abs_timeout);
这两个调用用于获取锁。如果锁已被占用,则 trylock 版本将失败。获取锁的 timedlock 定版本会在超时或获取锁后返回,以先发生者为准。因此,具有零超时的 timedlock 退化为 trylock 的情况。通常应避免使用这两种版本,但有些情况下,避免卡在(可能无限期的) 获取锁的函数中会很有用,我们将在以后的章节中看到(例如,当我们研究死锁时)。
四、条件变量
所有线程库还有一个主要组件(当然 POSIX 线程也是如此),就是存在一个条件变量 (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 实现可能会错误地唤醒等待的线程。在这种情况下,没有重新检查,等待的线程会继续认为条件已经改变。因此,将唤醒视为某种事物可能已经发生变化的暗示,而不是绝对的事实,这样更安全。
请注意,有时候线程之间不用条件变量和锁,用一个标记变量会看起来简单。例如,重写上面的等待代码,像这样:
while(ready == 0)
; // spin
相关的发信号代码看起来像:
ready=1;
千万不要这么做。首先,多数情况下性能差(长时间的自旋浪费CPU)。其次,容易出错。最近的研究[X+10]显示,线程之间通过标志同步(像上面那样),出错的可能性让人吃惊。在那项研究中,这些不正规的同步方法半数以上都是有问题的。
五、编译与运行
本章所有代码很容易运行。代码需要包括头文件pthread.h才能编译。链接时需要pthread 库,增加-pthread标记。
例如,要编译一个简单的多线程程序,只需像下面这样做:
prompt> gcc -o main main.c -Wall -pthread
只要 main.c 包含 pthreads 头文件,你就已经成功地编译了一个并发程序。像往常一样,它是否能工作完全是另一回事。