线程控制


来源:《基于UNIX/Linux的C系统编程》、《操作系统教程(第4版)》

通常,把由用户级程序创建的线程称为用户线程,其主要在用户空间中运行;把由内核创建的线程称为内核线程,其主要在系统空间中运行。所以在实际编程应用当中,就出现了两种线程模型,即用户级线程模型和内核级线程模型。

1、用户级线程模型(User Level Threads,ULT)

该模型中线程的调度者是在核外的用户程序(线程控制库)。模型中线程的产生和维护完全由用户级程序实现,内核只负责以进程为单位对系统资源和处理器进行调度与管理。在该模型下,用户线程与内核线程的对应关系是多对一关系,也就是说任意时刻只能有一个用户线程可以访问内核,各用户是并发获取内核服务的。其主要特点是:

  • 线程切换时无须改变进程的运行模式,从而减少了进程在核心态和用户态之间转换时所带来的资源消耗;
  • 线程的创建、删除和调度工作是由线程控制库实现的,这意味着内核并不知道线程的存在,所以线程的创建开销较小,其调度算法也相对灵活;
  • 基于用户级线程管理模式的程序有较好的移植性;
  • 多个线程只能共享所属进程的时间片,在多处理器系统上不能实现真正的并行。

2、内核级线程模型(Kernel Level Threads,KLT)

在该模型中线程的调度者是内核。系统的内核给应用程序提供一个可访问内核级线程的应用程序接口。系统内核管理线程的调度与上下文切换。在该模型下用户线程与内核线程的对应关系是一对一的关系,也就是说每创建一个用户线程都需要创建一个相应的内核线程。其主要特点是:

  • 在该模型下,内核可以把同一进程中的不同线程调度到多个处理机上运行,即支持多处理器并行处理机制;
  • 当进程的一个线程被阻塞时,进程中的其余线程也可以被调度并继续运行;
  • 由于内核程序本身是以多线程方式编写,所以采用多线程模式的应用程序在该系统下运行,差异不会很大;
  • 同一进程中的线程进行切换时需要进入到内核模式下完成,这点类似于进程;
  • 线程的创建、删除和调度工作都是由内核完成,其系统开销较大;
  • 每创建一个用户线程都需要创建一个相应的内核线程,带来额外的开销,所以许多系统对线程数目都有所限制。

现代UNIX,一般都采用用户级和内核级相结合的方式实现多线程,即在内核中同时运行多个内核线程,每个内核线程同时对应一组(多个)用户线程,且在任意时刻下每组只允许一个用户线程进入内核获取服务。这种模式虽然结合了上述两种模型的优点,但实现起来相对复杂,且需要内核的支持。

显然,多线程的实现模型和多进程有着本质的不同,进而导致两类模型下程序之间的执行差异,从系统编程的角度分析,主要存在以下几个方面的差异:

  • 创建的开销。创建线程的开销远远小于创建进程的开销。这是因为创建线程时,内核无须像创建进程那样单独复制进程的内存空间或文件描述符等,从而节省了大量的CPU时间,这使得新线程的创建比新进程的创建速度要提高10~100倍。但是,这并不意味着使用线程的数量不受CPU或内存的限制,当使用大量的线程时则会加大CPU的任务量,即使是在多核系统中线程的数量也不应该超过CPU的个数。而在创建线程时,CPU时间虽然会被大量占用,但其占用的时间是短暂的。所以,若任务量较大且系统硬件条件较好时,可以通过创建多个进程在最短的时间内完成任务;但是,若系统的内存较小且其实时性的要求不高时,则通过创建多线程予以解决的方式最为合理。因此,具体应该按照设计要求和系统的性能来选择不同的设计模型;
  • 上下文切换的开销。多线程(用户级线程模型)的上下文切换的开销远远小于多进程。这是因为多个线程在同一个进程中运行,无论线程如何切换都不用改变进程的运行模式,从而避免了进程在核心态和用户态之间转换时的资源消耗;
  • 通信问题。多线程间通信比多进程间通信过程简单,不需要额外的通信机制。这是因为线程间共享进程中同一数据空间(用户地址空间和其他进程资源),所以一个线程的数据可以直接为其他线程所用,而不像进程需要额外的通信机制。但是,若两个线程同一时刻访问同一个全局变量时,它们之间就会产生相互干扰;若多个线程并发调用同一个库函数时,其返回结果也是不可预知的。所以线程之间必须采用相应的同步机制,以协调线程间同步或互斥的关系;
  • 健壮性问题。在多线程系统下,如果一个线程出错,系统将终止整个进程,进而其他的无关线程也将被终止。但是在多进程环境下,由于进程在系统中能够独立运行,所以若一个进程被异常终止,其他进程也不会受到影响而继续执行。

