12. 线程同步与互斥

1. 线程互斥

1.1 相关背景概念

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

 线程是共享地址空间的-》线程会共享大部分资源-》公共资源-》导致各种情况的数据不一致问题-》为了解决这些问题:同步和互斥

1.2 互斥量mutex

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

⼀次执⾏结果:
thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

为什么会得到负数??
1. if 语句判断条件为真以后,代码可以并发的切换到其他线程
2. usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码
段
3. --ticket 操作本⾝就不是⼀个原⼦操作
-- 操作并不是原⼦操作,⽽是对应三条汇编指令:
1. load :将共享变量ticket从内存加载到寄存器中
2. update : 更新寄存器⾥⾯的值,执⾏-1操作
3. store :将新值,从寄存器写回共享变量ticket的内存地址

要解决以上问题,需要做到三点:
1. 代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。
2. 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程
进⼊该临界区。
3. 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。

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

1.2.2互斥量相关接口 

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

⽅法1,静态分配:
//这里pthread_mutex_t就像int,char...是个类型
//不需要被释放,程序运行结束,会自动释放
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 初始化的互斥量不需要销毁
• 不要销毁⼀个已经加锁的互斥量
• 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
 int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量加锁和解锁:

竞争申请锁,多线程都得先看到锁,锁本身就是临界资源!
申请锁的过程,必须是原子的!!!
1. int pthread_mutex_lock(pthread_mutex_t *mutex);
2. int pthread_mutex_unlock(pthread_mutex_t *mutex);
3. 返回值:成功返回0,失败返回错误号

成功:继续向后运行,访问临界区代码,访问临界资源
失败:阻塞挂起申请执行流

//非阻塞版本
int pthread_mutex_trylock(pthread_mutex_t *mutex);

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

• 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
• 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到
互斥量,那么pthread_ lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁

1.3 互斥量实现原理探究

锁提供的能力的本质:执行临界区代码由并行转换成串行;在我执行期间,不会被打扰,也是一种变相的原子性的表现!

 

 在多线程中只有一个 ‘1’ 充当这把锁

1.4 互斥量的封装

2. 线程同步

2.1 条件变量

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

2.2 同步概念与静态条件

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

2.3 条件变量函数

初始化

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
*restrict attr);
参数:
    cond:要初始化的条件变量
    attr:NULL

pthread_cond_t  实现 ** 条件变量(Condition Variable)** 的类型

销毁

int pthread_cond_destroy(pthread_cond_t *cond)

等待条件满足

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
mutex);
参数:
    cond:要在这个条件变量上等待
    mutex:互斥量,后⾯详细解释

唤醒等待

唤醒在指定条件变量下等待的所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);

唤醒在该条件变量下的一个线程
int pthread_cond_signal(pthread_cond_t *cond);

2.4 生产者消费者模型

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

2.4.2 模型优点

1. 生产过程和消费过程解耦
2. 支持忙闲不均
3. 提高效率

3不是体现在入交易场所和出交易场所上
而在于未来获取任务和处理具体任务,是并发的!!!
阻塞队列是一个容量具有上限的队列
不满足读写条件的时候
就要进行阻塞对应的线程

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

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

2.6 为什么 pthread_cond_wait 需要互斥量

• 条件等待是线程间同步的⼀种⼿段,如果只有⼀个线程,条件不满⾜,⼀直等下去都不会满⾜,
所以必须要有⼀个线程通过某些操作,改变共享变量,使原先不满⾜的条件变得满⾜,并且友好
的通知等待在条件变量上的线程。
• 条件不会⽆缘⽆故的突然变得满⾜了,必然会牵扯到共享数据的变化。所以⼀定要⽤互斥锁来保
护。没有互斥锁就⽆法安全的获取和修改共享数据。

 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
    pthread_mutex_unlock(&mutex);
    解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过
    pthread_cond_wait(&cond);
    pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
• 由于解锁和等待不是原⼦操作。调⽤解锁之后, 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,把互斥量恢复成原样。

2.7 条件变量使用规范

等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
    pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

