线程概念、线程同步、线程取消 - 《Linux/UNIX系统编程手册》读书笔记【3】

本文是在阅读《Linux/UNIX系统编程手册》1结合《Linux多线程服务端编程:使用muduo C++网络库》2、《现代C++语言核心特性解析》3等书,对线程有一次系统的学习和记录。包括系统提供的线程相关Pthread API以及它们在C++中相关封装的介绍。

创建线程比创建进程通常要快10倍甚至更多,因为调用fork()创建子进程时所需复制诸多属性,而线程间是共享的,不需要复制,特别是无需采用写时复制内存页,也无需复制页表。具体来讲线程共享有如下内容(并不不全面):

  • 进程ID、父进程ID、进程组ID、会话ID、控制终端。具体作用参考进程相关文章中的介绍。
  • 打开的文件描述符
  • 信号的处置函数设置。具体参考信号文章中的介绍。
  • 文件系统相关信息: 文件权限掩码、当前工作目录和根目录。
  • CPU时间消耗(由times()返回)、资源消耗(由getrusage()返回)、nice值(通过setpriority()和nice()设置)。

各线程有独有的一些属性,如下列出其中一部分。

  • Pthread线程ID、Linux线程ID。在下文章节中介绍。
  • 信号掩码(signal mask),用于阻塞某些信号传递。参考信号相关的文章。
  • 备选信号栈(sigaltstack())。
  • errno变量和一些用户设置的线程特有数据(thread_local)。
  • CPU亲和力(affinity)、实时调度策略(real-time scheduling policy)和优先级。

同一个进程中的所有线程共享虚拟内存空间和一些资源如打开文件等。每个线程有自己的栈空间,通常是线程库通过mmap()向操作系统申请,多线程的内存空间布局如下图。

同时执行4个线程的进程内存空间布局
同时执行4个线程的进程内存空间布局

tip
既然线程共享虚拟内存空间,当然利用合适的指针各个线程都可以访问对方栈中的数据,不过栈上的数据生命周期一般比较短暂,通常不会这样使用。

对于多线程的堆空间为了减少分配和释放时的数据竞争也可能不是同一个mmap分配块,参考记一次数据竞争导致内存损坏的coredump - 问题诊断文章中所写的堆内存管理部分。

20世纪80年代末、90年代初,存在着数种不同的线程接口。1995年POSIX.1c对POSIX线程API进行了标准化,该标准后来为SUSv3所接纳。

Pthreads API定义了一干数据类型,但标准并未规定如何实现这些数据类型,所以程序应避免对此类数据类型的变量结构或内容产生任依赖。例如不能使用比较操作符(==)去比较这些类型的变量,对于线程ID的类型pthread_t应该使用函数pthread_equal()来比较两个线程ID是否相同。如下表列出了其中的一部分。

数据类型描述
pthread_t线程 ID
pthread_mutex_t互斥对象(Mutex)
pthread_mutexattr_t互斥属性对象
pthread_cond_t条件变量(condition variable)
pthread_condattr_t条件变量的属性对象
pthread_key_t线程特有数据的键(Key)
pthread_once_t一次性初始化控制上下文(control context)
pthread_attr_t线程的属性对象

系统调用的返回值一般是: 返回0表示成功,返回-1表示失败,并设置errno来标识错误原因。Pthread API却以返回0表示成功,返回正值表示失败,并且该正值和传统UNIX系统调用置于errno中的值含义相同

应该来说Pthreads返回错误的方式相比于系统调用来说更加的简洁,另外在多线程中errno被设置成线程特有数据来保证多线程环境中的正确性。

tip
最初的 POSIX.1 标准沿袭 K&R 的 C 语言用法,允许程序将 errno 声明为 extern int errno。 SUSv3 却不允许这一做法(这一变化实际发生于 1995 年的 POSIX.1c 标准之中)。如今,需要声明 errno 的程序必须包含<errno.h>,以启用对 errno 的线程级实现。

在Linux平台上,编译多线程程序需要链接libpthread.so。一般不推荐直接使用编译参数-lpthread进行链接,而是推荐使用编译器参数-pthread。因为-pthread有如下的效果:

  • 定义_REENTRANT宏: 有些标准库在定义该宏会使用线程安全的版本,并且还可能影响编译器优化选项,使其生成更适合多线程环境的程序。
  • 链接libpthread库。

故编译多线程程序应当使用-pthread编译参数而不是-lpthread

启动程序时产生的进程只有主线程,其它线程需要使用pthread()创建。如下定义:

