<Linux> 线程安全
目录
一、Linux线程互斥
1. 进程线程间的互斥相关背景概念
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
临界资源和临界区
进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。
而多线程的大部分资源都是共享的,因为TCB都指向相同的地址空间,线程之间进行通信不需要费那么大的劲去创建第三方资源。
互斥和原子性
在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
例如,我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建多个新线程,让这多个新线程进行抢票,当票被抢完后新线程自动退出。
#include <iostream>
#include <string>
#include <cstring>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;
class threadData
{
public:
threadData(int number)
{
threadname = "Thread-" + to_string(number);
}
public:
string threadname;
};
//并发访问的共享数据
int tickets = 100;
const int NUM = 4;
void* getTickets(void* args)
{
threadData* td = static_cast<threadData*>(args);
while (true)
{
if (tickets > 0)
{
usleep(1000);
printf("%s get a ticket: %d\n", td->threadname.c_str(), tickets);
tickets--;
}
else break;
}
printf("%s quit...\n", td->threadname.c_str());
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData*> thread_data;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_data.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_data[i-1]);
tids.push_back(tid);
}
for (int i = 0; i < NUM; i++)
{
pthread_join(tids[i], nullptr);
delete thread_data[i];
}
return 0;
}
由结果来看,对一个全局变量进行多线程并发的++或--并不是安全的
共享数据被多线程并发访问引起数据不一致问题,所以对全局变量进程多线程并发--或++的操作是一个不安全的行为
数据的计算只能在CPU上计算,所以 tickets-- 对应汇编的过程:
- 将内存数据拿到CPU的寄存器中
- CPU内部进行--,数据自减计算
- 计算结果写回内存
这每一步都会对应一条汇编操作,因为线程是OS调度的基本单位,所以线程在执行的任何一个时间,都可能会被切换。所以线程也有自己的上下文数据,为了方便后续再次被调度时的数据恢复。
寄存器不等于寄存器的内容,线程在执行的时候,将共享数据加载到CPU的本质:把数据的内容变成了自己的上下文数据 —— 以拷贝的方式,为自己单独拿了一份数据
所以如果有1000张票,线程1在执行第一步(将内存数据拿到CPU的寄存器中)之后,由于时间片结束或因为其他原因被切换调度了,线程1会保存此时上下文数据(CPU寄存器数据,此时tickets还是1000张),然后切换线程2
由于thread1只进行了--
操作的第一步,因此thread2此时看到tickets的值还是1000。线程2很幸运,时间片很长并且没有被切换,它一直抢票共100张,当还剩900张票时线程2被调度,它也保存了自己的上下文数据
随后CPU又调度了线程1,此时在上下文数据恢复时,问题出现了,线程1的上下文数据记录的tickets是1000,所以CPU对tickets--后,向内存写入的tickets为999,这就是数据不一致问题,即多线程并发时的tickets--不是安全行为
为什么最后票数是负的?
例如 tickets 现在只剩1张票了,线程1读到了1,但是随即就被切换了,同理,线程2读到了1,也被切换了,线程3也读到了1,此时三个线程都读到了 tickets 是1,然后进入 if 块内部执行,需要注意的是 if 语句的 tickets > 0 是一种逻辑运算,也需要被加载到CPU计算,而在执行 if 块中的 tickets-- 操作时,OS 会再次从内存读取数据,而不是直接使用 if 判断时的 tickets 数据,所以例如线程3读到1之后,执行到了tickets--,减到了0,并写回内存,线程切换到线程1,线程1恢复上下文数据,继续执行 if 块代码,tickets--,读取内存 tickets 数据到 CPU,此时(0)-- 就成为了-1,线程再次切换,线程2也是同样的操作将(-1)--,将 tickets 写回内存,tickets = -2
2. 互斥量mutex
如何解决
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量。这里的问题就是 tickets-- 不是原子的,在 -- 时线程会切换,从而导致数据不一致问题,所以我们将该行为进行“加锁”,使得多线程并发执行到该操作时,变为串行执行——线程互斥
3. 互斥量接口
初始化互斥量
man pthread_mutex_init
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
pthread_mutex_t 是一种数据类型,联合体内嵌套结构体
动静态分配
调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
如果是全局的锁,可以赋值宏,不用init、destroy
如果不是全局的锁,必须使用init、destroy
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用
PTHREAD_MUTEX_INITIALIZER ,即静态分配的
初始化的互斥量不需要手动销毁。 - 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
互斥量解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功返回0,失败返回错误码。
互斥量应该在哪里进行上锁?
多线程操作临界资源的代码的区域 -- 临界区
加锁的本质:用时间换安全(并行改为串行)
加锁表现:线程对于临界区代码串行执行
加锁原则:尽量保证临界区越少越好,如果加锁的代码很多,那么并行的代码就少了,并发效率就会降低
将抢票系统加上互斥锁,示例:
#include <iostream>
#include <string>
#include <cstring>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;
class threadData
{
public:
threadData(int number, pthread_mutex_t *mutex)
{
threadname = "Thread-" + to_string(number);
lock = mutex;
}
public:
string threadname;
pthread_mutex_t *lock;
};
//并发访问的共享数据
int tickets = 3000;
const int NUM = 4;
void* getTickets(void* args)
{
threadData* td = static_cast<threadData*>(args);
int cnt = 0;
while (true)
{
//加锁
pthread_mutex_lock(td->lock); //申请成功,则继续执行;申请失败,则阻塞等待
if (tickets > 0)
{
usleep(1000);
printf("%s get a ticket: %d\n", td->threadname.c_str(), tickets);
tickets--, cnt++;
pthread_mutex_unlock(td->lock);
}
else
{
//这里也需要释放锁,因为如果if判断为false,就会执行else的break,如果不释放锁,就会造成锁一直存留
pthread_mutex_unlock(td->lock);
break;
}
usleep(13);
//这里模拟抢票之后的后续动作(将票的信息写入用户名下等等操作),如果没有usleep,那么该线程释放锁之后又会立即拿到锁
//会造成该线程竞争锁的能力很强,因为此时其他线程还在阻塞等待,将他们唤醒的效率(tcb调度队列)低于该线程再次申请锁
}
printf("%s quit..., cnt = %d\n", td->threadname.c_str(), cnt);
return nullptr;
}
int main()
{
//main函数的栈上,我们需要每一个线程都拿到相同的锁
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData*> thread_data;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i, &lock);
thread_data.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_data[i-1]);
tids.push_back(tid);
}
for (int i = 0; i < NUM; i++)
{
pthread_join(tids[i], nullptr);
delete thread_data[i];
}
pthread_mutex_destroy(&lock);
return 0;
}
全局或静态(静态变量也是全局变量)的锁可以不使用init、desteoy函数,可以直接使用宏PTHREAD_MUTEX_INITIALIZER
//全局锁
class threadData
{
public:
threadData(int number/*, pthread_mutex_t *mutex*/)
{
threadname = "Thread-" + to_string(number);
// lock = mutex;
}
public:
string threadname;
pthread_mutex_t *lock;
};
//全局锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
//并发访问的共享数据
int tickets = 3000;
const int NUM = 4;
void* getTickets(void* args)
{
threadData* td = static_cast<threadData*>(args);
int cnt = 0;
while (true)
{
//加锁
pthread_mutex_lock(&lock); //申请成功,则继续执行;申请失败,则阻塞等待
if (tickets > 0)
{
usleep(1000);
printf("%s get a ticket: %d\n", td->threadname.c_str(), tickets);
tickets--, cnt++;
pthread_mutex_unlock(&lock);
}
else
{
//这里也需要释放锁,因为如果if判断为false,就会执行else的break,如果不释放锁,就会造成锁一直存留
pthread_mutex_unlock(&lock);
break;
}
usleep(13);
//这里模拟抢票之后的后续动作(将票的信息写入用户名下等等操作),如果没有usleep,那么该线程释放锁之后又会立即拿到锁
//会造成该线程竞争锁的能力很强,因为此时其他线程还在阻塞等待,将他们唤醒的效率(tcb调度队列)低于该线程再次申请锁
}
printf("%s quit..., cnt = %d\n", td->threadname.c_str(), cnt);
return nullptr;
}
int main()
{
//main函数的栈上,我们需要每一个线程都拿到相同的锁
//pthread_mutex_t lock;
//pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData*> thread_data;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i/*, &lock*/);
thread_data.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_data[i-1]);
tids.push_back(tid);
}
for (int i = 0; i < NUM; i++)
{
pthread_join(tids[i], nullptr);
delete thread_data[i];
}
// pthread_mutex_destroy(&lock);
return 0;
}
线程饥饿:有互斥就可能会发生饥饿问题(在特定场景会发生),多线程竞争锁时,一个竞争能力弱的线程可能长时间申请不到锁资源,并且有可能一个线程重复的申请锁,因为当它持有锁执行完毕后释放锁,此时该线程如果又转头去申请锁,这个行为的速度高于唤醒其他被阻塞等待的线程去申请锁的速度,所以如果为了防止线程饥饿情况,我们可以制定两条规则:
- 其他没有申请到互斥量的线程要排队,不能在锁被释放后全一窝蜂全过来申请,这样会导致竞争能力弱的线程发生线程饥饿,并且这样的行为效率很低,毕竟最后只能有一个线程获得锁资源
- 释放锁后,线程不能立即重新申请锁,必须排到队列的尾部,即让所有的线程获取锁要按照一定的顺序获取资源,这被称为同步!
为了提高并发度、减小成本(轻量级进程)我们使用了多线程,因为线程之间资源的共享,引起了多线程并发访问数据而导致的数据不一致问题,所以又引入了互斥锁的概念,而单纯的互斥又会引发饥饿问题,这就引出了同步策略,我们之后再谈。
既然申请资源的线程都需要排队,那么还要什么锁呢?
需要注意排队是果,锁是因,每一个线程来访问临界资源时并不是直接就去排队了,而是先去试试该临界资源是否可以直接被访问,如果被锁了,才去排的队,所以锁的作用不是为了防止排队的线程,而是为了防止突然来到的线程对临界资源的访问
锁本身也是共享资源,每个线程申请释放的锁都相同,所以锁也是临界资源,即锁的申请与释放也是原子的
4. 互斥量实现原理
当代码执行在临界区中时,线程可以被切换吗?
可以切换,因为线程随时都可能被切换,但是我们可能有疑惑,我们上锁不就是为了防止线程切换而导致临界资源 tickets 被同时访问吗?
这是因为我们已经申请了锁,而在线程切换时我们并没有释放锁,线程手里拿着钥匙,别人是不可能解锁的,所以线程被切换走之后,照样没有人可以进入临界区访问临界资源,即对于其他线程而言,该临界区的资源是原子的(因为锁没有被释放),其他线程不能操作该临界区资源
加锁后的原子性体现在哪里?
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
例如,当线程2、3、4检测到其他状态时(锁未被释放)就会被阻塞
此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。
锁是否需要被保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
如何保证申请锁的过程是原子的?
上面我们已经说明了--和++操作不是原子操作,可能会导致数据不一致问题。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
lock和unlock的实现原理:
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
- 然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。
而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
- 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
- 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁
注意:
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了,也就是说该线程拿到钥匙了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
- 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
- CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
交换的本质:把内存中的数据(共享的锁,看作一个类型例如 int 1)交换到CPU的寄存器中,因为每个线程都有自己的上下文数据,也就是将数据交换到线程的硬件上下文中(是线程私有的)
整个过程就是把一个共享的锁,让一个线程以一条汇编的方式,交换到自己的上下文中,使锁成为该线程的私有数据,即线程持有了该锁
5. 简单封装互斥量
- 手动封装LockGuard.hpp文件,需要外部手动创建锁(我们使用全局的锁,使用宏,可以不用手动init、destroy),并传递 pthread_mutex_lock* 指针
- 在使用时巧妙使用花括号,限制LockGuard类对象的生命周期,从而达到创建锁,出作用域后自动调用析构函数释放锁
#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 LockGuard
{
public:
LockGuard(pthread_mutex_t* lock)
:_mutex(lock)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex _mutex;
};
#include <iostream>
#include <string>
#include <cstring>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include "LockGuard.hpp"
using namespace std;
// 使用我们封装的锁
class threadData
{
public:
threadData(int number /*, pthread_mutex_t *mutex*/)
{
threadname = "Thread-" + to_string(number);
// lock = mutex;
}
public:
string threadname;
// pthread_mutex_t *lock;
};
// 搭配全局锁使用
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// 并发访问的共享数据
int tickets = 3000;
const int NUM = 4;
void *getTickets(void *args)
{
threadData *td = static_cast<threadData *>(args);
int cnt = 0;
while (true)
{
//使用花括号(控制块)来限制lockguard对象的生命周期,从而实现锁的申请和释放
{
LockGuard lockguard(&lock);
if (tickets > 0)
{
usleep(1000);
printf("%s get a ticket: %d\n", td->threadname.c_str(), tickets);
tickets--, cnt++;
}
else
break;
}
usleep(13);
// 这里模拟抢票之后的后续动作(将票的信息写入用户名下等等操作),如果没有usleep,那么该线程释放锁之后又会立即拿到锁
// 会造成该线程竞争锁的能力很强,因为此时其他线程还在阻塞等待,将他们唤醒的效率(tcb调度队列)低于该线程再次申请锁
}
printf("%s quit..., cnt = %d\n", td->threadname.c_str(), cnt);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_data;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i /*, &lock*/);
thread_data.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_data[i - 1]);
tids.push_back(tid);
}
for (int i = 0; i < NUM; i++)
{
pthread_join(tids[i], nullptr);
delete thread_data[i];
}
// pthread_mutex_destroy(&lock);
return 0;
}
二、可重入与线程安全
1. 概念
1.1 可重入
定义:可重入函数是指在同一时刻可以被多个线程安全地调用,并且不会出现数据错乱或者其他问题。换句话说,一个可重入函数可以被同一个线程多次调用,即使它还没有执行完上一次调用。
特点:
- 无共享状态: 可重入函数内部不依赖于任何全局变量、静态变量或者其他共享资源。
- 线程局部数据: 所有函数内部的数据都是线程局部的,每个线程都拥有独立的数据副本。
- 递归调用: 可重入函数可以被递归调用,而不会导致错误。
1.2 线程安全
定义:线程安全的代码是指在多线程环境下,即使多个线程同时访问和修改共享数据,也能保证数据的一致性和正确性。
特点:
- 同步机制: 通常使用同步机制,如互斥锁、信号量、条件变量等,来保护共享资源,防止多个线程同时访问和修改。
- 原子操作: 某些操作必须是原子的,例如读取和写入共享变量。
- 线程局部存储: 使用线程局部存储(Thread Local Storage,TLS)将数据与特定线程绑定,避免共享数据带来的问题。
注意:重入描述的是函数被重复进入,线程安全描述的是多线程并发问题
2. 常见的线程不安全的情况
-
不保护共享变量的函数。
-
函数状态随着被调用,状态发生变化的函数。
-
返回指向静态变量指针的函数。
-
调用线程不安全函数的函数。
3. 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
4. 常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
5. 常见的可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
6. 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
7. 可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
三、常见锁概念
1. 死锁概念
单执行流可能产生死锁吗?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "LockGuard.hpp"
using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* Routine(void* args)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
pthread_exit((void*)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, Routine, nullptr);
pthread_join(tid, nullptr);
return 0;
}
用ps
命令查看该进程时可以看到,该进程当前的状态是Sl+
,其中的l
实际上就是lock的意思,表示该进程当前处于一种死锁的状态。
2. 死锁四个必要条件
- 互斥条件 :一个资源每次只能被一个执行流使用(前提)
- 请求与保持条件 :一个执行流因请求资源而阻塞时,对已获得的资源保持不放(原则)
- 不剥夺条件 : 一个执行流已获得的资源,在末使用完之前,不能强行剥夺(原则)
- 循环等待条件 : 若干执行流之间形成一种头尾相接的循环等待资源的关系(重要条件)
注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。
死锁的例子:
假设有两个线程 A 和 B,它们都需要访问两个资源 X 和 Y。
- 线程 A 已经获得了资源 X,现在需要资源 Y。
- 线程 B 已经获得了资源 Y,现在需要资源 X。
由于两个线程都无法获取到它们需要的资源,它们将互相等待,陷入死锁。
3. 避免死锁
死锁的产生必有以上四个条件,那么我们解决死锁原则上只需要保持其中一个不满足即可
- 去除互斥条件:重写代码,不使用锁。一般不会使用该方法
- 请求与不保持条件:请求对方的锁,释放自身的锁。使用 pthread_mutex_trylock 函数,如果申请失败不阻塞,而是直接返回(lock函数是直接阻塞),返回后再选择继续申请还是释放,这就很方便的破坏了保持条件
- 剥夺对方锁:释放对方的锁
- 加锁顺序一致:统一的按照锁1、锁2的顺序申请锁
- 避免锁未释放的场景:操作完立即释放锁
- 资源一次性分配:尽量把资源一次性给完,不要让线程去自己申请
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
四、Linux线程同步
1. 同步概念与竞态条件
同步:同步是指在多线程环境下,对多个线程的执行顺序进行协调,以确保它们按照预期的顺序访问和修改共享资源,从而避免因数据竞争而导致饥饿问题和程序错误。
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
线程饥饿:有互斥就可能会发生饥饿问题(在特定场景会发生),多线程竞争锁时,一个竞争能力弱的线程可能长时间申请不到锁资源,并且有可能一个线程重复的申请锁,因为当它持有锁执行完毕临界区代码后释放锁,此时该线程如果又转头去申请锁,这个行为的速度高于唤醒其他被阻塞等待的线程去申请锁的速度,所以如果为了防止线程饥饿情况,我们可以制定两条规则:
- 其他没有申请到互斥量的线程要排队,不能在锁被释放后全一窝蜂全过来申请,这样会导致竞争能力弱的线程发生线程饥饿,并且这样的行为效率很低,毕竟最后只能有一个线程获得锁资源
- 释放锁后,线程不能立即重新申请锁,必须排到队列的尾部,即让所有的线程获取锁要按照一定的顺序获取资源,这被称为同步!
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。
串联知识
多线程并发访问共享资源导致数据不一致问题,为解决数据不一致问题引出了互斥量概念,而由于多线程对于竞争互斥量的能力不同,会导致一些线程出现饥饿问题,此时我们又引出线程同步概念,解决线程饥饿问题。
同步是在保证数据安全的情况下,让线程访问资源具有一定的顺序性,使得所有排队的线程都有机会申请到互斥量。
既然申请资源的线程都需要排队,那么还要什么锁呢?
需要注意排队是果,锁是因,每一个线程来访问临界资源时并不是直接就去排队了,而是先去试试该临界资源是否可以直接被访问,如果被锁了,才去排的队,所以锁的作用不是为了防止排队的线程,而是为了防止突然来到的线程对临界资源的访问
2. 条件变量
条件变量是一个对象,它与一个互斥锁关联在一起。线程可以通过条件变量来等待某个特定条件的满足,而另一个线程可以通过条件变量来通知其他线程条件已经满足。
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起。
- 另一个线程使条件成立后唤醒等待的线程。
注意:条件变量要和锁搭配,因为要保证资源安全。
3. 条件变量函数
3.1 初始化条件变量
初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般设置为NULL即可。
返回值说明:
- 条件变量初始化成功返回0,失败返回错误码。
与mutex函数用法几乎完全相同
静态分配
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
3.2 销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
销毁条件变量需要注意:
- 使用
PTHREAD_COND_INITIALIZER
初始化的条件变量不需要销毁。
3.3 等待条件变量
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
在条件满足时,将线程挂到条件变量下等待,等待时会自动释放锁,直到被唤醒再次获取锁
参数说明:
- cond:需要等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
3.4 唤醒等待
唤醒等待的函数有以下两个:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
区别:
- pthread_cond_signal 函数用于唤醒等待队列中首个线程。
- pthread_cond_broadcast 函数用于唤醒等待队列中的全部线程。
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
格外注意多线程创建时,第四个参数要传malloc的地址还是拷贝一份数据
如果在创建多个线程的for循环内创建临时变量,并将该临时变量的地址强转为void*传递给线程函数,那么在下一次for循环时该临时变量就已经被销毁了,如果线程函数依旧使用这个已经被销毁的临时变量的地址,这会出现段错误。如果想让线程独立,那么就尽量不要传递地址,除非是malloc出来的对空间
释放等待队列的首个线程
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <vector>
using namespace std;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
const int NUM = 4; //多线程数量
int cnt = 0; //多线程修改全局变量
void* Count(void* args)
{
pthread_detach(pthread_self());
uint64_t number = (uint64_t)args;
printf("pthread: %d create success\n", number);
while (true)
{
//申请锁
pthread_mutex_lock(&lock);
//同步解决饥饿问题(某个线程霸占锁),让它们进来就在条件变量的队列里去排队
pthread_cond_wait(&cond, &lock); //传入锁参数原因:pthread_cond_wait让线程等待时,会自动释放锁,即如果你拿着锁又来排队这是不合理的
// printf("pthread: %d\n", *(uint64_t*)args);
printf("pthread: %d, cnt = %d\n", number, cnt++);
//释放锁
pthread_mutex_unlock(&lock);
}
}
int main()
{
// vector<pthread_t> tids;
for (uint64_t i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, Count, (void*)i);
// pthread_create(&tid, nullptr, Count, (void*)&i);
//注意第四个参数不是&i,因为for创建i之后,i值会因为循环i++而改变,如果传递&i,主线程与新线程共享同一个数据
//那么之前得到i=1的线程,它的i会随之变化,会引发多线程并发访问的问题,并且循环结束之后,局部变量i就会被销毁,所以传参时直接拷贝(void*)i
//即如果不想让主线程与新线程共享变量,那么就不要传局部变量
// tids.push_back(tid);
usleep(1000);
}
sleep(3);
printf("main thread ctrl begin: \n");
while (true)
{
sleep(1);
//每次唤醒一个条件变量队列里的第一个线程
pthread_cond_signal(&cond);
printf("signal one thread...\n");
}
//这里我们直接采用分离
// for (int i = 0; i < NUM; i++)
// {
// pthread_join(tids[i], nullptr);
// }
return 0;
}
流程解析:
四个线程被创建后,各自执行线程函数Count,其中每个线程都会申请到锁,然后执行pthread_cond_wait函数,在条件变量下等待,在条件变量下等待时会被释放锁,所以其他线程就会申请到锁,然后也进入条件变量下等待,然后释放锁,直到所有线程都在条件变量下等待。
因为我们对每个新线程都detach了,所以主线程不会阻塞等待新线程退出,所以我们要保证主线程最后一个退出,不然主线程创建完新线程后会直接执行到return 0,然后进程退出,线程也会被强制退出
在主线程尾部,我们while循环,每次唤醒一个线程,让它执行临界区代码,执行完毕后,该线程会释放锁,然后再次申请锁,再去条件变量下排队,所以这里的输出具有顺序性和周期性
释放等待队列的全部线程
如何判断一个线程是否要去条件变量队列里等待?
一定是临界资源没有就绪,例如tickets < 0,这时候就抢不了票了
我们怎么知道临界资源时否就绪了?
程序员手动判断,if (tickets < 0) 则临界资源不就绪,反之,则就绪
3.5 为什么 pthread_cond_wait 需要互斥量
-
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
-
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 等待的操作一定在加锁和解锁之间。还是以抢票为例,if (tickets > 0)这本身就是在访问临界资源,如果tickets < 0那么该线程可以选择离开或继续等待下一轮放票,如果继续等待则调用pthread_cond_wait ,否则解锁离开(当然了等待时也会被释放锁)
- 所以在调用
pthread_cond_wait
函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。 - 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
3.6 总结
- 等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
- 条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
- pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。
4. 条件变量使用规范
等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
这里判断条件为假使用的是while,这是为了防止伪唤醒情况,伪唤醒将在生产消费模型中解释
唤醒等待线程的代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
原文地址:https://blog.csdn.net/prodigy623/article/details/142736807
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!