一、前置知识
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 死锁
死锁: 是指两个或多个执行单元(线程、进程、事务等)因争夺资源而造成的一种永久阻塞状态,每个单元都在等待另一个单元释放其所持有的资源,且没有任何外部干预能打破这种僵局。