c++

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg);

新线程通过调用带有参数arg的函数start,即start(arg)而开始执行。参数arg类型为void*,可以为指向任意对象的指针。而start()的返回值也是void*类型,后续可以通过pthread_join()函数获取到线程的返回值。

在C++的封装的std::thread类并未提供arg参数接口,新线程执行的函数是无参数的。由于线程之前虚拟内存是共享的,所以此处的参数arg只是提供了一种方便,并不是必不可少的。

参数thread指向pthread_t类型的指针,返回创建的Pthread线程ID。标准明确指出,新线程可能在还未给thread赋值就开始执行,因此新线程一定不可直接使用调用线程的thread参数来获取自己的Pthread线程ID,而应该使用pthread_self()获取自己的线程ID。

参数attr指向pthread_attr_t类型的指针,为新线程设置一些属性,可以设置为NULL表示默认属性。可以设置线程的属性如下:

  • 线程栈的大小和初始地址。
  • 线程的调度策略和优先级。
  • 线程是以detach状态还是可join状态启动。

下面的例子展示了如何创建以detach状态启动的线程属性。

c

pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

pthread_attr_destroy(&attr);

在《Linux多线程服务端编程》2书中提到线程的创建需要注意:不能在进入main()函数前创建线程。原因是C++保证全局对象在进入main()之前完成构造,但是并不保证构造顺序,如果在main函数前创建线程可能访问到未初始化的全局对象,所以应该在进入main()函数后再创建线程,故像std::thread就不可作为全局对象存在,因为其对象一旦创建就会启动线程。

以下方式会终止进程,进程被终止后其所创建的所有线程都会被终止,不管线程是否是detach状态

  • 任意线程调用exit()
  • 主线程在main()函数中执行return语句。
  • 进程收到行为是终止程序的信号。

以下方式会终止线程,而不会影响进程中的其它线程继续执行

  • 线程start函数执行return语句。
  • 线程调用pthread_exit()即使由主线程调用,其它线程也会继续执行
  • 调用pthread_cancel()取消线程。后续章节将介绍。

调用pthread_exit()就如同在线程start函数中执行return,不同之处在于可在线程锁调用的任意函数中调用pthread_exit()return只能在start函数调用才有效果。其函数声明如下:

c

#include <pthread.h>

void pthread_exit(void *retval);

参数retval指定了线程的返回值,需要注意参数retval指向的数据不能再线程栈中,因为线程终止后,线程栈已经被释放。其返回值可以由另一个线程通过pthread_join()获取。

在C++封装的std::thread类中同样也没有提供线程返回值,同线程参数一样线程返回值并不是必须的,完全可以通过共享的内存空间传递返回值。

在Linux下有线程有如下两种ID。其一为Pthread线程ID,由Pthreads库在用户空间维护。其二为Linux线程ID,由操作系统在内核空间维护。

Pthread类型为pthread_t,由创建线程时pthread_create()返回给调用者或由pthread_self()来获取调用线程的Pthread线程ID。

c

#include <pthread.h>

pthread_t pthread_self();

Pthread线程ID有如下的特点:

  • Pthread线程ID是Pthreads库在用户空间维护的线程ID。故其只在进程内有意义,无法与操作系统调度建立关联。例如在/proc文件系统中找不到pthread_t对应的task。
  • 标准未规定pthread_t具体的底层类型,也就是无法在日志中可移植的打印Pthread线程ID,更不能使用其作为关联容器的key。
  • 实际在Linux上的实现其为指向结构体的指针,指向一块动态分配的内存。而内存是反复使用的,也就是Pthreads只保证同一进程内、同一时刻的各个线程ID不同,不能保证同一进程先后多个线程具有不同的线程ID

Linux线程ID类型为pid_t,可以由如下系统调用获取当前的Linux线程ID。

c

#include <unistd.h>

pid_t gettid(void);

Linux线程ID有如下的特点。

  • Linux的线程ID是操作系统在内核空间维护的线程ID。其作为操作系统内核调度的标识,可以在/proc/tid或/proc/pid/task/tid中找到对应的线程信息。
  • 其类型为pid_t,通常为整数,方便在日志中打印。
  • 它与进程ID、进程组ID、会话ID互相不会重复且全局唯一。并且Linux分配新的线程ID、进程组ID等采用递增轮回的方法,所以短时间内启动的多个线程会具有不同的ID。

线程的joindetach操作类似于进程的wait()子进程和忽略子进程终止信号SIGCHLD

