Lazy loaded image
🥳嵌入式Linux开发
Lazy loaded image多线程、并发与线程同步
Words 19463Read Time≈ 49 min
2024-11-27
2025-4-21
type
date
slug
category
icon
password

一、 介绍

1.1 线程概述

什么是进程,线程,彼此有什么区别
  1. 当我们运行一个程序的时候,系统就会创建一个进程,并分配地址空间和其他资源,最后把进程加入就绪队列直到分配到CPU 时间就可以正式运行了。
  1. 线程 是进程的一个执行流,有一个初学者可能误解的概念,进程就像一个容器一样,包括程序运行的程序段、数据段等信息,但是进程其实是不能用来运行代码的,真正运行代码的是进程里的线程。
  1. 当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以 main() 做为入口开始运行的,所以 main() 函数就是主线程的入口函数, main()函数所执行的任务就是主线程需要执行的任务。
  1. 在main函数里创建的多个子线程中,同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符、信号处理、定时器、信号量等等。
  1. 同一进程中的多个线程有各自的调用栈(call stack,我们称为线程栈),自己的寄存器环境(register context) 、 自己的线程本地存储(thread-local storage)。
    1. notion image
 

1.2 不同语言的线程管理库

C/C++
不同Linux系统下提供的底层线程有好几种,其中POSIX类型的线程最为常用。POSIX线程是pthread开头的一系列C语言API,数据结构包括mutex,condition等。C语言多线程不是必须的,只在一些场合适用,而例如单线程的服务器如redis和nginx也能达到较高的并发量。
python
由于python的GIL锁,提供的thread都是实质上单核的多线程,但是在IO密集型应用中仍然可以酌情使用。异步模型的普及也影响了python的特性,至少在3.4+版本,python就有了coroutine,而3.5又多了两个新的关键字async,await,用来代替之前的coroutine装饰器和yield from写法。
Java
Java很适合编写网络编程、多线程应用,它的多线程是原生的。基于Java的多线程和NIO,有知名的Netty框架。Java里常见的底层多线程结构包括Thread,Condition,Semaphore,Lock。Java的synchronized也是一种互斥机制的实现,叫做管程(monitor)。而且它实质上是一个可重入锁,即当前线程嵌套使用synchronized的时候不会发生死锁。顺带一提,Spark应用级别也有await和ssc.await的API。类似于Python,由于Node.js原来也是单线程的,吞吐会受到耗时计算的影响,Node.js v10.5.0 发布之前就是这种情况,在这一版本增加了对多线程的支持。

二、线程的基本用法

2.1 创建线程

启动程序时,只创建了main函数为入口的主线程,若要创建一个新线程,可使用如下函数:
函数参数和返回值含义如下:
  • thread:pthread_t 类型指针。创建成功后,线程ID保存在thread中。
  • attr:pthread_attr_t 类型指针,指定线程的各种属性,若为NULL,使用默认属性
  • start_routine:参数 start_routine 是一个函数指针,指向一个函数,新创建的线程从 start_routine() 函数开始运行,该函数返回值类型为void *,并且该函数的参数只有一个void *。
  • arg:传递给 start_routine()函数的参数,该内存需在线程生命周期存在,因此要指向全局或堆变量,若为NULL,则不需要传递参数给start_routine() 函数。
  • 返回值:成功返回 0;失败时将返回一个错误号,并且参数 thread 指向的内容是不确定的。
线程创建成功,新线程就会加入到系统调度队列中,获取到 CPU 之后就会立马从 start_routine()函数开始运行该线程的任务。但无法确定系统先调度主线程还是新创建的线程,若对执行顺序有强制要求,那么就需采用一些同步技术来实现。
使用示例
下面是使用pthread_create()函数创建一个新线程方法示例:
  • 主线程休眠了 1 秒钟,原因在于,如果主线程不进行休眠,它就可能会立马退出,这样可能会导致新创建的线程还没有机会运行,整个进程就结束了。
  • 通过 getpid()和 pthread_self()来获取进程 ID 和线程 ID。
⚠️
编译时出现了错误,提示“对‘pthread_create’未定义的引用”
  1. 添加<pthread.h> 2. 编译添加链接库的文件。 gcc -o testApp testApp.c -lpthread
输出结果

2.2 终止线程

终止线程的方法:
  1. 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;
  1. 线程调用 pthread_exit()函数;
  1. 调用 pthread_cancel()取消线程
⚠️
进程中的任意线程调用 exit()、_exit()或者_Exit(),都将会导致整个进程终止。
相关函数原型如下:
  • retval指定线程的退出码,可由另一个线程通过调用 pthread_join() 来获取。通过return结束线程,其返回值也可通过 pthread_join() 来获取。pthread_exit不同于return的是:可在线程函数调用的任意函数中调用pthread_exit()来终止线程。
  • 参数 retval 所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效;出于同样的理由,也不应在线程栈中分配线程 start 函数的返回值。
使用示例
新线程中调用 sleep()休眠,保证主线程先调用 pthread_exit()终止,休眠结束之后新线程也调用 pthread_exit()终止。
输出结果
主线程调用 pthread_exit()终止之后,整个进程并没有结束,而新线程还在继续运行。

2.3 回收线程

