目录
[1.1 互斥的相关概念](#1.1 互斥的相关概念)
[1.2 互斥的实现方式](#1.2 互斥的实现方式)
[1.2.1 互斥锁](#1.2.1 互斥锁)
[1. 为什么ticket会<=0](#1. 为什么ticket会<=0)
[2. 互斥锁的相关操作](#2. 互斥锁的相关操作)
[2.1 创建锁及初始化](#2.1 创建锁及初始化)
[2.2 加锁与解锁](#2.2 加锁与解锁)
[3. 保证ticket不会<=0](#3. 保证ticket不会<=0)
[4. 互斥锁的原理](#4. 互斥锁的原理)
[5. 互斥锁的封装](#5. 互斥锁的封装)
[2.1 线程同步的概念](#2.1 线程同步的概念)
[2.2 同步的实现方式](#2.2 同步的实现方式)
[2.2.1 条件变量](#2.2.1 条件变量)
[1. 条件变量的相关操作](#1. 条件变量的相关操作)
[1.1 创建条件变量及初始化](#1.1 创建条件变量及初始化)
[1.2 等待与唤醒](#1.2 等待与唤醒)
[2. 条件变量的封装](#2. 条件变量的封装)
[2.2.2 POSIX信号量](#2.2.2 POSIX信号量)
[1. 信号量的相关操作](#1. 信号量的相关操作)
[1.1 创建信号量及初始化](#1.1 创建信号量及初始化)
[1.2 P/V操作](#1.2 P/V操作)
[2. 信号量的封装](#2. 信号量的封装)
[3.1 死锁的定义](#3.1 死锁的定义)
[3.2 死锁产生的四个必要条件](#3.2 死锁产生的四个必要条件)
[3.3 避免死锁](#3.3 避免死锁)
1、线程互斥
1.1 互斥的相关概念
- 临界资源 :需要被保护 的共享资源。
- 临界区 :访问临界资源 的代码。
- 原子性 (不可分割):一个或多个操作要么全部执行成功 ,要么全部不执行 ,中间不会被打断。
- 互斥 解决的是 **"多个线程 / 进程不能同时访问共享资源 "** 的问题,核心是 "排他性"------ 确保同一时间 ,只有一个执行者 (线程 / 进程)能进入访问共享资源的 "临界区"。
1.2 互斥的实现方式
1.2.1 互斥锁
- pthread库提供了互斥锁(互斥量),对于一种整块临界资源,只有一把锁 (唯一),所以只有一个线程会执行临界区的代码 ,保证了执行临界区代码 的原子性。 即临界区的代码要么全部执行成功 (一个持有锁的线程),要么全部不执行 (其他没有申请到锁的线程),中间不会被打断(其他没有申请到锁的线程执行不了临界区的代码)。
- 例如多个线程修改同一计数器(读取->修改->写回)时,互斥锁能避免 "两个线程同时读取旧值、各自修改后覆盖" 的错误,从根本上防止数据竞争,实现线程间的基础协调。
- 注意:互斥锁保护的是代码段(临界区),而非数据本身。任何访问共享资源的代码都必须配合同一个互斥锁使用。
1. 为什么ticket会<=0
cpp
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 100; // 1-100号的票
void* buyTicket(void* arg)
{
char* name = static_cast<char*>(arg);
// 临界区begin
while(ticket > 0)
{
usleep(100); // 睡眠0.1ms
printf("%s buy ticket:%d\n",name,ticket);
--ticket;
}
// 临界区end
return nullptr;
}
int main()
{
pthread_t tid1,tid2,tid3;
pthread_create(&tid1,nullptr,buyTicket,(void*)"tid1");
pthread_create(&tid2,nullptr,buyTicket,(void*)"tid2");
pthread_create(&tid3,nullptr,buyTicket,(void*)"tid3");
pthread_join(tid1,nullptr);
pthread_join(tid2,nullptr);
pthread_join(tid3,nullptr);
return 0;
}
- 最后抢1号票的过程如下:
- 注意:
- 如果不写usleep,可能一个线程就直接抢完了。
- 实际上,这是抢票混乱其中的一种情况。
- 在这个场景中,判断也属于临界区,正是因为判断,所以tid2和tid3都能进入循环,抢最后的1号票。
- 因为临界区的代码操作 ,不是原子性 的,所以出现问题 。其实是线程并发而产生的问题 (本质是线程切换),称为线程安全问题。
2. 互斥锁的相关操作
2.1 创建锁及初始化
cpp
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 锁的类型 是pthread_mutex_t。锁有全局锁和局部锁。
- 全局锁 ,要在编译 时完成初始化 ,所以使用PTHREAD_MUTEX_INITIALIZER****初始化 。会自动销毁。
- 局部锁 ,要在运行 时完成初始化 ,所以使用pthread_mutex_init()初始化 ,第二个参数 用于配置锁的属性,一般传nullptr (默认属性)。**必须手动**pthread_mutex_destroy(pthread_mutex_t *mutex);进行销毁。
2.2 加锁与解锁
cpp
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- pthread_mutex_lock(pthread_mutex_t *mutex),申请加锁 。成功 ,继续向后执行 ,失败 ,阻塞并挂起 ,等待持有锁的线程释放锁。pthread_mutex_trylock(pthread_mutex_t *mutex),同上,失败 ,就是非阻塞(本文不考虑)。
- pthread_mutex_unlock(pthread_mutex_t *mutex),解锁。
- 注意:
- 所有的线程都要遵循锁的规则(加锁,解锁)。
- 一般对临界区进行加锁,不要包含太多的非临界区,会影响效率。
- 加锁之后,在临界区内部,即使线程被切换了,但是其他线程申请加锁,会失败,就会被阻塞并挂起。
3. 保证ticket不会<=0
cpp
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 100; // 1-100号的票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *buyTicket(void *arg)
{
char *name = static_cast<char *>(arg);
while (true)
{
pthread_mutex_lock(&lock);
if (ticket > 0)
{
usleep(100); // 模拟抢票耗时,睡眠0.1ms
printf("%s buy ticket:%d\n", name, ticket);
--ticket;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
return nullptr;
}
// 如果这样写,抢到锁后,就一直循环抢票了,其他线程都没有锁,抢不到票
// void* buyTicket(void* arg)
// {
// char* name = static_cast<char*>(arg);
// pthread_mutex_lock(&lock);
// while(ticket > 0)
// {
// usleep(100); // 睡眠0.1ms
// printf("%s buy ticket:%d\n",name,ticket);
// --ticket;
// }
// pthread_mutex_unlock(&lock);
// return nullptr;
// }
// 如果这样写,还是会抢票混乱,关键是抢到锁后要再判断
// void* buyTicket(void* arg)
// {
// char* name = static_cast<char*>(arg);
// while(ticket > 0)
// {
// pthread_mutex_lock(&lock);
// usleep(100); // 睡眠0.1ms
// printf("%s buy ticket:%d\n",name,ticket);
// --ticket;
// pthread_mutex_unlock(&lock);
// }
// return nullptr;
// }
int main()
{
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, buyTicket, (void *)"tid1");
pthread_create(&tid2, nullptr, buyTicket, (void *)"tid2");
pthread_create(&tid3, nullptr, buyTicket, (void *)"tid3");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}

- 注意:关键是抢到锁后要再判断。
- 这里发现1-100号的票都是tid1抢的 ,这并不代表代码有问题,只是多线程调度的正常现象。加锁的目的是保证数据安全,而不是保证线程执行的公平性。如果需要严格的公平性,可以考虑使用条件变量或其他同步机制来实现。
4. 互斥锁的原理
- 疑问:线程要竞争锁,那么锁就是共享资源,而锁是为了保护共享资源的,那么谁来保护锁?由申请加锁的原子性操作保证 ,锁的设计原理保证 了申请加锁 的过程是原子的。
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换 ,于只有一条指令 ,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。如下是lock和unlock的伪代码:
- **流程:**第一个线程lock,%a1 = 0,原子操作swap(%a1,mutex),%a1 = 1,mutex = 0,因为%a1>0,return,执行临界区的代码;后面的线程lock,%a1 = 0,原子操作swap(%a1,mutex),%a1 = 0,mutex = 0,因为%a1<=0,挂起等待;第一个线程unlock,mutex = 1。
- 注意:
- %a1每次lock都会置为0,unlock不需要swap(%a1,mutex),直接mutex = 1。
- 始终只有一个线程的%a1为1(%a1放到线程私有堆栈中 )。1本质就是唯一的一把锁。
5. 互斥锁的封装
cpp
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
pthread_mutex_lock(&_mutex);
}
void Unlock()
{
pthread_mutex_unlock(&_mutex);
}
pthread_mutex_t *Get()
{
return &_mutex;
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
// RAII,资源的初始化与释放与对象的生命周期绑定
class LockGuard
{
public:
LockGuard(Mutex &mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
2、线程同步
2.1 线程同步的概念
- 同步 的核心是解决****"多个进程协作时的'有序性'问题"**,本质** 是**"协调顺序"。**
2.2 同步的实现方式
2.2.1 条件变量
- pthread库提供了条件变量,用于解决 "线程需等待特定条件满足后再执行 " 的复杂场景 ,常与互斥锁配合使用 。其核心逻辑是:当线程检查到目标条件不满足 时(如 "队列已空"),会释放持有的互斥锁 并主动进入等待状态 ;当其他线程修改资源后使条件可能满足 时(如向队列添加数据),可通过条件变量唤醒等待的线程 ,让其重新获取锁并继续执行。
- 例如生产者 - 消费者模型中,消费者线程可通过条件变量等待 "队列有数据",生产者线程在生产数据后唤醒消费者。
1. 条件变量的相关操作
1.1 创建条件变量及初始化
cpp
#include <pthread.h>
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
- 条件变量的类型 是pthread_cond_t。条件变量有全局条件变量和局部条件变量。
- 全局条件变量 ,要在编译 时完成初始化 ,所以使用PTHREAD_COND_INITIALIZER****初始化 。会自动销毁。
- 局部条件变量 ,要在运行 时完成初始化 ,所以使用pthread_cond_init()初始化 ,第二个参数 用于配置条件变量的属性,一般传nullptr (默认属性)。**必须手动**pthread_cond_destroy(pthread_cond_t *cond);进行销毁。
1.2 等待与唤醒
cpp
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
-
pthread_cond_wait(cond, mutex),线程原子 地释放互斥锁 mutex 并等待条件变量 cond 被触发 (即阻塞)。当线程被唤醒时 ,需重新竞争互斥锁 后再返回。
-
pthread_cond_timedwait(cond, mutex, abstime) ,同上,阻塞 ,但带有超时时间,如果在指定时间内条件未触发 ,则返回ETIMEDOUT。
-
pthread_cond_signal(cond),唤醒 至少一个 等待在该条件变量上 的线程。
-
pthread_cond_broadcast(cond),唤醒 所有 等待在该条件变量上 的线程。
-
注意:
- 所有的线程都要遵循条件变量的规则(等待,被唤醒)。
- 流程:是先申请到加锁,然后判断,条件不满足,原子 地释放锁 再等待,当条件满足,被唤醒(可能会错误唤醒 或 唤醒时线程切换,条件再次不满足,所以要while判断)。
2. 条件变量的封装
cpp
#include <pthread.h>
#incude "Mutex.hpp"
namespace CondModule
{
class Cond
{
public:
// RAII,资源的初始化与释放与对象的生命周期绑定
Cond()
{
pthread_cond_init(_cond,nullptr);
}
void Wait(MutexModule::Mutex& mutex)
{
pthread_cond_wait(_cond,mutex.Get());
}
void Signal()
{
pthread_cond_signal(_cond);
}
void Broadcast()
{
pthread_cond_broadcast(_cond);
}
~Cond()
{
pthread_cond_destroy(_cond);
}
private:
pthread_cond_t _cond;
};
}
2.2.2 POSIX信号量
- 信号量 本质是一个计数器 ,是对一种多块临界资源的预定机制。如果只有一块资源,就称二元信号量,满足互斥,锁的底层就是使用二元信号量实现的。
- 既可以实现互斥(一个计数器初始值设为 1,确保同一时间只有一个线程获取资源),也可实现简单的计数器同步(如初始值为0的信号量用于线程间通知)。
- 同步的例子:当信号量值(商品数)为 0,消费者要等待生产者生产,当信号量值(空位数)为 0,生产者要等待消费者消费。
1. 信号量的相关操作
1.1 创建信号量及初始化
cpp
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
- 信号量的类型 是sem_t。
- int sem_init(sem_t *sem, int pshared, unsigned int value),pshared:0 表示线程间共享 ,非零 表示进程间共享 value:信号量初始值。
- int sem_destroy(sem_t *sem),进行销毁。
1.2 P/V操作
cpp
#include <semaphore.h>
int sem_wait(sem_t *sem); // P()
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
int sem_post(sem_t *sem); // V()
- int sem_wait(sem_t *sem),等待信号量,预定资源。如果sem-1 >=0,成功预定 了一块临界资源,如果sem-1 < 0,失败 ,阻塞并挂起。
- int sem_trywait(sem_t *sem),同上,如果失败就不阻塞。
- int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout),同上,阻塞 ,但带有超时时间,如果在指定时间内条件未触发 ,则返回ETIMEDOUT。
- int sem_post(sem_t *sem),增加信号量,释放资源。++sem。
- 注意:
- 为什么是P(--),V(++)操作。发明者,使用荷兰语,Prolaag尝试减少,Verhoog增加
- P/V操作是原子的,所以信号量这个共享资源是线程安全的。
2. 信号量的封装
cpp
#include <semaphore.h>
namespace SemModule
{
const unsigned int default_value = 1;
class Sem
{
public:
// RAII,资源的初始化与释放与对象的生命周期绑定
Sem(unsigned int sem_value = default_value)
: _sem_value(sem_value)
{
sem_init(&_sem, 0, _sem_value);
}
void P()
{
sem_wait(&_sem);
}
void V()
{
sem_post(&_sem);
}
~Sem()
{
sem_destroy(&_sem);
}
private:
unsigned int _sem_value;
sem_t _sem;
};
}
画外音:const unsigned int传给unsigned int 没关系吗?没关系,是数值拷贝,指针和引用才需要担心权限的问题。
3、死锁
3.1 死锁的定义
- 死锁 是指两个或两个以上 的并发执行单元 (线程或进程),在争夺系统资源 时,陷入一种相互等待对方释放资源的状态 ,若无外力干涉 ,这些单元都将陷入一种永久等待状态。
简单比喻:两个人在一座独木桥上迎面相遇。
person A 需要从左边去右边,但被 person B 挡住。
person B 需要从右边去左边,但被 person A 挡住。
两人都坚持不让,于是谁也过不去,形成了僵局。这就是死锁。
3.2 死锁产生的四个必要条件
- 这四个条件 必须同时满足 ,死锁才可能发生 。只要破坏其中任意一个 ,就能预防死锁。
- 互斥 条件 (Mutual Exclusion)
- 含义:资源一次只能被一个执行单元独占使用。如果另一个单元请求该资源,请求者必须等待,直到资源被释放。
- 例子:打印机、一个共享变量的写操作、一个互斥锁(Mutex)。
- 占有并等待 条件 (Hold and Wait)
- 含义:一个执行单元已经持有了至少一个资源 ,但又提出了新的资源请求 ,而该新资源恰好被其他单元占有 ,此时该单元进入等待状态 ,但并不释放自己已持有的资源。
- 例子:线程A锁住了锁L1,然后试图去锁L2;与此同时,线程B锁住了L2,然后试图去锁L1。
- 不可抢占 条件 (No Preemption)
- 含义:资源不能被 强制地从占有它的执行单元手中抢走 。只能 由占有者在使用完毕后主动释放。
- 例子:对于互斥锁,操作系统不能强行从一个线程那里把锁夺过来给另一个线程。线程必须自己调用 unlock()。
- 循环等待 条件 (Circular Wait)
- 含义:存在一个等待资源的循环链 。每个执行单元 都在等待下一个单元所占有的资源。
- 例子:线程A等待线程B占有的资源R2,线程B又在等待线程A占有的资源R1。这样就形成了一个A->B->A的循环等待。
3.3 避免死锁
- 核心思想 :破坏死锁的四个必要条件。
- 我们无法破坏"互斥"(因为锁的本质就是互斥),但可以破坏其他三个条件。
避免方法 | 破坏的条件 | 核心思想 | 优点 | 缺点 |
---|---|---|---|---|
固定顺序加锁 | 循环等待 | 定义全局统一的锁获取顺序 | 简单有效,最常用 | 需要全局规划,有时顺序难定 |
超时与回退 | 占有并等待 | 尝试获取锁,失败则释放已有锁 | 灵活性高,可应对未知顺序 | 实现复杂,可能活锁,性能开销 |
一次性申请 | 占有并等待 | 原子性地获取所有需要的锁 | 从根本上杜绝"持有等待" | 资源利用率低,可能提前占锁 |
锁粒度粗化 | 循环等待 | 减少所需锁的数量 | 简化编程模型,提升性能 | 并发度降低 |
4、线程安全
- STL中的容器 是否是线程安全的? 不是。
- 原因是,STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此STL默认不是线程安全 ,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
- 智能指针 是否是线程安全的? 对于unique_ptr,所有权独占 ,有且只有一个 unique_ptr实例拥有对一个对象的所有权和控制权,通常不跨线程(跨线程用std::move()),无并发问题 。对于shared_ptr,多个对象需要共用一个引用计数变量 ,所以会存在线程安全问题 。但是标准库实现的时候考虑到了这个问题,保证了引用计数的线程安全,但是指向的原始数据默认不是线程安全的。
- 注意:
- 函数可重入 ,一定 是线程安全 的,但是线程安全 ,函数不一定 是可重入的,如:发送信号造成死锁。
cpp
#include <pthread.h>
#include <signal.h>
#include <unistd.h>
pthread_mutex_t global_lock = PTHREAD_MUTEX_INITIALIZER;
// 一个线程安全的函数(但不可重入)
void process_data() {
pthread_mutex_lock(&global_lock); // (1) 加锁
// ... 执行一些操作,访问全局数据 ...
sleep(5); // 模拟一个耗时操作,在此期间信号很可能发生
// ... 执行更多操作 ...
pthread_mutex_unlock(&global_lock); // (3) 解锁
}
// 信号处理函数
void signal_handler(int sig) {
process_data(); // (2) 在信号处理中再次调用!
}
int main() {
signal(SIGINT, signal_handler); // 注册信号处理函数
process_data(); // 主调用
return 0;
}
- 死锁的发生流程:主线程 进入 process_data(),成功获取互斥锁 global_lock(执行点(1);然后开始sleep(5);在 sleep 期间,用户按下了 Ctrl+C(发送 SIGINT 信号);主线程被中断,转而执行 信号处理函数 signal_handler;信号处理函数 调用 process_data();process_data() 试图再次获取 global_lock(执行点(2));然而,这把锁正被主线程自己持有(它在被中断前已经拿到了锁);于是,信号处理函数(在主线程的上下文中执行),等待一把永远不可能被释放的锁(因为主线程被阻塞了,无法继续执行到解锁操作(3))会永远阻塞;死锁发生!