Linux多线程【线程互斥】
2025-8-28
| 2025-8-29
Words 13159Read Time 33 min
type
status
date
slug
summary
tags
category
icon
password

Linux多线程【线程互斥】

概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个 线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。

模拟抢票代码

notion image
  • 造成了数据不一致问题,肯定是和多线程并发有关系
  • 一个全局变量进行多线程并发++或者-操作是否安全?–>不安全!
  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • -ticket 操作本身就不是一个原子操作

取出ticket--部分的汇编代码
  • - 操作并不是原子操作,而是对应三条汇编指令:
  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址
要解决以上问题,需要做到三点:
  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
notion image

互斥量的接口

  • 在Ubuntu下查看需要先安装手册

初始化互斥量

notion image
  • 第一个是动态分配(需要手动销毁)
  • 第二个是静态分配(不需要手动销毁)

销毁互斥量

notion image
销毁互斥量需要注意:
  • 使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

互斥量加锁和解锁

notion image
调用 pthread_mutex_lock 时,可能会遇到以下情况:
  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
加锁的本质:是用时间来换取安全
加锁的表现:线程对于临界区代码串执行
加锁原则:尽量的要保证临界区代码,越少越好

改进模拟抢票代码(加锁)

  • 然后就可以使用上面的性质来进行改进模拟抢票代码
notion image
  • 或者使用全局初始化也可以
notion image
  • 如果纯互斥环境,如果锁分配不够合理,容易被其他线程的饥饿问题!
  • 不是说只要有互斥,必有饥饿
  • 适合纯互斥的场景,就用互斥
  • 如果是按照一定的顺序性获取资源,就叫同步

  • 其中在临界区中,线程可以被切换吗? 当然可以
    • 在线程被切出去的时候,是持有锁走的
    • 不在临界区之间,照样没有线程可以进入临界区访问临界资源
  • 对于其他线程来讲,一个线程要么没有锁,要么释放锁。
  • 当线程访问临界资源区的过程,对于其他线程是原子的。

小结

  1. 所以,加锁的范围,粒度一定要小
  1. 任何线程,要进行抢票,都得先申请锁,原则上,不应该有例外
  1. 所有线程申请锁,前提是所有线程都得看到这把锁,锁本身也是共享资源,加锁的过程,必须是原子的!原子性:要么不做,要做就做完,没有中间状态,就是原子性
  1. 如果线程申请锁失败了,我的线程要被阻塞
  1. 如果线程申请成功了,继续向后运行
  1. 如果线程申请锁成功了,执行临界区的代码了,执行临界区的代码是可以被切换的,其他线程无法进入,因为被切换了,但是没有释放,可以放心的执行完毕,没有任何线程能打扰。
结论:所有对于其他线程,要么没有申请锁,要么释放了锁,对于其他线程才有意义

什么是线程互斥,为什么需要互斥:
  • 线程互斥指的是在多个线程间对临界资源进行争抢访问时有可能会造成数据二义,因此通过保证同一时间只有一个线程能够访问临界资源的方式实现线程对临界资源的访问安全性

对锁封装 lockGuard.hpp

lockGuard.hpp
mythread.cc

互斥量实现原理探究

  • 经过上面的例子,已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
notion image
  1. CPU的寄存器只有一套,被所有的线程共享,但是寄存器里面的数据,属于执行流的上下文,属于执行流私有的数据
  1. CPU在执行代码的时候,一定要有对应的执行载体,线程和进程
  1. 数据在内存中没被所有线程共享
结论:把数据从内存移动到CPU寄存器中,本质是把数据从共享变成线程私有!!

  • 竞争锁本质是在谁先把xchgb做完
  • mutex简单理解就是一个0/1的计数器,用于标记资源访问状态:
    • 0表示已经有执行流加锁成功,资源处于不可访问,
    • 1表示未加锁,资源可访问。

Linux线程同步

条件变量

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  • 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情 况就需要用到条件变量。

