《Linux高性能服务器编程》第十四章 多线程编程

Github代码地址

第14章 多线程编程

本章讨论的线程相关的内容都属于 POSIX 线程(pthread)标准,不局限于 NPTL 实现,具体包括:

  • 创建线程和结束线程
  • 读取和设置线程属性
  • POSIX 线程同步方式:POSIX 信号量、互斥锁和条件变量

14.1 Linux 线程概述

线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体。

线程可分为内核线程和用户线程。

  • 内核线程 运行在内核空间,由内核来调度
  • 用户线程 运行在用户空间,由线程库来调度。

当进程的一个内核线程获得 CPU 的使用权时,它就加载并运行一个用户线程。由此,内核线程相当于用户线程运行的“容器”。一个进程可以用由 M 个内核线程和 N 个用户线程,其中 M <= N。并且在一个系统的所有进程中, M 和 N 的比值都是固定的。按照 M : N 的取值,线程的实现可以分为三种模式:完全在用户空间实现、完全由内核调度和双层调度

完全在用户空间实现的线程无须内核的支持,内核甚至根本不知道这些线程的存在。线程库负责管理所有执行线程,比如线程的优先级、时间片等。线程库利用 longjmp 来切换线程的执行,使它们看起来像是 “并发” 执行的。但实际上内核仍然是把整个进程作为最小单位来调度的。换句话说。一个进程的所有执行线程共享该进程的时间片,它们对外表现出相同的优先级。因此,对于这种实现方式而言, N = 1,即 M 个用户空间线程对应 1 个内核线程,而该内核线程实际上就是进程本身。完全在用户空间实现的线程的优点是:创建和调度线程都无须内核的干预,因此速度相当快。并且由于它不占用额外的内核资源,所以即使一个进程创建了很多线程,也不会对系统性能造成明显的影响。其缺点是:对于多处理器系统,一个进程的多个线程无法运行在不同的 CPU 上,因为内核是按照最小调度单位类分配 CPU 的。此外,线程的优先级只对同一个进程中的线程有效,比较不同进程中的线程的优先级没有意义。

完全由内核调度的模式将创建、调度线程的任务都交给了内核,运行在用户空间的线程库无须执行管理任务,这与完全在用户空间实现的线程恰恰相反。二者的优缺点也正好呼唤。

双层调度模式是前两种实现模式的混合体:内核调度 M 个内核线程,线程库调度 N 个用户线程。这种线程实现方式结合了前两种方式的优点:不但不会消耗过多的内核资源,而且线程切换速度也比较快,同时也可以充分利用多处理器的优势。

14.1.2 Linux 线程库

LinuxThreads 线程库的内核线程使用 clone 系统调用创建的进程模拟的。clone 系统调用和 fokr 系统调用的作用类似:创建调用进程的子进程。不过我们可以为 clone 系统调用指定 CLONE_THREAD 标志,这种情况下它创建的子进程与调用进程共享相同的虚拟地址空间、文件描述符和信号处理函数,这些都是线程的特点。不过,用进程来模拟内核线程会导致很多语义问题:

  • 每个线程拥有不同的 PID 不符合 POSIX 规范
  • Linux 信号处理本来是基于进程的,但现在一个进程内部的所有线程都能而且必须处理信号
  • 用户 ID、组 ID 对一个进程中的不同线程来说可能是不一样的
  • 程序产生的核心转储文件不会包含所有线程信息,而只包含产生该核心转储文件的线程的信息
  • 由于每个线程都是一个进程,因此系统允许的最大进程数也就是最大线程数

LinuxThreads 线程库的一个有名特性便是管理线程。它是进程中专门用于管理其他工作线程的线程。其作用包括:

  • 系统发送给进程的终止信号先由管理线程接收,管理线程再给其他工作线程发送同样的信号以终止它们

  • 当终止工作线程或工作线程主动退出时,管理线程必须等待它们技术,以避免僵尸进程

    僵尸进程是指一个已经终止、但是其父进程尚未对其进行善后处理获取终止进程的有关信息的进程,这个进程被称为“僵尸进程”(zombie)。

    怎样产生僵尸进程

    一个进程在调用exit命令结束自己的生命的时候,其实它并没有真正的被销毁,而是留下一个称为 僵尸进程(Zombie)的数据结构(系统调用exit, 它的作用是使进程退出,但也仅仅限于将一个正常的进程变成一个僵尸进程,并不能将其完全销毁)。

    在Linux进程的状态中,僵尸进程是非常特殊的一种,它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位 置,记载该进程的退出状态等信息供其他进程收集。除此之外,僵尸进程不再占有任何内存空间。它需要它的父进程来为它收尸,如果他的父进程没安装 SIGCHLD 信号处理函数调用wait或waitpid()等待子进程结束,又没有显式忽略该信号,那么它就一直保持僵尸状态,如果这时父进程结束了, 那么init进程自动会接手这个子进程,为它收尸,它还是能被清除的。但是如果如果父进程是一个循环,不会结束,那么子进程就会一直保持僵尸状态,这就是 为什么系统中有时会有很多的僵尸进程。

  • 如果主线程先于其他工作线程退出,则管理线程将阻塞它,知道所有其他工作线程都结束后才唤醒它

  • 回收每个线程堆栈使用的内存