Pthreads函数pthread_join()阻塞等待Pthread线程ID为thread的线程终止。其函数声明如下:

c

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);

若参数retval为非空指针,将会保存线程终止时返回的指针。

其在使用需要注意如下几点:

  • 如果join一个已经被join过的线程ID,将会导致未定义行为。例如,相同的线程ID在join后恰好被另一个线程重用,再度join就是对新线程的join
  • 如果线程未被detach则结束后必须使用join获取返回值释放资源,否则就会产生僵尸线程。其概念和僵尸进程一样,会浪费系统资源。在C++中std::thread如果在析构时未调用过join函数则会调用std::terminate()直接结束程序。

pthread_join()的功能和针对进程的wait()调用类似,但是有一些区别。

  • 线程之间的关系是对等的,可以互相调用join。而wait()只有父进程才可以对子进程调用。
  • 无法join任意线程,而wait()可以等待任意子进程终止。
  • join线程无法采用非阻塞的方式,而wait()可以设置WHOHANG标志采用非阻塞的方式。

有些情况下不关心线程的返回值,希望线程终止时自动清理。在这种情况下可以调用pthread_detach()将线程标记为分离(detach)的状态。

c

#include <pthread.h>

int pthread_detach(pthread_t thread);

其在使用时需要注意如下几点:

  • 线程可以自行设置自己的分离状态,执行pthread_detach(pthread_self());
  • 一旦线程处于分离状态,就不能使用join来获取其状态,也无法恢复为可连接状态。
  • 进程终止时,其所有线程都会被终止,不管线程是否在分离状态

线程的优势在于同一进程下的所有线程共享虚拟内存空间,可以很方便的共享信息。不过,方便是有代价的需要使用同步手段避免竞态条件(race condition)。只要有一个线程修改多线程共享的变量,对该变量的读写都需要加锁。术语临界区(critical section)指访问某一个共享资源的代码片段,并且该代码的执行应为原子操作。

互斥量(mutex)是最简单的同步手段,简单说保护临界区任何时刻至多一个线程在mutex划分的临界区执行

动态初始化和释放互斥量需要调用如下函数。

c

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数mutex为初始化操作的目标互斥量变量的指针,可以是栈上自动变量或分配与堆上的变量。

参数attr是指向pthread_mutexattr_t类型的指针,用于定义互斥量的属性,其有如下几种type。

  • PTHREAD_MUTEX_NORMAL: 如果线程试图对本线程已加锁的互斥量加锁,就会发生死锁。不具有死锁自检功能。
  • PTHREAD_MUTEX_ERRORCHECK: 对互斥量的所有操作都会执行错误检查。会影响性能一般作为调试手段。
  • PTHREAD_MUTEX_RECURSIVE: 即递归锁,内部有锁计数器,同一个线程可以多次加锁使得锁计数器递增,解锁使得锁计数器递减直到为0时释放该锁。
  • PTHREAD_MUTEX_DEFAULT: 默认属性,attr为NULL时默认创建的类型。标准并定义具体的类型,保留最大的灵活性。在Linux,其行为和PTHREAD_MUTEX_NORMAL一样。

属性的使用例子如下。

c

pthread_mutex_t mutex;
pthread_mutexattr_t attr;

pthread_mutexattr_init(&attr);
// 设置递归锁属性
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 使用带有递归属性的属性对象初始化互斥锁
pthread_mutex_init(&mutex, &attr);
pthread_mutexattr_destroy(&attr);

属性还支持其它一些设置,例如设置互斥量的作用域为进程间pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED),可以通过共享内存共享互斥锁进程间使用。还有一些设置优先级等的设置项,可以参考这篇文章

同时,互斥量还支持静态初始化,使用方法如下。

c

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

其不可使用pthread_mutex_destroy销毁。Linux还提供了非标准的静态初始值,如递归锁(PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP),不过为了可移植性不应使用这些初始值。

tip

标准规定,互斥量本身不可被复制。在C++中std::mutex不可复制并且也不支持移动语义。不可以移动的原因我认为是移动语义是一个对象把所有权转给另一个对象,而失去所有权的对象本身还可以继续使用,所以必须能表示失去所有权的std::mutex,那么每次调用std::mutex相关函数时就需要判断对象是否拥有所有权,这比较麻烦所以干脆不支持移动语义即可。

由于std::mutex不可移动和复制,含有std::mutex的对象也不能放入容器中,如果需要的话只能放入std::mutex的指针。

初始化后,互斥量处于未锁定状态。函数pthread_mutex_lock可以锁定某个互斥量,函数pthread_mutex_unlock可以解锁某个互斥量。