同步概念与竞态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

初始化

  • cond:要初始化的条件变量
  • attr:NULL
notion image

销毁

等待条件满足

  • cond:要在这个条件变量上等待
  • mutex:互斥量
notion image

唤醒等待

notion image

案例:

notion image
  • 也还可以唤醒多个线程
notion image
为什么 pthread_cond_wait需要互斥量?
  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
notion image
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了
  • 由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作
  • int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex);进入该函数后, 会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

封装一下条件变量

生产者消费者模型

3种关系、2种角色、1个交易场所:
  • 3种关系:生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者(互斥 / 同步)。
  • 2种角色:生产者、消费者(线程承担)。
  • 1个交易场所:内存中特定的一种内存结构。
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
优点:
  1. 支持忙闲不闲
  1. 生产和消费进行解耦
  1. 支持并发
notion image
生产者vs生产者:互斥
消费者和消费者:互斥
生产者和消费者:互斥,同步

基于BlockingQueue的生产者消费者模型

BlockingQueue
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
notion image
根据前面我们自己封装的编写下面代码:

BlockQueue.hpp

Task.hpp

main.cc

POSIX信号量

信号量的本质是一把计数器,那么这把计数器的本质是什么??
  • 来描述资源数目的,把资源是否就绪放在了临界区之外,申请信号量时,其实就间接的已经在做判断了!
  • -P>原子的->申请资源
  • ++V>原子的->归还资源
信号量申请成功了,就一定保证会拥有一部分临界资源吗?
  • 只要信号量申请成功,就一定会获得指定的资源!
  • 申请mutex,只要拿到了锁,就可以获得临界资源,并且不担心被切换。
临界资源可以当成整体,可不可以看成一小部分一小部分呢?
  • 结合场景,一般是可以的
信号量:
  • -P:1->0 —->加锁
  • ++V: 0->1 —->释放锁
  • 这样的叫做二元信号量 == 互斥锁

初始化信号量

notion image
参数:
  • pshared:0表示线程间共享,非零表示进程间共享
  • value:信号量初始值

销毁信号量

notion image

等待信号量

notion image
功能:等待信号量,会将信号量的值减1

发布信号量

notion image
  • 功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。

基于环形队列的生产消费模型(代码验证)

环形队列中的生产者和消费者什么时候会访问同一个位置?
  • 当这两个同时指向同一个位置的时候,只有满or空的时候(互斥and同步)
  • 其它时候都指向的是不同的位置(并发)
notion image
notion image
因此,操作的基本原则:
① 空:消费者不能超过生产者 –>生产者先运行
② 满:生产者不能把消费者套一个圈里,继续再往后写入 –>消费者先运行
  • 谁来保证这个基本原则呢?
    • 信号量来保证。
  • 生产者最关心的是什么资源?
    • 空间
  • 消费者最关心的是什么资源?
    • 数据
  • 怎么保证,不同的线程,访问的是临界资源中不同的区域呢?
    • 通过程序员编码保证

Sem.hpp

RingQueue.hpp

Task.hpp

main.cc

  • 一个线程查看直观一些:
notion image
  • 多个线程一起跑,打印就会显示的很乱
notion image

日志实现(策略者模式)

线程池

一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
线程池的应用场景:
① 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
② 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
③ 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
线程池示例:
  1. 创建固定数量线程池,循环从任务队列中获取任务对象
  1. 获取到任务对象后,执行任务对象中的任务接口

ThreadPool.hpp

Thread.hpp

lockGuard.hpp

Logger.hpp

Cond.hpp

Task.hpp

main.cc

测试

notion image

基于单例模式(懒汉模式)来创建的线程池

  1. 在创建线程池的时候需要加锁
  1. 双重if判定,避免不必要的锁竞争

ThreadPool.hpp

main.cc

测试

notion image

可重入VS线程安全

概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重 入函数,否则,是不可重入函数。

