Bootstrap

Linux 线程概念

线程的基本概念,线程 VS 进程

与进程类似,线程是允许应用程序并发执行多个任务的一种机制,线程参与系统调度,事实上,系统调度的最小单元是线程、而并非进程。

线程(Thread):
线程是进程内的一个执行单元,它共享进程的资源(如内存、文件描述符等),但有自己的执行栈和程序计数器。
线程是系统调度的基本单位,多个线程可以并发执行,提高程序的响应性和性能。
进程(Process):
进程是操作系统中资源分配的基本单位,是一个正在执行的程序的实例。每个进程都有独立的地址空间和资源。
进程之间是相互独立的,而线程之间是相互依赖的,允许更高效的资源共享。

线程标识

就像每个进程都有一个进程 ID 一样,每个线程也有其对应的标识,称为线程 ID。进程 ID 在整个系统中是唯一的,但线程 ID 不同,线程 ID 只有在它所属的进程上下文中才有意义。

pthread_self()来获取自己的线程 ID
pthread_equal()函数来检查两个线程 ID 是否相等

创建线程

pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程

终止线程

⚫ 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;
⚫ 线程调用 pthread_exit()函数;
⚫ 调用 pthread_cancel()取消线程

调用 pthread_exit()相当于在线程的 start 函数中执行 return 语句,不同之处在于,可在线程 start 函数所调用的任意函数中调用 pthread_exit()来终止线程。如果主线程调用了 pthread_exit(),那么主线程也会终止,但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止。

如果进程中的任意线程调用 exit()、_exit()或者_Exit(),那么将会导致整个进程终止,这里需要注意!

回收线程

在父、子进程当中,父进程可通过 wait()函数(或其变体 waitpid())阻塞等待子进程退出并获取其终止状态,回收子进程资源;而在线程当中,也需要如此,通过调用 pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源。

取消线程

在通常情况下,进程中的多个线程会并发执行,每个线程各司其职,直到线程的任务完成之后,该线程中会调用 pthread_exit()退出,或在线程 start 函数执行 return 语句退出。有时候,在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。

取消状态以及类型

默认情况下,线程是响应其它线程发送过来的取消请求的,响应请求然后退出线程。当然,线程可以选择不被取消或者控制如何被取消,通过pthread_setcancelstate()和 pthread_setcanceltype()来设置线程的取消性状态和类型。

pthread_setcancelstate 用于设置线程的取消状态。如果设置为禁用状态,则在调用期间,该线程将不会响应取消请求。

pthread_setcanceltype 用于设置线程的取消类型,影响线程响应取消请求的时机。

⚫ PTHREAD_CANCEL_DEFERRED:取消请求到来时,线程还是继续运行,取消请求被挂起,直到线程到达某个取消点(cancellation point,将在 11.6.3 小节介绍)为止,这是所有新建线程包括主线程默认的取消性类型。
⚫ PTHREAD_CANCEL_ASYNCHRONOUS:可能会在任何时间点(也许是立即取消,但不一定)取消线程,这种取消性类型应用场景很少,不再介绍!

在多线程程序中调用 fork() 时,子进程继承调用线程的取消状态和类型,这可能会影响子进程的行为。当线程调用 exec() 时,进程的取消状态和类型被重置为默认值(PTHREAD_CANCEL_ENABLE 和 PTHREAD_CANCEL_DEFERRED)。

线程可取消性的检测

假设线程执行的是一个不含取消点的循环(譬如 for 循环、while 循环),那么这时线程永远也不会响应取消请求,也就意味着除了线程自己主动退出,其它线程将无法通过向它发送取消请求而终止它。

此时可以使用 pthread_testcancel(),该函数目的很简单,就是产生一个取消点,线程如果已有处于挂起状态的取消请求,那么只要调用该函数,线程就会随之终止。

分离线程

默认情况下,当线程终止时,其它线程可以通过调用 pthread_join()获取其返回状态、回收线程资源,有时,程序员并不关系线程的返回状态,只是希望系统在线程终止时能够自动回收线程资源并将其移除。在这种情况下,可以调用 pthread_detach()将指定线程进行分离,也就是分离线程。

注册线程清理处理函数

学习了 atexit()函数,使用 atexit()函数注册进程终止处理函数,当进程调用 exit()退出时就会执行进程终止处理函数;其实,当线程退出时也可以这样做,当线程终止退出时,去执行这样的处理函数,我们把这个称为线程清理函数(thread cleanup handler)。

与进程不同,一个线程可以注册多个清理函数,这些清理函数记录在栈中,每个线程都可以拥有一个清理函数栈,栈是一种先进后出的数据结构,也就是说它们的执行顺序与注册(添加)顺序相反,当执行完所有清理函数后,线程终止。

线程通过函数 pthread_cleanup_push()和 pthread_cleanup_pop()分别负责向调用线程的清理函数栈中添加和移除清理函数

当线程执行以下动作时,清理函数栈中的清理函数才会被执行:
⚫ 线程调用 pthread_exit()退出时;
⚫ 线程响应取消请求时;
⚫ 用非 0 参数调用 pthread_cleanup_pop()
除了以上三种情况之外,其它方式终止线程将不会执行线程清理函数,譬如在线程 start 函数中执行return 语句退出时不会执行清理函数。

