本篇文章,继续来和大家分享与Linux相关的知识。本次的内容主要会涉及到锁的使用,锁的原理,锁的封装以及线程安全。

锁的使用

要解决多线程并发访问,导致的数据不一致问题。需要使用锁。那怎么使用锁呢?有两种方式。第一种是定义一个局部的锁;然后,使用pthread_mutex_init初始化;用完之后,用pthread_mutex_destroy进行销毁。第二种是定义一个全局的锁,使用宏进行初始化,不需要使用函数pthread_mutex_destroy销毁锁。

Linux-线程间的同步与互斥(2)_锁的封装

第一种

我们先来演示第一种方法:

我们需要让所有线程申请同一把锁。那怎么让所有线程看到的是同一把锁呢?我们可以在主线程创建一个局部锁,然后,传给其他线程。

明确了思路,我们直接在我们之间写的模拟抢票程序上做修改。

Linux-线程间的同步与互斥(2)_原生锁的使用_02

锁我们已经定义好了,怎么去加锁,解锁呢?使用pthread_mutex_lock和pthread_mutex_unlock函数。

Linux-线程间的同步与互斥(2)_线程安全_03

具体使用如下:

Linux-线程间的同步与互斥(2)_锁的封装_04

加锁成功之后,我们把tickets共享资源称之为临界资源。而访问临界资源的那一小块代码,我们称之为临界区

以下三点是我们需要知道的:

一:加锁的本质是用时间来换取安全。

二:加锁的表现是线程对于临界区代码串行执行。

三:加锁的原则是尽量的要保证临界区代码,越少越好。

为什么要保证临界区越小越好呢,锁是不是越多越好呢?线程执行临界区的代码,只能串行跑。线程默认是并发访问的,加锁多了,临界区增多,串行就增多了,会降低线程的并发度,进而降低效率。

现在,我们编译运行一下,我们的程序:

Linux-线程间的同步与互斥(2)_原生锁的使用_05

虽然抢票没有抢到负数的现象了,但抢票的线程好像只有thread-2。这是为什么呢

这里引入一个VIP自习室的例子讲解。每次进入VIP自习室前,可以拿走自习室前桌子上的红色锁进入自习室。进入自习室后,可以用这个锁,把自习室的门锁住,避免别人来打扰。

由于来自习的人很多,但自习室很少。自习室外站了很多人。你写了很久的作业,想走了。你刚把锁放到桌子上,后悔了。又拿着锁回到了自习室。你的心情反复无常,你换锁,拿锁来回了好几次。别人依然拿不到锁,这是什么原因?就因为你离锁近,竞争力更强。

刚刚这个例子的竞争环境,我们可以理解为纯互斥环境。如果锁分配不合理,就容易导致其他人拿不到锁,进入自习室。如果我们把人理解成线程,那就是锁的分配不均,容易导致其他线程拿不到锁。我们把这个问题,称之为饥饿问题

Linux-线程间的同步与互斥(2)_锁的封装_06

我们再回过头看程序的运行结果,就理解了为什么一直是thread-2在抢票。因为thread-2刚刚把锁放下,又拿了起来。

只有一个线程抢票,这是不是说明我们的代码有问题呢?并不是,我们的代码没有问题,只是逻辑上有问题。

根据生活实际看,抢完票后,会进行一些信息认证。这里我们可以简单的用usleep进行模拟。

Linux-线程间的同步与互斥(2)_原生锁的使用_07

编译运行,我们就能看到三个线程交互着票。

Linux-线程间的同步与互斥(2)_锁的封装_08

线程对于锁的访问一定是并发的,但不同的线程对于锁的竞争能力可能不同。如果一个线程的竞争能力,比较强,就可能造成饥饿问题。

那针对饥饿问题,我们该怎么解决呢

自习室的观察员,观察了一下,发现这样下去不行。于是,定下了两条规矩:

1.外面来的,必须排队

2.出来的人,不能立马重新申请锁,必须排到队列的尾部

依照新规定,自习室外排起了长队,依次申请锁。我们把按照一定的顺序获取资源,称之为同步

Linux-线程间的同步与互斥(2)_线程安全_09

每个线程在进入临界区访问临界资源的时候,它的第一件事都必须是先申请同一把锁。大家都要申请锁,那锁不也是共享资源吗

锁本身是共享资源。所以,申请锁和释放锁本身就是被设计成为了原子性操作(如何做到的?)

思考一个问题:在临界区中,线程可以被切换吗?

答案是可以被切换。为啥?因为锁还没被你释放。

我们还是拿刚刚自习室的例子来理解,你自习,自习,突然想上厕所,可以去吗?当然可以。去厕所解手的这个时间,别人能不能进自习室。不能。为啥?因为你去解手的这个过程,锁一直在你的身上,别人进不去。

我们站在等待人的视角看,对于我们,锁只有申请和释放两种状态。其他线程就相当于等待人,也就是其他线程只能看到申请锁和释放锁两种状态。而申请锁和释放锁,分别对应着,任务未开始做和任务已完成。如果一件事,只有未完成和已完成两种状态,要么不做,要做就是做完。那么我们就称,做这件事,是原子。

