喜欢的点赞,收藏,关注一下把!
到目前为止我们学了线程概念,线程控制接下来我们进行下一个话题,线程互斥。
有没有考虑过这样的一个问题,既然线程一旦被创建,几乎所有资源都是被所有线程共享的。 那多个线程访问同一份共享资源有没有什么问题?
下面我们模拟一下抢票的场景,看到底有没有什么问题
#include
#include
#include
#include
//共享资源,火车票
int ticket = 10000;
void *GetTicket(void *args)
{
string name = static_cast<const char *>(args);
while (true)
{
if (ticket > 0)
{
cout << name << " 正在进行抢票: " << ticket << endl;
ticket--;
//以微秒为单位进行休眠,模拟真实的抢票要花费的时间
usleep(1000);
}
else
{
break;
}
}
}
int main()
{
pthread_t t1,t2,t3,t4;
pthread_create(&t1,nullptr,GetTicket,(void*)"thread->1");
pthread_create(&t2,nullptr,GetTicket,(void*)"thread->2");
pthread_create(&t3,nullptr,GetTicket,(void*)"thread->3");
pthread_create(&t4,nullptr,GetTicket,(void*)"thread->4");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
pthread_join(t4,nullptr);
return 0;
}
我们想看到的现象是抢到负数票,那怎么实现呢?
既然想看到抢到负数票,就需要尽可能的让多个线程交叉执行。
多个线程交叉执行的本质:就是让调度器尽可能的频繁发生线程调度与切换。
线程一般在时候发生切换呢?
时间片到了,来了更高优先级的线程,线程等待的时候。
那线程是什么时候检测上面的问题呢?
从内核态返回用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换。
那修改一下代码
void *GetTicket(void *args)
{
string name = static_cast<const char *>(args);
while (true)
{
if (ticket > 0)
{
//线程进来之后先休眠,要被切走
usleep(1000);
cout << name << " 正在进行抢票: " << ticket << endl;
ticket--;
//以微秒为单位进行休眠,模拟真实的抢票要花费的时间
//usleep(1000);
}
else
{
break;
}
}
}
出问题了,放了10000张票,结果抢到了10002张票。现象就是这个样子。那为什么会出现这样的问题?
所谓判断的本质逻辑:
1.读取内存数据到CPU内部寄存器中
2.进行判断
所以ticket=1,多个线程可以同时执行这个判断语句。对不对?
答案是不对的。
我们只有一个CPU,只有一份寄存器,不能同时判断,但是注意我们写了usleep语句,线程是要被切换走的,但是寄存器中的内容是属于这个线程的,因此1也要被切走
剩下的线程就可以开始竞争执行if语句判断,但很不幸最终都是进去后先休眠。
当线程1被唤醒恢复上下文,执行到ticket- -;
ticket- -有三个步骤:1.读取数据,2.更改数据,3.写回数据
此时线程1从内存中读取ticke还是1,最后写回内存ticket为0
线程2此时醒来恢复上下文,它不知道内存中ticket此时是0,往下执行到ticket- -,此时取到ticket是0,最后写回内存中ticket是-1
线程3也醒来,和上面一样,执行到ticket- -,从内存取到数据为-1,写回内存中是-2
就是因为我们判断和更新分开,而在中间发生了大量的线程切换,最终可能出现ticket本来就是1了,但是你却放了大量线程同时进来对ticket变量做减减操作,进而导致我们的数据出现了负数的情况
那没有判断,多线程单纯对一个全局变量进行修改是安全的吗?
假设ticket初始是1000,多个线程进行执行ticket- -操作
假设刚开始threadA执行。
补充:
对变量进行++,或者- - ,在C/C++上看起来只有一条语句,但是汇编之后至少是三条语句
1.从内存读取数据到CPU寄存器中
2.在寄存器中让CPU进行对应的算逻运算
3.写回到新的结果到内存中变量的为止
未来会对应三条汇编语句!!
threadA,做完第1,2步ticket变成999了,准备执行第3步,
但不幸的是threadA被切走了,虽然被切走了,但是等会回来还是从被切走的地方继续往下执行。寄存器只有一份,但寄存器的内容属于当前进程的上下文,threadA被切走了,自然这些东西也被要拿走到threadA的上下文
threadB被调度 ,threadB是新来的,它要重新开始执行这个语句。从1000开始减。第1,2,3步一直在疯狂执行。执行了800次,当这一次ticket写回到内存变成200后,再次执行到第一步,
时间片到了就把threadB切换走了。
然后把thradA拿回来了,首先恢复它的上下文,它的寄存器放的依旧还是999,然后继续执行第3步。
但是这一下就完蛋了,threadB好不容易把ticket减到了200,你一下给人感到了999。此时是不是相当于多线程运算的时候发生了干扰问题。
所以即便没有上面if,只有- -依旧出现问题。
我们得到的结论就是:我们定义的全局变量,在没有保护的时候,往往是不安全的,像上面多个线程在交替执行造成的数据安全问题,发生了数据不一致问题。
下一步就是解决这个问题。如何解决呢?
加锁
在解释之前,我们先复盘一下以前学过的知识。
1.多个执行流进行安全访问的共享资源 ---- 临界资源
2.我们把多个执行流中,访问临界资源的代码 ---- 临界区
(并不是整个代码都是临界区,只有访问临界资源的代码才是临界区)
临界区往往是线程代码中很小的一部分
3.想让多个线程串行访问共享资源 ---- 互斥
像刚刚的情况就是我们多线程在并发或并行的访问而没有保护而所导致出现的问题。
4.对一个资源进行访问的时候,要么不做,要么做完 ---- 原子性 理解它,我们看看不是原子性的情况
就比如这个,执行第1,2步,但到第3步的是被切走了,请问thradA对ticket- -操作是原子的吗?
不是,虽然它做了,但是没做完,有中间状态,第一步,第二步,第三步是可以被打断了的,这就不是原子性。虽然ticket- -只是一句语句,但是未来会对应三条汇编语句。
我们给原子性一个的概念
一个对资源进行的操作,如果只用一条汇编就能完成 ---- 原子性
反之:就不是原子的
当前这样理解是为了方便表述,所以这样理解。但是假设原子性是一个圈,我们刚说的这个只是原子性的一个子集。
接下来我们细谈这把锁。
锁也是一个数据类型,它的类型是
pthread_mutex_t
如果一个锁定义好了,我们必须对它进行初始化
如果是把局部锁,就必须用init初始,用完之后destroy销毁
mutex:要初始化的锁
attr:锁的属性,我们设置成nullptr就可以了
全局的锁,只需这样就自动初始化,销毁
而使用锁想对某段代码进行安全访问,必须加锁
未来不想保护了,就解锁
我们用用这把锁
int ticket = 10000;
//全局锁
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
void *GetTicket(void *args)
{
string name = static_cast<const char *>(args);
while (true)
{
//加锁
pthread_mutex_lock(&lock);
if (ticket > 0)
{
usleep(1000);
cout << name << " 正在进行抢票: " << ticket << endl;
ticket--;
//以微秒为单位进行休眠,模拟真实的抢票要花费的时间
//usleep(1000);
pthread_mutex_unlock(&lock);//解锁
}
else
{
//加锁可能条件不满足走到else,这里也需要解锁
pthread_mutex_unlock(&lock);
break;
}
//不能这里解锁,不然if条件不满足,走到else直接跳出循环还没有解锁
//pthread_mutex_unlock(&lock);
}
}
在加锁和解锁之间的就是传说中的临界资源,而访问临界资源的代码就是临界区,而通过加锁解锁也保证这部分代码要么不做要做完成。
局部锁我们也看看怎么用
int ticket = 10000;
//全局锁
//pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
//又想把"thread->1"参数给GetTicket,也想把锁给它
class ThreadData
{
public:
ThreadData(pthread_mutex_t* mutex_p,const string& name)
:_mutex_p(mutex_p)
,_threadname(name)
{}
~ThreadData()
{}
public:
pthread_mutex_t* _mutex_p;
string _threadname;
};
void *GetTicket(void *args)
{
ThreadData* td=static_cast<ThreadData*>(args);
while (true)
{
//加锁
pthread_mutex_lock(td->_mutex_p);
if (ticket > 0)
{
usleep(1000);
cout << td->_threadname<< " 正在进行抢票: " << ticket << endl;
ticket--;
//以微秒为单位进行休眠,模拟真实的抢票要花费的时间
//usleep(1000);
pthread_mutex_unlock(td->_mutex_p);//解锁
}
else
{
//加锁可能条件不满足走到else,这里也需要解锁
pthread_mutex_unlock(td->_mutex_p);
break;
}
}
}
int main()
{
//局部锁
pthread_mutex_t lock;
pthread_mutex_init(&lock,nullptr);//使用前初始化
#define NUM 4
vector<pthread_t> tids(NUM);
for (int i = 0; i < NUM; ++i)
{
char namebuffer[64];
snprintf(namebuffer, sizeof(namebuffer), "thread->%d", i + 1);
//锁和threadname都传给函数
ThreadData* td=new ThreadData(&lock,namebuffer);
pthread_create(&tids[i], nullptr, GetTicket, td);
}
for (auto &thread : tids)
{
pthread_join(thread, nullptr);
}
pthread_mutex_destroy(&lock);//使用后销毁
return 0;
}
运行结果是,我们的程序变慢了,这是为什么?(这里图片看不出来。
)。并且只有一个线程在抢?
加锁和解锁的过程是多个线程串行执行的,所以程序变慢了。
只有一个线程在抢的原因是:锁只规定互斥访问,没有规定必须让谁先优先执行,锁就是真是的让多个执行流进行竞争的结果。为什么一直是这个线程在跑因为它竞争锁的能力比其他线程强。
现在问题是,一般抢完票就完了吗?
当然不是,我们所看到票的信息,都是已经处理完之后给我们显示的。
所以一个线程抢完票之后还要做其他事情,这样才有机会让其他线程有持有锁的可能性
void *GetTicket(void *args)
{
//string name = static_cast(args);
ThreadData* td=static_cast<ThreadData*>(args);
while (true)
{
//加锁
pthread_mutex_lock(td->_mutex_p);
if (ticket > 0)
{
usleep(1000);
cout << td->_threadname<< " 正在进行抢票: " << ticket << endl;
ticket--;
//以微秒为单位进行休眠,模拟真实的抢票要花费的时间
//usleep(1000);
pthread_mutex_unlock(td->_mutex_p);//解锁
}
else
{
//加锁可能条件不满足走到else,这里也需要解锁
pthread_mutex_unlock(td->_mutex_p);
break;
}
//模拟处理其他事情
usleep(1);
}
}
接下来我们思考几个问题
1.如何看待锁
ticket是一个全局变量,被多个线程同时访问,我们称这个变量为共享资源或全局资源,这个共享资源经过锁的保护变成了临界资源,临界资源可以保证我们进行安全的访问,这个没什么问题。
那这把锁是不是也一定是要被多个线程访问临界资源前要先访问这把锁。因为要保护共享资源,所以要先加锁,要加锁是不是得每个线程都先看到并访问这把锁。
a.锁,本身就是一个共享资源!
全局的变量是要被保护的,而锁是用来保护全局的资源的。锁本身也是全局资源,锁的安全谁来保护呢?
b.pthread_mutex_lock、pthread_mutex_unlock:加锁的过程必须是安全的!
那是如何设计的呢?
加锁的过程其实是原子的! 要么就申请到,要么就不申请,不会存在中间状态。
c.如果申请成功,就继续向后执行,如何申请暂时没有成功,执行流会如何?
void *GetTicket(void *args)
{
//string name = static_cast(args);
ThreadData* td=static_cast<ThreadData*>(args);
while (true)
{
//加锁
pthread_mutex_lock(td->_mutex_p);
pthread_mutex_lock(td->_mutex_p);//申请一次,再申请一次
if (ticket > 0)
{
usleep(1000);
cout << td->_threadname<< " 正在进行抢票: " << ticket << endl;
ticket--;
//以微秒为单位进行休眠,模拟真实的抢票要花费的时间
//usleep(1000);
pthread_mutex_unlock(td->_mutex_p);//解锁
}
else
{
//加锁可能条件不满足走到else,这里也需要解锁
pthread_mutex_unlock(td->_mutex_p);
break;
}
//模拟处理其他事情
usleep(1);
}
}
我们看到代码就不再运行了,多线程每一个线程都在,但都不跑了
如果申请暂时没有成功,执行流会阻塞!(相当于挂起的状态) 去休眠了,直到这个锁释放了,OS或库会自动唤醒这个线程让它继续向后执行。
d.谁持有锁,谁进入临界区!
上面我们把临界资源,临界区,互斥都说过了,现在也都能理解了,可是原子性,为了理解给上面的解锁,只不过是一个子集。下面我们再把原子性问题谈一谈。
这个问题很好回答,刚说的,其他线程只能阻塞等待!
绝对可以的!随便切!
当持有锁的线程被迫切走的时候,是抱着锁被切走的,即便自己被切走了,其他线程依旧无法申请成功,也便无法向后执行!直到我最终释放这个锁!
所以,对于其他线程而言,有意义锁的状态,无非两种
1.申请锁前
2.释放锁后
而站在其他线程的角度,看待当前线程持有锁的过程,就是原子的!
在未来我们使用锁的时候,一定要尽量保证临界区的粒度(锁中间保护代码的多少)要非常小。因为是串行访问的。
并且如果让线程1,线程2访问公共资源就加锁串行起来,线程3不加锁直接访问,这样是不对的。加锁是程序员行为,必须做到要加就都要加!
2.如何理解加锁和解锁的本质
加锁的过程是原子的!
其实你注意会发现解锁的要求其实并不高,我加锁了未来肯定只有一个执行流在解锁。解锁这件事情对原子性的要求或者安全性要求并不是特别特别强,但我们依旧要保证它的原子性,库也是这样设计的。
接下来我们就看看加锁解锁的实现原理,是如何保证原子性的。
到现在我们已经发现单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据不一致性问题。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
在继续往下谈的时候,我们先建立一个共识
1.CPU内寄存器只有一套被所有执行流共享
2.CPU内寄存器的内容,是每个执行流私有的,称之为运行时上下文。
线程A先跑,要进行加锁,加锁的伪代码就是lock下面的语句,
线程A先把0放到%al寄存器中
此时线程A把0放到寄存器中,可以被切走吗?在加锁任何一条汇编执行前或执行后,任何一条语句线程都可以被切换。
现在问题是把0放到%al中,这条语句的本质是什么?
是不是相当于把0值放到线程A的上下文当中!
如果这个时候线程A被切走,线程A一点都不担心,因为它知道我要被切走时,我要把这个0也要带走,当我回来时我在把0放回来。
接下来执行下一句汇编
内存中的mutex变量,线程A可以访问,线程B也可以访问,那这个mutex变量不就是我们的共享变量吗,就如同ticket,而刚刚竟然用一条汇编让线程A将寄存器中的值和内存中的值做交换,这个交换的动作是一条汇编完成,交换的本质是什么?
交换的本质:共享的数据,交换到我的上下文中!!!
就相当于线程A把这个锁拿走了,此时刚把这条语句执行完,线程A就被切走了,切走了线程A怕不怕,一点都不怕,先别着急把我切走,我要把我的上下文带走。
线程B被调度,依旧要执行加锁的逻辑,首先把0放到%al寄存器中
接下来也要进行交换,可是现在交换是0换0,线程B此时申请锁就不成功了
不成功,线程B只能挂起等待了。
线程A只要申请成功了,即使后序被切走了,但是一点都不担心,因为它是持有锁被切走的,其他线程来也申请不到锁。
线程B被切走了把0也带走,OS又调度线程A然后恢复上下文把1放到%al里,继续向下执行,经过if判断申请锁成功返回,执行自己的语句
解锁的代码就特别简单了,一句mov把1拷贝到mutex,唤醒其他等待锁的线程,然后return就结束了
其他线程就可以以同样的逻辑进行加锁解锁。
3.如果我们想简单的使用,该如何进行封装设计
把这个锁封装起来
//Mutex.hpp
#pragma once
#include
#include
using namespace std;
class Mutex
{
public:
Mutex(pthread_mutex_t* lock_p=nullptr)
:_lock_p(lock_p)
{}
void lock()
{
if(_lock_p) pthread_mutex_lock(_lock_p);
}
void unlock()
{
if(_lock_p) pthread_mutex_unlock(_lock_p);
}
~Mutex()
{}
private:
pthread_mutex_t* _lock_p;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* lockp)
:_mutex(lockp)
{
_mutex.lock();//在构造函数中进行加锁
}
~LockGuard()
{
_mutex.unlock();//在析构函数中进行解锁
}
private:
Mutex _mutex;
};
//mythread.cc
#include
#include
#include "Mutex.hpp"
int ticket = 10000;
// 又想把"thread->1"参数给GetTicket,也想把锁给它
class ThreadData
{
public:
ThreadData(pthread_mutex_t *mutex_p, const string &name)
: _mutex_p(mutex_p), _threadname(name)
{
}
~ThreadData()
{
}
public:
pthread_mutex_t *_mutex_p;
string _threadname;
};
void *GetTicket(void *args)
{
// string name = static_cast(args);
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
{
//构造的时候自动加锁,后面的代码都处于加锁状态
//局部变量生命周期随作用域,也就是这个代码块
//一次循环结束后自动调用析构函数,也就是自动解锁了
//这里不想把模拟其他事情也加锁,不然都是一个线程再跑,因此把加锁代码单独弄个作用域
LockGuard lockguard(td->_mutex_p);
if (ticket > 0)
{
usleep(1000);
// cout << name<< " 正在进行抢票: " << ticket << endl;
cout << td->_threadname << " 正在进行抢票: " << ticket << endl;
ticket--;
}
else
{
break;
}
}
// 模拟处理其他事情
usleep(1000);
}
}
int main()
{
// 局部锁
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr); // 使用前初始化
#define NUM 4
vector<pthread_t> tids(NUM);
for (int i = 0; i < NUM; ++i)
{
char namebuffer[64];
snprintf(namebuffer, sizeof(namebuffer), "thread->%d", i + 1);
ThreadData *td = new ThreadData(&lock, namebuffer);
pthread_create(&tids[i], nullptr, GetTicket, td);
}
for (auto &thread : tids)
{
pthread_join(thread, nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
就我们刚刚写的代码,票都被一个线程抢了,这个线程错了吗?
它没错,它本来就是互斥安全的访问,但并不合理。造成了其他线程的饥饿状态,在继续往下学习中,我们先谈一点概念。
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况:
常见的线程安全的情况:
常见不可重入的情况:
常见可重入的情况:
可重入与线程安全联系
可重入与线程安全区别
这里我们就说一种
死锁
具体来说就是指一组执行流不管是线程还是进程,它在持有自己的锁资源的同时,还想方设法的去申请对方的锁资源,因为大家互相持有自己的还申请对方的,而锁是不可抢占的锁,不可抢占就是我拿了锁除非我主动归还,否则你想要我的锁我不给。所以当我们互相持有自己的锁还想要对方的锁的时候,进而可能导致我们多执行流互相等待对方的资源而导致代码无法推进的情况。
举个例子
张三和李四这两个小朋友一起去商店,我是这家店的老板,他俩爸爸妈妈各自给他们一人五毛钱,问我棒棒糖多少钱,我说一块钱,张三问李四是不是有五毛钱,李四说是,张三说你把五毛钱给我把,我凑成一块钱买棒棒糖好不好,李四说为什么我要给你,你怎么不给我呢,你能不能把你的五毛钱给我,我凑成一块钱我来买棒棒糖。张三当然也不同意了。
这两个小朋友就互相拿着自己五毛钱,还在要着对方的五毛钱,两个人争执不下谁都没买成棒棒糖,这两个小朋友的状态就叫做死锁。
接下来我们谈谈死锁。
在多把锁的场景下,我们持有自己的锁不释放,还要对方的锁,对方也是如此,此时就容易造成死锁!
1.一把锁,有可能死锁吗?
当然有可能。
就比如说别人可以把你绊倒,你自己可以把自己绊倒吗?当然是可以的。
2.为什么会有死锁?我们看看逻辑链条
一定是你用了锁<—为什么你要用锁呢?<—保证临界资源的安全<----为什么要保证临界资源的安全?<—多线程访问我们可能会出现数据不一致的问题<—为什么会出现数据不一致的问题?<—因为是我们多线程并且使用的是全局资源<—为什么多线程访问全局资源会造成这样的问题<—多线程大部分资源(全局的)是共享的<—多线程共性
任何技术都有自己的边界,是解决问题的,但是可能在解决问题的同时,一定可能会引入新的问题!
死锁四个必要条件
这是锁的四个必要条件可能还会有其他条件,但是只要你需要得到死锁,这四个条件必须同时满足。
互斥:很好理解,就是必须保证访问某种资源是互斥的,这个没什么问题,这个是我们锁的基本特性,你是一把锁,本身就具有互斥能力,没有互斥能力怎么能谈你是一把锁呢?
请求与保持:请求就是我要你,保持就是我不释放我的。我要你的,但我不释放我的。
不剥夺:就像刚才要五毛钱,你要不给我我就揍你这是剥夺,另一种是把五毛钱给我,你别害怕我不打你我也不抢你的,我要你自愿给我。这叫做不剥夺条件。
循环等待,A有自己的锁,它去要B的锁,B有自己的锁,它去要A的锁。这就是循环等待,刚才的张三和李四就是形成了循环等待。
一旦死锁这四个条件都必须同时满足!那只要破坏这四个条件之间的其中一个死锁就不满足了。
避免死锁
互斥是锁的一种特性这不用考虑了没有办法破坏,你不用锁了也根本不会产生死锁问题。
请求与保持好处理,比如说我们的线程要访问一个或多个临界资源,它需要同时拥有两把锁,申请第一把锁成功,如果第二把锁申请失败了,失败了就把自己曾经申请的锁释放掉。此时就不会造成死锁了。
不剥夺,所谓不剥夺就是不能抢,那我们可以设置一个竞争策略,比如A申请到锁,再去申请B的锁,B的锁被其他线程拿到了,这个时候我们比较定义的优先级或其他,假设A的优先级比较高,那拿到B的锁的线程必须主动释放锁。
循环等待,就相当于我们在申请锁的时候,A线程先申请A锁,在申请B锁,而B线程先申请B锁,在申请A锁,所以两个线程天然申请锁的顺序就是环状的。我们可以尽量不让线程出现这个环路情况,我们让两个线程申请锁的顺序保持一致,就可以破坏循环等待问题。两个线程都是先申请A锁在申请B锁。
资源一次性分配,比如说你有一万行代码,有五处要申请锁,你可以最开始一次就给线程分配好,而不要把五处申请打散到代码各个区域里所导致加锁场景非常复杂
避免死锁算法
一个线程申请到锁,另一个线程可以解锁吗?比如说A线程申请到锁,B线程可以释放这个锁吗?