类比于父子进程中,父进程通过wait函数(或其变体 waitpid() )阻塞等待子进程退出并获取其终止状态,回收子进程资源。线程中也需如此, 通过调用 pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源。
notion image
相关函数原型如下:
函数参数和返回值含义如下:
  • thread 指定需要等待的线程。
  • 退出线程状态复制到*retval所指内存区域。
    • pthread_cancel()取消,PTHREAD_CANCELED 放在*retval 中。
    • 目标线程的终止状态不感兴趣,则可将参数 retval 设置为 NULL。
⚠️
若线程并未分离(detached),则必须使用 pthread_join()来等待线程终止,回收线程资源;如果线程终止后,其它线程没有调用 pthread_join()函数来回收该线程,那么该线程将变成僵尸线程,与僵尸进程的概念相类似;同样,僵尸线程除了浪费系统资源外,若僵尸线程积累过多,那么会导致应用程序无法创建新的线程。 当然,如果进程中存在着僵尸线程并未得到回收,当进程终止之后,进程会被其父进程回收,所以僵尸线程同样也会被回收。
⚠️
pthread_join() 和 waitpid() 差异
  1. 线程之间关系是对等的。进程中的任意线程均可调用 pthread_join()函数来等待另一个线程的终止。这与进程间层次关系不同,父进程如果使用 fork()创建了子进程,那么它也是唯一能够对子进程调用 wait()的进程。
  1. 不能以非阻塞的方式调用 pthread_join()。对于进程,调用 waitpid()既可以实现阻塞方式等待、也可以实现非阻塞方式等待。
使用示例
输出结果:
主线程调用 pthread_create()创建新线程之后,新线程执行 new_thread_start()函数,而在主线程中调用pthread_join()阻塞等待新线程终止,新线程终止后,pthread_join()返回,将目标线程的退出码保存在*tret 所指向的内存中。

2.4 取消线程

在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。
相关函数原型如下:
函数参数和返回值含义如下:
  • 参数 thread 指定需要取消的目标线程。
  • 成功返回 0,失败将返回错误码。
⚠️
发出取消请求之后,函数 pthread_cancel()立即返回,不会等待目标线程的退出。默认情况下,目标线程也会立刻退出。其行为表现为如同调用了参数为 PTHREAD_CANCELED的pthread_exit() 函数。
线程可以设置自己不被取消或者控制如何被取消,这种情况,只是提出取消请求而已。
使用示例
主线程创建新线程,新线程 new_thread_entry()函数直接运行 for 死循环;主线程休眠一段时间后,调用pthread_cancel()向新线程发送取消请求,接着再调用 pthread_join()等待新线程终止、获取其终止状态,将线程退出码打印出来。
输出结果:
退出码为-1,也就是PTHREAD_CANCELED。

2.4.1 取消状态以及类型

默认情况下,线程是响应其它线程发送过来的取消请求的,响应请求然后退出线程。当然,线程可以选择不被取消或者控制如何被取消。
相关函数原型如下:
函数参数和返回值含义如下:
  • state 取消性状态目标值。
    • PTHREAD_CANCEL_ENABLE:默认值,线程可以取消。
    • PTHREAD_CANCEL_DISABLE:线程不可被取消,如果此类线程接收到取消请求,则会将请求挂起,直至线程的取消性状态变为 PTHREAD_CANCEL_ENABLE。
  • type 取消性类型目标值。
    • PTHREAD_CANCEL_DEFERRED:默认值,取消请求到来时,线程还是继续运行,取消请求被挂起,直到线程到达某个取消点。
    • PTHREAD_CANCEL_ASYNCHRONOUS:可能会在任何时间点(也许是立即取消,但不一定)取消线程,这种取消性类型应用场景很少。
  • oldstate 保存前一个取消性状态,如果对之前的状态不感兴趣,Linux 允许将参数 oldstate 设置为 NULL。
  • pthread_setcancelstate()调用成功将返回 0,失败返回非 0 值的错误码。
使用示例
下面例子在新线程的 new_thread_entry()函数中调用 pthread_setcancelstate()函数将线程的取 消性状态设置为 PTHREAD_CANCEL_DISABLE
新线程 new_thread_entry()函数中调用 pthread_setcancelstate()将自己设置为不可被取消,主线程延时 1 秒钟之后调用 pthread_cancel()向新线程发送取消请求,那么此时新线程是不会终止的pthread_cancel() 立刻返回之后进入到 pthread_join()函数,那么此时会被阻塞等待新线程终止。
输出结果:
⚠️
当某个线程调用fork()创建子进程时,子进程会继承调用线程的取消性状态和取消性类型,而当某线程调用exec函数时,会将新程序主线程的取消性状态和类型重置为默认值,也就是PTHREAD_CANCEL_ENABLE和PTHREAD_CANCEL_DEFERRED。

2.4.2 取消点

若将线程的取消性类型设置为 PTHREAD_CANCEL_DEFERRED 时(线程可以取消状态下),收到其它线程发送过来的取消请求时,仅当线程抵达某个取消点时,取消请求才会起作用
取消点是一系列函数,执行到这些函数的时候,才会真正响应取消请求。在没有出现取消点时,取消请求是无法得到处理的,究其原因在于系统认为,但没有到达取消点时,线程此时正在执行的工作是不能被停止的,正在执行关键代码,此时终止线程将可能会导致出现意想不到的异常发生
取消点函数包括哪些呢?下表给大家简单地列出了一些:
notion image
通过命令为"man 7 pthreads",可查询取消点。
notion image

2.4.3 线程可取消性的检测

