目录
一、资源共享问题:
1、数据不一致:
2、临界区与临界资源:
二、多线程模拟抢票:
出现问题:
三、锁:
1、锁的创建与销毁:
2、加锁操作:
2、解决抢票遗留问题:
3、锁的细节问题:
4、锁的原理:
5、封装锁:
6、什么是死锁:
7、解决死锁:
四、可重入 与 线程安全
一、资源共享问题:
1、数据不一致:
当多线程对全局变量(也就是共享资源)进行访问的时候,会出现一个线程正在进行访问读取处理,此时发生线程切换,然后导致另一个线程修改了共享资源的情况,这样就导致了数据不一致问题
2、临界区与临界资源:
在多线程中,能够被多线程同时看到的资源成为临界资源,在代码中,涉及对临界资源的操作代码称为临界区
二、多线程模拟抢票:
我们通过多线程来实现一个抢票系统:
抢票时间和抢票成功后支付之类的时间直接用usleep()模拟
其中,我们将临界资源ticket作为票数,然后在每一个执行流中进行抢票
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>using namespace std;#define NUM 4class threadData
{
public:threadData(int number){_threadname = "thread-" + to_string(number);}public:string _threadname;
};int ticket = 1000;void *getticket(void *argc)
{threadData *td = static_cast<threadData *>(argc);const char *name = td->_threadname.c_str();while (1){if (ticket > 0){usleep(1000);printf("who=%s,get a ticket : %d\n", name, ticket);ticket--;}else{break;}usleep(50);}delete td;printf("%s quit ...\n",name);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, getticket, thread_data[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}return 0;
}
出现问题:
如上,会发现出现了如上的问题:抢到了负的票,这显然是错误的,毕竟ticket序列最小应该是1
那么是如何出现上述的情况呢?
上述的ticket就是临界资源,是多线程访问的结果,并且并没有对其做加锁保护,进而导致在访问的时候出现错误,那具体是发生了什么呢?
首先我们要了解:ticket--这个操作:
这并不是只有一条语句的,在翻译成汇编语句会出现三条语句:
1、现将内存中的ticket读到CPU寄存器中
2、在CPU内部进行--操作
3、将计算后的结果写回内存
上述的每一步都对应着一条汇编语句:
1、 mov [XXX] eax
2、对ticket--
3、mov eax[XXX]
这样才将ticket进行减一,在上述的三条语句的执行中,线程是可以随时被切换的,
假如我们此时有线程1和线程2,然后当线程1执行第一步读取数据后就被切走了此时就会将这个线程的上下文带走,比如将ticket = 1000带走
然后线程2来了,在读取数据,进行计算,在将计算后的结果返回内存,
倘若线程2一直在循环进行,没有被切走,当计算到ticket=100的时候被切走了,
此时线程1回来,然后首先恢复上下文数据,将寄存器中的数据变为1000,然后进行计算,得到结果999写回内存中
那这样就完了,好不容易线程2将内存中的数据变为100,之后被线程1变回999,这就导致了数据不一致问题,所以我们得到结论:ticket--不是原子的,在中间随时可以被打扰的
那么回到我们抢票系统,ticket不仅仅只在--,而且在进行逻辑判断,所以当一个线程在进行逻辑判断的时候,别的进程也有可能在进行逻辑判断,所以存在一个情况,当我们的ticket为1的时候,然后此时多个线程在进行判断,并且都判断成功了,此时就会出现多个--,这样就会出现负数
三、锁:
对于上述数据不一致问题,我们需要使用一个锁的东西
那么锁有什么用呢?
在多个线程访问同一个资源的时候,用锁对临界区的代码进行加锁,这就导致了线程对临界区的代码进行串行执行,也就是任何一个时刻只允许一个线程去访问加锁的代码,这是一种时间换安全的做法
1、锁的创建与销毁:
如下是创建锁的创建和销毁
pthread_mutex_init中的参数1:这是锁的地址,参数2:初始化的时候,锁的相关属性,一般传nullptr即可
返回值:成功返回0,失败返回错误码
其中一般会首先通过pthread_mutex_t这个类型创建我们的锁,如下,这个锁是一个联合体,我们会使用就行了
创建出锁后有两种使用方法,一个是在全局创建使用,此时利用宏进行初始化,一个是在局部创建使用,当在局部创建使用的时候,需要进行初始化,释放等等
局部创建:手动初始化销毁
int main()
{//定义锁pthread_mutex_t lock;//初始化锁pthread_mutex_init(&lock,nullptr);//进行加锁操作//销毁锁pthread_mutex_destroy(&lock);return 0;
}
全局创建:自动初始化销毁
//定义锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int main()
{//直接进行加锁操作return 0;
}
2、加锁操作:
如上,这是锁的加锁和解锁分别对应lock和unlock函数
参数都是锁的地址,返回值都是成功返回0,失败返回错误码
加锁:
当前互斥锁没有被别人持有,正常加锁,函数返回0
当前互斥锁被别人持有,加锁失败,当前线程被阻塞(执行流被挂起),无法向后运行,直到得到锁资源
解锁:
在加锁成功并完成对临界资源的访问后,就应该进行解锁,将锁资源让出,供其他线程(执行流)进行加锁
加锁原则:
尽量要保证临界区的代码越少越好,因为线程对于临界区代码串行执行,
2、解决抢票遗留问题:
我们对ticket的临界区进行加锁,在这之前在threadData类中增加锁成员,接着在ticket访问的临界区中加锁,这样就能够保证一个线程在抢票的时候能够不被别的线程打扰
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>using namespace std;pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;#define NUM 4class threadData
{
public:threadData(int number){_threadname = "thread-" + to_string(number);}public:string _threadname;
};int ticket = 1000;void *getticket(void *argc)
{threadData *td = static_cast<threadData *>(argc);const char *name = td->_threadname.c_str();while (1){pthread_mutex_lock(&lock);if (ticket > 0){usleep(1000);printf("who=%s,get a ticket : %d\n", name, ticket);ticket--;pthread_mutex_unlock(&lock);}else{pthread_mutex_unlock(&lock);break;}usleep(50);}delete td;return nullptr;
}int main()
{vector<pthread_t> tids;pthread_mutex_t lock;for (int i = 1; i <= NUM; i++){pthread_t tid;threadData *td = new threadData(i);pthread_create(&tid, nullptr, getticket, td);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}return 0;
}
如上,这样在加锁之后,就能够保证不会抢票到负数
3、锁的细节问题:
像上面那种锁的使用是比较好理解的,接下来关于锁的细节是重中之重:
1、多个线程访问同一份资源的时候,当进行加锁保护的时候,多个线程必须加同一把锁,才能够保证线程间互斥
2、在存互斥环境,如果锁分配不够合理,容易导致其他线程的饥饿问题!----- 不是说只要有互斥,必有饥饿
上述讲的有点抽象:接下来举个例子:
在一个学校有一个vip自习室,有一下规则:
1、在门外只有一把钥匙,只有一个人拿到钥匙后才能进入这个自习室
2、自习室内可以直到自己忙完后,自愿退出才将钥匙交出去
当早上的时候,小王来到了这个自习室,此时小王拿到了这个钥匙进入了自习室,那么其他学生想要进这个自习室就需要在门外等待 -----这就是多线程的阻塞等待
当小王想要出去吃饭的时候,刚一出去把钥匙挂在门口,但是此时发现有很多其他的人都在排队等待,于是又拿着钥匙回去了,此时别的人是抢不过小王的,因为小王是离钥匙最近的,其他人的竞争力比不过小王,长时间下去,外面的人进不了自习室也就是线程拿不到锁资源 ----- 这样就会导致多线程的饥饿问题
3、让所有的线程获取锁资源,按照一定的顺序性获取资源 -----同步
这个VIP自习室有一个管理员,当看到小王一直这样搞,导致其他人都没有得到很好的复习,并且当小王真的走了之后,发现外面的人一窝蜂地去抢这个钥匙,于是管理员认为不能够这样,于是增加了两条规则:
a)当里面的人把钥匙挂在门口后,不能马上拿钥匙,需要重新排队
b)外面的人需要有序排队
这样,所有的线程都能够有序地获得锁资源,这种按照一定的顺序性获取的资源叫做同步
4、在临界区中,线程可以被切换,此时线程在被切出去的时候,是持有锁被切走的,并没有释放锁,此时这个线程没有在调度期间,照样没有线程能够进入临界区访问临界资源,因为那个线程被切走了并没有释放锁
5、对于其他线程来说,一个线程要么没有锁,要么释放锁,所以一个正在访问临界区的线程的对于其他线程是原子的
6、锁本身就是共享资源,所以申请锁和释放锁本身被设计成为了原子性操作 -----如何做到的
当我们加锁申请了一块临界区,但是每一个线程在访问临界区的时候都需要先访问同一块锁,所以,这个锁本身都应该是一个临界资源,那么锁能够保证临界区的安全性,谁来保证锁的安全性呢 ----- 锁自己就能够保证自己的安全性 -----如何做到的呢?
接下来看看锁的原理:
4、锁的原理:
为什么ticket--不是原子的,因为它会被汇编翻译成三条语句,所以我们定义,原子:一条汇编语句就是原子的
大多数CPU的体系结构(比如 ARM、X86、AMD 等)都提供了swap或者exchange指令,这种指令可以把寄存器和内存单元的数据直接交换,由于这种指令只有一条语句,可以保证指令执行时的原子性
即使在多个CPU中,其总线是只有一套的,访问内存也是有先后的,当一个CPU正在进行swap或者exchange指令的时候,另外的CPU的swap或者exchange指令只能等待别的CPU处理完后,在进行处理,所以swap或者exchange指令在多CPU下也是原子的
如下伪代码就是pthread_mutex_lock的汇编:
move $0, %al
xchgb %al, mutex
if (al寄存器的内容 > 0){return 0;
} else 挂起等待;
goto lock;
理解:
首先,我们把锁理解为一个简单的变量为1,
1、move $0,%al这是将CPU中一个寄存器eax中的al置为0,此时这个寄存器中的数据也就是al = 0是线程1的上下文,这是线程1私有的
2、xchgb %al,mutex,这是将eax寄存器中的值和mutex中的值交换,这样,就是所谓的申请锁成功了
3、接着就是进行判断:al中的内容如果>1,此时就证明申请锁成功,返回即可,否则申请失败就将该线程挂起,当再
次唤醒进程的时候goto lock重新执行即可
在多线程的条件下:
1、线程1执行move $0, %al初始化al = 0,然后此时线程1被切走,该线程要将它自己的上下文带走,就需要将al = 0带走
2、此时线程2被CPU调度,也是先执行move $0, %al,然后执行xchgb %al, mutex将mutex中的数据交换到线程2的上下文中的al中,此时线程2对应的al就为1,mutex中的数据为0
3、此时切走线程2,恢复线程1,线程1恢复的第一件事就是恢复它自己的上下文,也就是将al = 0恢复到CPU中的寄存器中
4、此时线程1继续往后执行xchgb %al, mutex,发现交换后al里面的值和mutex里面的值都为0,在后面进行判断的时候是无法return成功的,会一直被挂起
5、等到线程2回来的时候,恢复对应的上下文,然后进行判断,发现是>0的,此时申请锁成功,返回0
通过上述过程,我们发现申请锁本质上是执行xchgb %al, mutex,进行交换,无论怎么进行线程的切换,发现数据1只会有一个线程拿到,也就是只有一个线程能够成功申请锁,因为这是xchgb %al, mutex一条汇编语句,所以我们认为申请锁是原子的。
5、封装锁:
当我们进行使用锁的时候,有时候会忘记释放锁,所以我们可以利用创建对象时调用构造函数,对象生命周期结束时调用析构函数的特点,进行自动添加释放锁
#pragma once
#include <iostream>class Lock
{
public:Lock(pthread_mutex_t* lock):_lock(lock){pthread_mutex_lock(_lock);}~Lock(){pthread_mutex_unlock(_lock);}
private:pthread_mutex_t* _lock;
};
接着在上述抢票中就可以通过局部变量的创建自动进行加锁和解锁,这是RALL风格的锁
void *getticket(void *argc)
{threadData *td = static_cast<threadData *>(argc);const char *name = td->_threadname.c_str();while (1){//在下面的代码块作用域中利用局部变量_lock实现自动加锁释放锁{Lock _lock(&lock);if (ticket > 0){usleep(1000);printf("who=%s,get a ticket : %d\n", name, ticket);ticket--;}else{break;}}usleep(50);}delete td;return nullptr;
}
6、什么是死锁:
在一组进程中的多线程,各个线程均占有其不会释放的资源,并且还在互相申请对方的资源,将二者处于的一种永久等待的状态
也就是在多线程中,一方面持有自己申请的锁,还申请其他线程的锁,并且双方线程即不释放自己的锁,并且也不能抢对方的锁,只是申请,如果不给也没办法,所以就会阻塞,导致死锁问题
当有多把锁的时候,互相申请对放的锁且不释放自己的锁,会出现死锁
当有一把锁时,如果多次申请就会导致死锁问题:
如上,这就是对同一把锁申请了两次,这是属于程序猿写的bug,这个时候就会阻塞
死锁产生的四个必要条件:
互斥:一个资源每次只能被一个执行流使用-----前提
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放,同时申请 对方的资源-----原则
不剥夺条件:不能强行剥夺其他线程的资源-----原则环路等待:若干执行流之间形成一种首尾相接的循环等待资源关系-----重要条件
有死锁,这四个条件一定成立;这四个条件成立,不一定存在死锁;这四个条件有一个不成立,必定不存在死锁
如上两个都是属于环路等待
7、解决死锁:
那么死锁问题怎么解决呢?
我们知道这四个条件有一个不成立,就不存在死锁,所以我们可以破坏其中的一个,可以避免死锁问题
1、破坏互斥:这个是前提,如果想要避免的话可以不加锁,那也就解决不了多线程数据不一致问题,想要破坏这个条件没啥意义
2、破坏请求与保持:在加锁的时候,可以使用trylock,是lock的非阻塞版本
3、破坏不剥夺:也就是剥夺对方的锁,也就是将对方的锁资源释放掉,就能够破坏不剥夺条件
4、破坏环路等待:按照顺序申请锁,按照顺序释放锁,就不会出现环路等待的情况了
四、可重入 与 线程安全
概念:
可重入:同一个函数被多个线程(执行流)调用,当前一个执行流还没有执行完函数时,其他执行流可以进入该函数,这种行为称之为 重入;在发生重入时,函数运行结果不会出现问题,称该函数为 可重入函数,否则称为 不可重入函数
线程安全:多线程并发访问同一段代码时,不会出现不同的结果,此时就是线程安全的;但如果在没有加锁保护的情况下访问全局变量或静态变量,导致出现不同的结果,此时线程就是不安全的
其中,可重入和不可重入这是函数的特点,线程安全是多线程并发的特点,
一般, 一个函数是不可重入的,那么在多线程执行下,就有可能出现问题
但是, 如果一个函数是可重入的,那么在多线程的调用下,它一定也是线程安全的
常见线程不安全情况:
不保护共享变量的函数
函数状态随着被调用, 状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
常见线程安全情况:
每个线程对于全局变量或者静态变量只读!
类或者接口原子的!
多个线程之间的切换不会导致接口结果产生二义性
常见不可重入情况:
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
常见可重入情况:
不使用全局变量或静态变量
不使用 malloc 或 new 开辟空间
不调用不可重入函数
不返回全局或静态数据,所有的数据都由函数调用者提供
使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系:
函数如果是可重入的,那么多线程调用这个函数的过程,就是线程安全的
函数是不可重入的,那么一般不可多线程使用,有可能引发线程安全问题
如果一个函数中使用了全局变量,那么这个函数既不是线程安全的,也不是可重入的
可重入与线程安全区别:
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,反过来可重入函数一定是线程安全的
如果对于临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数中没有被释放会引发死锁,因此是不可被重入的
不可重入函数在多线程访问时,可能会出现线程安全问题,但是如果一个函数可重入,不会有线程安全问题;
可重入是描述函数的特点的,没有褒义贬义是特点,线程安全是描述线程并发问题的,二者没有直接联系