c

#include <pthread.h>

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

需要注意: 函数pthread_mutex_unlock解锁之前已遭调用线程锁定的互斥量。不可对未处于锁定或被其他线程锁定的互斥量解锁

另外Pthreads库还提供如下两个加锁函数。

  • pthread_mutex_trylock(): 在锁被其它线程持有时会马上返回EBUSY错误,而不是一直阻塞。
  • pthread_mutex_timedlock(): 可以指定一个超时的绝对时间abstime,在到达指定时间还未获取锁会返回ETIMEOUT错误。注意这里采用绝对时间,假如系统时间跳变会有影响。

在大多数经过良好设计的应用程序中,线程对互斥量的持有时间应尽可能短, 以避免妨碍其他线程的并发执行。

互斥量的实现采用了机器语言级的原子操作(在内存中执行,对所有线程可见),只有发生锁的争用时才会执行系统调用

Linux上,互斥量的实现采用了futex(源自“快速用户空间互斥量”[fast user space mutex]的首字母缩写),而对锁争用的处理则使用了futex()系统调用。本书无意描述futex,其设计意图也并非供用户空间(user space)应用程序直接使用。

条件变量允许一个线程休眠去等待直至另一个线程通知去执行某些操作。其作用是当共享变量的状态改变发出通知其它线程,共享变量的状态需要使用互斥量保护,所以条件变量必须结合互斥量使用

类似互斥量,条件变量也分为动态初始化和静态初始化。其中动态初始化和释放的函数声明如下。

c

#include <pthread.h>

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);

其中,参数cond为初始化的cond变量指针。参数attr为条件变量的属性,目前只支持是否设置进程共享pthread_condattr_setpshared()

同样也支持静态初始化,如下例子。

c

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

函数pthread_cond_wait()将阻塞线程等待收到cond条件变量的通知,函数pthread_cond_signal()pthread_cond_broadcast()针对参数cond所指定的条件变量发送唤醒通知。

c

#include <pthread.h>

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

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);

函数pthread_cond_wait()会进行如下几个操作。

  • 释放传入的mutex和进入线程休眠等待。两个操作是原子的。
  • 当别的线程对cond条件变量唤醒通知,该线程就被唤醒获取mutex锁,拿到锁后函数返回。

也就是说函数调用前和返回后都会持有mutex锁。对于pthread_cond_wait()函数需要注意如下。

  1. 必须配合mutex一起使用,共享变量的状态要用mutex保护。并且在其调用期间必须指定同一个互斥量,多线程对同一条件变量调用wait()时若使用多个互斥量则会导致未定义的行为。

  2. 在mutex已经上锁的情况下才能调用wait函数。并且上锁后需要先检查条件是否满足,不满足才能调用wait函数

  3. 共享变量的状态判断和wait()函数必须放入while循环中。因为wait()返回后不能对共享变量的状态做任何假设,原因如下:

    • 共享变量的状态在wait()返回时可能已经被其它线程修改。
    • 可能会发生虚假唤醒的情况。在一些多处理器系统上,为了确保高效的实现,即使没有其他线程对该条件变量发送信号,等待此条件变量的线程仍然有可能醒来。标准对此明确认可。
tip

函数pthread_cond_timedwait()支持传入参数abstime来指定线程等待条件变量通知时休眠时间的上限,如果超时则返回ETIMEOUT错误。该时间默认为系统绝对时间,也就是会被系统时间跳变所影响。

可以使用pthread_condattr_setclock(&attr, CLOCK_MONOTONIC);创建条件变量时修改绝对时间为相对的递增时间,可以避免系统时间跳变的影响。

不过对于像C++提供的条件变量封装std::condition_variable的wait函数同时支持绝对时间和相对时间的时间参数,但Pthreads库的条件变量只能在创建时指定时间类型。所以在glibc 2.30中又增加了pthread_cond_clockwait函数,可以在调用时指定等待的时间类型,从而解决这个问题。后续的C++的std::condition_variable实现应该要使用该函数。

函数pthread_cond_signal()pthread_cond_broadcast()区别在于pthread_cond_signal()只保证至少一个wait()的线程被唤醒,而pthread_cond_broadcast()则会唤醒所有遭阻塞的线程。使用pthread_cond_broadcast()应总能产生正确结果,因为所有wait()线程都要能处理多余和虚假的唤醒。而pthread_cond_signal()更为高效。