⚠️
假设线程执行的是一个不含取消点的循环(譬如 for 循环、while 循环),那么这时线程永远也不会响应取消请求,也就意味着除了线程自己主动退出,其它线程将无法通过向它发送取消请求而终止它。
现实项目中,存在这样情况:线程运行在一个循环中,而循环体执行的函数不存在任何一个取消点。而线程需要在接收到取消请求后终止。这时候可以添加 pthread_testcancel() 产生一个取消点。
相关函数原型如下:
使用示例
下面例子中,pthread_testcancel()可以产生取消点,主线程便可以终止新线程。
输出结果:

2.5 分离线程

默认情况下,通过调用 pthread_join()获取其返回状态、回收线程资源。但对于这样场景:不关心线程的返回状态,只希望系统在线程终止时能自动回收线程资源并将其移除。可以在调用pthread_detach() 将指定线程进行分离。
相关函数原型如下:
函数参数和返回值含义如下:
  • thread 指定需要分离的线程,调用 pthread_detach(pthread_self()) 可将自己分离。
  • 调用成功将返回 0,失败将返回一个错误码。
⚠️
一旦线程处于分离状态,就不能再使用 pthread_join()来获取其终止状态,此过程是不可逆的,一旦处于分离状态之后便不能再恢复到之前的状态。处于分离状态的线程,当其终止后,能够自动回收线程资源。
使用示例
主线程创建新的线程之后,休眠 3 秒钟,调用 pthread_join()等待新线程终止;新线程调用pthread_detach(pthread_self()) 将自己分离,休眠 2 秒钟之后 pthread_exit()退出线程;主线程休眠 3 秒钟是能够确保调用 pthread_join()函数时新线程已经将自己分离了。
输出结果
由于线程已分离,主线程调用 pthread_join()确实会出错,错误提示为“Invalid argument”。

2.6 注册线程清理处理函数

类比与进程调用exit() 退出,执行 atexit()函数注册进程的终止处理函数。线程中也可以在终止退出时,去执行这样的处理函数,把这个称为线程清理函数(thread cleanup handler)。
不同于进程,一个线程可以注册多个清理函数,这些清理函数记录在栈中,每个线程都可以拥有一个清理函数栈(先进后出,执行顺序与注册(添加)顺序相反),当执行完所有清理函数后,线程终止。
线程通过函数pthread_cleanup_push()pthread_cleanup_pop()分别负责向调用线程的清理函数栈中添加和移除清理函数,函数原型如下所示:
函数参数和返回值含义如下:
  • routine 是一个函数指针,指定需要添加的清理函数
  • arg 为routine 函数的参数。
  • execute 为0时,清理函数不会被调用,只是将清理函数栈中最顶层的函数移除。非 0,除了将清理函数栈中最顶层的函数移除之外,还会执行清理函数。
⚠️
当线程执行以下动作时,清理函数栈中的清理函数才会被执行:
  • 线程调用 pthread_exit() 退出时;
  • 线程响应取消请求时;
  • 用非 0 参数调用 pthread_cleanup_pop()。
使用示例:
新线程调用 pthread_cleanup_push()函数添加线程清理函数,调用了三次,但每次添加的都是同一个函数,只是传入的参数不同;清理函数添加完成,休眠一段时间之后,调用 pthread_exit()退出。之后还调用了 3 次pthread_cleanup_pop(),在这里的目的仅仅只是为了与 pthread_cleanup_push()配对使用,否则编译不通过。
输出结果
从打印结果可知,先添加到线程清理函数栈中的函数会后被执行,添加顺序与执行顺序相反。
⚠️
  1. 将new_thread_start 中pthread_exit替换为return,不会执行清理函数。
  1. pthread_cleanup_pop(1) 会立即执行最顶层清理函数。

2.7 线程属性

如前所述,调用 pthread_create()创建线程,可对新建线程的各种属性进行设置。在 Linux 下,使用pthread_attr_t 数据类型定义线程的所有属性。参数 attr 设置为 NULL,表示使用属性的默认值创建线程。如果不使用默认值,参数 attr 必须要指向一个pthread_attr_t对象,而不能使用 NULL。
相关函数原型如下:
pthread_attr_t 数据结构中包含的属性比较多,下面介绍线程栈的位置和大小、线程调度策略和优先级,以及线程的分离状态属性等。

2.7.1 线程栈属性

每个线程都有自己的栈空间,pthread_attr_t 数据结构中定义了栈的起始地址以及栈大小,调用函数pthread_attr_getstack()可以获取这些信息,函数 pthread_attr_setstack()对栈起始地址和栈大小进行设置,其函数原型如下所示:
函数pthread_attr_setstack 参数和返回值含义如下:
  • attr:参数 attr 指向线程属性对象。
  • stackaddr:调用 pthread_attr_getstack()可获取栈起始地址,并将起始地址信息保存在*stackaddr 中;
  • stacksize:调用 pthread_attr_getstack()可获取栈大小,并将栈大小信息保存在参数 stacksize 所指向的内存中;
  • 返回值:成功返回 0,失败将返回一个非 0 值的错误码。
函数 pthread_attr_setstack()参数和返回值含义如下:
  • attr:参数 attr 指向线程属性对象。
  • stackaddr:设置栈起始地址为指定值。
  • stacksize:设置栈大小为指定值;
  • 返回值:成功返回 0,失败将返回一个非 0 值的错误码。
如果想单独获取或设置栈大小、栈起始地址,可以使用下面这些函数:
使用示例:

2.7.2 分离状态属性

2.5 节介绍线程分离,我们可以修改 pthread_attr_t 结构中的 detachstate线程属性,让线程一开始运行就处于分离状态。
相关函数原型如下:
  • detachstate 指定线程分离状态属性。
    • PTHREAD_CREATE_DETACHED:新建线程一开始运行便处于分离状态,以分离状态启动线程,无法被其它线程调用 pthread_join()回收,线程结束后由操作系统收回其所占用的资源;
    • PTHREAD_CREATE_JOINABLE:这是 detachstate 线程属性的默认值,正常启动线程,可以被其它线程获取终止状态信息。
使用示例:

2.8 线程安全

当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题,本小节将讨论线程安全相关的话题。

2.8.1 线程栈

每个线程拥有自己的栈空间,将其称为线程栈。在线程中定义的局部变量时分配到线程栈中,不会相互干扰。线程栈的大小和起始地址,一般由系统默认分配,也可使用2.7.1 线程栈属性说明的接口进行配置。
notion image

2.8.2 可重入函数

执行流:单线程程序只有一条执行流,多线程程序,同一个存在多条独立、并发的执行流。进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。
⚠️
对比MCU中硬件中断处理函数,在异步硬件中断信号下开启另一条执行流。
可重入函数:可被多条执行流同时调用,且总能产生正确(可预期)的结果的函数。从微观上,是该函数被上一条执行流还未执行完,就被另一个执行流开始调用。从宏观上,指的就是被多个执行流同时调用。
下面示意图说明的是 func() 在main() 函数中正在运行,信号处理函数接收到信号处理同时调用 func()。 如果每次出现这种情况执行 func()函数都能产生正确的结果,那么 func()函数就是一个可重入函数。
notion image
第二种情况是,多线程环境下,多个线程并发调用同一个函数。
⚠️
多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题,如果多条执行流同时调用一个不可重入函数则可能会得不到预期的结果、甚至有可能导致程序崩溃!不止是在应用程序中,在一个包含了中断处理的裸机应用程序中亦是如此!所以不可重入函数通常存在着一定的安全隐患。
可重入函数分类
  • 绝对可重入函数:无条件的
    • 函数内所使用到的变量均为局部变量,换句话说,该函数内的操作的内存地址均为本地栈地址;
    • 函数参数和返回值均是值类型;
    • 函数内调用的其它函数也均是绝对可重入函数。
  • 带条件的可重入函数:有条件的
    • 例子1:以下函数,glob 变量进行读写操作、会导致数据不一致的问题,是典型的不可重入函数。
      修改如下,函数 func()内仅读取全局变量 glob 的值,而不更改它的值:
      修改完之后,函数 func()内仅读取了变量 glob,而并未更改 glob 的值,那么此时函数 func()就是一个可重入函数了;但它需要满足一个条件:当多个执行流同时调用函数 func()时,全局变量 glob 的值绝对不会在其它某个地方被更改;譬如线程 1 和线程 2 同时调用了函数 func(),但是另一个线程 3 在线程 1 和线程 2 同时调用了函数 func()的时候,可能会发生更改变量 glob 值的情况,如果是这样,那么函数 func()依然是不可重入函数。这就是有条件的可重入函数的概念,这通常需要程序员本身去规避这类问题。
      例子2:
      这是一个参数为引用类型的函数,传入了一个指针,并在函数内部读写该指针所指向的内存地址。该函数只有在传入的指针不是多线程共享变量地址才是可重入函数。若传入指针为共享数据,会出现数据不一致情况。

2.8.3 C 库函数可重入版本和不可重入版本

通过man 3 ctime, 查询到它们“ATTRIBUTES”信息。
notion image
  • 标记的可重入函数都是线程安全函数
  • 带 env 和 locale 之类标签,指需要满足条件才是可重入函数。如果是绝对可重入函数,MT-Safe 标签后面不会携带任何标签,譬如数学库函数 sqrt。
    • env:这个标签指的是该函数内部会读取进程的某个/某些环境变量(全局变量,只能读取,不能修改);
    • locale:locale 指的是本地,通常该类函数传入了指针,前面也提到了传入了指针的可重入函数应该要满足什么样的条件才是可重入(非多线程共享变量)。

2.8.4 线程安全函数

线程安全函数:一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果。
notion image
线程安全函数包括可重入函数,可重入函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是可重入函数。
下面举例说明:
这个函数是一个不可重入函数,同样也是一个线程不安全函数。
如果对该函数进行修改,使用线程同步技术(譬如互斥锁)对共享变量 glob 的访问进行保护,在读写该变量之前先上锁、读写完成之后在解锁。这样,该函数就变成了一个线程安全函数,但是它依然不是可重入函数,因为该函数更改了外部全局变量的值。
通过 man 7 pthreads 可以查询到线程不安全函数。
notion image

2.8.5 一次性初始化

在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段,通常比较容易想到的就是将其放在 main()主函数进行初始化,这样也就是意味着该段代码只在主线程中被调用,只执行过一次。大家想一下这样的问题:当你写了一个 C 函数 func(),该函数可能会被多个线程调用,且该函数中有一段初始化代码,该段代码只能被执行一次(无论哪个线程执行都可以)、如果执行多次会出现问题。
pthread_once()函数可以实现这个功能,相关函数原型如下:
函数参数和返回值含义如下:
  • once_control:pthread_once_t 类型变量;
  • init_routine:只能被执行一次的代码段,即使 pthread_once()函数会被多次执行,但它能保证 init_routine()仅被执行一次。
  • 返回值:调用成功返回 0;失败则返回错误编码以指示错误原因。
使用示例:
输出结果:
initialize_once()函数只被执行了一次,也就是被编号为 1 的线程所执行,其它线程均未执行该函数。

2.8.6 线程特有数据

⚠️
C 库中有很多函数都是非线程安全函数,非线程安全函数在多线程环境下,被多个线程同时调用时将会发生意想不到的结果,得不到预期的结果。
很多库函数都会返回一个字符串指针,譬如 asctime()、ctime()、localtime()等,返回出来的字符串可以被调用线程直接使用,但该字符串缓冲区通常是这些函数内部所维护的静态数组或者是某个全局数组。
多次调用这些函数返回的字符串其实指向的是同一个缓冲区,每次调用都会刷新缓冲区中的数据。这些函数是非线程安全的。
针对这些非线程安全函数,可以使用线程特有数据将其变为线程安全函数。
线程特有数据主要涉及到 3 个函数:pthread_key_create()、pthread_setspecific()以及pthread_getspecific()。通过运用这些函数,将线程调用的函数转化为线程安全函数,为每个调用线程维护一份变量的副本。
下面通过线程私有数据方式,将strerror()非线程安全方式写法转化为线程安全写法。首先看一下非线程安全实现方式:
  • 利用了glibc 定义的一对全局变量_sys_errlist 是一个指针数组,其中的每一个元素指向一个与 errno 错误编号相匹配的描述性字符串;_sys_nerr 表示_sys_errlist数组中元素的个数。
  • 返回的字符串指针,其实是一个静态数组,当多个线程同时调用该函数时,那么 buf 缓冲区中的数据将会出现混乱。
下面演示my_strerror这个非线程安全函数在多线程中运行情况。
输出结果:
子线程和主线程锁获取到的错误描述信息是相同的,字符串指针指向的是同一个缓冲区;原因就在于,my_strerror()函数是一个非线程安全函数,函数内部修改了全局静态变量、并返回了它的指针,每一次调用访问的都是同一个静态变量,所以后一次调用会覆盖掉前一次调用的结果。
通过线程特有数据技术进行改进。
输出结果:
改进版的 strerror()所做的第一步是调用 pthread_once(),以确保只会执行一次 create_key()函数,而在create_key()函数中便是调用 pthread_key_create()创建了一个键、并绑定了相应的解构函数 destructor(),解构函数用于释放与键关联的所有线程私有数据所占的内存空间。
接着,函数 strerror()调用 pthread_getspecific()以获取该调用线程与键相关联的私有数据缓冲区地址.
  • 如果返回为 NULL,则表明该线程是首次调用 strerror()函数,因为函数会调用 malloc()为其分配一个新的私有数据缓冲区,并调用 pthread_setspecific()来保存缓冲区地址、并与键以及该调用线程建立关联。
  • 如果pthread_getspecific()函数的返回值并不等于 NULL,那么该值将指向以存在的私有数据缓冲区,此缓冲区由之前对 strerror()的调用所分配。
通过如上修改,buf 成线程特有数据的缓冲区地址,而非全局的静态变量。改进版的 strerror 成为一个线程安全函数。

2.8.7 线程局部存储

通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量;而使用__thread 修饰符修饰全局变量或静态变量(线程局部存储),此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。
⚠️
线程局部存储的主要优点在于,比线程特有数据的使用要简单。要创建线程局部变量,只需简单地在全局或静态变量的声明中包含__thread 修饰符即可
使用__thread 修饰符,需要注意以下几点:
  1. 如果变量声明中使用了关键字 static 或 extern,那么关键字__thread 必须紧随其后。
  1. 与一般的全局或静态变量申明一样,线程局部变量在声明时可设置一个初始值。
  1. 可以使用 C 语言取值操作符(&)来获取线程局部变量的地址。
示例代码演示线程局部存储:
输出结果:
从地址便可以看出来,主线程和子线程中使用的 buf 绝不是同一个变量,这就是线程局部存储,使得每 个线程都拥有一份对变量的拷贝,各个线程操作各自的变量不会影响其它线程。

三、详细说明

 
Clion 多线程标准库的添加Tinythread库
notion image

3.1 资源的线程安全问题

3.1.1 非原子操作

3.1.2 资源可见性

3.1.3 代码重排性(还和编译器的处理有关系)

会出现死循环
不会出现4结果
编译器优化只会考虑单线程内,而不会考虑多线程并发

3.2 volatile

  • 目的是禁止保编译器优化读写操作
  • 并非为并发程序设计
  • 不会保证访问的原子性
  • 与其他语言volatile不要混淆
  • msvc赋予强制刷新缓存的寓意,可以保证可见性
 

3.3 原子类型

3.3.1 无锁实现 - 原子锁

atomic_is_lock_free()
false 是加锁实现的
ture 是cpu原子实现的
 

3.3.2 无锁实现 - 原子标志位

3.4 锁

3.5 Thread Local

 

3.6 线程开发常见问题

3.6.1 多线程竞争问题

 
 
 
 
 

四、线程同步

4.1 为什么需要线程同步?

  • 线程同步是为了对共享资源(多个线程都会进行访问的资源)的访问进行保护。
  • 保护的目的是为了解决数据一致性的问题。
  • 出现数据一致性问题其本质在于进程中的多个线程对共享资源的并发访问(同时访问)。
  • 如何解决对共享资源的并发访问出现数据不一致的问题?采用线程同步技术(互斥锁、条件变量、自旋锁以及读写锁等),来实现同一时间只允许一个线程访问该变量,防止出现并发访问的情况、消除数据不一致的问题。