线程属性

如前所述,调用 pthread_create()创建线程,可对新建线程的各种属性进行设置。在 Linux 下,使用pthread_attr_t 数据类型定义线程的所有属性。

pthread_attr_t 数据结构中定义了栈的起始地址以及栈大小,调用函数pthread_attr_getstack()可以获取这些信息,函数 pthread_attr_setstack()对栈起始地址和栈大小进行设置

如果想单独获取或设置栈大小、栈起始地址,可以使用下面这些函数:

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
int pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr);
int pthread_attr_getstackaddr(const pthread_attr_t *attr, void **stackaddr);

分离状态属性

如果对现已创建的某个线程的终止状态不感兴趣,可以使用pthread_detach()函数将其分离,那么该线程在退出时,操作系统会自动回收它所占用的资源。如果我们在创建线程时就确定要将该线程分离,可以修改 pthread_attr_t 结构中的 detachstate 线程属性,让线程一开始运行就处于分离状态。调用函数 pthread_attr_setdetachstate()设置 detachstate 线程属性,调用pthread_attr_getdetachstate()获取 detachstate 线程属性

线程安全

进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用 pthread_create()创建了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。

线程栈

既然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。

可重入函数

如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。

当 main()函数正在执行 func()函数代码,此时进程收到了 SIGINT 信号,便会打断当前正常执行流程、跳转到 sig_handler()函数执行,进而调用 func、执行 func()函数代码。在信号处理函数中,执行完 func()之后,信号处理函数退出、返回到主程序流程,也就是被信号打断的
位置处继续运行。如果每次出现这种情况执行 func()函数都能产生正确的结果,那么 func()函数就是一个可重入函数。
在这里插入图片描述

线程安全函数

“一个函数被多个线程同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数”,多个线程指的就是多个执行流(不包括信号处理函数执行流),所以从这里看跟可重入函数的概念是很相似的。
在这里插入图片描述

一次性初始化

当你写了一个 C 函数 func(),该函数可能会被多个线程调用,并且该函数中有一段初始化代码,该段代码只能被执行一次(无论哪个线程执行都可以)、如果执行多次会出现问题。

尽管 pthread_once()调用会出现在多个线程中,但该函数会保证 init_routine()函数仅执行一次,究竟在哪个线程中执行是不定的。

线程特有数据

线程特有数据也称为线程私有数据,简单点说,就是为每个调用线程分别维护一份变量的副本(copy),每个线程通过特有数据键(key)访问时,这个特有数据键都会获取到本线程绑定的变量副本。这样就可以避免变量成为多个线程间的共享数据。

线程特有数据的核心思想其实非常简单,就是为每一个调用线程(调用某函数的线程,该函数就是我们要通过线程特有数据将其实现为线程安全的函数)分配属于该线程的私有数据区,为每个调用线程分别维护一份变量的副本。

pthread_key_create()、pthread_setspecific()
以及 pthread_getspecific() pthread_key_delete()

调用 pthread_key_delete()函数将释放参数 key 指定的特有数据键,可以供下一次调用 pthread_key_create()时使用;调用 pthread_key_delete()时,它并不将查当前是否有线程正在使用该键所关联的线程私有数据缓冲区,所以它并不会触发键的解构函数,也就不会释放键关联的线程私有数据区占用的内存资源,并且调用pthread_key_delete()后,当线程终止时也不再执行键的解构函数。

所以,通常在调用 pthread_key_delete()之前,必须确保以下条件:
⚫ 所有线程已经释放了私有数据区(显式调用解构函数或线程终止)。
⚫ 参数 key 指定的特有数据键将不再使用。

看例子

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#define MAX_ERROR_LEN 256
static char buf[MAX_ERROR_LEN];
/**********************************
 * 为了避免与库函数 strerror 重名
 * 这里将其改成 my_strerror
 **********************************/
static char *my_strerror(int errnum)
{
    if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
        snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
    else
    {
        strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
        buf[MAX_ERROR_LEN - 1] = '\0'; // 终止字符
    }
    return buf;
}
static void *thread_start(void *arg)
{
    char *str = my_strerror(2); // 获取错误编号为 2 的错误描述信息
    printf("子线程: str (%p) = %s\n", str, str);
    pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
    pthread_t tid;
    char *str = NULL;
    int ret;
    str = my_strerror(1); // 获取错误编号为 1 的错误描述信息
    /* 创建子线程 */
    if (ret = pthread_create(&tid, NULL, thread_start, NULL))
    {
        fprintf(stderr, "pthread_create error: %d\n", ret);
        exit(-1);
    }
    /* 等待回收子线程 */
    if (ret = pthread_join(tid, NULL))
    {
        fprintf(stderr, "pthread_join error: %d\n", ret);
        exit(-1);
    }
    printf("主线程: str (%p) = %s\n", str, str);
    exit(0);
}

