线程同步的4种方式:互斥锁、条件变量、读写锁、信号量
了解概念-临界资源、互斥、临界区、原子性
回想一下在信号量那部分提起过的几个概念,将多个执行流串行安全访问的共享资源称为临界资源,多个执行流中访问临界资源的代码所在的地址空间称为临界区,临界区往往是线程代码的一两句,想让多个线程串行访问共享资源,保护方法之一是互斥。
要么不做,要么就是做完,只有两态,称为原子性。只用一条汇编语句就能完成对资源进行操作,就是原子性操作,否则就不是原子性操作(++/–要用三条汇编)
代码必须要有互斥行为指的是1、当线程A进入临界区执行时,不允许其他线程进入该临界区;2、如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区;3、如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量,也叫互斥锁。
thread mutex互斥锁
线程栈是由线程库帮用户维护的
场景:多线程购票–负数票
场景:购票系统。1000张票==>设为全局变量。让每个线程充当一个抢票用户,然后多线程同时对一个全局变量进行操作,实验现象:当票数<0时,一些线程仍显示抢负数票成功。
要看到上述现象,要求代码中让多个线程交叉执行(本质是让调度器尽可能频繁发生线程调度),才会发生对全局数据判断出错。
线程发生切换的条件
线程一般在什么时候发生切换呢?
1、时间片到了;2、更高优先级的线程来了;3、线程等待的时候。
线程怎么判断自己要被切换了呢?
该线程从内核态返回用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换。
#include "/home/yyq/linux-class/2023_03_23_ThreadMutex/Thread.hpp"
#include <memory>
#include <unistd.h>
int tickets = 1000;
void* getTicket(void* args)
{
std::string username = static_cast<const char*>(args);
while(1)
{
if(tickets > 0)
{
//模拟真实抢票要花费的时候
usleep(12345);//1秒 = 1000毫秒= 1000 000微秒
std::cout << username << "正在抢票 " << tickets << std::endl;
tickets--;
}
else
{
std::cout << "=====没票了=====" << std::endl;
break;
}
usleep(1000);
}
return nullptr;
}
int main()
{
std::unique_ptr<Thread> thread1(new Thread(1, getTicket, (void*)"user1..."));
std::unique_ptr<Thread> thread2(new Thread(2, getTicket, (void*)"user2..."));
std::unique_ptr<Thread> thread3(new Thread(3, getTicket, (void*)"user3..."));
std::unique_ptr<Thread> thread4(new Thread(4, getTicket, (void*)"user4..."));
thread1->join();
thread2->join();
thread3->join();
thread4->join();
return 0;
}
每个线程都会先执行usleep();
,属于3线程等待的时候这个切换条件。某个线程休眠的时候,其他线程正在抢票,当该线程被唤醒时,就容易发生负数抢票的情况。
++/–在内核中做了什么
- 判断的本质逻辑是,1、读取内存数据到cpu的寄存器中;2、加法器判断(tickets+(-1)>0);
tickets--
做的是:1、读取数据;2、更改数据;3、写回数据;
– 操作并不是原子操作,而是对应三条汇编指令:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
负数票原因分析
分析:当线程1进去的时候,把tickets读到寄存器里,值为1,然后线程2进来了,线程1进入休眠就被切换,带着自己的上下文就走了;线程2也读到1,线程3也读到1,线程4也读到1…每个线程都认为还有1张票。线程1醒过来了,要再去读一下tickets,还是1,打印然后–(读取+更改+写回),tickets变成0;线程2醒过来,再去读一下tickets,是0,打印然后–(读取+更改+写回),tickets变成-1;线程3醒过来,再去读一下tickets,是-1,打印然后–(读取+更改+写回),tickets变成-2;线程4醒过来,再去读一下tickets,是-2,打印然后–(读取+更改+写回),tickets变成-3,所以出现了票数为负数的情况!
问题:当tickets==1时,这4个线程能不能同时进入到getTicket函数?可以。能不能同时进行if(tickets > 0)条件判断?不可以。
对一个全局变量进行多线程更改是安全的吗?如果操作是原子的,则是安全的。++/–不是原子操作。对变量进行++或者–操作,在C/C++的代码虽然只有一条,但汇编以后至少是3条语句(1、读取内存数据到cpu的寄存器中;2、在寄存器中让cpu进行对应的算逻运算;3、将寄存器新的数据写回内存对应位置),这3步任何一步被打断,都要从这一步重新执行。
我们定义的全局变量在没有被保护的情况下,多线程交替执行造成的数据安全问题==>数据不一致。
如何解决–加锁!
mutex互斥量 – 锁
pthread_mutex_lock
和pthread_mutex_unlock
之间保护起来的得是临界区,用锁保护的区域没写对也不行。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//局部锁要用init初始化和destory销毁
-----------------
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//这样定义的是全局锁,PTHREAD_MUTEX_INITIALIZER表示初始化,不用再调用初始化pthread_mutex_init和销毁pthread_mutex_destroy函数
-----------------
int pthread_mutex_lock(pthread_mutex_t *mutex);//阻塞式申请加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//非阻塞式申请加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//在lock和unlock之间的代码就是临界区
mutex输出型参数
互斥锁的使用方法
初始化互斥锁
//方法1,静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//方法2,动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
trylock
返回0表示成功加锁;否则返回错误码,表示此时有线程已经加锁了。即非阻塞式加锁,可以避免死锁。
销毁互斥锁
销毁互斥量需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁! - 不要销毁一个已经lock的互斥量!
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁!
如何看待锁?
多个线程是用锁来进行互斥的,锁用来保护这部分全局资源。锁本身就是共享资源,也是临界资源。所以要求pthread_mutex_lock 加锁的过程必须是安全的。
加锁的过程是原子的!
- 要么申请成功,执行流进入临界区,访问临界资源;
- 要么申请不成功,此时执行流会阻塞(挂起状态),直到锁被释放,执行流再被唤醒。
执行流持有锁后,才能进入临界区。多线程中,当线程1申请锁成功进入临界区,其余线程处于阻塞状态,且线程1可以被切换。当持有锁的线程被切换时,其余线程依旧无法成功申请锁,直到该线程主动解锁。
对于其他线程而言,有意义的锁的状态,就两种:申请锁前、释放锁后(因为其他线程此时可以成功申请锁),持有锁时这个状态无意义的(因为其他线程此时没法申请锁,只能阻塞)==> 所以站在其他线程的角度看待当前线程持有锁的过程就是原子的。
未来我们在使用锁的时候,一定要尽量保证临界区的粒度非常小。既安全保护了共享资源,又不降低多线程运行速度。(线程加锁,要么全部线程都加,要么全部都不加)
锁的安全由谁来保护?
加锁和解锁的底层实现
加锁和解锁的过程是多个线程串行执行的。锁只规定了互斥访问,没有规定让哪个执行流优先执行,故谁能先加锁是多条执行流竞争的结果。
加锁,会让所有执行流串行访问临界区,从而达到互斥的目的。
为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器内容 > 0){
return 0;
}else
等待挂起;
goto lock;
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
共识:CPU内寄存器只有一套,是被所有执行流共享的,但是CPU寄存器上的内容是每个执行流私有的!
这里的0表示锁已被申请,1表示锁未被申请;%al表示寄存器里的内容;进程初始化锁,就相当于再自己的内存单元放了1。
lock–加锁的过程:
movb $0, %al
:表示把0放到寄存器里 ==> 如果此时被切换,这条语句就是将0放在当前线程的上下文里。如果此时线程被切换了,也不用担心,该进程会带走自己的上下文;
xchgb %al, mutex
:表示交换寄存器和内存单元的数据进行交换,而mutex是共享变量(当前值为1),一条汇编指令就完成了,将共享的mutex数据(值为1)交换到寄存器(当前值为0)里,那么寄存器里的值就是1,进程的内存单元中共享数据为0,意思就是当前线程把锁拿走了。如果此时该线程被切换了,1就会在当前线程的上下文中,此时别的线程在进程的共享单元也拿不到1,就会等待挂起,而持有1的线程执行return 0;可以继续执行。
unlock–解锁的过程:
movb $1, mutex
:表示把1放到内存单元中的共享数据mutex里,相当于归还锁。
加锁后的多线程购票代码
#include <iostream>
#include <string>
#include <cstring>
#include <cassert>
#include <pthread.h>
#include <vector>
#include <memory>
#include <unistd.h>
//封装一个大号结构体,把信息给线程 -- 局部锁
class ThreadData
{
public:
ThreadData(const std::string threadname, pthread_mutex_t* pmutex)
: _threadName(threadname), _pmutex(pmutex)
{}
~ThreadData(){}
public:
std::string _threadName;
pthread_mutex_t* _pmutex;
};
int tickets = 1000;
void *getTicket(void *args)
{
ThreadData* td = static_cast<ThreadData *>(args);
while (1)
{
pthread_mutex_lock(td->_pmutex);
i (tickets > 0)
{
// 模拟真实抢票要花费的时候
usleep(12345); // 1秒 = 1000毫秒= 1000 000微秒
std::cout << td->_threadName << "正在抢票 " << tickets << std::endl;
tickets--;
pthread_mutex_unlock(td->_pmutex);
}
else
{
pthread_mutex_unlock(td->_pmutex);
std::cout << "======没票了======" << std::endl;
break;
}
usleep(1000);
}
return nullptr;
}
int main()
{
// 创建,初始化线程
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
#define NUM 4
std::vector<pthread_t> tids(NUM);
// 用系统接口写多线程抢票
for(int i = 0; i < NUM; ++i)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "thread %d", i+1);
ThreadData* td = new ThreadData(buffer, &lock);
pthread_create(&tids[i], nullptr, getTicket, (void*)td);
}
for(const auto& tid : tids)
{
pthread_join(tid, nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
互斥锁的缺点:死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源(1把锁及以上),但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
在有多把锁的场景下,持有自己的锁不释放,还要申请其他的锁,其他线程也是如此,这样很容易造成死锁。只有1把锁,也会造成死锁,比如线程A申请到了锁,A又去申请锁,自己就会被阻塞挂起,这个时候没人去释放这把锁,就变成死锁了。
多线程的特性是大部分资源包括全局资源是共享的,所以在进行多线程访问的时候会出现数据不一致的问题,为了保护临界资源的安全,我们就要用锁,由此会带来死锁的问题。
除了死锁,还存在重复锁定和解锁,每次都会检查共享数据结构,浪费时间和资源;繁忙查询的效率非常低等问题。
死锁的四个必要条件
4个条件均需满足
- 互斥条件:一个资源每次只能被一个执行流使用;(这是锁的基本特性)
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放;(线程A自己有锁,又去申请锁,进而无法释放)
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺;
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。(基于前3个条件造成的,如线程A有自己的锁,又去要线程B的锁;线程B有自己的锁,又去要线程C的锁;线程C有自己的锁,又去要线程A的锁;)
避免死锁的方法
死锁检测算法、银行家算法等都是依靠下面的方法
- 破坏死锁的四个必要条件。互斥条件和不可剥夺条件由共享资源本身的使用特性所决定的,因此不好破坏,相反还应加以保证;请求与保持条件可以用try_lock和及时释放锁破坏;不剥夺条件可以用优先级或状态破坏;循环等待条件可以用一致加锁顺序破坏。
- 加锁顺序一致;
- 避免锁未释放的场景;
- 资源一次性分配。
线程A申请的锁可以被线程B释放,但在实际应用中,最好是谁申请谁释放。
银行家算法:安全状态是非死锁状态,而不安全状态并不一定是死锁状态。即系统处于安全状态一定可以避免死锁,而系统处于不安全状态则仅仅可能进入死锁状态。银行家算法的实质就是要设法保证系统动态分配资源后不进入不安全状态,以避免可能产生的死锁。
RAII风格 封装锁
#pragma once
#include <iostream>
#include <thread>
class Mutex
{
public:
Mutex(pthread_mutex_t* lock = nullptr) : _lock(lock)
{}
void lock()
{
if(_lock) pthread_mutex_lock(_lock);
}
void unlock()
{
if(_lock) pthread_mutex_unlock(_lock);
}
~Mutex()
{}
private:
pthread_mutex_t* _lock;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* mutex) : _mutex(mutex)
{
_mutex.lock();//在构造函数里加锁
}
~LockGuard()
{
_mutex.unlock();//在析构函数里解锁
}
private:
Mutex _mutex;
};
在调用的时候直接创建LockGuard对象即可
LockGuard lockguard(&lock); // RAII风格 该对象的生命周期就只在循环体内,会自动析构解锁
可重入与线程安全
常见的线程不安全的情况
- 不保护共享变量的函数;
- 函数状态随着被调用,状态发生变化的函数;
- 返回指向静态变量指针的函数;
- 调用线程不安全函数的函数;
常见的可重入情况
- 不使用全局变量或静态变量;
- 不使用用malloc或者new开辟出的空间;
- 不调用不可重入函数;
- 不返回静态或全局数据,所有数据都有函数的调用者提供;
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据;
可重入与线程安全联系
函数是可重入的,那就是线程安全的。函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题,如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
可重入函数是线程安全函数的一种;线程安全不一定是可重入的,而可重入函数则一定是线程安全的。如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。