我们在上一节多线程提到没有任何保护措施的抢票是会造成数据不一致的问题的。
那我们怎么办?
答案就是进行加锁。
目录
- 加锁:
- 认识锁和接口:
- 初始化:
- 加锁 && 解锁:
- 全局的方式:
- 局部的方式:
- 原理角度理解:
- 实现角度理解:
- 同步:
加锁:
认识锁和接口:
初始化:
这个就是我们互斥锁的类型。其中互斥代表任何时刻只允许一个线程进行访问,锁代表为了实现互斥提供的一种方式。
锁不是一个单纯的内置类型,而是一个
那么就肯定要对他进行初始化。
其中我们有两种初始化方式。
对于全局的锁,我们使用宏的方式。
而局部的锁我们则需要进行init初始化。
全局的所使用宏初始化后就不需要进行destory,因为随着生命周期的结束,会自动被系统回收。
但是局部的锁使用结束时要使用destory进行销毁。
加锁 && 解锁:
对于锁我们现在要有一个理解:对于多线程时,每个线程都会竞争这把锁,但只有一人会竞争成功,失败的会被阻塞,直到锁被解锁。
我们的锁是进行保护临界区的,或者说是保护临界资源。
而我们保护临界资源,本质是对临界区代码进行保护,怎么样理解这句话呢?
我们所有的资源都是通过代码进行访问的,所以本质上就是把访问资源的代码保护起来。
加锁之后当然要进行解锁
所以我们就来改进一下上一章节产出的封装线程库 + 抢票的代码。
全局的方式:
我们要先看一下错误的加锁方式:
现象:
原因:因为只有一个线程会抢到锁,而对于上图程序而言一旦加锁就势必要把全部的票数抢完才可以解锁,也就意味着别的线程都无法抢票了。
所以上图的加锁方式是错误的,失去了多线程的意义。
正确的加锁:
现象:
结论:
- 加锁的范围要小粒度,非临界区是并行执行,临界区是串行执行,当你的粒度过大,串行的就多了,效率就低下了。
- 任何线程,进行抢票都需要申请锁,并不能因为程序是你写的而是个别线程出现特例。
- 所有的线程都申请锁,前提是所有的线程都可以看到这把锁,这意味着锁是共享资源,如何保证锁的安全?锁是原子的!
- 原子性:要么没做,要么就是做完,没有中间状态。他的反例就是吃饭,吃饭有没吃,也有吃了,但是还有吃饭中.
- 线程申请锁失败了就要被阻塞
- 线程申请锁成功继续运行
- 如果线程申请锁成功了,在执行临界区代码,在执行临界区代码期间可以被切换吗?
答案是可以的,并且其他线程依旧无法进入,因为被切换的进程带着锁走了并没有释放!
结论:对于没有锁的线程,只有申请了锁的线程释放了线程才是有意义的。
其实对于访问临界区,对于无锁线程来说也是原子的。
局部的方式:
局部锁我们就要修改一下上节课的代码:
首先创建mythread时就不能单纯的只有name,还要再多一个mutex指针,于是我们选择传一个结构体指针即可。
具体变动如下,随着传入数据的修改也要修改一下连带的部分,造成了牵一发动全身的情形。
而引入了模板就可以避免这些问题,这就是模板的好处,但是由于这里我们并没有使用模板,所以暂时只能这样
#include <iostream>
#include <string>
#include <unistd.h>
#include <cstdio>
#include <pthread.h>
class ThreadData
{
public:
ThreadData(const std::string name, pthread_mutex_t* mutex) : _name(name), _mutex(mutex)
{
}
public:
std::string _name;
pthread_mutex_t* _mutex;
};
namespace cyc
{
class mythread
{
public:
typedef void (*func_t)(ThreadData*);
mythread(ThreadData* td, func_t func) : _td(td), _func(func), _isRunning(false)
{
}
~mythread()
{
}
void Excute()
{
_isRunning = true;
_func(_td);
_isRunning = false;
}
static void *routine(void *arg)
{
mythread *self = static_cast<mythread *>(arg);
self->Excute();
return nullptr;
}
void Start()
{
int n = ::pthread_create(&_tid, nullptr, routine, (void *)this);
if (n != 0)
{
perror("pthread_create fail");
exit(1);
}
}
void Stop()
{
if (_isRunning)
{
pthread_cancel(_tid);
_isRunning = false;
}
}
void Join()
{
int n = pthread_join(_tid, nullptr);
if (n != 0)
{
perror("pthread_join fail");
exit(1);
}
std::cout << _td->_name << " Join sucess..." << std::endl;
delete _td;
}
std::string GetStatus()
{
if (_isRunning)
return "Running";
else
return "sleeping";
}
private:
ThreadData *_td;
pthread_t _tid;
func_t _func;
bool _isRunning;
};
}
传参时new一下
加锁时直接找td的成员即可。
此外我们还有另一种加锁方式,称之为RAII风格。
定义一个局部变量,当此时循环开始或者结束时自动创建,而正好这个类的构造与析构包含了lock与unlock,避免了繁琐的上下锁。
原理角度理解:
其实我们在加锁与解锁已经说明了很大一部分原理了。
接下来我们要探讨一下为什么lock后,
申请到锁的线程会继续执行程序,而其他线程会阻塞住?
最主要的原因就是申请到锁的线程在lock中会返回一个值,从而继续运行,而申请失败的线程则会不返回,线程就是阻塞了。
另外,我们的lock函数与pthread_mutex_t这个类型都是Pthread库中的,这个库会维护这套东西,当申请失败就会线程状态设置为S,放入等待队列中,当申请成功的线程unlock后,阻塞在lock函数内部的线程被重新唤醒,继续申请锁,重复以上步骤。
实现角度理解:
到实现层面上我们就必须谈谈原子性。
原子性在概念上是两态的,一条汇编就是原子的,他有多种体现形式,比如我们说过的抢票代码。
这里插个嘴,比较深的了解一下硬件,对整体节奏无影响:
我们的程序经过编译之后形成汇编,就像printf->十几行汇编->二进制:此时我们的二进制由两部分构成,一部分是二进制数据(int float…),一部分是二进制指令(被CPU进行执行)。
可是CPU怎么认识二进制指令?
CPU除了有寄存器构成,还有硬件电路构成,其中我们的数据寄存器中存储着数据,当我们进行加减乘除时怎么操作?CPU具有指令集,指令集可以知道执行什么操作,但具体怎么执行要靠硬件电路。
所以CPU在设计时就存在指令集这一概念。
因为CPU最初可以认识加减乘除,所以我们最开始的程序是由二进制编写的,比如纸带…–>但是效率太低,我们就有了汇编,也就有了编译器-------->C/C++。
这些语言都在指令集的基础上才得以存在。
为了实现互斥锁操作,大多数体系结构(CPU)都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码看一下
现在我们将这个伪代码走一遍可以更好的感受锁(下图是一些辅助知识帮助理解)。
具体步骤:
同步:
我们可以观察到抢票的结果:其中一个线程抢票很频繁,这也是我们同步要解决的问题。
我们现在就要举例子进行一个形象的解释: