一、前置知识
1.1 共享资源
共享资源:在多线程或多进程环境中(即多个执行流),共同访问的数据或设备资源。
共享资源本身不是问题,问题出在多个执行流同时、无协调地访问它时,会产生数据不一致、结果错误等并发问题。
举例说明: 1.多个线程同时读写同一个全局变量 int counter = 0; 2.多个线程同时访问同一个文件 3.多个线程同时向同一块内存区域写入数据 4.多个线程竞争同一台打印机
1.2 临界资源
临界资源:在多线程执行流中,需要被保护的共享资源。
关键点在于"需要被保护",并非所有共享资源都是临界资源,只有那些一旦被并发访问就会出错的资源才是临界资源。
举例说明: int g_count = 0; // 全局计数器,多线程共享 // 线程A g_count++; // 线程B g_count++;
1.3 临界区
临界区:每个线程内部,访问临界资源的那段代码。
注意:临界资源是"资源",临界区是"代码",临界区是保护临界资源的代码区域。
// 线程函数 void* thread_func(void* arg) { printf("进入线程\n"); // 非临界区,随便执行 // ---- 临界区开始 ---- int temp = g_count; // 读取临界资源 temp = temp + 1; // 修改 g_count = temp; // 写回临界资源 // ---- 临界区结束 ---- printf("退出线程\n"); // 非临界区,随便执行 return NULL; }1.4 原子性
原子性: 指一个操作不会被任何调度机制打断,该操作只有两种状态——要么完成,要么未完成,不存在中间状态。
代码示例:需要原子性的原因。
// 线程函数 void* thread_func(void* arg) { printf("进入线程\n"); // 非临界区,随便执行 // ---- 临界区开始 ---- g_count++; // ---- 临界区结束 ---- printf("退出线程\n"); // 非临界区,随便执行 return NULL; }对于 g_count++ 这一行代码在CPU层面实际上是三条指令:
1. 从内存读取 g_count 到寄存器(Load) 2. 寄存器中的值加一(Add) 3. 将结果写回内存中的 g_count(Store)
非原子操作带来的问题:
如果线程A执行完第1步后被调度走,线程B完整地执行了三步,然后线程A继续执行第2、3步,就会丢失线程B的修改。
二、线程互斥
2.1 互斥的引入
互斥: 在任何时刻,互斥机制保证有且只有一个执行流进入临界区访问临界资源,从而对临界资源起到保护作用。
类比理解:假设一个房间有ATM机,有且只能一个人进入该房间。
一个人进去后锁上房间门 ↓ 其他人只能在门外等待 ↓ 里面的人出来并开门 ↓ 下一个等待者才能进入
2.2 场景分析
2.2.1 场景引入:多线程进行抢票
对于一个共享资源
ticket,如果没有对共享资源进行保护,当多个线程需要修改它时会导致该共享资源的数据混乱。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <pthread.h> int ticket = 10; void *route(void *arg) { char *id = (char *)arg; while (1) { if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; } else { break; } } return NULL; } int main() { pthread_t t1, t2, t3, t4; pthread_create(&t1, NULL, route, (void *)"thread 1"); pthread_create(&t2, NULL, route, (void *)"thread 2"); pthread_create(&t3, NULL, route, (void *)"thread 3"); pthread_create(&t4, NULL, route, (void *)"thread 4"); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); }输出结果:
thread 1 sells ticket:10 thread 3 sells ticket:10 ← 同一张票被卖了两次 thread 2 sells ticket:9 thread 4 sells ticket:9 ← 又重复了 thread 1 sells ticket:8 ... thread 3 sells ticket:1 thread 2 sells ticket:0 ← 非法:0号票不存在 thread 4 sells ticket:-1 ← 非法:超卖
2.2.2 核心并发bug:临界区未保护
临界区未进行保护:从if (ticket > 0)到ticket--这整段代码就是临界区,ticket就是临界资源。
if (ticket > 0) // 步骤A:检查 { usleep(1000); // 步骤B:故意延时,放大问题 printf("%s sells ticket:%d\n", id, ticket); // 步骤C:读取 ticket--; // 步骤D:修改 }A. 错误类型一:重复售卖同一张票
时刻T1: thread1 执行 if(ticket>0),此时ticket=10,条件成立 时刻T2:thread1 打印 "thread 1 sells ticket:10" 时刻T3: thread1 被调度走(usleep让出CPU) 时刻T4: thread2 执行 if(ticket>0),此时ticket仍=10,条件也成立 时刻T5: thread2 打印 "thread 2 sells ticket:10",然后 ticket-- → ticket=9 时刻T5: thread1 恢复,然后 ticket-- → ticket=8
B. 错误类型二:超卖 (ticket变为负数)
时刻T1: thread1 执行 if(ticket>0),此时ticket=1,条件成立 时刻T2: thread1 被调度走(usleep让出CPU) 时刻T3: thread2 执行 if(ticket>0),此时ticket仍=1,条件也成立 时刻T4: thread2 打印 "thread 2 sells ticket:1",然后 ticket-- → ticket=0 时刻T5: thread1 恢复,打印 "thread 1 sells ticket:0",然后 ticket-- → ticket=-1
C. 错误类型三:数据竞争(Data Race)
ticket-- 本身不是原子操作(读-改-写三步),多个线程同时对同一个内存地址执行非原子写操作
2.2.3 剖析非原子写操作: ticket--
对于这一行代码ticket--而言,在CPU执行时分为三条指令:
LOAD R1, [ticket] ; // 第1条:从内存读取 ticket 的值到寄存器 R1 SUB R1, R1, 1 ; // 第2条:寄存器 R1 中的值减 1 STORE [ticket], R1 ; // 第3条:将 R1 的新值写回内存中的 ticket 1. 从内存读取 ticket 到寄存器(Load) 2. 寄存器中的值减一(Sub) 3. 将结果写回内存中的 ticket(Store)
CPU的调度器可以在这三条指令之间的任意间隙打断当前线程,切换到另一个线程,从而导致了线程读到了旧值:
切换点1:线程A执行完LOAD后被切走
切换点2:线程A执行完SUB后被切走
切换点3:线程A执行完STORE后被切走
A. 切换点1:线程A执行完LOAD后被切走
线程A: LOAD R1, [ticket] → R1 = 5 *** 被切走 *** 线程B: LOAD R1, [ticket] → R1 = 5 SUB R1, R1, 1 → R1 = 4 STORE [ticket], R1 → 内存中 ticket = 4 *** 被切走 *** 线程A: SUB R1, R1, 1 → R1 = 4 (注意:R1仍是旧值5,每个线程有独立的上下文,即有独立的寄存器) STORE [ticket], R1 → 内存中 ticket = 4
结果:ticket 从 5 变成了 4,而不是预期的 3,两个线程各卖了一张票,但只扣了一次,丢失了一次更新。
B. 切换点2:线程A执行完SUB后被切走
线程A: LOAD R1, [ticket] → R1 = 5 SUB R1, R1, 1 → R1 = 4 *** 被切走 *** 线程B: LOAD R1, [ticket] → R1 = 5 (内存中还是5!) SUB R1, R1, 1 → R1 = 4 STORE [ticket], R1 → 内存中 ticket = 4 *** 被切走 *** 线程A: STORE [ticket], R1 → 内存中 ticket = 4
结果:同样是 ticket=4 而非 3。本质原因相同——线程B在线程A写回之前读取了旧值。
C. 切换点3:线程A执行完STORE后被切走
线程A: LOAD R1, [ticket] → R1 = 5 SUB R1, R1, 1 → R1 = 4 STORE [ticket], R1 → 内存中 ticket = 4 *** 被切走 *** 线程B: LOAD R1, [ticket] → R1 = 4 (读到的是正确的新值) SUB R1, R1, 1 → R1 = 3 STORE [ticket], R1 → 内存中 ticket = 3
结果:ticket=3,正确, 因为线程A完整执行了三条指令后才被切走,线程B读到的是已经更新的值。
2.2.4 解决非原子操作
A. 互斥性
本质理解:当代码进入临界区执行时,不允许其他线程进入该临界区。
B. 空闲让进
本质理解:如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
不能浪费机会:临界区空闲时,如果有线程想进入,就必须让其中一个进去,不能让临界区空着而所有申请者都在外面干等。
只能放一个进去:即使有多个线程同时申请,也只能选择其中一个放行,不能因为"大家都等了很久"就同时放进去多个。
C. 无关不阻
本质理解:如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
一个线程如果当前不在临界区内(包括从未进入、已经离开、或者正在执行非临界区代码),它对临界区的访问权就不应该对其他线程产生任何影响,
简而言之:不在临界区里的线程,无论它处于什么状态(运行、阻塞、挂起、甚至崩溃),都不应该成为其他线程进入临界区的障碍
2.3 互斥锁接口的认识
关于锁相关的接口 //全局变量 pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER; //初始化互斥锁 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr); //加锁 int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex); //解锁 int pthread_mutex_unlock(pthread_mutex_t *mutex); //回收互斥锁 int pthread_mutex_destroy(pthread_mutex_t *mutex);
2.4.1 互斥锁的类型声明
pthread_mutex_t:是 POSIX 线程库中定义互斥锁的类型,本质是一个结构体(或联合体)。
内部封装了锁的状态、持有者、等待队列等信息。
//互斥锁的类型 pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER;
初始化有两种方式:
1. 静态初始化:PTHREAD_MUTEX_INITIALIZER 适用场景: 全局变量、静态变量(编译期确定地址) 2. 动态初始化:pthread_mutex_init() 适用场景:局部变量、堆上分配的锁(运行期创建)
2.4.2 初始化互斥锁
//初始化互斥锁 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
2.4.3 加锁
//加锁 int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex);
2.4.4 解锁
//解锁 int pthread_mutex_unlock(pthread_mutex_t *mutex);
2.4.5 回收互斥锁
//回收互斥锁 int pthread_mutex_destroy(pthread_mutex_t *mutex);
2.4 互斥锁的原理
2.5 Demo演示:互斥量的封装
#pragma once #include <iostream> #include <pthread.h> namespace MutexModule { class Mutex { public: Mutex() { pthread_mutex_init(&_mutex, nullptr); } Mutex(const Mutex &) = delete; Mutex &operator=(const Mutex &) = delete; void lock() { pthread_mutex_lock(&_mutex); } void unlock() { pthread_mutex_unlock(&_mutex); } // 暴露底层句柄,供条件变量等需要原生 pthread_mutex_t* 的场景使用 pthread_mutex_t *native_handle() { return &_mutex; } ~Mutex() { pthread_mutex_destroy(&_mutex); } private: pthread_mutex_t _mutex; }; class LockGuard { public: LockGuard(Mutex &mutex) : _mutex(mutex) { _mutex.lock(); } LockGuard(const LockGuard &) = delete; LockGuard &operator=(const LockGuard &) = delete; ~LockGuard() { _mutex.unlock(); } private: Mutex &_mutex; }; }三、线程同步
3.1 同步的引入
3.2 条件变量接口的认识
#include <pthread.h> pthread_cond_t cond = PTHREAD_COND_INITIALIZER; //初始化条件变量 int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr); //唤醒条件变量 int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond); //等待条件变量 int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime); //回收条件变量 int pthread_cond_destroy(pthread_cond_t *cond);
3.3 生产者和消费者模型 -基于条件变量实现
#pragma once #include <iostream> #include <pthread.h> #include <string> #include <vector> #include <queue> #include <unistd.h> #include "myCond.hpp" #include "myMutex.hpp" namespace CvQueueModule { const int defaultcap = 5; // 该容量仅用于测试 template <typename T> class CvQueue { private: bool IsFull() { return (_p_step + 1) % _cap == _c_step; } bool IsEmpty() { return _p_step == _c_step; } public: CvQueue(int capacity = defaultcap) : _cap(capacity), _cv_q(capacity + 1), _p_step(0), _c_step(0) {} void Enqueue(const T &in) { // 获取互斥锁,进入临界区。 // pthread_mutex_lock(&_lock); { MutexModule::LockGuard lockguard(_mutex); // 为什么使用while,而不使用if条件判断? // 1.消费者线程可能被虚假唤醒(Spurious Wakeup),而导致生成数据时出现越界 // 2.多个消费者线程都被唤醒 或 连续被唤醒,也会导致生成数据时出现越界 while (IsFull()) { // 生产者线程等待队列 // 1.pthread_cond_wait调用成功,释放锁并睡眠,等待"缓冲区有空位"的通知进行唤醒。 // 2.当线程被唤醒时,默认在临界区内唤醒,需要pthread_cond_wait再次申请锁 // 3.若当前线程被唤醒时,但是申请锁失败,此时会在锁上阻塞等待。 // pthread_cond_wait(&_full_cond, &_lock); _full_cond.Wait(_mutex); } // 向缓冲区队列中添加数据 _cv_q[_p_step] = in; _p_step = (_p_step + 1) % _cap; // 运行到此,"缓冲区可能有数据" → "缓冲区含有数据",向消费者进行通知 // 1.消费者在 _empty_cond 的阻塞队列中,唤醒消费者 // 2.消费者不在_empty_cond 的阻塞队列中,signal 直接返回,通知丢失(条件变量不存储状态)。 // pthread_cond_signal(&_empty_cond); _empty_cond.Signal(); // 唤醒消费者 } //释放互斥锁 } T Pop() { T out; { // 获取互斥锁,进入临界区。 // pthread_mutex_lock(&_lock); MutexModule::LockGuard lockguard(_mutex); while (IsEmpty()) { // 消费者线程等待队列 // 1.pthread_cond_wait调用成功,释放锁并睡眠,等待"缓冲区是否有数据"的通知进行唤醒。 // 2.当线程被唤醒时,默认在临界区内唤醒,需要pthread_cond_wait再次申请锁 // 3.若当前线程被唤醒时,但是申请锁失败,此时会在锁上进行阻塞等待。 // pthread_cond_wait(&_empty_cond, &_lock); _empty_cond.Wait(_mutex); } // 向缓冲区队列中获取数据 out = _cv_q[_c_step]; _c_step = (_c_step + 1) % _cap; // 运行到此,"缓冲区数据可能满了" → "缓冲区数据一定不满",向生产者进行通知 // 1.生产者在 _full_cond 的阻塞队列中,唤醒消费者 // 2.生产者不在_full_cond 的阻塞队列中,signal 直接返回,通知丢失(条件变量不存储状态)。 // pthread_cond_signal(&_full_cond); _full_cond.Signal(); }//释放互斥锁 return out; } private: std::vector<T> _cv_q; // 缓冲区 int _cap; // 缓冲区的容量大小 int _p_step; // 生产者在缓冲区中的位置 int _c_step; // 消费者在缓冲区中的位置 MutexModule::Mutex _mutex; // 互斥锁 CondModule::Cond _full_cond; // 生产者等待:缓冲区非满 CondModule::Cond _empty_cond; // 消费者等待:缓冲区非空 }; }四、POSIX信号量
4.1 基本概念
信号量(Semaphore)本质上是一个非负整数计数器,用于控制多个执行流对共享资源的访问。
P操作:尝试将信号量值减1。
P操作成功:若当前值大于0,则立即减1并返回;
P操作失败:若值当前值为0,则调用者阻塞等待,直到信号量变为正数。
V操作:将信号量值加1。
若有其他执行流正在等待该信号量,则唤醒其中一个。
4.2 信号量接口的认识
#include <semaphore.h> //初始化信号量 int sem_init(sem_t *, int, unsigned); //回收信号量 int sem_destroy(sem_t *); //P操作:尝试将信号量值减1 int sem_post(sem_t *); //V操作:将信号量值加1。 int sem_wait(sem_t *);
4.2.1 初始化信号量
#include <semaphore.h> int sem_init(sem_t *sem, int pshared, unsigned int value); 参数: pshared:0表示线程间共享,⾮零表示进程间共享 value:信号量初始值
4.2.2 回收信号量
int sem_destroy(sem_t *sem);
4.2.3 等待信号量 --P操作
功能:等待信号量,会将信号量的值减1 int sem_wait(sem_t *sem); //P()
4.2.4 发布信号量 --V操作
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。 int sem_post(sem_t *sem);//V()
4.3 生产者和消费者模型 -基于信号量实现
#pragma once #include "mySem.hpp" #include "myMutex.hpp" #include <vector> namespace SemQueueModule { const int defaultcap = 5; template <typename T> class SemQueue { public: SemQueue(int capacity = defaultcap) : _cap(capacity), _sem_q(capacity), _blank_sem(capacity), _p_step(0), _data_sem(0), _c_step(0) { } void Enqueue(const T &in) { // 生产者 // 1.生产者查看是否有空位 _blank_sem.P(); { MutexModule::LockGuard lockguard(_mutex); // 2.生产资源 _sem_q[_p_step] = in; // 3.更新生产者在环形队列中的位置 _p_step = (_p_step + 1) % _cap; } // 4.通知消费者环形队列中增加了资源 => 环形队列增加了资源 _data_sem.V(); } T Pop() { T out; // 消费者 // 1.查看环形队列中是否有资源 _data_sem.P(); { MutexModule::LockGuard lockguard(_mutex); // 2.获取环形队列中的资源 out = _sem_q[_c_step]; // 3.更新消费者在环形队列中的位置 _c_step = (_c_step + 1) % _cap; } // 4.通知生产者环形队列中释放了资源 => 环形队列增加了空位 _blank_sem.V(); return out; } ~SemQueue() {} private: std::vector<T> _sem_q; // 模拟环形队列 int _cap; // 环形队列的有效长度 // 生产者 SemModule::Sem _blank_sem; // 生产者:环形队列中的空位置 int _p_step; // 生产者:在环形队列中的位置 // 消费者 SemModule::Sem _data_sem; // 消费者: 环形队列中的数据 int _c_step; // 消费者:在环形队列中的位置 MutexModule::Mutex _mutex; //互锁 }; }五、线程安全
5.1 什么是线程安全
5.2 Linux 下常见的线程安全问题
5.2.3 死锁
死锁: 是指两个或多个执行单元(线程、进程、事务等)因争夺资源而造成的一种永久阻塞状态,每个单元都在等待另一个单元释放其所持有的资源,且没有任何外部干预能打破这种僵局。