💓博主CSDN主页:麻辣韭菜💓
⏩专栏分类:Linux初窥门径⏪
🚚代码仓库:Linux代码练习🚚
🌹关注我🫵带你学习更多Linux知识
🔝
前言
由于线程之间存在竞争,就导致了多线程有的线程涝的涝死,饿的饿死,就需要让线程之间保持某种平衡,让它们被CPU雨露均沾。这就是所谓的同步。由于临界资源只有一份,线程之间同时共享临界资源。为了防止临界资源的安全,线程之间需要互斥。
1.资源共享问题
在Linux 线程控制文章我们知道了一个进程中的所有线程,在地址空间中的代码区、未初始化区、什么堆区也好、栈区也好,还是共享区也好都是共享的。
就好比下面这个代码
#include <iostream>
int n = 0;
int main()
{
n++;
return 0;
}
n是属于main函数栈帧中,如果我们创建线程,那么所有线程都是可以看见它。如果两个线程同时对它++,那么很可能n的值会超过我们的预期。
#include <thread>
#include <iostream>
using namespace std;
int n = 0;
int main()
{
thread t1([]()
{
for (int i = 0; i < 100000; i++)
{
n++;
}
});
thread t2([]()
{
for (int i = 0; i < 100000; i++)
{
n++;
}
});
t1.join();
t2.join();
cout << n << endl;
return 0;
}
打印出来的结果确实超出我们预期这是为什么?
n++; 这句代码确实是一句代码,但是对于汇编而是3条指令
也就是说当t1对n进行++时,其实是分为3步,同理t2也是3步。这就导致了++不是原子操作
t1在执行add这条汇编指令时,也就是说当t1进行++时,调度的时间片到了,没有执行第3条指令mov,而t1被切换走时,会带走自己下上文数据。而这时t2被调度。而这个期间t2一直被调度疯狂的++,且完整的执行完汇编语句,将寄存器的值拷贝到内存中,等t1在被调度回来。t1将自己的上下文数据写回寄存器中,它就会执行第3条mov汇编语句,而不是从新开始。而它的n值是1,拷贝会内存这就出事了。覆盖了t2对n++的值。
结论:多线程场景中对全局变量线程并发访问并不是安全的。
2.进程线程间的互斥相关背景概念
前面的简单实验我们可以得出几个名词
对多线程来说:
n就是临界资源,n++就是临界区 ,两个线程不让同时访问临界资源,叫做互斥。n++这个操作,要么是一次性完成的,要么是未完成的(未开始)那就是原子操作。
总结:
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完
成
3.锁
既然多线程之间并发访问,会导致临界资源安全性的问题,互斥能让它们对临界资源起到保护作用。
那利用什么方式能让它们每个时刻只有一个线程访问临界资源?
我们先举个生活中例子 公共厕所。
厕所在社会中属于公共资源,每个人上厕所都会把们关上,且把门上的锁进行反锁。而这个过程就是独享这份"公共资源"。
那对于代码中,如何对临界资源上锁?
Linux中对于锁也是有接口的 我们先用指令了解接口说明文档
3.1锁文档说明
指令:man 3 pthread_mutex_lock
pthread_mutex_lock(pthread_mutex_t *mutex)
- 功能:锁定由
mutex
引用的互斥锁。 - 行为:如果互斥锁已经被其他线程锁定,则调用线程将阻塞,直到互斥锁可用。
- 返回状态:如果成功,函数返回0;互斥锁被锁定,调用线程成为其所有者。
互斥锁类型
- PTHREAD_MUTEX_NORMAL:不提供死锁检测。重复锁定会导致死锁。解锁未锁定的互斥锁会导致未定义行为。
- PTHREAD_MUTEX_ERRORCHECK:提供错误检查。重复锁定或解锁未锁定的互斥锁将返回错误。
- PTHREAD_MUTEX_RECURSIVE:维护一个锁定计数。首次成功获取互斥锁时,锁定计数设置为1。每次重复锁定时,计数增加1;每次解锁时,计数减少1。计数归零时,互斥锁对其他线程可用。解锁未锁定的互斥锁将返回错误。
- PTHREAD_MUTEX_DEFAULT:默认类型,尝试递归锁定会导致未定义行为。如果解锁的互斥锁不是由调用线程锁定的,或者没有被锁定,将导致未定义行为。
pthread_mutex_trylock(pthread_mutex_t *mutex)
- 功能:与
pthread_mutex_lock()
相似,但如果互斥锁已经被锁定(包括当前线程),调用将立即返回,而不是阻塞。
pthread_mutex_unlock(pthread_mutex_t *mutex)
- 功能:释放由
mutex
引用的互斥锁。 - 释放方式:依赖于互斥锁的类型属性。如果有线程因互斥锁变为可用而被阻塞,调度策略将决定哪个线程获得互斥锁。
信号处理
- 如果等待互斥锁的线程接收到信号,从信号处理程序返回后,线程将继续等待互斥锁,就像没有被中断一样。
返回值
- 如果成功,
pthread_mutex_lock()
和pthread_mutex_unlock()
函数返回0;否则,返回错误编号以指示错误。
指令:man 3 pthread_mutex_init
pthread_mutex_destroy()
- 功能:销毁由
mutex
引用的互斥锁对象,使其变为未初始化状态。 - 安全:只能销毁未被锁定的互斥锁。尝试销毁一个被锁定的互斥锁将导致未定义行为。
- 重新初始化:销毁的互斥锁可以通过
pthread_mutex_init()
重新初始化。 - 错误行为:销毁操作后引用互斥锁将导致未定义行为。
pthread_mutex_init()
- 功能:使用
attr
指定的属性初始化mutex
引用的互斥锁。如果attr
是NULL
,则使用默认属性。 - 状态:初始化成功后,互斥锁变为已初始化且未锁定状态。
- 同步使用:只能使用
mutex
本身进行同步操作,不能使用其副本。 - 重复初始化:尝试重复初始化已初始化的互斥锁将导致未定义行为。
PTHREAD_MUTEX_INITIALIZER
- 用途:用于静态分配的互斥锁的初始化。效果等同于使用
NULL
作为属性参数调用pthread_mutex_init()
,但不会执行错误检查。
返回值
- 成功时,
pthread_mutex_destroy()
和pthread_mutex_init()
返回0;失败时,返回错误编号以指示错误。
文档介绍完毕,我们直接开始代码实操
代码示例
先来一个没有加锁的模拟抢票的过程。
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
#include <cstring>
#include <cstdio>
#include <iostream>
using namespace std;
class ThreadData
{
public:
ThreadData(int number)
{
_threadname = "thread-" + to_string(number);
}
string GetName()
{
return _threadname;
}
private:
string _threadname;
};
#define NUM 5
int tickets = 1000; // 火车票
void *GetTickets(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
const char *name = td->GetName().c_str();
while (true)
{
if (tickets > 0)
{
usleep(1000);
printf("线程:%s抢到一张票,tickets剩余:%d\n", name, tickets);
tickets--;
}
else
break;
printf("线程%s ... 退出\n", name);
}
return nullptr;
}
int main()
{
// 创建多线程
vector<pthread_t> tids; // 数组放线程ID
vector<ThreadData *> thread_datas; // 线程信息
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
ThreadData *td = new ThreadData(i);
thread_datas.emplace_back(td);
pthread_create(&tid, nullptr, GetTickets, thread_datas[i-1]);
tids.emplace_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
和之前一样如果在实现中,我们这个代码就出问题了。乘客给钱了,但是没有票了。
3.2 加锁 解锁操作
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
#include <cstring>
#include <cstdio>
#include <iostream>
using namespace std;
class ThreadData
{
public:
ThreadData(int number)
{
_threadname = "thread-" + to_string(number);
}
string GetName()
{
return _threadname;
}
private:
string _threadname;
};
#define NUM 5
int tickets = 1000; // 火车票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; //静态分配。
void *GetTickets(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
const char *name = td->GetName().c_str();
while (true)
{
pthread_mutex_lock(&lock);
if (tickets >= 0)
{
usleep(1000);
printf("线程:%s抢到一张票,tickets剩余:%d\n", name, tickets);
tickets--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
printf("线程:%s ... 退出\n", name);
}
return nullptr;
}
int main()
{
// 创建多线程
vector<pthread_t> tids; // 数组放线程ID
vector<ThreadData *> thread_datas; // 线程信息
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
ThreadData *td = new ThreadData(i);
thread_datas.emplace_back(td);
pthread_create(&tid, nullptr, GetTickets, thread_datas[i - 1]);
tids.emplace_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
我使用的是静态的锁,其实也就是个宏
静态分配 的优点在于 无需手动初始化和手动销毁,锁的生命周期伴随程序,缺点就是定义的 互斥锁 必须为 全局互斥锁
当然我们也可以使用动态分配,动态分配需要我们手动初始化。
pthread_mutex_t lock; //动态要初始化
pthread_mutex_init(&lock,nullptr);
3.3 ARII风格锁
但是使用锁的方式很容易造成死锁的问题,就比如上面的代码需要二次解锁。我们可以利用ARII的思想
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock)
: _lock(lock)
{
}
void lock()
{
pthread_mutex_lock(_lock);
}
void unlock()
{
pthread_mutex_unlock(_lock);
}
~Mutex() {}
private:
pthread_mutex_t *_lock;
};
class lockGudard
{
public:
lockGudard(pthread_mutex_t *lock) //
: _mutex(lock)
{
_mutex.lock();
}
~lockGudard()
{
_mutex.unlock();
}
private:
Mutex _mutex;
};
void *GetTickets(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
const char *name = td->GetName().c_str();
while (true)
{
//pthread_mutex_lock(&lock);
lockGudard lockguard(&lock); //ARII
if (tickets >= 0)
{
usleep(1000);
printf("线程:%s抢到一张票,tickets剩余:%d\n", name, tickets);
tickets--;
// pthread_mutex_unlock(&lock);
}
else
{
//pthread_mutex_unlock(&lock);
break;
}
printf("线程:%s ... 退出\n", name);
}
return nullptr;
}
ARII思想把资源的生命周期交给对象,利用C++类的特性。具体详情请看C++ 智能指针
3.4 深入理解锁
从结果来看,加锁之后临界资源确实被保护了,但是对锁来讲这里还有许多细节,我们一一来扣。
细节1:加锁的位置?
每一个线程访问临界资源前都是要加锁的,本质是对临界区加锁,所以在临界区的代码,有些代码是不涉及临界资源的,例如上图在循环代码之前加锁,也就是说线程要拿到锁才能进入循环。
如果不在循环之前加锁而是在if之前加锁,那么所有线程都能进入循环。如果还有其他不涉及临界的代码。这个线程没有拿到锁是不是就可以执行其他代码?
建议加锁时,粒度要尽可能的细,因为加锁后区域的代码是串行化执行的,代码量少一些可以提高多线程并发时的效率
细节2:多线程之间访问同一个临界资源可以不是同一把锁?
当然不行,多线程之间访问临界资源,如果一个线程自己带锁,那么它就不会阻塞等待。那它就起飞了,没有人管了。只有多线程之间看到同一把互斥锁,才能让它们互斥。
细节3:互斥锁既能是全局的,又能是局部。那它不也是临界资源?它如何保证自己的安全?
加锁 是为了保护 临界资源 的安全,但 锁 本身也是 临界资源,这就像是一个 先有鸡还是先有蛋的问题,锁 的设计者也考虑到了这个问题,于是对于 锁 这种 临界资源 进行了特殊化处理:加锁 和 解锁 操作都是原子的,不存在中间状态,也就不需要保护了
我们先来看一段互斥锁伪汇编
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器里的内容 > 0){
return 0;
} else
挂起等待;
goto lock;
假设线程a先拿到锁
- 将
%al
寄存器的值设置为0。 - 使用
xchgb
指令尝试将%al
与mutex
原子地交换值。假设mutex
原本为1,%al
将包含旧的mutex
值(也是1),并且mutex
现在被设置为0。 - 如果
%al
为1,表示当前线程a成功获取了锁,可以继续执行临界区代码。 - 线程b申请锁
- 检查
%al
寄存器的值。如果它大于0,表示其他线程a已经持有锁,当前线程b应该返回0(通常表示错误或失败)。 - 如果锁已被其他线程持有,当前线程将挂起等待,直到锁变为可用状态。
- 一旦锁可用,线程再次尝试获取锁,跳转回
lock
标签处继续执行。
unlock:
movb $1, mutex
唤醒等待 [锁资源] 的线程;
return
假设现在线程a解锁:
1. 将mutex的值设置为1
2. 将线程b唤醒,线程b再执行lock那一套逻辑。
3. 从解锁函数返回执行后面代码。
注意:1.xchgb原子操作不会被任何调度打断,要么完成,要么未完成。
2. 寄存器的值不等于线程上下文的值。
细节4:一个线程可以一直拿着锁吗?
理论上是可行的,如果一个线程是拿着锁的,如果它的线程调度时间片到了,也是有可能连锁一起带走。为什么这么说
如果现在这间vip室室免费且小明先拿到🔑进来,但是小明又想出去玩一会,然后小明出门把🔑放在🔒,准备走的时候,发现有很多人,小明心里又不想失去这间vip室。所以小明又重新拿着🔑进入vip室,其他人都没有抢过小明,因为小明离🔑最近。
对于线程来说,那就是拿到🔒意味就能访问临界区。其他线程只能阻塞等待,而解锁的过程就是线程离开临界区,其他线程访问临界区。
3.5 毁🔒
当进程退出我们也是需要对锁资源进行清理,销毁互斥锁可以释放与之关联的系统资源。
pthread_mutex_destroy(&lock);
就是这么简单的一句。
3.6 可重入&&线程安全
概念:
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
我们所学的大部分函数都是不可重入的
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
反之其他的函数都是可以重入的
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
3.7 常见锁概念
3.7.1死锁
概念:死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
必要条件只要有一个不成立,都不会出现死锁问题。
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
担心死锁问题的小伙伴,不用怕,直接无脑ARII锁。 当然也有常见的避免 死锁 问题的算法:死锁检测算法、银行家算法
4.条件变量
从这个结果来看,绝大部分都是线程1在抢票,我的运行结果都没有5号线程和2号线程。
导致这两个线程饥饿,这并不是我们想要的,能否按照一定的顺序有序的访问。造成这样的原因还是因为线程之间的竞争导致的。
这时我们又要重新说到vip的房间了。小明拿到🔑,下次再申请🔑时候,小明最近。所以其他人是抢不到的,这时管理员看不下去了,对小明说你不能这样干,管理员强行对小明进行限制。
对于线程来说也是同理,我们需要对线程进行一定条件的限制。让其他线程雨露均沾。
我们又要引入一个新的概念条件变量。
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
可以把 条件变量 看作一个结构体,其中包含一个 队列 结构,用来存储正在排队等候的线程信息,当条件满足时,就会取 队头 线程进行操作,操作完成后重新进入 队尾
同步概念与竞态条件
4.1同步操作相关函数
PTHREAD_COND_INITIALIZER
是一个宏,用于在编译时初始化条件变量对象。
优缺点和PTHREAD_MUTEX_INITIALIZER 一样
条件变量函数 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
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);
//这个函数的作用是唤醒所有等待指定条件变量 cond 的线程。
int pthread_cond_signal(pthread_cond_t *cond);
//这个函数用于唤醒等待指定条件变量 cond 的一个线程。
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
#include <cstring>
#include <cstdio>
#include <iostream>
using namespace std;
class ThreadData
{
public:
ThreadData(int number)
{
_threadname = "thread-" + to_string(number);
}
string GetName()
{
return _threadname;
}
private:
string _threadname;
};
#define NUM 5
int tickets = 1000; // 火车票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 静态分配。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 条件变量静态全局变量
void *GetTickets(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
const char *name = td->GetName().c_str();
while (true)
{
pthread_mutex_lock(&lock);
pthread_cond_wait(&cond, &lock);
if (tickets > 0)
{
usleep(1000);
tickets--;
printf("线程:%s抢到一张票,tickets剩余:%d\n", name, tickets);
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
printf("线程:%s ... 退出\n", name);
return nullptr;
}
int main()
{
// 创建多线程
vector<pthread_t> tids; // 数组放线程ID
vector<ThreadData *> thread_datas; // 线程信息
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
ThreadData *td = new ThreadData(i);
thread_datas.emplace_back(td);
pthread_create(&tid, nullptr, GetTickets, thread_datas[i - 1]);
tids.emplace_back(tid);
}
while (tickets > 0)
{
usleep(1000);
pthread_cond_signal(&cond);
cout << " 主线程唤醒新线程..." << endl;
}
pthread_cond_broadcast(&cond); //这里需要唤醒所有等待的线程
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
问题1:为什么条件变量的等待函数要在锁的后面?
条件不满足时,要去等待队列阻塞等待被唤醒。
一个线程拿到了锁,不解锁去等待,后面的线程不就拿不到锁,不就死锁了吗?
wait函数调用时会自动释放锁,这也是为什么第二个参数是锁。
问题2:我们怎么知道我们要让一个线程去休眠了那?
首先临界资源也是有状态的,要么就绪,要么不就绪。不就绪条件不满足。所以线程回去休眠
问题3:你怎么知道临界资源是就绪还是不就绪的?
很简单 我们 if 这里进行判断不就是访问临界资源吗?这也是为什么判断会在加锁之后。
总结:线程同步与互斥 主要讲解了 锁 和 条件变量 函数接口使用。包括互斥锁的概念、操作、原理,以及多线程与互斥锁的封装;最后简单学习了线程同步相关内容,重点在于对条件变量的理解及使用。至于互斥锁+条件变量的实战:生产者消费者模型将会在下一篇文章中完成