目录
一、线程互斥的概念
1. 多线程下全局数据的安全问题
先来看下面的测试程序:
在这个程序是模拟多个线程进行抢票。在这个程序里,一共有1000张票,每个线程都可以去枪票。当票数为0的时候就是没票了,程序结束。运行该程序:
当运行结束后,可以发现,有些线程抢票抢到的数字是负数和0。这就很奇怪了,在上面的程序汇总,每个线程每次拿一张票,当票数小于0就应该结束。那为什么这里会有线程拿到负数的票呢?
在了解原因前,要先有三个概念。
(1)多个线程较差执行的本质,就是让调度器频繁发送线程调度与切换。
(2)线程一般会在时间片到了,有更高优先级线程或线程等待的时候进行切换。
(3)线程是在从内核态返回用户态的时候对线程调度进行检测,如果满足条件,就发生线程切换。
有了上面的三个概念,这里的问题就很好理解了。这几个线程在运行时是并发式运行,这就会出现一些特殊情况。例如当票只剩1张时,线程1进来抢票,但是线程1一进来就进入休眠,于是被切走换成线程2进来。此时票数依然为1。线程2进来后,也进入休眠,此时又切换为线程1;线程1此时已经结束休眠,于是去拿走了编号为1的票,并将票由1置0并退出;此时线程2结束休眠,于是调度器将线程2切换进来。虽然此时已经没有票了,但线程2依然在循环内,于是线程2拿走编号为0的假票并将票由0置为-1并退出。在这个过程中,就由于线程的并发式运行,导致了线程拿到了错误的信息。
简单来讲,这个问题就是在票数为1的情况下,让多个线程进入了循环。
上面是多代码语句时出现的问题。但是,如果仅仅有一条代码时,就绝对不会出错吗?答案是否定的。以--num为例子。
--num这是一个运算语句,虽然我们写起来只是一条语句,但是在汇编上,它至少是“从内存中读取数据到寄存器”,“在寄存器中让CPU进行对应的逻辑运算”和“将新的结果写回内存中”三条语句。而我们知道,线程切换是随时可能进行的,那么就可能会出现这种情况:
假设num = 1000。线程1从内存中读取num的值,然后这个值写到寄存器中进行了--使得num = 999。当线程1正要将数据写回内存时,调度器将线程1切换走了。线程1在切走的时候,就将自己的上下文数据一起带走了,即num = 999。
此时线程2被切换进来,由于内存中的数据还未更新,num依然为1000。于是线程2也将num加载到寄存器并进行--运算,但是这次线程2进行运算时由于还没有线程需要切换进来,于是它一直进行--,将num--到了100后就将这100写回到了内存中,此时内存中的num = 100。
当线程2结束后,线程1又进来了,并且带着自己的上下文数据num = 999回来了。线程1在上次被切走时执行到了写回数据这一步,于是线程1就继续执行自己的代码,将num = 999也写回了内存里。这一写回就出问题了,因为此时内存中由线程2写回的num = 100就被覆盖,num重新回到了999。
通过上面的两个例子,就可以得出一个结论:我们所定义的全局变量,往往是不安全的。例如在上面的多线程交替执行的情况下,就可能出现数据不一致的问题。
要解决这个问题,就要进行“加锁”。
2 线程互斥相关背景概念
要了解线程互斥,首先要有以下几个概念:
(1)临界资源:多线程执行流进行安全访问的功效资源,就叫做临界资源。
(2)临界区:每个执行流中访问临界资源的代码,就叫做临界区。临界区一般是代码中的很少一部分
(3)互斥:让多个执行流串行访问进入临界区,访问临界资源。
(4)原子性:不会被任何调度机制打断,只有要么做,要么不做的两态。实现原子性的一种方案,就是对资源进行操作时,只用一条汇编完成任务。
因此,线程互斥,简单来讲其实就是将多个线程并发式访问临界资源修改为串行式访问临界资源。要实现线程互斥,就可以对线程“加锁”。
二、线程加锁
在linux中的“锁”,其实也是一种数据类型,叫做“pthread_mutex”。
1. 锁生成和销毁
在linux中,由于锁本身也是一种数据类型,所以在生成时,要对锁进行初始化,不用时,就要销毁锁。
在生成锁的“pthread_mutex_init()”函数中,第一个参数mutex为输出型参数,由于输出该函数生成的锁。,第二个参数为锁的相关属性。
而销毁锁的“pthread_mutex_destroy()”函数中则只需要传入需要销毁的锁即可。
注意,锁的生成有点特别。一个锁如果是在线程内生成,则需要使用pthread_mutex_init()函数;但如果是要在全局域生成一个锁,则直接用定义一个pthread_mutex变量,然后将“PTHREAD_MUTEX_INITALIZER”这个宏传入即可生成。
2. 对一个锁加锁
要对生成的一个锁加锁,使用“pthread_mutex_lock()”函数即可。
直接将所传入即可。该函数可以对一个锁加锁。该函数的加锁是阻塞式加锁的。即如果一个线程已经加了锁,在未解锁的情况下,再次加同一个锁,此时该线程就会申请锁失败被阻塞。并且还会导致其他线程也无法申请锁。
3. 对一个锁解锁
要解开一个锁,则使用“pthread_mutex_unlock()”函数即可。
三、解决多线程并发式访问临界资源问题
要解决多线程以并发式访问临界资源的问题,就可以通过加锁来实现。同样的,先以下面的代码为例子:
这个程序运行后,会出现有线程拿到小于1的数字的情况,原因上面已经讲解了,这里就不再多说。
现在将程序修改为如下所示:
此时,就创建了一个锁,对启动函数中的临界区进行了加锁,并在离开临界区时解锁。运行该程序:
乍一看程序似乎没有问题,但是如果大家自己观察就会发现,此时一直都是只有一个线程可以拿到票。导致这种现象的原因就是,锁的存在只是让加锁和解锁的区域被线程串行访问,并没有规定线程访问的先后顺序。因此,获取锁就是让多个执行流进行竞争的结果。而上面一直只有一个线程获取锁,就是因为这个线程的竞争能力最强,一直在反复的获取锁和解开锁。
如果你不想出现这种状况,就可以在打印结果后让该线程休眠一段时间,此时该线程就无法在解锁的同时立刻去获取锁,以此让其他线程可以获取锁进入临界区:
注意,在未来使用锁时,要尽量的保证临界区的粒度非常小。这个粒度,简单来讲就是加锁和解锁中间的代码数量。
因为锁的存在会让线程从并发访问临界区变为串行访问临界区,这势必就会导致多线程情况下访问临界区的效率会非常低。以上面的程序为例,如果大家自己写了代码并运行起来,就会发现,当加了锁后,该程序的运行速度明显慢于没加锁的时候。
四、如何看待锁
1. 锁限制线程串行访问
锁其实就是一个数据类型。锁的作用是保护全局资源。但是,既然锁可以保护全局资源,这就意味着锁也需要被所有线程看到,因此,锁的本身也是全局资源。并且锁自身的安全也是需要保护的。
如果要正确的对一个线程加锁和解锁,就必须要保证加锁和解锁的过程是安全的,因此,加锁的过程其实原子的。
当一个线程申请锁成功后,就会继续向前执行;如果申请锁失败,就会阻塞等待。
有了上面的认识,我们再来思考一下,锁究竟是如何做到让其他没有持有锁的线程进入临界区的。
要知道,在OS当中,是没有锁这一概念的,每个线程在CPU中随时都可以被调度。这也就意味着一个持有锁的线程在临界区中运行时,也可能会被切走。当持有锁的线程被切走时,它会带走自己的上下文数据,而这些数据中,就包括了锁。简单来讲,就是当持有锁的线程在临界区中被切走时,是带着锁一起走的,只要这个线程不归还锁,其他线程就永远无法进入临界区。
由此可以得出,对于其他线程而言,只有有锁和没有锁的状态,此时,这些线程就是原子的。
2. 加锁和解锁的原子性
首选,我们要有以下两个概念。
(1)CPU中只有一套寄存器,这套寄存器被所有执行流共享。
(2)寄存器中的数据是每个执行流所独有的,在且被切走时执行流会带走寄存器中的内容作为自己的上下文。
在上文中说了,保持原子性的一种方式,其实就是只用一条汇编完成工作。其中,加锁的过程其实通过这以方式保持原子性的。而加锁和解锁的汇编保持原子性的方式,就是只用一条语句进行数据交换。可以看成如下图所示:
要使用锁,就要先有一个锁的变量mutex,mutex就保存在内存中。而mutex的值,在这里,我们将它看做1。假设有线程A和线程B两个线程,在内存中存在锁后,当线程A运行时遇到pthread_lock()函数,它就会去执行如上的汇编。首先,线程A会将一个0传入寄存器%al中,然后运行到下一条汇编,这条汇编会将内存中保存的mutex中的1与寄存器中的0交换。此时,寄存器中的数据就变更为1,内存中的锁变更为0。
就在这时,CPU将线程A切换走了,让线程B进来了。虽然线程A被切走了,但是它在走的时候将寄存器内的数据也一并带走了,其中就包括锁的值1。而线程B进来遇到pthread_lock()后,也是首先传一个0给寄存器,再将寄存器内的值与内存中的锁的值交换。但是此时内存中的锁的值是0,交换后依然是0。当线程B被切走时,它也会将数据拿走。
此时线程A再进来从原来的位置继续运行,此时线程A拿到的锁是1,于是在下一条汇编中,线程A就返回并进入临界区访问临界资源。
当线程A访问了一会儿临界资源后,线程B又被切进来执行下一条汇编。但是线程B拿到的锁的值是0,于是它走到else位置,将自己挂起等待。此时CPU看到线程B挂起等待了,于是又将线程A切进来,让线程A继续执行。
让线程A终于执行完临界区的代码后,就会将线程A拿到的1重新归还给内存中的mutex并唤醒等待mutex的线程B,让线程A和线程B重新竞争锁。
五、对锁进行简单的封装
有了上面对锁的概念,我们就可以利用这些接口自行封装一个锁了。
#pragma once
#include<iostream>
#include<pthread.h>
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 mutexGuard
{
public:
mutexGuard(pthread_mutex_t* mutex)
:_mutex(mutex)
{
_mutex.lock();//在构造函数中上锁
}
~mutexGuard()
{
_mutex.unlock();//在析构函数中解锁
}
private:
Mutex _mutex;
};
在这里,mutexGuard才是提供该外部使用的类。创建这个类对象是就完成加锁,析构这个类对象时就完成解锁。这些代码实现起来非常简单,这里就不再过多赘述了。
六、可重入与线程安全
1.可重入与线程安全的概念
线程安全:多个线程并发执行同一段代码时,不会不出现不同的结果。在没有锁保护的情况下对全局变量或者静态变量进行操作就会出现这个问题。
重入:同一个函数被不同的执行流调用,当前一个执行流还没有执行完,就有其他执行流再次进入,这种情况就成为重入。一个函数在重入的情况下,运行结果不会出现任何不同或任何问题, 该函数就被称为可重入函数。反之则为不可重入函数。
2. 常见的线程不安全情况
(1)不保护共享变量的函数
(2)函数状态随着被调用,状态发生变化的函数
(3)返回指向静态变量的指针的函数
(4)调用线程不安全函数的函数
3. 常见的线程安全情况
(1)每个线程对全局变量或者静态变量只有读取的权限,没有写入的权限。
(2)类或者接口对于线程来说是原子的
(3)多个线程之间的切换不会导致该接口的执行结果存在二义性
4. 常见不可重入的情况
(1)调用了malloc/free函数,因为malloc函数就是用全局链表来管理堆的。而链表结构就有可能因为线程切换被破坏。
(2)调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
(3)可重入函数体内使用了静态的数据结构
5. 常见可重入的情况
(1)不使用全局变量或静态变量
(2)不使用malloc或new开辟出来的空间
(3)不调用不可重入函数
(4)不返回静态或全局数据,所有数据都由函数的调用者提供
(5)使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
6. 可重入与线程安全联系
一般来讲,创建出来的线程都是要去执行对应的函数的。因此,函数是否重入其实也影响到了线程是否安全。
(1)函数是可重入的,那就是线程安全的。
(2)函数是不可重入的,那就不能由多个线程在没有锁保护的情况下使用,有可能引发线程安全问题
(3)如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
7. 可重入与线程安全区别
(1)可重入函数是线程安全函数的一种
(2)线程安全不一定是可重入的,而可重入函数一定是线程安全的。可重入函数仅仅是线程安全的一种状态。
(3)如果将对临界资源的访问加上所,那么这个函数就是线程安全的。但如果这个可重入函数的锁未释放造成死锁,那么这个函数就是不可重入的。
七、死锁
1.死锁的概念
死锁是指在一组执行流,它在持有自己的锁资源的同时,还去申请其他执行流的锁资源。而锁资源又是不可抢占式的,即只能被一个执行流获取且其他执行流不能强行拿走这的资源。此时这两个执行流因为互相申请对方的锁,导致双方都处于等待状态而无法释放自身的锁资源,使得其他执行流也无法获取锁资源。这种情况就叫做“死锁”。
从上面的概念来看,并不太好理解。所以这里就举一个例来帮助理解。
假设现在有A和B两个小朋友,A和B身上各有五毛钱。当A和B在外面玩时,A看到了一个棒棒糖,这个棒棒糖要卖1块钱。于是A就告诉B,让B把他身上的五毛钱给他,他好去买棒棒糖吃。B听了后也想吃这个棒棒糖。于是就B也告诉A,让A把他的五毛钱给他,他也想买棒棒糖吃。而A听了就不乐意了,不愿意给;B看A不愿意给,于是B也不愿意给。此时A和B就僵持住了,都不愿意把钱给对方,只想让对方把钱给自己,让自己去买棒棒糖吃。
在上面的这个例子中,A和B就可以分别看做一个执行流;它们所持有的五毛钱,就可以看做锁资源;A和B因为互相要对方身上的五毛钱,且A和B都不愿意给对方而导致僵持的状态,就可以看成死锁。
简单来讲,在多把锁的场景下,多个执行流持有自己的锁资源不愿意释放,还不断申请对方的锁资源,此时就可能导致死锁。
既然多把锁可能造成死锁,那么一把锁有没有可能导致死锁呢?答案是会的。例如我们自己写的代码中,在第100行的时候我们就已经让执行流申请了一个锁,但到了1000行的时候我们在没有释放锁资源的情况下,又让执行流去申请同一把锁,此时就可能导致死锁。
2. 形成死锁的四个必要条件
要形成死锁,就必须要同时满足以下四个条件:
(1)互斥
互斥,即执行流要访问的资源,必须是只允许串行访问,不允许并行访问的。
(2)请求与等待
不同的执行流在不断的请求对方的锁资源导致双方都进入阻塞等待状态
(3)不剥夺
当一个执行流申请另一个执行流的锁资源时,不能强行拿走对方的锁资源,只能让对方自愿给予。
(4)环路等待
这一条件是基于上述三个条件所导致的不同执行在持有自身锁资源的同时申请对方的锁资源,进而出现双方都阻塞等待对方释放锁资源而形成的环路等待。
3. 避免死锁的方法
要避免形成死锁,只需要破坏形成死锁的4个必要条件之一即可。
第一个互斥条件,很明显是不能破坏的,因为使用锁就是为了让执行流在执行某段代码时互斥。
第二个请求与保持条件,要求双方都要不断申请对方的锁资源,因此可以在加锁时保持加锁的顺序一致,以避免出现互相申请的情况。同时,还要避免锁未释放的场景,防止执行流一直持有锁导致其他执行流无法申请锁。
还要一种解决方案,就是资源一次性分配。简单来讲,就是尽量将需要的使用的临界资源放在一起,用尽可能少的锁进行加锁,避免加太多锁以至太过复杂。
虽然有这些避免死锁的方法,但在实际中,我们可能并不知道到底有没有出现死锁。因此,就有了一些死锁检测算法。例如在多线程并发执行时,其他线程都在正常执行代码和申请锁释放锁,但有一个单独的线程它的内部存有大量的计数器,每有一个线程申请锁和释放锁后,这个线程就将计数器对应的++或--。如果这个线程发现它内部的某个计数器上有值且长时间不变化,此时就可能出现了死锁问题,这个线程就直接去将锁释放掉或者返回某种信号将进程终止并提示可能出现死锁。
由于锁过多可能会导致出现死锁的情况和锁让线程串行执行降低运行效率的关系,在实际中,尽量少用甚至不用锁,只在确实需要使用锁的时候再使用,不要随意使用。