4.2 互斥锁

互斥锁(mutex)又叫互斥量,从本质上说是一把锁,在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁)。
  • 释放锁,多个被阻塞线程会被唤醒,尝试对互斥锁进行加锁,一个加锁成功后,其他线程就不能再次上锁,再次进入阻塞。
  • 程序设计上,需要保证所有线程访问共享资源都按相同数据访问规则,不能允许遗漏的线程可以在没有获得锁情况下访问共享资源。
互斥锁操作接口:

4.2.1 初始化

4.2.2 加锁解锁

  • 调用成功时返回 0;失败将返回一个非 0 值的错误码;
  • 调用 pthread_mutex_lock(),若互斥锁已经被其他线程锁定,则会一直阻塞。
  • 调用 pthread_mutex_unlock()函数将已经处于锁定状态的互斥锁进行解锁。以下行为均属错误:
    • 对处于未锁定状态的互斥锁进行解锁操作;
    • 解锁由其它线程锁定的互斥锁。
输出结果:
可以看到正确结果 20000000。但是用锁,消耗时间变长。

4.2.3 pthread_mutex_trylock()函数

调用 pthread_mutex_trylock()函数尝试对互斥锁进行加锁,如果互斥锁处于未锁住状态,那么调用将会锁住互斥锁并立马返回,如果互斥锁已经被其它线程锁住,调用会加锁失败,但不会阻塞,而是返回错误码 EBUSY
  • 成功返回 0,失败返回一个非 0 值的错误码
上面的例子可修改成如下:

4.2.4 销毁互斥锁

  • 调用成功情况下返回 0,失败返回一个非 0 值的错误码。
  • 不能销毁还没有解锁的互斥锁,否则将会出现错误;
  • 没有初始化的互斥锁也不能销毁。

4.2.5 互斥锁死锁

当超过一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁。
程序中使用一个以上的互斥锁,如果允许一个线程一直占有第一个互斥锁,并且在试图锁住第二个互斥锁时处于阻塞状态,但是拥有第二个互斥锁的线程也在试图锁住第一个互斥锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,会被一直阻塞,于是就产生了死锁。
解决方法:
  1. 定义互斥锁的层级关系,当多个线程对一组互斥锁操作时,总是应该按照相同的顺序对该组互斥锁进行锁定。
  1. 程序复杂情况下,可能会导致无法按照相同的顺序对一组互斥锁进行锁定。这种情况下,可以线程先使用函数pthread_mutex_lock()锁定第一个互斥锁,然后使用 pthread_mutex_trylock()来锁定其余的互斥锁。如果任一pthread_mutex_trylock()调用失败(返回 EBUSY),那么该线程释放所有互斥锁,可以经过一段时间之后从头再试。

4.2.6 互斥锁的属性

  • 调用成功返回 0,失败将返回非 0 值的错误码。
  • 互斥锁的类型属性控制着互斥锁的锁定特性,一共有 4 中类型:
    • PTHREAD_MUTEX_NORMAL:一种标准的互斥锁类型,不做任何的错误检查或死锁检测。
    • PTHREAD_MUTEX_ERRORCHECK:此类互斥锁会提供错误检查。
      • 同一线程对同一互斥锁加锁两次;
      • 线程对由其它线程锁定的互斥锁进行解锁,返回错误;
      • 线程对处于未锁定状态的互斥锁进行解锁。
    • PTHREAD_MUTEX_RECURSIVE:此类互斥锁允许同一线程在互斥锁解锁之前对该互斥锁进行多次加锁,然后维护互斥锁加锁的次数,把这种互斥锁称为递归互斥锁,但是如果解锁次数不等于加锁次数,则是不会释放锁的。
    • PTHREAD_MUTEX_DEFAULT:此类互斥锁提供默认的行为和特性。行为与PTHREAD_MUTEX_NORMAL类似

4.3 条件变量

条件变量用于自动阻塞线程,直到某个特定事件发生或某个条件满足为止,通常情况下,条件变量是和互斥锁一起搭配使用的。
使用条件变量主要包括两个动作:
  1. 一个线程等待某个条件满足而被阻塞;
  1. 另一个线程中,条件满足时发出“信号”。
 
新线程中会不停的循环检查全局变量 g_avail 是否大于 0,故而造成 CPU 资源的浪费。采用条件变量这一问题就可以迎刃而解!条件变量允许一个线程休眠(阻塞等待)直至获取到另一个线程的通知(收到信号)再去执行自己的操作,譬如上述代码中,当条件 g_avail > 0 不成立时,消费者线程会进入休眠状态,而生产者生成产品后(g_avail++,此时 g_avail 将会大于 0),向处于等待状态的线程发出“信号”,而其它线程收到“信号”之后,便会被唤醒!

4.3.1 条件变量初始化

对于初始化与销毁操作,有以下问题需要注意:
  • 在使用条件变量之前必须对条件变量进行初始化操作,使用 PTHREAD_COND_INITIALIZER 宏或 者函数 pthread_cond_init()都行;
  • 对已经初始化的条件变量再次进行初始化,将可能会导致未定义行为;
  • 对没有进行初始化的条件变量进行销毁,也将可能会导致未定义行为;
  • 对某个条件变量而言,仅当没有任何线程等待它时,将其销毁才是最安全的;
  • 经 pthread_cond_destroy()销毁的条件变量,可以再次调用 pthread_cond_init()对其进行重新初始化。