2.8 条件变量的封装

  

2.9 POSIX信号量(预定机制)

2.9.1 相关接口

POSIX信号量和SystemV信号量作⽤相同,都是⽤于同步操作,达到⽆冲突的访问共享资源⽬的。但POSIX可以⽤于线程间同步。
初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
    pshared:0表⽰线程间共享,⾮零表⽰进程间共享
    value:信号量初始值
销毁信号量
int sem_destroy(sem_t *sem);
等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
上一个代码⽣产者-消费者的例⼦是基于queue的,其空间可以动态分配,现在基于固定⼤⼩的环形队列重写这个程序(POSIX信号量):

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

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

 环形结构起始状态和结束状态都是⼀样的,不好判断为空或者为满,所以可以通过加计数器或者 标记位来判断满或者空。另外也可以预留⼀个空的位置,作为满的状态

 进一步理解信号量:

信号量把对临界资源是否存?就绪?等的条件,以原子性的形式,呈现在访问临界资源之前就判断了!!

唤醒队列,固定大小,我们自己用自己的下标访问的

如果资源可以拆分(多个线程同时访问资源的不同部分),可以考虑sem

如果资源是整体使用的(对一个共享的结构体变量进行读写操作),就使用mutex

2.9.3 封装代码

Mutex.hpp

Sem.hpp 
 RingQueue.hpp

Main.cc

3. 线程池

准备工作:

准备线程的封装
准备锁和条件变量的封装
引⼊⽇志,对线程进⾏封装

3.1 日志与策略模式

针对⼀些经典的常⻅的场景, 给定了⼀些对应的解决⽅案, 这个就是 设计模式
⽇志认识
计算机中的⽇志是记录系统和软件运⾏中发⽣事件的⽂件,主要作⽤是监控运⾏状态、记录异常信
息,帮助快速定位问题并⽀持程序员进⾏问题修复。它是系统维护、故障排查和安全管理的重要⼯
⽇志格式以下⼏个指标是必须得有的
• 时间戳
• ⽇志等级
• ⽇志内容
以下⼏个指标是可选的
• ⽂件名⾏号
• 进程,线程相关id信息等
⽇志有现成的解决⽅案,如:spdlog、glog、Boost.Log、Log4cxx等等,我们依旧采⽤⾃定义⽇志的
⽅式。
这⾥我们采⽤设计模式-策略模式来进⾏⽇志的设计


例:
[可读性很好的时间] [⽇志等级] [进程pid] [打印对应⽇志的⽂件名][⾏号] - 消息内容,⽀持可
变参数
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [17] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [18] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [20] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [21] - hello world
[2024-08-04 12:27:03] [WARNING] [202938] [main.cc] [23] - hello world

3.2 线程池设计

线程池:
⼀种线程使⽤模式。线程过多会带来调度开销,进⽽影响缓存局部性和整体性能。⽽线程池维护着多个线程,等待着监督管理者分配可并发执⾏的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利⽤,还能防⽌过分调度。可⽤线程数量应该取决于可⽤的并发处理器、处理器内核、内存、⽹络sockets等的数量。
线程池的应⽤场景:
• 需要⼤量的线程来完成任务,且完成任务的时间⽐较短。 ⽐如WEB服务器完成⽹⻚请求这样的任
务,使⽤线程池技术是⾮常合适的。因为单个任务⼩,⽽任务数量巨⼤,你可以想象⼀个热⻔⽹站
的点击次数。 但对于⻓时间的任务,⽐如⼀个Telnet连接请求,线程池的优点就不明显了。因为
Telnet会话时间⽐线程的创建时间⼤多了。

• 对性能要求苛刻的应⽤,⽐如要求服务器迅速响应客⼾请求。
• 接受突发性的⼤量请求,但不⾄于使服务器因此产⽣⼤量线程的应⽤。突发性⼤量客⼾请求,在没
有线程池情况下,将产⽣⼤量线程,虽然理论上⼤部分操作系统线程数⽬最⼤值不是问题,短时间
内产⽣⼤量线程可能使内存到达极限,出现错误
线程池的种类
a. 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执⾏任务对象中
的任务接⼝
b. 浮动线程池,其他同上

