Linux-线程同步于互斥

发布于:2025-05-24 ⋅ 阅读:(20) ⋅ 点赞:(0)

1.线程互斥

1.1进程线程间的互斥相关背景概念

• 临界资源:多线程执⾏流共享的资源就叫做临界资源

• 临界区:每个线程内部,访问临界资源的代码,就叫做临界区

• 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起 保护作⽤

• 原⼦性:不会被任何调度机制打断的操作,该操作只有两态,要么完成, 要么未完成

1.2互斥量

• ⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量 归属单个线程,其他线程⽆法获得这种变量。

• 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完 成线程之间的交互。

• 多个线程并发的操作共享变量,会带来⼀些问题。

为什么可能⽆法获得争取结果?

• if 语句判断条件为真以后,代码可以并发的切换到其他线程

• usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码 段

• --ticket 操作本⾝就不是⼀个原⼦操作

-- 操作并不是原⼦操作,⽽是对应三条汇编指令:

• load :将共享变量ticket从内存加载到寄存器中

• update :更新寄存器⾥⾯的值,执⾏-1操作

• store :将新值,从寄存器写回共享变量ticket的内存地址

要解决以上问题,需要做到三点:

• 代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。

• 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程 进⼊该临界区。

• 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。

要做到这三点,本质上就是需要⼀把锁。Linux上提供的这把锁叫互斥量。

互斥量的接口

初始化互斥量

初始化互斥量有两种⽅法:

• ⽅法1,静态分配:

1 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

• ⽅法2,动态分配:

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

参数:mutex:要初始化的互斥量  attr:NULL

销毁互斥量

销毁互斥量需要注意:

• 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁

• 不要销毁⼀个已经加锁的互斥量

• 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁

1 int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

返回值:成功返回0,失败返回错误号

调⽤ pthread_ lock 时,可能会遇到以下情况:

• 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功

• 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到 互斥量,那么pthread_lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。

改进上⾯的售票系统:

1.3互斥量实现原理

• 经过上⾯的例⼦,⼤家已经意识到单纯的 i++ 或者 ++i 都不是原⼦的,有可能会有数据⼀致性 问题

• 为了实现互斥锁操作,⼤多数体系结构都提供了swap或exchange指令,该指令的作⽤是把寄存器和 内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,即使是多处理器平台,访问内存的总线周 期也有先后,⼀个处理器上的交换指令执⾏时另⼀个处理器的交换指令只能等待总线周期。

现在 我们把lock和unlock的伪代码改⼀下

1.4互斥量的封装

                                         

RAII⻛格的互斥锁,C++11也有,⽐如: 

std::mutex mtx;

std::lock_guard<std::mutex> guard(mtx);

2.线程同步

2.1条件变量

• 当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

• 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列 中。这种情况就需要⽤到条件变量。

2.2同步概念与竞态条件

• 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从⽽有效避免 饥饿问题,叫做同步

• 竞态条件:因为时序问题,⽽导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也 不难理解

2.3条件变量函数

简单案例:

• 我们先使⽤PTHREAD_COND/MUTEX_INITIALIZER进⾏测试,对其他细节暂不追究

• 然后将接⼝更改成为使⽤ pthread_cond_init/pthread_cond_destroy 的⽅式,⽅便后 续进⾏封装

2.4生产者消费者模型

⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。⽣产者和消费者彼此之间 不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔 给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取,阻塞队列就相当于⼀个缓冲区, 平衡了⽣产者和消费者的处理能⼒。这个阻塞队列就是⽤来给⽣产者和消费者解耦的。

2.4.1生产者消费者模型优点

• 解耦

• ⽀持并发

• ⽀持忙闲不均

2.5基于BlockingQueue的⽣产者消费者模型        

在多线程编程中阻塞队列(Blocking Queue)是⼀种常⽤于实现⽣产者和消费者模型的数据结构。其与 普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放⼊了元 素;当队列满时,往队列⾥存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是 基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

2.5.1C++ queue模拟阻塞队列的⽣产消费模型

我们先写单⽣产,单消费。然后改成多⽣产,多消费(这⾥代码其实不变)。

  注意:这⾥采⽤模版,是想告诉我们,队列中不仅仅可以防⽌内置类型,⽐如int,对象也可 以作为任务来参与⽣产消费的过程         

2.6为什么pthread_cond_wait需要互斥量   

• 条件等待是线程间同步的⼀种⼿段,如果只有⼀个线程,条件不满⾜,⼀直等下去都不会满⾜, 所以必须要有⼀个线程通过某些操作,改变共享变量,使原先不满⾜的条件变得满⾜,并且友好 的通知等待在条件变量上的线程。 