线程控制的职责是对进程中全部线程实施有效的管理,这意味着应该具有创建新线程、阻塞线程、激活线程和撤销线程等的能力。

1、获取线程标识

类似进程,每个线程都有一个线程ID,进程ID在整个系统中是唯一的,但线程不是,线程ID只在它所处的进程环境中有效。线程ID用pthread_t数据结构类型来表示。与线程ID有关的函数有两个:pthread_self函数和pthread_equal函数,前者可以获取本线程ID,后者用于比较两个线程ID。

 所需头文件  #include <pthread.h>
函数原型  pthread_t pthread_self(void);
参数  无
 返回值  调用线程的线程ID
 所需头文件  #include <pthread.h>
 函数原型  int pthread_equal(pthread_t tid1, pthread_t tid2);
 参数  tid1:线程1的ID;tid2:线程2的ID
 返回值  若相等返回非0值,否则返回0

2、创建线程

创建线程函数pthread_create,其函数第一个参数是线程的标识,第二个参数是指向线程属性的指针,第三个参数是线程所要执行的函数名称,第四个参数是指向所要传递给执行函数的参数指针。其函数语法如下表所示:

所需头文件  #include <pthread.h>
函数原型  int pthread_create(pthread_t *thread, pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
参数  thread:线程标识符
 attr:线程属性设置,通常取为NULL
 start_routine:线程函数的起始地址,是一个以指向void的指针作为参数和返回值的函数指针
 arg:传递给start_routine的参数
函数返回值  若函数调用成功,返回0,否则返回-1

3、终止线程

线程是依进程而存在的,当进程终止时,线程也会随即终止。比如在调用exit、_exit或_Exit函数来终止线程时,由于这些调用都将会导致整个进程的终止,虽然所需要的线程可以得到终止,但进程中其他线程也会被终止。为此下面介绍几种针对某一线程的终止方法。

一般来说,线程的终止主要有两种情况:正常终止和非正常终止。线程能够主动调用pthread_exit函数或者return函数都可使线程正常退出,这是可预见的退出方式,属于正常终止;而非正常终止则是线程在其他线程干预下,或者由于自身运行出错而退出,这种退出方式是不可预见的。

(1)正常终止

理论上讲,pthread_exit函数和return函数的功能是相同的,线程结束时会在内部自动清理线程相关的资源,但实际上二者有着很大的不同。

  • 若在进程主函数main中调用pthread_exit函数,则将使主函数所在的线程(一个线程)退出;但若是调用函数return,编译器则将调用进程退出函数(如_exit函数),进而导致进程及其所有线程结束运行。
  • 若在线程宿主函数中调用return,且return语句包含在pthread_cleanup_push和pthread_cleanup_pop函数对之中时,则将在编译时导致段错误。

此外,由于进程中各个线程的运行都是相互独立的,线程的终止并不会相互通知,也不会影响其他线程,终止的线程所占用的资源也不会随着线程的终止而得到释放。正如进程之间可以用wait函数来同步终止进程并释放资源一样,线程之间也有类的机制,那就是pthread_join函数,其用于等待线程的结束,第一个参数是所要等待的线程ID,第二个参数是一个指向返回值的指针,如果此参数为NULL,则表示返回值不被考虑。pthread_exit和pthread_join函数语法分别如下表:

所需头文件 #include <pthread.h>
函数原型  void pthread_exit(void *retval);
参数  retval:线程结束时的返回值,该值由其他函数如pthread_join来获取
函数返回值  无
所需头文件 #include <pthread.h>
函数原型  int pthread_join(pthread_t th, void **thread_return);
参数  th:等待线程的标识符
 thread_return:用户定义的指针,用来存储被等待线程结束时的返回值(不为NULL时)
函数返回值  若函数调用成功,返回0,否则返回-1

pthread_join函数的调用者将挂起并等待th线程终止,如果thread_return不为NULL,则*thread_return指向retval,这里retval就是pthread_exit调用者线程的返回值。需要指出,被等待的线程应处于JOIN状态,而不是DETACHED状态(分离状态),且pthread_join函数的一次调用仅能等待一个线程终止。

所谓线程的分离状态是指处于该状态的线程并不被其他线程所等待,而是自己结束运行并自动释放系统资源,也不会将结果或状态值返回给主线程。采用此类方式结束线程,可以使线程结束后立即释放系统资源,而不必等待其他线程,从而降低系统开销,节省系统资源。函数pthread_detach可以使线程进入分离状态,一旦线程进入分离状态将无法再返回到非分离状态(JOIN状态)。其函数语法说明如下表:

所需头文件 #include <pthread.h>
函数原型  void pthread_detach(pthread_t th);
参数  th:进入分离状态的线程ID
函数返回值  若函数调用成功,返回0,否则返回-1并将错误码写入errno

需要指出,若进程中某个线程执行了pthread_detach之后仍使用pthread_join函数等待并回收,则将返回错误。为了避免内存泄漏,对于所有线程的终止操作,要么设为DETACHED状态,要么就使用pthread_join函数来回收。

(2)非正常终止

调用pthread_exit函数可以主动终止自身线程,但在实际应用中经常会碰到一个线程要终止另一个线程执行的操作。此时可以调用pthread_cancel函数来实现这种操作,但是在被取消的线程内部需要调用pthread_setcancel函数和pthread_setcanceltype函数来设置自己的取消状态。函数语法说明如下表:

所需头文件 #include <pthread.h>
函数原型  void pthread_cancel(pthread_t th);
参数  th:进入分离状态的线程ID
函数返回值  若函数调用成功,返回0,否则返回-1并将错误码写入errno

有关线程终止的属性并不包含在pthread_attr_t结构中,此处说明:

  • PTHREAD_CANCEL_ENABLE:表示会响应其他线程提出的取消请求;
  • PTHREAD_CANCEL_DISABLE:表示不响应取消请求。

这两个参数由函数pthread_setcancelstate实现,原型如下:

#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);

另外,还可以设置线程是立即响应取消请求退出线程,还是运行到取消点时再退出。

  • PTHREAD_CANCEL_DEFERRED:仅当cancel状态为Enable时有效,表示线程收到信号后继续运行,直到下一个取消点时再退出;
  • PTHREAD_CANCEL_ASYNCHRO:仅当cancel状态为Enable时有效,表示线程收到信号后立即执行取消动作(退出)。

这两个参数由函数pthread_setcanceltype实现,原型如下:

#include <pthread.h>
int pthread_setcanceltype(int type, int *oldtype);
void pthread_testcancel(void);

其中pthread_testcancel函数为检查本线程是否处于cancel状态。若处于cancel状态,则进行取消动作,否则直接返回。

4、清理已终止的线程

不论是正常终止还是非正常终止,线程都会存在资源释放的问题,在不考虑因运行出错而退出的前提下,如何保证线程在终止时能够顺利释放自己所占用的资源则是一个必须要考虑的问题。比如,线程为了访问临界资源而为其上锁,但在访问过程中若该线程被外界取消,而此时线程或者处于响应取消状态,或者采用异步方式响应,或者在打开独占锁以前的运行路径上存在取消点,则该临界资源将永远处于锁定状态而得不到释放。由于外界取消动作是不可预见的,所以需要一个机制来简化对资源释放的操作。

在UNIX中,pthread_cleanup_push和pthread_cleanup_pop函数便是用于自动释放资源的函数对。从pthread_cleanup_push所指定的调用点到pthread_cleanup_pop之间的程序段中的终止动作(包括调用pthread_exit和取消点终止)都将执行pthread_cleanup_push函数所指定的清理函数。其函数原型如下:

#include <pthread.h>
void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);

