文章目录
前言
学线程互斥的用处在于:在多线程程序里,很多数据和资源是共享的(比如全局变量、文件、socket、内存池)。如果不加限制,多个线程可能会在同一时间修改同一份数据,导致结果错误或者程序崩溃。互斥锁的作用就是保证某一段代码在同一时刻只能被一个线程执行,从而避免数据竞争,让结果稳定可靠。
一、线程竞争案例
我们来编写一个线程竞争资源的代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
using namespace std;
class customer {
public:
int _ticket_num = 0; // 该顾客买到的票数
pthread_t _tid; // 线程ID
string _name; // 顾客名字
};
int g_ticket = 10000; // 总票数
void* buyTicket(void* args) {
customer* cust = (customer*)args;
while (true) {
if (g_ticket > 0) {
usleep(1000); // 模拟出票耗时
cout << cust->_name << " get ticket: " << g_ticket << endl;
g_ticket--;
cust->_ticket_num++;
} else {
break;
}
}
return nullptr;
}
int main() {
vector<customer> custs(5);
// 创建 5 个顾客线程
for (int i = 0; i < 5; i++) {
custs[i]._name = "customer-" + to_string(i + 1);
pthread_create(&custs[i]._tid, nullptr, buyTicket, &custs[i]);
}
// 等待所有顾客线程结束
for (int i = 0; i < 5; i++) {
pthread_join(custs[i]._tid, nullptr);
}
// 打印每个顾客买到的票数
for (int i = 0; i < 5; i++) {
cout << custs[i]._name
<< " get tickets: " << custs[i]._ticket_num << endl;
}
return 0;
}
我们先从自定义函数和自定义类开始讲解,再到main函数的讲解:
class customer:有三个参数,都是顾客自身的信息,在整体程序当中,每一个顾客对应一个线程
void* buyTicket:用于给顾客售票,顾客每增加一张票,则g_ticket减1
main:创建五个进程,并且相继竞争购票
演示结果:
gch@hcss-ecs-f59a:/gch/code/HaoHao/learn2/day6$ ./exe
......
customer-3 get ticket: 0
customer-4 get ticket: -1
customer-1 get ticket: -2
customer-5 get ticket: -3
customer-1 get tickets: 2000
customer-2 get tickets: 2001
customer-3 get tickets: 2000
customer-4 get tickets: 2001
customer-5 get tickets: 2002
那么这里就有问题了,明明只有1000张票,为什么五个人加起来的数量是1004张呢
if
语句判断条件为真以后,代码可以并发的切换到其他线程--ticket
操作本身就不是一个原子操作
这个ticket
在线程当中属于共享资源,因为只有一个CUP并且它是单核的,一次只能执行一个进程,为了实现进程的同步,操作系统内核会在线程还没执行完函数时打断线程,让CUP运行其它的线程,但是上一个线程还没执行玩ticket,下一个进程就已经进入if
函数了,所以会导致--ticket
被运行多次
g_ticket
是共享资源,因为所有线程都可以访问和修改它。多个线程同时访问它,就有可能出现冲突。虽然单核 CPU 在 同一时刻 只能执行一个线程,但操作系统会通过 时间片轮转 或 线程调度 不断切换线程。
- 一个线程可能执行到一半(比如刚判断
g_ticket > 0
)就被操作系统挂起。 - CPU 会切换给另一个线程去执行。
- 一个线程可能执行到一半(比如刚判断
假设线程 A 执行到:
if (g_ticket > 0) // 假设 g_ticket = 1
此时线程 A 还没来得及执行 g_ticket--
。操作系统把 CPU 切给线程 B。线程 B 也执行到同样的 if (g_ticket > 0)
判断,此时它看到 g_ticket
还是 1
,于是也进入 if
。最终,两个线程都执行了 g_ticket--
,但是 g_ticket
只应该被减一次。这就是 竞态条件:多个线程同时操作共享资源,导致结果错误。
二、互斥,锁
要解决上述共享资源冲突问题,需要满足三点条件:
- 互斥执行:当某个线程进入临界区执行代码时,其他线程不能同时进入该临界区。
- 公平进入:如果多个线程同时请求进入临界区,而此时没有线程在执行,只允许其中一个线程进入。
- 非阻塞退出:线程在临界区外时,不得阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
演示代码:
#include <iostream>
#include <string>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <cstring>
using namespace std;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
class customer
{
public:
int _ticket_num = 0;
pthread_t _tid;
string _name;
};
int g_ticket = 10000;
void* buyTicket(void* args)
{
customer* cust = (customer*)args;
while(true)
{
pthread_mutex_lock(&mutex);
if(g_ticket > 0)
{
usleep(1000);
cout << cust->_name << " get ticket: " << g_ticket << endl;
g_ticket--;
cust->_ticket_num++;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main()
{
vector<customer> custs(5);
for(int i = 0; i < 5; i++)
{
custs[i]._name= "customer-" + to_string(i + 1);
pthread_create(&custs[i]._tid, nullptr, buyTicket, &custs[i]);
}
for(int i = 0; i < 5; i++)
{
pthread_join(custs[i]._tid, nullptr);
}
for(int i = 0; i < 5; i++)
{
cout << custs[i]._name << " get tickets: " << custs[i]._ticket_num << endl;
}
return 0;
}
在这段代码里:
pthread_mutex_lock(&mutex)
让线程尝试获取锁,如果别的线程已经持有锁,它就会阻塞等待。- 当线程获得锁后,它才能进入临界区操作共享资源
g_ticket
。 - 执行完之后,调用
pthread_mutex_unlock(&mutex)
释放锁,这样别的线程才能继续进入临界区。
所以,锁的作用就是:
- 保证同一时刻只有一个线程能修改
g_ticket
,避免出现多个线程同时减票而导致的数据错误。
演示结果:
gch@hcss-ecs-f59a:/gch/code/HaoHao/learn2/day6$ ./exe
......
customer-1 get tickets: 2214
customer-2 get tickets: 2391
customer-3 get tickets: 1761
customer-4 get tickets: 1806
customer-5 get tickets: 1828
上述是全局静态锁,如果是局部锁需要在使用完之后用pthread_mutex_destroy
进行销毁
互斥锁初始化方式主要有三种
- 静态初始化(全局/静态作用域)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
在 编译期 就完成初始化。
适合全局变量、静态变量。
生命周期随进程结束自动回收,不需要显式销毁
动态初始化
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
pthread_mutex_destroy(&mutex);
- 在 运行时 用
pthread_mutex_init
初始化。 - 适合函数内的局部变量,或需要 动态分配的结构体里的成员。
- 用完必须
pthread_mutex_destroy
,否则可能导致资源泄漏。
三,线程安全
线程安全指的是:当多个线程同时访问某个函数、数据结构或代码片段时,程序的行为依然是正确的、可预期的,不会出现数据错乱或未定义行为
换句话说:
- 线程安全:多个线程并发访问 → 程序结果仍然正确。
- 线程不安全:多个线程并发访问 → 可能导致错误(数据竞争、死锁、崩溃)。
3-1 为什么会线程不安全
根本原因是:多线程共享内存,但调度是抢占式的。
假设我们有一个全局变量:
int counter = 0;
void *worker(void *arg) {
for (int i = 0; i < 10000; i++) {
counter++; // 不是原子操作!
}
return NULL;
}
如果两个线程同时执行 counter++:
实际会分解成三步:读 counter
,counter + 1
,写回 counter
。
假如两个线程交叉执行,就可能丢失更新(最终结果小于 20000)。
这就是 数据竞争,典型的线程不安全,所以我们在这种线程不安全的情况,我们可以使用锁来实现线程安全。
线程安全 = 在多线程并发情况下,程序逻辑和数据结果依旧正确,不会出现竞态问题。
四,重入函数
重入的核心是同一个函数被多个执行流交错执行。想象一个函数正在执行到一半,突然被打断(可能是另一个线程开始执行,或者信号处理函数触发),而这个打断它的执行流也调用了同一个函数,这就发生了重入
4-1 两种重入场景的具体分析
- 多线程重入(并发重入)
当多个线程同时调用同一个函数时,就可能发生重入。
可重入示例:
// 可重入函数:只使用局部变量
int add(int a, int b) {
int temp; // 局部变量,每个线程有独立副本
temp = a + b;
return temp;
}
每个线程调用add()
时,局部变量temp
是线程私有,不会互相干扰。
不可重入示例:
// 不可重入函数:使用全局变量
int global_num = 0;
int increment() {
global_num++; // 读取-修改-写入三步操作,可能被打断
return global_num;
}
- 信号导致的重入(异步重入)
当程序正在执行函数 A 时,突然收到信号,系统会暂停当前执行流,转去执行信号处理函数。如果信号处理函数也调用了函数 A,就会发生重入。
危险示例:
#include <signal.h>
#include <stdio.h>
FILE *file;
void signal_handler(int signum) {
// 信号处理函数也操作file,导致重入
fputs("Signal handled\n", file);
}
int main() {
file = fopen("test.txt", "w");
signal(SIGINT, signal_handler); // 注册Ctrl+C的处理函数
// 主程序正在操作file时,若收到信号会导致重入
fputs("Main writing\n", file);
// ... 其他操作
fclose(file);
return 0;
}
- 主程序正在执行
fputs()
(操作全局变量file)时,若按下Ctrl+C
触发信号 - 信号处理函数也调用
fputs()
操作同一个file
- 可能导致文件缓冲区数据混乱,甚至程序崩溃
3、可重入函数的判定准则
一个函数要成为可重入函数,必须满足:
- 不使用全局变量或静态变量,或对其访问进行特殊保护
- 不使用
malloc/free
(会操作全局内存管理结构) - 不调用其他不可重入函数(如标准库中的
printf
、fputs
等I/O
函数) - 不依赖硬件资源的状态(如不直接操作硬件寄存器)
五,死锁
死锁是并发编程中一种常见且危险的状态,指两个或多个执行流(线程、进程)相互等待对方持有的资源,且彼此都无法继续推进的僵局。
5-1 死锁的核心概念
当多个执行执行流同时竞争有限的共享资源时,若每个执行流都持有一部分资源,同时又等待其他执行流释放所需资源,就会形成循环等待,导致所有执行流都无法继续执行,这种状态称为死锁。
5-2 死锁产生的四大必要条件
- 互斥条件:资源具有排他性,同一时间只能被一个执行流使用(如一把锁只能被一个线程持有)。
- 持有并等待条件:执行流已经持有至少一个资源,同时又在等待获取其他执行流持有的资源。
- 不可剥夺条件:已获取的资源不能被强制剥夺,只能由持有者主动释放(如线程持有的锁不能被其他线程强制释放)。
- 循环等待条件:存在执行流的循环链,每个执行流都在等待下一个执行流持有的资源(如线程 A 等线程 B 的资源,线程 B 等线程 A 的资源)。
演示代码:
#include <pthread.h>
#include <stdio.h>
// 定义两个全局锁(资源)
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;
// 线程1的执行函数:先锁lock1,再等lock2
void *thread1(void *arg) {
pthread_mutex_lock(&lock1);
printf("线程1持有lock1,等待lock2...\n");
// 模拟处理时间,增加死锁概率
sleep(1);
pthread_mutex_lock(&lock2); // 等待线程2释放lock2
// 业务操作(实际中不会执行到)
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
return NULL;
}
// 线程2的执行函数:先锁lock2,再等lock1
void *thread2(void *arg) {
pthread_mutex_lock(&lock2);
printf("线程2持有lock2,等待lock1...\n");
// 模拟处理时间,增加死锁概率
sleep(1);
pthread_mutex_lock(&lock1); // 等待线程1释放lock1
// 业务操作(实际中不会执行到)
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, thread1, NULL);
pthread_create(&t2, NULL, thread2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
执行结果:
线程 1 持有 lock1
并等待 lock2
,线程 2 持有 lock2
并等待 lock1
,形成循环等待,程序永远卡在等待状态,即死锁。
5-3 死锁的危害
- 程序卡住,无法继续执行,需要强制终止
- 资源被永久占用,无法释放
- 难以调试,死锁可能在特定
timing
下才触发,复现困难
5-4 避免死锁的常用方法
- 破坏循环等待条件:对所有资源按固定顺序获取(如规定必须先获取
lock1
,再获取lock2
)。
破坏持有并等待条件:一次性获取所有所需资源,获取不到则释放已持有的资源并重试。
使用带超时的锁:如pthread_mutex_timedlock
,超时后释放资源并重新尝试。
定期检测死锁:通过工具(如pstack
、gdb
)或自定义算法检测死锁,发现后强制释放资源。