• 条件不会⽆缘⽆故的突然变得满⾜了,必然会牵扯到共享数据的变化。所以⼀定要⽤互斥锁来保 护。没有互斥锁就⽆法安全的获取和修改共享数据。

2.7条件变量使用规范

2.8条件变量的封装

为了让条件变量更具有通⽤性,建议封装的时候,不要在Cond类内部引⽤对应的封装互斥 量,要不然后⾯组合的时候,会因为代码耦合的问题难以初始化,因为⼀般⽽⾔Mutex和 Cond基本是⼀起创建的。

2.9POSIX信号量

POSIX信号量和SystemV信号量作⽤相同,都是⽤于同步操作,达到⽆冲突的访问共享资源⽬的。但 POSIX可以⽤于线程间同步。

上⼀节⽣产者-消费者的例⼦是基于queue的,其空间可以动态分配,现在基于固定⼤⼩的环形队列重写这 个程序(POSIX信号量):

2.10基于环形队列的生产者消费者模型

• 环形队列采⽤数组模拟,⽤模运算来模拟环状特性

3.线程池

下⾯开始,我们结合我们之前所做的所有封装,进⾏⼀个线程池的设计。在写之前,我们要做如下准 备

• 准备线程的封装

• 准备锁和条件变量的封装

• 引⼊⽇志,对线程进⾏封装

3.1日志于策略模式

什么是设计模式?

IT⾏业这么⽕,涌⼊的⼈很多.俗话说林⼦⼤了啥⻦都有.⼤佬和菜鸡们两极分化的越来越严重.为了让 菜鸡们不太拖⼤佬的后腿,于是⼤佬们针对⼀些经典的常⻅的场景,给定了⼀些对应的解决⽅案,这个就 是设计模式。

⽇志认识

计算机中的⽇志是记录系统和软件运⾏中发⽣事件的⽂件,主要作⽤是监控运⾏状态、记录异常信 息,帮助快速定位问题并⽀持程序员进⾏问题修复。它是系统维护、故障排查和安全管理的重要⼯ 具

⽇志格式以下⼏个指标是必须得有的

• 时间戳

• ⽇志等级

• ⽇志内容

以下⼏个指标是可选的

• ⽂件名⾏号

• 进程,线程相关id信息等

⽇志有现成的解决⽅案,如:spdlog、glog、Boost.Log、Log4cxx等等,我们依旧采⽤⾃定义⽇志的 ⽅式。

这⾥我们采⽤设计模式-策略模式来进⾏⽇志的设计,具体策略模式介绍,详情看代码和课程。

我们想要的⽇志格式如下:

3.2线程池设计

线程池:

⼀种线程使⽤模式。线程过多会带来调度开销,进⽽影响缓存局部性和整体性能。⽽线程池维护着多 个线程,等待着监督管理者分配可并发执⾏的任务。这避免了在处理短时间任务时创建与销毁线程的 代价。线程池不仅能够保证内核的充分利⽤,还能防⽌过分调度。可⽤线程数量应该取决于可⽤的并 发处理器、处理器内核、内存、⽹络sockets等的数量。

线程池的应⽤场景:

需要⼤量的线程来完成任务,且完成任务的时间⽐较短。⽐如WEB服务器完成⽹⻚请求这样的任 务,使⽤线程池技术是⾮常合适的。因为单个任务⼩,⽽任务数量巨⼤,你可以想象⼀个热⻔⽹站 的点击次数。但对于⻓时间的任务,⽐如⼀个Telnet连接请求,线程池的优点就不明显了。因为 Telnet会话时间⽐线程的创建时间⼤多了。

对性能要求苛刻的应⽤,⽐如要求服务器迅速响应客⼾请求。

接受突发性的⼤量请求,但不⾄于使服务器因此产⽣⼤量线程的应⽤。突发性⼤量客⼾请求,在没 有线程池情况下,将产⽣⼤量线程,虽然理论上⼤部分操作系统线程数⽬最⼤值不是问题,短时间 内产⽣⼤量线程可能使内存到达极限,出现错误.

线程池的种类

a. 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执⾏任务对象中 的任务接⼝

b. 浮动线程池,其他同上

此处,我们选择固定线程个数的线程池。

                                              

 3.3线程安全的单例模式

某些类,只应该具有⼀个对象(实例),就称之为单例.

在很多服务器开发场景中,经常需要让服务器加载很多的数据(上百G)到内存中.此时往往要⽤⼀个单例 的类来管理这些数据.

3.3.1饿汉实现方式和懒汉实现方式

3.3.2饿汉⽅式实现单例模式

3.3.3懒汉⽅式实现单例模式

3.3.3懒汉⽅式实现单例模式(线程安全版本)

注意事项:

1. 加锁解锁的位置

2. 双重if判定,避免不必要的锁竞争

3. volatile关键字防⽌过度优化