3.3 线程安全的单例模式

单例模式是一种创建型设计模式,其核心思想是确保一个类在程序中只有一个实例,并提供一个全局访问点来访问这个实例

 3-3-2 饿汉实现方式和懒汉实现方式

1.吃完饭, ⽴刻洗碗, 这种就是饿汉⽅式. 因为下⼀顿吃的时候可以⽴刻拿着碗就能吃饭.
2.吃完饭, 先把碗放下, 然后下⼀顿饭⽤到这个碗了再洗碗, 就是懒汉⽅式.

懒汉⽅式最核⼼的思想是 "延时加载". 从⽽能够优化服务器的启动速度.

 3-3-3 饿汉实现方式

template <typename T>
class Singleton {
    static T data;
public:
    static T* GetInstance() {
    return &data;
    }
};

只要通过 Singleton 这个包装类来使⽤ T 对象, 则⼀个进程中只有⼀个 T 对象的实例

 3-3-4 懒汉实现方式

template <typename T>
class Singleton {
    static T* inst;
public:
    static T* GetInstance() {
    if (inst == NULL) {
        inst = new T();
    }
    return inst;
    }
}
存在⼀个严重的问题, 线程不安全.
第⼀次调⽤ GetInstance 的时候, 如果两个线程同时调⽤, 可能会创建出两份 T 对象的实例.
但是后续再次调⽤, 就没有问题了

  3-3-4 懒汉方式实现单例模式(线程安全版本)

// 懒汉模式, 线程安全
template <typename T>
class Singleton {
    volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
    static std::mutex lock;
public:
    static T* GetInstance() {
    if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提⾼性能.
    lock.lock(); // 使⽤互斥锁, 保证多线程情况下也只调⽤⼀次 new.
    if (inst == NULL) {
        inst = new T();
        }
        lock.unlock();
    }
    return inst;
    }
};
注意事项:
1. 加锁解锁的位置
2. 双重 if 判定, 避免不必要的锁竞争
3. volatile关键字防⽌过度优化

3.4 单例式线程池

Log.hpp

就是上文刚刚实现的

Thread.hpp

Cond.hpp

Mutex.hpp

*ThreadPool.hpp

Main.cc

注意:

4. 线程安全与重入问题

4.1 概念

线程安全:就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结
果。⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。
重⼊:同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重⼊函数。
多线程重⼊函数
信号导致⼀个执⾏流重复进⼊函数

4.2 结论

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

可重⼊与线程安全区别

可重⼊函数是线程安全函数的⼀种
线程安全不⼀定是可重⼊的,⽽可重⼊函数则⼀定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还
未释放则会产⽣死锁,因此是不可重⼊的。
注意:
• 如果不考虑 信号导致⼀个执⾏流重复进⼊函数 这种重⼊情况,线程安全和重⼊在安全⻆
度不做区分
• 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
• 可重⼊描述的是⼀个函数是否能被重复进⼊,表⽰的是函数的特点

5. 常见锁概念

5.1 死锁

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

 造成的结果是

5.2 死锁四个必要条件

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

5.3 避免死锁

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

5.4 相关算法

死锁检测算法(了解)
银⾏家算法(了解)

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

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

不是.
原因是, STL 的设计初衷是将性能挖掘到极致, ⽽⼀旦涉及到加锁保证线程安全, 会对性能造成巨⼤的影响.
⽽且对于不同的容器, 加锁⽅式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使⽤, 往往需要调⽤者⾃⾏保证线程安全

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

对于 unique_ptr, 由于只是在当前代码块范围内⽣效, 因此不涉及线程安全问题. 对于 shared_ptr, 多个对象需要共⽤⼀个引⽤计数变量, 所以会存在线程安全问题. 但是标准库实现的时 候考虑到了这个问题, 基于原⼦操作(CAS)的⽅式保证 shared_ptr 能够⾼效, 原⼦的操作引⽤计数.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值