4.3.2 通知和等待条件变量

条件变量的主要操作便是发送信号(signal)和等待。发送信号操作即是通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变,这些处于等待状态的线程收到通知之后便会被唤醒,唤醒之后再检查条件是否满足。等待操作是指在收到一个通知前一直处于阻塞状态。
pthread_cond_signal()和 pthread_cond_broadcast()的区别在于:二者对阻塞于 pthread_cond_wait()的多个线程对应的处理方式不同,pthread_cond_signal()函数至少能唤醒一个线程,而pthread_cond_broadcast()函数则能唤醒所有线程。使用 pthread_cond_broadcast()函数总能产生正确的结果,唤醒所有等待状态的线程,但函数 pthread_cond_signal()会更为高效,因为它只需确保至少唤醒一个线程即可,所以如果我们的程序当中,只有一个处于等待状态的线程,使用 pthread_cond_signal()更好。
当程序当中使用条件变量,当判断某个条件不满足时,调用 pthread_cond_wait()函数将线程设置为等待状态(阻塞)。pthread_cond_wait()函数包含两个参数:
cond:指向需要等待的条件变量,目标条件变量;
mutex:参数 mutex 是一个 pthread_mutex_t 类型指针,指向一个互斥锁对象;条件变量通常是和互斥锁一起使用,因为条件的检测(条件检测通常是需要访问共享资源的)是在互斥锁的保护下进行的,也就是说条件本身是由互斥锁保护的。
pthread_cond_wait()函数内部会对参数 mutex 所指定的互斥锁进行操作,调用pthread_cond_wait()函数时,调用者把互斥锁传递给函数,函数会自动把调用线程放到等待条件的线程列表上,然后将互斥锁解锁;当 pthread_cond_wait()被唤醒返回时,会再次锁住互斥锁。
⚠️
注意注意的是,条件变量并不保存状态信息,只是传递应用程序状态信息的一种通讯机制。如果调pthread_cond_signal()和 pthread_cond_broadcast()向指定条件变量发送信号时,若无任何线程等待该条件变量,这个信号也就会不了了之。 当调用 pthread_cond_broadcast()同时唤醒所有线程时,互斥锁也只能被某一线程锁住,其它线程获取锁失败又会陷入阻塞。
使用条件变量对上面例子进行修改,当消费者线程没有产品可消费时,让它处于等待状态,直到生产者把产品生产出来;当生产者把产品生产出来之后,再去通知消费者。
全局变量 g_avail 作为主线程和新线程之间的共享资源,两个线程在访问它们之间首先会对互斥锁进行上锁。
  • 消费者线程中,当判断没有产品可被消费时(g_avail <= 0),调用 pthread_cond_wait()使得线程陷入等待状态,等待条件变量,等待生产者制造产品;调用 pthread_cond_wait()后线程阻塞并解锁互斥锁;
  • 在生产者线程中,它的任务是生产产品(使用g_avail++来模拟),产品生产完成之后,调用pthread_mutex_unlock()将互斥锁解锁,并调用 pthread_cond_signal() 向条件变量发送信号;这将会唤醒处于等待该条件变量的消费者线程,唤醒之后再次自动获取互斥锁,然后再对产品进行消费(g_avai--模拟)。

4.3.3 条件变量的判断条件

从 pthread_cond_wait()返回后,并不能确定判断条件是真还是假,其理由如下:
  • 当有多于一个线程在等待条件变量时,任何线程都有可能会率先醒来获取互斥锁,率先醒来获取到互斥锁的线程可能会对共享变量进行修改,进而改变判断条件的状态。譬如上面示例中,如果有两个或更多个消费者线程,当其中一个消费者线程从 pthread_cond_wait()返回后,它会将全局共享变量 g_avail 的值变成 0,导致判断条件的状态由真变成假。
  • 可能会发出虚假的通知。
因此在上面示例中,使用了 while 循环、而不是 if 语句,来控制对 pthread_cond_wait()的调用。

4.3.4 条件变量的属性

调用 pthread_cond_init()函数初始化条件变量时,可以设置条件变量的属性。条件变量包括两个属性:进程共享属性和时钟属性。

4.4 自旋锁

与互斥锁功能相似,互斥锁是基于自旋锁来实现的。
如果在获取自旋锁时,自旋锁处于未锁定状态,那么将立即获得锁(对自旋锁上锁);如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”,直到该自旋锁的持有者释放了锁。
由此介绍可知,自旋锁与互斥锁相似,但是互斥锁在无法获取到锁时会让线程陷入阻塞等待状态;而自旋锁在无法获取到锁时,将会在原地“自旋”等待。“自旋”其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁,“自旋”一词因此得名。
自旋锁的不足之处在于:自旋锁一直占用的 CPU,它在未获得锁的情况下,一直处于运行状态(自旋),所以占着 CPU,如果不能在很短的时间内获取锁,这无疑会使 CPU 效率降低。
因此我们要谨慎使用自旋锁,自旋锁通常用于以下情况:需要保护的代码段执行时间很短,这样就会使得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间;在这种情况下就比较适合使用自旋锁,效率高!
综上所述,再来总结下自旋锁与互斥锁之间的区别:
  • 实现方式上的区别:互斥锁是基于自旋锁而实现的,所以自旋锁相较于互斥锁更加底层;
  • 开销上的区别:获取不到互斥锁会陷入阻塞状态(休眠),直到获取到锁时被唤醒;而获取不到自旋锁会在原地“自旋”,直到获取到锁;休眠与唤醒开销是很大的,所以互斥锁的开销要远高于自旋锁、自旋锁的效率远高于互斥锁;但如果长时间的“自旋”等待,会使得 CPU 使用效率降低,故自旋锁不适用于等待时间比较长的情况。
  • 使用场景的区别:自旋锁在用户态应用程序中使用的比较少,通常在内核代码中使用比较多;因为自旋锁可以在中断服务函数中使用,而互斥锁则不行,在执行中断服务函数时要求不能休眠、不能被抢占(内核中使用自旋锁会自动禁止抢占),一旦休眠意味着执行中断服务函数时主动交出了CPU 使用权,休眠结束时无法返回到中断服务函数中,这样就会导致死锁!