对于signal()和broadcast()两个函数,需要注意一下。

  1. 调用前应该在锁的保护下修改共享变量的状态。
  2. signal()和broadcast()两个函数本身是线程安全的,不需要在锁的保护下调用。故先解锁互斥量再通知条件变量效率会更高,减少临界区的大小,并且其它线程被唤醒时,互斥量已经解锁,可以直接加锁而不是再次休眠等待互斥量。

多线程程序有时需要初始化动作只能发生一次。可以使用pthread_once()实现一次性初始化。

c

#include <pthread.h>

int pthread_once(pthread_once_t *once_control, void (*init)(void));

参数once_control是pthread_once_t类型的指针,指向初始化为PTHREAD_ONCE_INIT静态变量。对init函数的首次调用将修改once_control所指向的内容,以便后续调用不会再执行init

Pthreads的早期版本不能对互斥量进行静态初始化,只能使用pthread_mutex_init()([Butenbof,1996]),这也是函数pthread_once()存在的主要原因。随着静态分配互斥量功能的问世,库函数可以使用一个经静态分配的互斥量和一个静态布尔型(Boolean)变量来实现一次性初始化。虽然如此,出于方便的考虑,函数pthread_once()得以保留。

在C++中对其封装为std::call_once()。不过C++中可以使用局部静态变量,其可以保证在控制流第一次遇到静态数据的声明语句时,变量初始化一次。并且在C++11标准中规定,初始化只会在某一线程上单独发生,在初始化完成之前,其他线程不会越过静态数据的声明而继续运行。故局部静态变量是懒加载(初始化)且是线程安全。所以一次初始化其实用处不多。

线程局部存储指的是对象内存在线程开始时分配,线程结束时回收且每个线程都有自己的实例。GCC添加了关键字__thread编译器扩展来表明变量是线程局部存储。而C++11在标准中增加了thread_local对该特性支持。thread_local可以修饰任意类型的变量。可以类似如下使用。

c++

struct X {
  thread_local static int i;
};

thread_local X a;

int main() {
  thread_local X b;
}

多线程操作thread_local修饰的变量可以不用加锁,因为每个线程操作的都是自己线程的一份拷贝。

实际上,在Linux中线程局部存储是使用Pthreads的API实现。可以调佣pthread_key_createpthread_key_delete创建和删除一个类型为pthread_key_t的键,利用这个键每个线程可以使用pthread_setspecific函数设置线程特有的内存数据,随后可以通过pthread_getspecific函数获取之前调用线程设置的内存数据。

线程取消提供了向指定线程发送一个取消请求功能,线程不是立即结束,而是在执行到被标记为取消点(cancellation point)的系统函数时线程才会结束。该功能确实是比较“鸡肋”,一个好的程序设计不应该使用。这里只进行简单介绍。

线程正常退出的方式只有一种,即自然死亡。任何从外部强行终止线程的做法和想法都是错的。佐证有:Java的Thread class把stop()suspend()destroy()等函数都废弃(deprecated)了,Boost.Threads根本就不提供thread::cancel()成员函数。因为强行终止线程的话(无论是自杀还是他杀),它没有机会清理资源。也没有机会释放已经持有的锁,其他线程如果再想对同一个mutex加锁,那么就会立刻死锁。因此我认为不用去研究cancellation point这种“鸡肋”概念。

函数ptthread_cancle()可以指定thread发送取消请求。线程可以通过pthread_setcancelstate()设置线程是否支持取消和pthread_setcanceltype()设置取消的类型,支持如下两种类型。

  • PTHREAD_CANCEL_DEFERED: 线程执行到取消点才会结束。可以用pthread_testcancel()主动设置一个取消点。

    在C++中,cancellation point的实现与C语言有所不同,线程不是执行到此函数就立刻终止,而是该函数会抛出异常。这样可以有机会执行stack unwind,析构栈上对象(特别是释放持有的锁)。

  • PTHREAD_CANCEL_ASYNCHRONOUS: 异步取消,线程在任何点都可能被取消。有可能在线程持有某些资源时,例如锁,就被取消执行,此时就非常危险。所以支持异步取消的线程不能持有任何资源。

通过pthread_cleanup_push()可以设置在线程被取消时执行一些清理函数。

在C++20中新增了std::jthread也支持“线程取消”,其采用std::stop_token flag方式,在线程中主动获取是否被取消的flag,从而主动结束线程。相比于pthread_cancle()这种方法是合作式的而不是强制式。所以说不应该在程序中使用ptthread_cancle()方式从外部结束线程,而应当在线程内部主动结束。