管理线程的引入,增减了额外的系统开销。并由于只能运行在一个 CPU 上,所以 LinuxThreads 线程也不能充分利用多出力系统的优势。后来 NPTL 线程库产生,其主要优势在于:

  • 内核线程不再是一个进程,因此避免了很多用进程模拟内核线程导致的语义问题
  • 摈弃了管理线程,终止线程、回收线程堆栈等工作都可以由内核来完成
  • 由于不存在管理线程,所以一个进程的线程可以运行在不同的 CPU 上,从而充分利用了多处理器系统的优势
  • 线程的同步由内核来完成。隶属于不同进程的线程之间也能共享互斥锁,因此可以实现跨进程的线程同步

14.2 创建线程和结束线程

Linux 系统上,它们都定义在 pthread.h 头文件中

  1. pthread_create

    创建一个线程的函数时 pthread_create 定义如下:

    #include <pthread.h>
    int pthread_create( pthread_t* thread, const pthread_attr_t* attr, void* ( *start_routine )( void* ), void* arg );
    
    • thread 新线程的标识符,后续 phtread_* 函数通过它来引用新线程。其类型 pthread_t 的定义如下

      #include <bits/pthreadtypes.h>
      typedef unsigned long int pthread_t
      

      可见,pthread_t是一个整型类型。实际上,Linux 上几乎所有的资源标识符都是一个整形数,比如 socket、各种 System V IPC 标识符等

    • attr 用于设置新线程的属性。给它传递 NULL 表示使用默认线程属性。线程拥有众多属性。

    • start_routine / arg 分别指定新线程将运行的函数及其参数

    该函数成功返回 0 。

  2. pthread_exit

    线程一旦被创建好,内核就可以调度内核线程来执行 start_routine 函数指针所指向的函数了。线程函数在结束时最好调用如下函数,以确保安全、干净地退出

    #include <pthread.h>
    void pthread_exit( void* retval );
    

    pthread_exit 通过 retval 参数向线程地回收者传递其退出信息。它执行完之后不会返回到调用者,而且永远不会失败

  3. pthread_join

    一个进程中的所有线程都可以通过 pthread_join 函数来回收其他线程(前提是目标线程是可回收的),即等待其他线程结束,这类似于回收进程的 waitwaitpid 系统调用。定义如下:

    #include <pthread.h>
    int pthread_join( pthread_t thread, void** retval );
    
    • thread 目标线程的标识符
    • retval 目标线程返回的退出信息。该函数会一直阻塞,直到被回收的线程结束为止。

    该函数可能引发的错误码

    错误码描述
    EDEADLK可能引起死锁。比如两个线程互相针对对方调用 pthread_join,或者线程对自身调用
    EINVAL目标线程是不可回收的,或者已经有其他线程在回收该目标线程
    ESRCH目标线程不存在
  4. pthread_cancel

    异常终止一个线程,即取消线程,可通过如下函数实现:

    #include <pthread.h>
    int pthread_cancel( phtread_t thread );
    
    • thread 目标线程的标识符。该函数成功返回 0,失败则返回错误码。不过接收到取消请求的目标线程可以决定是否允许被取消以及如何取消,这分别由如下两个函数完成:

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

      这两个函数的第一个参数分别用于设置线程的取消状态 ( 是否允许取消 ) 和取消类型 ( 如何取消 ),第二个参数分别记录线程原来的取消状态和取消类型。

    • state 参数有两个可选值

      • PTHREAD_CANCEL_ENABLE 允许线程被取消。默认取消
      • PTHREAD_CANCEL_DISABLE 禁止线程被取消。这种情况,如果一个线程收到取消请求,则它会将请求挂起,知道该线程允许被取消
    • type 参数也有两个可选值

      • PTHREAD_CANCEL_ASYNCHRONOUS 线程随时都可以被取消。他将使得接收到取消请求的目标线程立即采取行动
      • PTHREAD_CANCEL_DEFERRED 允许目标线程推迟行动,直到它调用了下面几个所谓取消点函数中的一个:pthread_join、pthread_testcancel、pthread_cond_wait、pthread_cond_timedwait、sem_waitsigwait。根据 POSIX 标准,其他可能阻塞的系统调用,比如 read、wait,也可以称为取消点。

