4.2 线程安全、线程取消

线程安全、线程取消以及一些额外细节

线程安全

线程安全函数

若函数可同时供多个线程安全调用,则称之为线程安全函数。如以下函数:

static int glob = 0;
static void incr(int loops) {
    int loc, j;
    for(j = 0;j < loops;j++) {
        loc = glob;
        loc++;
        glob = loc;
    }
}

一般导致线程不安全的典型原因是:使用了所有线程之间共享的全局或静态变量。可通过互斥量与共享变量关联起来,已保证线程安全。

一次性初始化

一次性初始化是指,无论创建多少线程,初始化动作只发生一次:

#include <pthread.h>

/*
@brief	无论调用过 pthread_once 多少次,都只会调用 init 一次
@param	once_control	一般初始化为:pthread_once_t once_var = PTHREAD_ONCE_INIT;
*/
int pthread_once(pthread_once_t *once_control, void (*init)(void));

此函数经常与线程特有数据结合使用。

线程特有数据

线程特有数据为每个线程提供一份独有的数据副本。一般步骤如下:

  1. 函数创建一个键(key),用以区分不同函数使用的线程特有函数,可通过 pthread_key_create 创建,且只需要创建一次,所以需要配合 pthread_once 使用。

  2. pthread_key_create还允许调用者指定一个自定义解构函数,在线程终止时释放该线程的特有数据块

  3. 使用 malloc 创建特有数据

  4. 上一步分配的数据地址可通过 pthread_setspecificpthread_getspecific设置和获取。一般先获取数据地址,若为 NULL 说明还没有分配,然后使用 pthread_setspecific设置数据地址。

#include <pthread.h>

// 设置 key 以及解构函数
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
// 设置与 key 绑定的特有数据块地址,成功返回 0, 失败返回一个正数
int pthread_setspecific(pthread_key_t key, const void *value);
// 返回与 key 绑定的数据块,没有绑定过返回 NULL
void *pthread_getspecific(pthread_key_t key);

一个通用的模板:

#include <pthread.h>

static pthread_once_t once = PTHREAD_ONCE_INIT;
static pthread_key_t func_key;

static void destructor(void *buf) {
    free(buf);
}

static void createKey(void) {
    int s;
    s = pthread_key_create(&func_key, destructor);
}

void func(void) {
    char *buf;	// 线程特有数据
    pthread_once(&once, createKey);
    buf = pthread_getspecific(func_key);
    if(buf == NULL) {
        buf = malloc(BUF_SIZE);
        pthread_setspecific(func_key, buf);
    }
    // 使用线程特有数据
}

需要注意的是,key 的数量是有限制的。

线程局部存储

与线程特有数据类似,线程局部存储提供了持久的没线程存储。但这个是非标准特性,在某些 Unix 发行版上可能不被支持。 只需要通过特殊的声明即可创建线程局部变量:

static __thread type buf[MAX_ERROR_LEN];

带有 __thread说明符的变量,每个线程都拥有该变量的拷贝,此变量将一直存在,直至线程终止。

线程取消

线程取消是指,向线程发送一个请求,要求其立即退出。

#include <pthread.h>
// 向线程发送取消请求,成功返回 0,失败返回一个正值
int pthread_cancel(pthread_t thread);

通过以下函数可以帮助线程对取消请求的响应过程加以控制:

#include <pthread.h>

/*
@brief	将调用线程的取消性状态设置为参数 state 给定的值,并将原状态通过 oldstate 返回
    	state 可取
        - PTHREAD_CANCEL_DISABLE	线程不可取消,收到取消请求则挂起此请求,直到启用线程取消请求
        - PTHREAD_CANCEL_ENABLE		线程可以取消,默认值
*/
int pthread_setcancelstate(int state, int *oldstate);
/*
@brief	如果线程可以取消,以下函数规定了线程取消的方式
    	type 可取
        - PTHREAD_CANCEL_ASYNCHRONOUS	异步取消
        			        可能会在任何时间(也许是立即取消)取消线程
                                        此时清理函数仍然会执行,但函数当前状态是不确定的
                                        一般可异步取消的线程不能分配任何资源和互斥量或锁
        - PTHREAD_CANCEL_DEFERED        取消请求保持挂起,直到取消点,为默认类型
*/
int pthread_setcanceltype(int type, int *oldtype);
#include <pthread.h>
// 在此处创建一个取消点
void pthread_testcancel();

每个线程都有一个清理函数栈,在收到取消请求时,线程会自动调用函数栈内的函数。

#include <pthread.h>
// 向函数栈内添加函数
void pthread_cleanup_push(void (*routine)(void*), void *arg);
// 将函数栈顶部的函数弹出,execute 非 0 时还会执行这个弹出的函数
void pthread_cleanup_pop(int execute);