4.4.1 自旋锁初始化

函数参数和返回值含义如下:
  • 参数 lock 指向了需要进行初始化或销毁的自旋锁对象
  • 参数 pshared 表示自旋锁的进程共享属性
    • PTHREAD_PROCESS_SHARED:共享自旋锁。该自旋锁可以在多个进程中的线程之间共享;
    • PTHREAD_PROCESS_PRIVATE:私有自旋锁。只有本进程内的线程才能够使用该自旋锁。
  • 在调用成功的情况下返回 0;失败将返回一个非 0 值的错误码

4.4.2 自旋锁加锁和解锁

使用示例:
输出结果:
将互斥锁替换为自旋锁之后,测试结果打印也是没有问题的,并且通过对比可以发现,替换为自旋锁之后,程序运行所耗费的时间明显变短了,说明自旋锁确实比互斥锁效率要高,但是一定要注意自旋锁所适用的场景。

4.5 读写锁

互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁有3 种状态:读模式下的加锁状态(以下简称读加锁状态)、写模式下的加锁状态(以下简称写加锁状态)和不加锁状态(见),一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。因此可知,读写锁比互斥锁具有更高的并行性!
notion image
 
读写锁有如下两个规则:
  • 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁)的线程都会被阻塞。
  • 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。
读写锁非常适合于对共享数据读的次数远大于写的次数的情况。
读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住。

4.5.1 读写锁初始化

4.5.2 读写锁上锁和解锁

  • 当处于写模式加锁状态时,其它线程调用 pthread_rwlock_rdlock()pthread_rwlock_wrlock() 函数均会获取锁失败,从而陷入阻塞等待状态;
  • 当处于读模式加锁状态时,其它线程调用pthread_rwlock_rdlock() 函数可以成功获取到锁,如果调用 pthread_rwlock_wrlock()函数则不能获取到锁,从而陷入阻塞等待状态。
使用示例:
使用读写锁来实现线程同步,全局变量 g_count 作为线程间的共享变量,主线程中创建了 5 个读取 g_count 变量的线程,它们使用同一个函数 read_thread,这 5 个线程仅仅对 g_count 变量进行读取,并将其打印出来,连带打印线程的编号(1~5);主线程中还创建了 5 个写 g_count 变量的线 程,它们使用同一个函数 write_thread,write_thread 函数中会将 g_count 变量的值进行累加,循环 10 次,每次将 g_count 变量的值在原来的基础上增加 20,并将其打印出来,连带打印线程的编号(1~5)。
输出结果:

4.5.3 读写锁的属性

读写锁只有一个属性,那便是进程共享属性,它与互斥锁以及自旋锁的进程共享属性相同。Linux下提供了相应的函数用于设置或获取读写锁的共享属性。
函数 pthread_rwlockattr_setpshared()参数和返回值:
attr:指向 pthread_rwlockattr_t 对象;
pshared:调用 pthread_rwlockattr_setpshared()设置读写锁的共享属性,将其设置为参数 pshared 指定的值。参数 pshared 可取值如下:
  • PTHREAD_PROCESS_SHARED:共享读写锁。该读写锁可以在多个进程中的线程之间共享;
  • PTHREAD_PROCESS_PRIVATE:私有读写锁。只有本进程内的线程才能够使用该读写锁,这是读写锁共享属性的默认值。
返回值:调用成功的情况下返回 0;失败将返回一个非 0 值的错误码。
使用方式如下:

4.6 总结

本章介绍了线程同步的几种不同的方法,包括互斥锁、条件变量、自旋锁以及读写锁,当然,除此之外,线程同步的方法其实还有很多,譬如信号量、屏障等等,如果大家有兴趣可以自己查阅相关书籍进行学习。 在实际应用开发当中,用的最多的还是互斥锁和条件变量,当然具体使用哪一种线程同步方法还是得根据场景来进行选择,方能达到事半功倍的效果!

引用

  1. 【正点原子】I.MX6U嵌入式Linux C应用编程指南V1.1.pdf · 正点原子IMX6U仓库/正点原子I.MX6U文档 - 码云 - 开源中国
  1. POSIX Threads Programming - LLNL HPC Tutorials
  1. Thread support library - cppreference.com
  1. TinyCThread
  1. TinyCThread: tinycthread.h File Reference ,开源、轻量、对C11线程管理函数兼容性好的C语言线程管理函数库
  1. CPU眼里的:竞争 | 线程切换 | 上下文 - 阿布的视频 - 知乎
 
上一篇
进程和进程间通信
下一篇
多核异构核间通信

Comments
Loading...