函数pthread_cleanup_push和函数pthread_cleanup_pop采用先入后出的栈结构管理,void routine(void *arg)函数在调用pthread_cleanup_push时压入清理函数栈,多次对pthread_cleanup_push函数的调用将在清理函数栈中形成一个函数链,在执行该函数链时按照压栈相反顺序弹出。execute参数表示在执行到pthread_cleanup_pop函数时是否需要在弹出清理函数的同时执行该函数,其实参数为0表示不执行,非0为执行,同时该参数并不影响异常终止时清理函数的执行。需要指出,这两个函数必须成对出现,且必须位于程序的同一级别的代码段中才能通过编译。代码如下:

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

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

void *thr_fn(void *arg) {
    printf("thread start\n");
    pthread_cleanup_push(cleanup, "thread first handler");
    pthread_cleanup_push(cleanup, "thread second handler");
    printf("thread push complete\n");
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
}

int main() {
    pthread_t tid;
    void *tret;
    pthread_creat(&tid, NULL, thr_fn, (void *)1);
    pthread_join(tid, &tret);
    printf("thread exit code %d\n", (int)tret);
    return 0;
}

5、使用线程的私有数据

在多线程环境下,进程内的所有线程共享进程的数据空间,进程内的全局变量自然为所有线程共享。但是在程序设计中,有时需要保存线程之间的“全局”变量,这种特殊的变量仅在某个进程内部有效。如常见的变量errno,它主要用于返回标准的出错代码。errno的角色尴尬,若作为局部变量,可是几乎每个函数都应该可以访问它;但若作为全局变量,则在一个线程里输出的很可能是另一个线程的出错信息。对于此类问题, 一般的解决方式是通过创建线程的私有数据(Thread-specific Data,TSD)。线程的私有数据可以被进程内的各个线程所访问,但是它可以屏蔽区分各个线程的信息。