14.3 线程属性

pthread_attr_t 结构体定义了一套完整的线程属性:

#include <bits/pthreadtypes.h>
#define __SIZEOF_PTHREAD_ATTR_T 36
typedef union{
	char __size[ __SIZEOF_PTHREAD_ATTR_T ];
    long int __align;
} pthread_attr_t;

可见,各种线程属性全部包含在一个字符数组中。线程库定义了一些列函数来操作 pthread_attr_t 类型的变量,以方便我们获取和设置线程属性,这些函数包括:

#include <pthread.h>
/* 初始化线程属性对象 */
int pthtread_attr_init ( pthread_attr* attr );
/* 销毁线程属性对象。被销毁的线程属性对象只有再次初始化之后才能继续使用 */
int pthread_attr_destroy ( pthread_attr_t* attr );
/* 下面这些函数用于获取和设置线程属性对象的某个属性 */
int pthread_attr_getdetachstate( const pthread_attr_t* attr, int* detachstate );
int pthread_attr_setdetachstate( pthread_attr_t* attr, int detachstate );
int ptrhead_attr_getstackaddr( const pthread_attr_t* attr, int detachstate );
int pthread_attr_setstackaddr( pthread_attr_t attr, void* stackaddr );
int pthread_attr_getstacksize( const pthread_attr_t* attr, size_t* stacksize );
int pthread_attr_setstacksize( pthread_attr_t* attr, size_t stacksize );
int pthread_attr_getstack( const pthread_attr_t* attr, void** stackaddr, size_t* stacksize );
...
  • detachstate 线程的脱离状态。它有 PTHREAD_CREATE_JOINABLEPTHREAD_CREATE_DETACH 两个可选值。前者指定线程是可以被回收的,后者使调用线程脱离与进程中其他线程的同步,脱离了与其他线程同步的线程称为“脱离线程”。脱离线程在退出时将自行释放其占用的系统资源。线程创建时该属性的默认值时 PTHREAD_CREATE_JOINABLE。此外,也可以使用 pthread_detach 函数直接将线程设置为脱离线程
  • stackaddrstacksize, 线程堆栈的起始地址和大小。可以使用 ulimt -s 命令来查看或者修改这个默认值
  • guardsize 保护区域大小。如果大于 0,系统创建线程的时候会在其堆栈的尾部额外分配 guardsize 字节的空间,作为保护堆栈不被错误地覆盖的区域。如果等于 0,则不为新创建的线程设置对战保护区。使用者可以通过 pthread_attr_setstackaddrpthread_attr_setstack 手动设置线程的堆栈,则 guardsize 属性被忽略
  • schedparam 线程调度参数。其类型是 sched_param 结构体
  • schedpolicy 线程调度策略。该属性有 SCHED_FIFO、SCHED_RRSCHED_OTHRE 三个可选值,第三个是默认值。SCHED_RR 表示采用轮转算法( round-robin )调度,SCHED_FIFO 表示使用先进先出的方法调度,这两种调度方法都具备实时调度功能,但只能用于以超级用户身份运行的线程。
  • inheritsched 是否继承调用线程的调度属性。该属性有关 PTHREAD_INHERIT_SCHEDPTHREAD_EXPLICIT_SCHED 两个可选值。前者表示新县城沿用其创建者的线程调度参数,这种情况下再设置新线程的调度参数属性将没有任何效果。后者表示调用者要明确地指定新线程地调度参数
  • scope 线程间竞争 CPU 的范围,即线程优先级的有效范围。POSIX 标准定义了该属性的 PTHREAD_SCOPE_SYSTEMPTHREAD_SCOPE_PROCESS 两个可选值,前者表示目标线程与系统中所有线程一起竞争 CPU 的使用,后者表示目标线程仅与其他隶属于统一进程的线程京城CPU 的使用,目前 Linux 只支持 PTHREAD_SCOPE_SYSTEM 一种取值

14.4 POSIX 信号量

和多进程程序一样,多线程程序也必须考虑同步问题。讨论 3 种专门用于线程同步的机制:POSIX 信号量、互斥量和条件变量。

Linux 上,信号量 API 有两组。一组是 13 章讨论的 System V IPC 信号量,两一个组是 POSIX 信号量。两组接口很相似,但不保证能呼唤。

POSIX 信号量函数的名字都以 sem_ 开头。常用的 POSIX 信号量函数如下:

#include <semaphore.h>
int sem_init( sem_t* sem, int pshared, unsigned int value );
int sem_destroy( sem_t* sem );
int sem_wait( sem_t* sem );
int sem_trywait( sem_t* sem );
int sem_post( sem_t* sem );