3.4单例线程池

4.线程安全和重入问题

概念

线程安全:就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结 果。⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量 或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。

重⼊:同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊, 我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被 称为可重⼊函数,否则,是不可重⼊函数。

学到现在,其实我们已经能理解重⼊其实可以分为两种情况

• 多线程重⼊函数

• 信号导致⼀个执⾏流重复进⼊函数

常⻅的线程不安全的情况

• 不保护共享变量的函数

• 函数状态随着被调⽤,状态发⽣变化的函数

• 返回指向静态变量指针的函数

• 调⽤线程不安全函数的函数

常⻅的线程安全的情况

• 每个线程对全局变量或者静态变量只有读取 的权限,⽽没有写⼊的权限,⼀般来说这些 线程是安全的

• 类或者接⼝对于线程来说都是原⼦操作

• 多个线程之间的切换不会导致该接⼝的执⾏ 结果存在⼆义性

常⻅不可重⼊的情况

• 调⽤了malloc/free函数,因为malloc函数 是⽤全局链表来管理堆的

• 调⽤了标准I/O库函数,标准I/O库的很多实 现都以不可重⼊的⽅式使⽤全局数据结构

• 可重⼊函数体内使⽤了静态的数据结构

常⻅可重⼊的情况

• 不使⽤全局变量或静态变量

• 不使⽤ malloc或者new开辟出的空间

• 不调⽤不可重⼊函数

• 不返回静态或全局数据,所有数据都有函数 的调⽤者提供

• 使⽤本地数据,或者通过制作全局数据的本 地拷⻉来保护全局数据

可重⼊与线程安全联系

• 函数是可重⼊的,那就是线程安全的(其实知道这⼀句话就够了)

• 函数是不可重⼊的,那就不能由多个线程使⽤,有可能引发线程安全问题

• 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重⼊的。

可重⼊与线程安全区别

可重⼊函数是线程安全函数的⼀种

线程安全不⼀定是可重⼊的,⽽可重⼊函数则⼀定是线程安全的。

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

注意:

如果不考虑信号导致⼀个执⾏流重复进⼊函数这种重⼊情况,线程安全和重⼊在安全⻆ 度不做区分

但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点

可重⼊描述的是⼀个函数是否能被重复进⼊,表⽰的是函数的特点

5.常见锁的概念

5.1死锁

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

• 为了⽅便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进⾏后续资源的访问

申请⼀把锁是原⼦的,但是申请两把锁就不⼀定了

5.2死锁的四个必要条件

• 互斥条件:⼀个资源每次只能被⼀个执⾏流使⽤

• 请求与保持条件:⼀个执⾏流因请求资源⽽阻塞时,对已获得的资源保持不放

• 不剥夺条件:⼀个执⾏流已获得的资源,在末使⽤完之前,不能强⾏剥夺

循环等待条件:若⼲执⾏流之间形成⼀种头尾相接的循环等待资源的关系

5.3避免死锁的方法

• 破坏死锁的四个必要条件

破坏循环等待条件问题:资源⼀次性分配,使⽤超时机制、加锁顺序⼀致

5.4避免死锁的算法

• 死锁检测算法(了解)

• 银⾏家算法(了解)

6.STL,智能指针和线程安全

6.1STL中的容器是否是线程安全的?

不是.

原因是,STL的设计初衷是将性能挖掘到极致,⽽⼀旦涉及到加锁保证线程安全,会对性能造成巨⼤的影 响.

⽽且对于不同的容器,加锁⽅式的不同,性能可能也不同(例如hash表的锁表和锁桶). 

因此STL默认不是线程安全.如果需要在多线程环境下使⽤,往往需要调⽤者⾃⾏保证线程安全.

6.2智能指针是否线程安全的?

对于unique_ptr,由于只是在当前代码块范围内⽣效,因此不涉及线程安全问题.

对于shared_ptr,多个对象需要共⽤⼀个引⽤计数变量,所以会存在线程安全问题.但是标准库实现的时 候考虑到了这个问题,基于原⼦操作(CAS)的⽅式保证shared_ptr能够⾼效,原⼦的操作引⽤计数.

7.其他常见的各种锁

• 悲观锁:在每次取数据时,总是担⼼数据会被其他线程修改,所以会在取数据前先加锁(读锁, 写锁,⾏锁等),当其他线程想要访问数据时,被阻塞挂起。

• 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新 数据前,会判断其他数据在更新前有没有对数据进⾏修改。主要采⽤两种⽅式:版本号机制和 CAS操作。

• CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则⽤新值更 新。若不等则失败,失败则重试,⼀般是⼀个⾃旋的过程,即不断重试。

• ⾃旋锁,读写锁


网站公告

今日签到

点亮在社区的每一天
去签到