线程私有数据采用一键多值技术, 即一个键对应多个数值。使用线程私有数据时,首先要为每个线程数据创建一个相关联的键。在各个线程内部,都使用这个公用的键来指代线程数据,但是在不同的线程中,这个键代表的数据是不同的。操作线程私有数据的函数主要有4个,即pthread_key_create(创建一个键)、pthread_setspecific(为一个键设置线程私有数据)、pthread_getspecific(从一个键读取线程私有数据)和pthread_key_delete(删除一个键)。这类函数原型如下:

#include <pthread.h>
int pthread_key_create(pthread_key_t *key, void (*destr_function)(void *));
int pthread_setspecific(pthread_key_t key, const void *pointer);
void *pthread_getspecific(pthread_key_t key);
int pthread_key_delete(pthread_key_t key);

pthread_key_create从TSD池中分配一项,将其值赋给key供以后访问使用,它的第一个参数key为指向键值的指针,第二个参数为一个函数指针,如果指针不为空,则在线程退出时将以key所关联是数据为参数调用destr_function,释放分配的缓冲区。key一旦被创建,所有线程都可以访问它,但各线程可以根据自己的需要向key中填入不同的值,这就相当于提供了一个同名而不同值的全局变量,一键多值。一键多值靠得是一个关键数据结构数组,即TSD池其结构如下:
static struct pthread_key_struct pthread_keys[PTHREAD_KEYS_MAX] = {0, NULL};

pthread_setspecific函数将pointer的值与key相关联。用pthread_setspecific为一个键指定新的线程数据时,线程必须先释放原有的线程数据用以回收空间。

pthread_getspecific函数用来得到与key相关联的数据。

pthread_key_delete函数用来删除一个键,键所占用的内存将被释放。需要指出,在键所占用的内存被释放时,而与该键关联的线程数据所占用的内存并不被释放。所以,线程数据的释放必须在释放键之前完成。

例1:实现创建和使用线程的私有数据。

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

pthread_key_t key;

void *thread2(void *arg) {
    int tsd = 5;
    printf("thread %d is running\n", (int)pthread_self());
    pthread_setspecific(key, (void *)tsd);
    printf("thread %d returns %d\n", (int)pthread_self(), (int)pthread_getspecific(key));
}

void *thread1(void *arg) {
    int tsd = 0;
    pthread_t thid2;
    printf("thread %d is running\n", (int)pthread_self());
    pthread_setspecific(key, (void *)tsd);
    pthread_create(&thid2, NULL, thread2, NULL);
    sleep(2);
    printf("thread %d returns %d\n", (int)pthread_self(), (int)pthread_getspecific(key));
}

int main() {
    pthread_t thid1;
    printf("main thread begins running\n");
    pthread_key_create(&key, NULL);
    pthread_create(&thid1, NULL, thread1, NULL);
    sleep(5);
    pthread_key_delete(key);
    printf("main thread exit\n");
    return 0;
}

2016-01-05 13:59:11屏幕截图

程序中主线程创建了线程thread1(TID:284038912),线程thread1创建了线程thread2(TID:275646208)。两个线程分别将tsd作为线程私有数据。从运行结果看出,两个线程的tsd的修改互不干扰,thread1在创建thread2后,睡眠2s并等待thread2执行完毕,主线程睡眠5s等待thread1结束,thread1和thread2分别对tsd进行了修改,但并没有tsd的正常取值。

线程属性

 

Leave a comment

邮箱地址不会被公开。 必填项已用*标注