这些函数的第一个参数 sem 执行被操作的信号量

  • sem_init 用于初始化一个未命名的信号量。
    • pshared 指定信号量的类型。如果其值为 0,就表示这个信号量是当前进程的局部信号量,否则该信号量就可以在多个进程之间共享。
    • value 指定信号量的初始值。此外,初始化一个已经被初始化的信号量将导致不可预期的结果
  • sem_destroy 用于销毁信号量,以释放其占用的内核资源。如果销毁一个整备其他线程等待的信号量,则将导致不可预期的结果
  • sem_wait 以原子操作的方式将信号量的值减 1。如果信号量的值为 0,则 sem_wait 将被阻塞,直到这个信号量具有非 0 值
  • sem_trywaitsem_wait 相似,不过它始终立即返回,而不论被操作的信号量是否具有非 0 值,相当于 sem_wait 的非阻塞版本。当信号量的值为非 0 时,sem_trywait 对信号量执行减 1 操作。当信号量的值为 0 时,将返回 -1 并设置 errno 为 EAGAIN
  • sem_post 以原子操作的方式将信号量的值加 1。当信号量值大于 0 时,其他正在调用 sem_wait 等待信号量的线程将被唤醒

14.5 互斥锁

互斥锁( 也称互斥量 ) 可以用于保护关键代码段,以确保其独占式的访问,这有点像一个二进制信号量。。当进入关键代码段时,我们需要获得互斥锁来将其加锁,这等价于二进制信号量的 P 操作;当离开关键代码段时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程,这等价于二进制信号量的 V 操作

14.5.1 互斥锁基础 API

POSIX 互斥锁的相关函数主要有如下 5 个:

#include <pthread.h>
int pthread_mutex_init( pthread_mutex_t* mutex, const pthread_mutexattr_t* mutexattr );
int pthread_mutex_destroy( pthread_mutex_t* mutex );
int ptrhead_mutex_lock( pthread_mutex_t* mutex );
int pthread_mutex_trylock( phtread_mutex_t* mutex );
int pthread_mutex_unlock( phtread_mutex_t* mutex );

这些函数的第一个参数 mutex 指向要操作的目标互斥锁,互斥锁的类型时 pthread_mutex_t 结构体。

  • pthread_mutex_init 初始化互斥锁。

    • mutexattr 指定互斥锁属性。如果设置为 NULL, 则表示使用more属性。除了这个函数,还可以使用如下方式来初始化一个互斥锁

      pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
      

      PTHREAD_MUTEX_INITIALIZER 实际上只是把互斥锁的各个字段都初始化为 0.

  • pthread_mutex_destroy 用于销毁互斥锁,以释放器占用的内核资源。销毁一个已经加锁的互斥锁将导致不可预期的候过

  • pthread_mutex_lock 以原子操作的方式给一个互斥锁加锁。如果目标互斥锁已经被锁上,则 pthread_mutex_lock 调用将阻塞,直到该互斥锁的占有者将其解锁

  • pthread_mutex_trylock 与 pthread_mutex_lock 类似,不过始终立即返回,相当于非阻塞版本。当互斥锁已经被加锁时,pthread_mutex_trylock 将返回错误码 EBUSY。需要注意的是,这里讨论的 pthread_mutex_lockpthread_mutex_trylock 的行为是针对普通锁而言的。

  • pthread_mutex_unlock 以原子操作的方式给一个互斥锁解锁。如果此时有其他线程正在等待这个互斥锁,则这些线程种的某一个将获得它

14.5.2 互斥锁属性

pthread_mutexattr_t 结构体定义了一套完整的互斥锁属性。线程库提供了一系列函数来操作 pthread_mutexattr_t 类型的变量,以方便哦我们获取和设置互斥锁属性。

#include <pthread.h>
/* 初始化互斥锁属性对象 */
int ptrhead_mutexattr_init( phtread_mutexattr_t* attr );
/* 销毁互斥锁属性对象 */
int pthread_mutexattr_destroy( pthread_mutexattr_t* attr );
/* 获取和设置互斥锁的 pshared 属性 */
int pthread_mutexattr_getpshared( const pthread_mutexattr_t* attr, int* pshared );
int pthread_mutexattr_setpshared( pthread_mutexattr_t* attr, int* pshared );
/* 获取和设置互斥锁的 type 属性 */
int pthread_mutexattr_gettype( const pthread_mutexattr_t* attr, int type );
int pthread_mutexattr_settype( pthread_mutexattr_t* attr, int type );

互斥锁属性 pshared 执行随否允许跨进程共享互斥锁,可选值有两个:

  • PTHREAD_PROCESS_SHARED 互斥锁可以被进程共享
  • PTHREAD_PROCESS_PRIVATE 互斥锁只能被和锁的初始化线程隶属于同一个进程的线程共享