常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

如果不考虑信号导致一个执行流重复进入函数这种重入情况,线程安全和重入在安全角度不做区分
  • 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
  • 可重入描述的是一个函数是否能被重复进入,表示的是函数的特点

常见锁概念

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配
可以使用std::lock()同时锁定两个互斥锁

避免死锁算法

  • 死锁检测算法
  • 银行家算法

STL、智能指针和线程安全

STL中的容器不是线程安全的
原因:
  • STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响。
  • 而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)。
  • 因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
智能指针是否是线程安全的
  • 对于unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
  • 对于shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证shared_ptr能够高效, 原子的操作引用计数。

常见的锁

  • 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
  • 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
  • CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
  • 挂起等待锁:当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁。当临界区运行的时间较长时,我们一般使用挂起等待锁。我们先让线程PCB加入到等待队列中等待,等锁被释放时,再重新申请锁。
之前所学的互斥锁就是挂起等待锁
  • 自旋锁:当某个线程没有申请到锁的时候,该线程不会被挂起,而是每隔一段时间检测锁是否被释放。如果锁被释放了,那就竞争锁;如果没有释放,过一会儿再来检测。如果这里使用挂起等待锁,可能线程刚加入等待队列,锁就被释放了,因此,当临界区运行的时间较短时,我们一般使用自旋锁
  • 自旋锁只需要把mutex变成spin

读者写者问题

在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁
notion image
  • 3种关系:写者和写者(互斥),读者和读者(没有关系),读者和写者(互斥关系)
  • 2种角色:读者、写者
  • 1个交易场所:读写场所

  • 读者写者 vs 生产者消费者
本质区别:消费者会把数据拿走,而读者不会

初始化:
销毁:
加锁和解锁:
  • 设置读写优先:
    • 分为读者优先和写者优先。
  • 读者写者进行操作的时候,读者非常多,频率特别高,写者比较少,频率不高
  • 存在写者饥饿的情况,但是很少出现

代码实现的较为简单的读者写者问题

读者:
read操作:
写者:
write操作:

读者优先(Reader-Preference)

在这种策略中,系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据),而不会优先考虑写者。这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限),特别是当读者频繁到达时。

写者优先(Writer-Preference)

在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿(即读者长时间无法获得读取权限),特别是当写者频繁到达时。

自旋锁

自旋锁是一种多线程同步机制,用于保护共享资源免受并发访问的影响。在多个线程尝试获取锁时,它们会持续自旋(即在一个循环中不断检查锁是否可用)而不是立即进入休眠状态等待锁的释放。这种机制减少了线程切换的开销,适用于短时间内锁的竞争情况。但是不合理的使用,可能会造成CPU的浪费。
原理:
自旋锁通常使用一个共享的标志位(如一个布尔值)来表示锁的状态。当标志位为true时,表示锁已被某个线程占用;当标志位为false时,表示锁可用。当一个线程尝试获取自旋锁时,它会不断检查标志位:
  • 如果标志位为false,表示锁可用,线程将设置标志位为true,表示自己占用了锁,并进入临界区。
  • 如果标志位为true(即锁已被其他线程占用),线程会在一个循环中不断自旋等待,直到锁被释放。
自旋锁实现伪代码:
样例代码:
在使用自旋锁时,需要确保锁被释放的时间尽可能短,以避免CPU资源的浪费。
在多CPU环境下,自旋锁可能不如其他锁机制高效,因为它可能导致线程在不同的CPU上自旋等待。
所以自旋锁是一种适用于短时间内锁竞争情况的同步机制,它通过减少线程切换的开销来提高锁操作的效率。然而,它也存在CPU资源浪费和可能引起活锁等缺点。在使用自旋锁时,需要根据具体的应用场景进行选择,并确保锁被释放的时间尽可能短。
  • Linux
  • Linux多线程【线程控制】Linux基于责任链模式实现消息队列
    Loading...