在这里插入图片描述
从以上测试结果可知,子线程和主线程锁获取到的错误描述信息是相同的,字符串指针指向的是同一个缓冲区;原因就在于,my_strerror()函数是一个非线程安全函数,函数内部修改了全局静态变量、并返回了它的指针,每一次调用访问的都是同一个静态变量,所以后一次调用会覆盖掉前一次调用的结果。

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
#define MAX_ERROR_LEN 256
static pthread_once_t once = PTHREAD_ONCE_INIT;
static pthread_key_t strerror_key;
static void destructor(void *buf)
{
    free(buf); // 释放内存
}
static void create_key(void)
{
    /* 创建一个键(key),并且绑定键的解构函数 */
    if (pthread_key_create(&strerror_key, destructor))
        pthread_exit(NULL);
}
/******************************
 * 对 strerror 函数重写
 * 使其变成为一个线程安全函数
 ******************************/
static char *strerror(int errnum)
{
    char *buf;
    /* 创建一个键(只执行一次 create_key) */
    if (pthread_once(&once, create_key))
        pthread_exit(NULL);
    /* 获取 */
    buf = pthread_getspecific(strerror_key);
    if (NULL == buf)
    {                                // 首次调用 my_strerror 函数,则需给调用线程分配线程私有数据
        buf = malloc(MAX_ERROR_LEN); // 分配内存
        if (NULL == buf)
            pthread_exit(NULL);
        /* 保存缓冲区地址,与键、线程关联起来 */
        if (pthread_setspecific(strerror_key, buf))
            pthread_exit(NULL);
    }
    if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
        snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
    else
    {
        strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
        buf[MAX_ERROR_LEN - 1] = '\0'; // 终止字符
    }
    return buf;
}

改进版的 strerror()所做的第一步是调用 pthread_once(),以确保只会执行一次 create_key()函数,而在create_key()函数中便是调用 pthread_key_create()创建了一个键、并绑定了相应的解构函数 destructor(),解构函数用于释放与键关联的所有线程私有数据所占的内存空间。
接着,函数 strerror()调用 pthread_getspecific()以获取该调用线程与键相关联的私有数据缓冲区地址,如果返回为 NULL,则表明该线程是首次调用 strerror()函数,因为函数会调用 malloc()为其分配一个新的私有数据缓冲区,并调用 pthread_setspecific()来保存缓冲区地址、并与键以及该调用线程建立关联。如果pthread_getspecific()函数的返回值并不等于 NULL,那么该值将指向以存在的私有数据缓冲区,此缓冲区由之前对 strerror()的调用所分配。剩余部分代码与 非线程安全版的 strerror()实现类似,唯一的区别在于,buf 是线程特有数据的缓冲区地址,而非全局的静态变量。

线程局部存储

通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量;而线程局部存储在定义全局或静态变量时,使用__thread 修饰符修饰变量,此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。

更多细节问题

线程与信号

⑴、信号如何映射到线程
信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的,而在另一些方面是属于单个线程层面的

⑵、线程的信号掩码
对于一个单线程程序来说,使用 sigprocmask()函数设置进程的信号掩码,在多线程环境下,使用pthread_sigmask()函数来设置各个线程的信号掩码

⑶、向线程发送信号

调用 kill()或 sigqueue()所发送的信号都是针对整个进程来说的,它属于进程层面,具体该目标进程中的哪一个线程会去处理信号,由内核进行选择。事实上,在多线程程序中,可以通过 pthread_kill()向同一进程中的某个指定线程发送信号。除了 pthread_kill()函数外,还可以调用 pthread_sigqueue()函数;pthread_sigqueue()函数执行与 sigqueue类似的任务,但它不是向进程发送信号,而是向同一进程中的某个指定的线程发送信号

⑷、异步信号安全函数
应用程序中涉及信号处理函数时必须要非常小心,因为信号处理函数可能会在程序执行的任意时间点被调用,从而打断主程序。接下来介绍一个概念—异步信号安全函数(async-signal-safe function)。

static pthread_mutex_t mutex;
static int glob = 0;
static void func(int loops)
{
    int local;
    int j;
    for (j = 0; j < loops; j++)
    {
        pthread_mutex_lock(&mutex); // 互斥锁上锁
        local = glob;
        local++;
        glob = local;
        pthread_mutex_unlock(&mutex); // 互斥锁解锁
    }
}

该函数虽然对全局变量进行读写操作,但是在访问全局变量时进行了加锁,避免了引发竞争冒险;它是一个线程安全函数,假设线程 1 正在执行函数 func,刚刚获得锁(也就是刚刚对互斥锁上锁),而这时进程收到信号,并分派给线程 1 处理,线程 1 接着跳转去执行信号处理函数,不巧的是,信号处理函数中也调用了 func()函数,同样它也去获取锁,由于此时锁处于锁住状态,所以信号处理函数中调用 func()获取锁将会陷入休眠、等待锁的释放。这时线程 1 就会陷入死锁状态,线程 1 无法执行,锁无法释放;如果其它线程也调用 func(),那它们也会陷入休眠、如此将会导致整个程序陷入死锁!

⑸、多线程环境下信号的处理

;