互斥锁属性 type 指定互斥锁的类型。Linux 支持如下 4 种类型的互斥锁:

  • PTHREAD_MUTEX_NORMAL 普通锁。这是互斥锁默认的类型。当一个线程与一个普通锁加锁之后,其余请求该所的线程将形成一个等待队列,并在该锁解锁后按优先级获得。这种所保证了资源分配的公平性,但这种锁也容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁:对一个已经被其他线程加锁的普通所解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可语气的候过
  • PTHREAD_MUTEX_ERRORCHECK 检错锁。一个线程如果对一个已经加锁的检错锁再次加锁,则加锁操作返回 EREADLK。对一个已经被其他线程加锁的检错锁解锁,或者对一个已经解锁的检错锁再次解锁,则解锁操作返回EPERM
  • PTHREAD_MUTEX_RECURSIVE 嵌套锁。这种锁允许一个线程在释放锁之前多次对它加锁而不发生死锁。不过其他线程如果要获得这个锁,则当前所的拥有者不许执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套所解锁,或者对一个已经解锁的嵌套所再次解锁,则解锁操作返回 EPERM
  • PTHREAD_MUTEX_DEFAULT 默认锁。一个线程如果对一个已经加锁的默认锁再次加锁,或者对一个已经被其他线程加锁的默认锁解锁,或者对一个已经解锁的默认锁再次解锁,将导致不可预期的候过。这种锁在实现的时候可能被映射为上面三种锁之一。

14.5.3 死锁举例

互斥锁的一个很严重的问题就是死锁。死锁使得一个或多个线程被挂起而无法继续执行,而且这种情况还不容易被发现。前文,在一个线程中对一个已经加锁的普通锁再次加锁,将导致死锁。这种情况可能出现在设计得不够仔细地递归函数中。另外,如果两个线程按照不同地顺序来申请两个互斥锁,也容易产生死锁

lock.cpp

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MZxHbkkm-1614686726227)(image/image-20210302161312740.png)]

在该代码种,主线程试图先占有互斥锁 mutex_a,然后操作被该所保护地变量 a,但操作完毕后,主线程没有立即释放互斥锁 mutex_a,而是又申请互斥锁 mutex_b,并在两个互斥锁地保护下,操作变量 a 和 b,最后才一起释放这两个互斥锁;于此同时,子线程则按照相反地顺序来申请互斥锁 mutex_amutex_b,并在两个锁的保护下操作变量 a 和 变量 b。用 sleep 函数来模拟连续两次调用 phtread_mutex_lock 之间的时间差,以确保代码中的两个线程各自先占有一个互斥锁( 主线程占有 mutex_a,子线程占有 mutex_b ),然后各自等待另一个互斥锁。这样,两个线程就僵持住了,谁都不能继续往下执行,从而形成死锁。

14.6 条件变量

如果互斥锁是用于同步线程对共享数据的访问的话,那么条件变量则是用于在线程之间同步共享数据的值。条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程。

条件变量相关的函数主要有如下 5 个:

#include <pthread.h>
int pthread_cond_init( pthread_cond_t* cond, const pthread_condattr_t* cond_attr );
int pthread_cond_destroy( pthread_cond_t* cond );
int pthread_cond_broadcast( pthread_cond_t* cond );
int pthread_cond_signal( pthread_cond_t* cond );
int pthread_cond_wait( pthread_cond_t* cond, pthread_mutex_t* mutex );