这两个函数在某些系统上可能用宏实现,所以这两个配对的操作需要在同一个语法块。例如,以下代码就不正确:

pthread_cleanup_push(func, arg);
...
if(cond) {
    pthread_cleanup_pop(0);
}

一些细节

信号、进程、线程

Unix 信号远早于 Pthreads,所以信号与线程模型之间存在一些冲突。在实际开发中,多线程模型应尽量避免使用信号。 信号与线程有以下注意点:

  • 信号动作属于进程层面。进程的任一线程收到动作为 stop 或 terminate 的信号,则会终止该进程所有线程。

  • 对信号的处置属于进程层面,进程中的所有线程共享对每个信号的处置设置。如果使用 sigaction为某信号设置了处理函数,当收到信号时,任何线程都会去调用该处理函数。

  • 信号的发送也可以针对某个线程,下面几种信号是面向线程的:

    • 信号的产生源于线程上下文对特定硬件指令的执行(SIGBUS,SIGFPE,SIGKILL,SIGSEGV)

    • 线程对已断开的管道进行写操作时产生的 SIGPIPE

    • pthread_killpthread_sigqueue 发出的信号

    • 其他机制产生的信号全是面向进程的。

  • 多线程程序收到一个信号,只会选择一条线程来接收此信号。

  • 信号掩码是针对线程的。

  • 进程挂起信号,以及每条线程挂起的信号,内核分别维护记录。

操作线程掩码

刚创建的新线程会从其创建者处继承信号掩码的一份拷贝。

#include <signal.h>
// 与 sigprocmask 用法完全一致
// 成功返回 0,失败返回一个正数
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);

向线程发送信号

#include <signal.h>
// 向线程发送 thread 发送 sig 信号
// 成功返回 0,失败返回一个正数
int pthread_kill(pthread_t thread, int sig);

#define _GNU_SOURCE
#include <signal.h>
// 将 pthread_kill 与 sigqueue 功能合并
// 成功返回 0,失败返回一个正数
int pthread_sigqueue(pthread_t thread, int sig, const union sigval value);

多线程中处理异步信号

没有任何 Pthreads API 为异步信号安全函数,所以不能在信号处理函数中加以调用。所以多线程程序中处理异步信号,通常不应该将信号处理函数作为接收信号到达的通知机制,一般推荐的方法如下:

  • 所有线程都阻塞可能接受的所有异步信号,一般在创建线程之前由主线程阻塞,新线程会拥有一份拷贝。

  • 再创建一个专用线程,调用 sigwaitXXX函数接收信号。

当收到信号时,专有线程可以安全地修改共享变量(结合互斥量),并可以调用并非异步信号安全的函数,也可以就条件变量发出信号。 如有多个线程在等待同一信号,那么信号到达时只有一个线程会实际接收到,并且无法确定收到信号的会是哪一条线程。

线程与进程控制

这里主要描述线程执行 execforkexit后的一些注意点:

  • 线程与 exec:任一线程调用 exec系列函数后,调用程序将被完全替换。除了调用 exec的线程外,其他线程立即消失,且不会为线程特有函数执行解构,与清理函数。进程所有的互斥量和条件变量均会消失。调用 exec后,调用线程的线程 ID 是不确定的。

  • 线程与 fork:多线程程序调用 fork 时仅会将发起调用的线程复制到子进程中,其他线程均在子进程中消失,也不会执行相关的解构函数,会导致一些问题:进程的互斥量等也会复制到子进程中,会导致十分棘手的问题产生。函数 pthread_atfork可以注册函数,在调用 fork 的之前执行注册函数。

  • 线程与 exit:任何线程调用了 exit,所有线程均消失,不会执行特有数据的解构。

线程实现模型

  • 多对一(M:1)实现(用户级线程):线程创建、调度、同步等均在用户空间的线程库处理,内核并不知道多线程的存在。优势在于速度十分快,因为不需要切换到内核状态,不需要内核支持,移植比较容易。也存在一些缺陷:一个线程阻塞,则所有线程都被阻塞。

  • 一对一(1:1)实现(内核级线程):此实现中,每一个线程映射一个单独的 KSE,内核对每个线程做调度处理。1:1 实现消除了 M:1 实现的各种弊端,尽管速度没有用户级线程快,但综合上还是优于用户及线程。NPTL 采用的为 1:1 模型。

  • 多对多(M:N)实现(两级模型):旨在结合前两个模型的优点,但其设计过于复杂。

对于 Pthreads API,Linux 有过两种实现:

  • LinuxThreads:最初的实现,已经过时

  • NPTL(Native POSIX Threads Library):始于 Linux 2.6 版本,现在所有线程库开发均基于此,之前介绍的也为 NPTL 多线程实现。

最后更新于