这样一来,通过加锁,就可以保证,我当前线程在访问临界区期间,对于其他线程来讲是原子的。

Linux-线程间的同步与互斥(2)_线程安全_10

我们一开始的问题是,为什么加锁?->因为并发问题。->为什么会有并发问题?->因为使用多线程访问全局变量。->为什么用多线程?->提高并发度,但不想像创建进程那么重,而是通过线程的方式简单创建。

从一开始的加锁问题,延伸到多线程问题。我们会发现每解决一个问题,又会诞生新的问题。也就是说任何的解决方案,都会伴随着新的问题产生。至于是否继续解决新的问题,取决于问题是否影响我们。

第二种

我们来演示第二种使用锁的方式:

这种方式是定义一个全局的锁,使用宏来初始化。它既不需要pthread_mutex_destroy销毁锁,也不需要使用怕pthread_mutex_init进行初始化。

Linux-线程间的同步与互斥(2)_锁的封装_11

编译运行,效果是一样的。

Linux-线程间的同步与互斥(2)_线程安全_12

锁的使用,我们了解完了。那它究竟做了什么呢?

锁的原理

我们知道tickets--不是原子的?会变成三条汇编语句。那什么是原子呢?我们可以简单理解为一条汇编语句就是原子的

CPU中会有一条指令swap或exchange,它的作用是把寄存器和内存单元的数据相互交换。

Linux-线程间的同步与互斥(2)_锁的封装_13

加锁,解锁本质就是调用函数。那我们去理解加锁和解锁这两个函数呢

Linux-线程间的同步与互斥(2)_锁的封装_14

早期的通用的寄存器,简称ax,只有16位。后来扩展成了32位,就叫eax(Extended Accumulator Register)。怎么扩展?再拼一个16位的寄存器。较低的16位,用al表示。ah表示较高的16。

Linux-线程间的同步与互斥(2)_原生锁的使用_15

这里为了方便理解,我们简单的认为al就是eax。锁其实就是一个mutex变量,它的内容为一。

假设线程1调用了,lock函数。第一步movb $0, %al,的作用就是将寄存器al的内容置零。第二步xchgb %al, mutex,的作用就是将寄存器al的内容和内存中的mutex进行交换。交换完成后,寄存器al中内容为1。mutex的内容为0。

Linux-线程间的同步与互斥(2)_锁的原理_16

这时候,很不巧,线程1被切换走了,它会把的上下文拿走,也就是把1拿走。

线程2来了,它先执行第一步movb $0, %al,将al寄存器的内容置0。再执行第二步xchgb %al, mutex,进行交换。交换之后,al的内容还是0。再往后走,线程2就被挂起了。

Linux-线程间的同步与互斥(2)_锁的封装_17

线程1回来了,它会先把自己的上下文,加载到寄存器。加载之后,al的内容为1,往后执行,就返回0。

Linux-线程间的同步与互斥(2)_线程安全_18

交换数据的本质是,把内存中的数据,交换到CPU的寄存器中。而把数据的交换的寄存器中,实际上,是把数据交换到线程的硬件上下文中。线程的上下文是私有的。如此一来,把一个共享的锁,让线程以一个汇编的方式,交换到自己的上下文中。这个线程就持有锁了。

Linux-线程间的同步与互斥(2)_原生锁的使用_19

加锁的过程,我们理解了。那解锁怎么理解?

解锁其实就是把mutex的内容置1。

Linux-线程间的同步与互斥(2)_原生锁的使用_20

为什么解锁,不是在回来的时候调用xchgb把寄存器中的1置换到mutex中呢?谁说锁要非得自己解。这样设计是为了方便别人也可以帮我解锁。

Linux-线程间的同步与互斥(2)_原生锁的使用_21

锁的封装

我们可以对原生库的锁进行封装,做一个自己的锁。

Linux-线程间的同步与互斥(2)_原生锁的使用_22

在刚刚的抢票程序中,使用我们自己的锁。

Linux-线程间的同步与互斥(2)_线程安全_23

编译运行,效果是一样的。C++底层锁的封装,采用的就是我们刚刚封装的方式。

Linux-线程间的同步与互斥(2)_原生锁的使用_24

线程安全

多个线程访问同一段代码不出问题,就叫做线程安全。

线程安全,描述的是线程。可重入,描述的是函数。我们学的库和函数,99%都是不可重入的。

更多细节,大家自己了解吧!

Linux-线程间的同步与互斥(2)_锁的封装_25

好了,到这里,我们本次的分享就到此结束了,不知道我有没有说明白,给予你一点点收获。关于更多和Linux相关的知识,会在后面的文章更新。如果你有所收获,别忘了给我点个赞,这是对我最好的回馈,当然你也可以在评论发表一下你的收获和心得,亦或者指出我的不足之处。如果喜欢我的分享,别忘了给我点关注噢。