这些函数的第一个参数 cond 指向要操作的目标条件变量,条件变量的类型是 pthread_cond_t 结构体。

  • pthread_cond_init 用于初始化条件变量。

    • cond_attr 执行条件变量的属性。如果设置为 NULL,则表示使用默认属性。条件变量的属性不多,而且和互斥锁的属性类型相似。除了 pthread_cond_init 函数外,还可以使用如下方式来初始化一个条件变量

      pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

      PTHREAD_COND_INITIALIZER 实际上只是把条件变量的各个字段都初始化为 0

  • pthread_cond_destroy 用于销毁条件变量,以释放其占用的内核资源。销毁一个正在被等待的条件变量将失败并返回 EBUSY

  • pthread_cond_broadcast 以广播的方式唤醒所有等待目标条件变量的线程

  • pthread_cond_signal 用于唤醒一个等待目标条件变量的线程。只与哪个线程将被唤醒,则取决于线程的优先级和调度测录。有时候我们可能像唤醒一个指定的线程,但 pthread 没有对该需求提供解决办法。不过可以间接地实现该需求:定义一个能够唯一标识目标线程的全局变量,在唤醒等待条件变量的线程前先设置该变量为目标线程,然后采用广播方式唤醒所有等待条件变量的线程,这些线程被唤醒后都检查该变量以判断被唤醒的是否是自己,如果是,就开始执行后续代码,如果不是继续等待。

  • pthread_cond_wait 用于等待目标条件变量。

    • mutex 用于保护条件变量的互斥锁,以确保 pthread_cond_wait 操作的原子性。在调用 phtread_cond_wait 前,必须确保互斥锁 mutex 已经加锁。

    • pthread_cond_wait 函数执行时,首先把调用线程放入条件变量的等待队列中,然后将互斥锁 mutex 解锁。可见,从 pthread_cond_wait 开始执行到其调用线程被放入条件变量的等待队列之间的这段时间内,pthread_cond_signalpthread_cond_broadcast 等函数不会修改条件变量。即:pthread_cond_wait 函数不会错过目标条件变量的任何变化。当 pthread_cond_wait 函数成功返回时,互斥锁 mutex 将再次被锁上

      #include <pthread.h>
      #include <unistd.h>
       
      static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
      static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
       
      struct node {
      int n_number;
      struct node *n_next;
      } *head = NULL;
       
      /*[thread_func]*/
      static void cleanup_handler(void *arg)
      {
          printf("Cleanup handler of second thread./n");
          free(arg);
          (void)pthread_mutex_unlock(&mtx);
      }
      static void *thread_func(void *arg)
      {
          struct node *p = NULL;
       
          pthread_cleanup_push(cleanup_handler, p);    // 拓展https://blog.csdn.net/longbei9029/article/details/72871714
          while (1) 
      	{
      		pthread_mutex_lock(&mtx);  //这个mutex主要是用来保证pthread_cond_wait的并发性
      		while (head == NULL)   
      		{ //这个while要特别说明一下,单个pthread_cond_wait功能很完善,为何这里要有一个while (head == NULL)呢?因为pthread_cond_wait里的线程可能会被意外唤醒,如果这个时候head != NULL,则不是我们想要的情况。这个时候,应该让线程继续进入pthread_cond_wait
      			pthread_cond_wait(&cond, &mtx); // pthread_cond_wait会先解除之前的pthread_mutex_lock锁定的mtx,然后阻塞在等待对列里休眠,直到再次被唤醒(大多数情况下是等待的条件成立而被唤醒,唤醒后,该进程会先锁定先pthread_mutex_lock(&mtx);,再读取资源
      			//用这个流程是比较清楚的/*lock-->unlock-->wait() return-->lock*/
      		}
              p = head;
              head = head->n_next;
              printf("Got %d from front of queue/n", p->n_number);
              free(p);
              pthread_mutex_unlock(&mtx); //临界区数据操作完毕,释放互斥锁
          }
          pthread_cleanup_pop(0);
          return 0;
      }
       
      int main(void)
      {
          pthread_t tid;
          int i;
          struct node *p;
          pthread_create(&tid, NULL, thread_func, NULL);   //子线程会一直等待资源,类似生产者和消费者,但是这里的消费者可以是多个消费者,而不仅仅支持普通的单个消费者,这个模型虽然简单,但是很强大
          /*[tx6-main]*/
          for (i = 0; i < 10; i++) 
      	{
              p = malloc(sizeof(struct node));
              p->n_number = i;
              pthread_mutex_lock(&mtx);             //需要操作head这个临界资源,先加锁,
              p->n_next = head;
              head = p;
              pthread_cond_signal(&cond);
              pthread_mutex_unlock(&mtx);           //解锁
              sleep(1);
          }
          printf("thread 1 wanna end the line.So cancel thread 2./n");
          pthread_cancel(tid);             //关于pthread_cancel,有一点额外的说明,它是从外部终止子线程,子线程会在最近的取消点,退出线程,而在我们的代码里,最近的取消点肯定就是pthread_cond_wait()了。关于取消点的信息,有兴趣可以google,这里不多说了
          pthread_join(tid, NULL);
          printf("All done -- exiting/n");
          return 0;
      }
      

14.7 线程同步机制包装类

分了充分复用代码,将前面讨论的 3 种线程同步机制分别封装成 3 个类,是现在 locker.h

locker.h

#ifndef LOCKER_H
#define LOCKER_H

#include <exception>
#include <pthread.h>
#include <semaphore.h>

/* 封装信号量的类 */
class sem{
public:
    /* 创建并初始化信号量 */
    sem(){
        if( sem_init( &m_sem, 0, 0) != 0 ){
            /* 构造函数没有返回值,可以通过抛出异常来报告错误 */
            throw std::exception();
        }
    }
    /* 销毁信号量 */
    ~sem(){
        sem_destroy( &m_sem );
    }
    /* 等待信号量 */
    bool wait(){
        return sem_wait( &m_sem ) == 0;
    }
    /* 增加信号量 */
    bool post(){
        return sem_post( &m_sem ) == 0;
    }
private:
    sem_t m_sem;
};
/* 封装互斥锁的类 */
class locker{
public:
    /* 创建并初始化锁 */
    locker(){
        if( pthread_mutex_init( &m_mutex, NULL ) != 0 ){
            throw std::exception();
        }
    }
    /* 销毁互斥锁 */
    ~locker(){
        pthread_mutex_destroy( &m_mutex );
    }
    /* 获取互斥锁 */
    bool lock(){
        return pthread_mutex_lock( &m_mutex ) == 0;
    }
    /* 释放互斥锁 */
    bool unlock(){
        return pthread_mutex_unlock( &m_mutex ) == 0;
    }
private:
    pthread_mutex_t m_mutex;
};
/* 封装条件变量的类 */
class cond{
public:
    /* 创建并初始化条件变量 */
    cond(){
        if( pthread_mutex_init( &m_mutex, NULL ) != 0 ){
            throw std::exception();
        }
        if( pthread_cond_init( &m_cond, NULL ) != 0 ){
            /* 构造函数中一旦初夏难问题,就应该立即释放已经成功分配了的资源 */
            pthread_mutex_destroy( &m_mutex );
            throw std::exception();
        }
    }
    /* 销毁条件变量 */
    ~cond(){
        pthread_mutex_destroy( &m_mutex );
        pthread_cond_destroy( &m_cond );
    }
    /* 等待条件变量 */
    bool wait(){
        int ret = 0;
        pthread_mutex_lock( &m_mutex );
        ret = pthread_cond_wait( &m_cond, &m_mutex );
        pthread_mutex_unlock( &m_mutex );
        return ret == 0;
    }
    /* 唤醒等待条件变量的线程 */
    bool signal(){
        return pthread_cond_signal( &m_cond ) == 0;
    }
private:
    pthread_mutex_t m_mutex;
    pthread_cond_t m_cond;
};

#endif

14.8 多线程环境

14.8.1 可重入函数

如果一个函数能被多个线程同时调用且不用发生竞态条件,则我们称它是线程安全的 ( thread safe ),或者说它是可重入函数。Linux库函数只有一小部分是不可重入的,比如 inet_ntoa函数,以及 getservbynamegetservbyport函数。这些库函数之所以不可重入的主要原因是其内部使用了静态变量。但这这都有对应的可重入版本,这些可重入版本的函数名是在原函数名尾部加上 _r

14.8.2 线程和进程

如果一个多线程程序的某个线程调用了 fork 函数,那么新创建的子进程是否将自动创建和父进程相同数量的线程呢?答案是 “否”。子进程只拥有一个执行线程,它是调用 fork 的那个线程的完整复制。并且子进程将自动继承父进程中互斥锁(条件变量与之类似)的状态。也就是说,父进程中已经被加锁的互斥锁在子进程中也是被锁住的。这就引起了一个问题:子进程可能不清楚从父进程继承而来的互斥锁的具体状态(是加锁还是解锁状态)。这个互斥锁可能被加锁了,但并不是由调用 fork 函数的那个线程锁住的,而是由其他线程锁住的。如果是这种情况,则子进程若再次对该互斥锁执行加锁操作就会导致死锁

fork_lock.cpp

#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <wait.h>
#include <stdio.h>

pthread_mutex_t mutex;
/* 子线程运行的函数。它首先获得互斥锁 mutex,然后暂停 5 s,再次释放该互斥锁 */
void* another( void* arg ){
    printf( "in child thread, lock the mutex\n" );
    pthread_mutex_lock( &mutex );
    sleep(5);
    pthread_mutex_unlock( &mutex );
}
int main(){
    pthread_mutex_init( &mutex, NULL );
    pthread_t id;
    pthread_create( &id, NULL, another, NULL );
    /* 父进程中的主线程暂停 1s,以确保在执行 fork 操作之前,子线程已经开始运行并获得了互斥变量 mutex */
    sleep(1);
    int pid = fork();
    if( pid < 0 ){
        pthread_join( id, NULL );
        pthread_mutex_destroy( &mutex );
        return 1;
    }
    else if ( pid == 0 ){ //子进程
        printf( "I am in the child, want to get the lock\n" );
        /* 子进程从父进程继承了互斥锁 mutex 状态,该互斥锁处于锁住的状态,这是父进程中的子线程执行 pthread_mutex_lock
        引起的,因此,下面这句加锁操作会一直阻塞,尽管从逻辑上来说它是不应该阻塞的 */
        pthread_mutex_lock( &mutex );
        printf( "I can not run to here, ...\n" );
        pthread_mutex_unlock( &mutex );
        exit( 0 );
    }
    else{
        wait( NULL );
    }
    pthread_join( id, NULL );
    pthread_mutex_destroy( &mutex );
    return 0;
}

不过,pthread提供了一个专门的函数 pthread_atfork,以确保 fork 调用后父进程和子进程都拥有一个清楚的锁状态,定义如下:

#include <pthread.h>
int pthread_atfork( void (*prepare)(void), void (*parent)(void), void (*child)(void) );

该函数将建立 3 个 fork 句柄来帮助我们清理互斥锁的状态。

  • prepare句柄将在 fork 调用创建出子进程之前被执行。它可以用来锁住所有父进程中的互斥锁。
  • parent句柄则是在 fork 调用创建出子进程之后,而 fork 返回之前,在父进程中被执行。他的作用是释放所有在 prepare 句柄中被锁住的互斥锁
  • child 句柄是 fork 返回之前,在子进程中被执行。和 parent句柄一样,child句柄也是用于释放所有在 parent 句柄中被锁住的互斥锁。

因此要让上面的fork_lock.cpp代码正常工作,就应该在其中的 fork 调用前加入如下代码

void prepare(){
    pthread_mutex_lock( &mutex );
}
void infork(){
    pthread_mutex_unlock( &mutex );
}
pthread_atfork( prepare, infork, infork );

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IckyRioH-1614686726234)(image/image-20210302194428495.png)]

14.8.3 线程和信号

每个线程都可以独立地设置信号掩码。在 10.3.2 小节,设置进程信号掩码和函数 sigprocmask,但在多线程环境下,应使用如下所示地 pthread 版本的 sigprocmask 函数来设置线程信号掩码

#include <pthread.h>
#include <signal.h>
int pthread_sigmask( int how, const sigset_t* newmask, sigset_t* oldmask );

该函数的参数的含义与 sigprocmask 参数完全相同。

由于进程中的所有线程共享该进程的信号,所以线程库将根据线程掩码决定把信号发送给哪个具体的线程。因此,如果我们在每个子线程中都单独设置信号掩码,就很容易导致逻辑错误。此外,所有线程共享信号处理函数。也就是说,当一个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一个信号设置的信号处理函数。这两点都说明,我们应该定义一个专门的线程来处理所有的信号。这可以通过如下两个步骤实现:

  1. 在主线程创建出其他子线程之前就调用 pthread_sigmask来设置好信号掩码,所有先创建的子线程都将自动继承这个信号掩码。这样做之后,实际上所有线程都不会响应被屏蔽的信号了。

  2. 在某个线程中调用如下函数来等待信号并处理:

    #include <signal.h>
    in sigwait( const sigset_t* set, int* sig );
    
    • set 指定需要等待的信号的集合。可以简单地将其指定为在第 1 补种创建的信号掩码,标识在该线程中等待所有被屏蔽的信号。
    • sig 指向的整数用于存储该函数返回的信号值。

    一旦 sigwait 正确返回,就可以对接收到的信号做处理了。如果使用了 sigwait ,就不应该再为信号设置信号处理函数了。

如下代码展示了如何通过上述两个步骤实现在一个线程中处理所有信号

pthread_sig.cpp

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

#define handle_error_en( en, msg )\
do { errno = en; perror(msg); exit( EXIT_FAILURE ); } while( 0 )

static void* sig_thread( void* arg ){
    sigset_t* set = ( sigset_t* ) arg;
    int s, sig;
    for( ;; ){
        /* 第二个步骤,调用 sigwait 等待信号 */
        s = sigwait( set, &sig );
        if ( s != 0 )
            handle_error_en( s, "sigwait" );
        printf( "Signal handling thread got signal %d\n", sig );
    }
}
int main( int argc, char* argv[] ){
    pthread_t thread;
    sigset_t set;
    int s;
    /* 第一个步骤,在主线程种设置信号掩码 */
    sigemptyset( &set );
    sigaddset( &set, SIGQUIT );
    sigaddset( &set, SIGUSR1 );
    s = pthread_sigmask( SIG_BLOCK, &set, NULL );
    if ( s != 0 )
        handle_error_en( s, "pthread_sigmask" );
    s = pthread_create( &thread, NULL, &sig_thread, (void*)&set );
    if( s != 0 )
        handle_error_en( s, "pthread_create" );
    pause();
}

最后,pthread还提供了下面的方法,使得我们可以明确地将一个信号发送给指定地线程:

#include <signal.h>
int pthread_kill( pthread_t thread, int sig );
  • thread 指定目标线程
  • sig 指定待发送的信号。如果 sig 为 0,则 pthread_kill 不发送信号,但它仍会执行错误检查。我们可以利用这种方式来检测目标线程是否存在
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Artintel

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值