分享

锁:并发的守护者

 偏扁豆 2020-08-11

操作系统作为最复杂的软件系统之一,又是绝大多数软件的平台支撑,主要靠的是三驾马车:虚拟化、并发和持久化。本文既是面向操作系统写,也是面向编程写,但主角是并发。

——前言

没有编程语言不支持并发(For Efficiency)

并发是靠锁实现的吗?

并发靠的是CPU的分时与调度(可以参见我前面的文章)、内存的分段和分页、IO队列机制等技术实现的,锁技术是保证并发过程不出错的守护者。

就好比,张三同时卖西瓜、粮食和日用百货,但是只有日用百货是他自己的本行,其他两样都是临时替别人卖的,卖的钱和货都不能出错,这时候如果有一个方法、机制或者举措能够保证张三能够确保这些都不出错就好了。在计算机科学里,这个机制称作锁。

并发一定要加锁吗?

其实,从开发者而言,利用高级程序语言很少能够用加锁,这是由于CPU芯片级、操作系统级和程序语言编译器级都对程序语言的原子性进行了规范和约束,仅有不多的专业领域频繁和锁打交道,最常见的就是数据库系统了。

原子操作是不可分的,在执行完毕不任何其它任务或事件中断。

一般来说,锁机制分为原语、关中断和总线锁。总线锁就是我们常说的锁了。

所谓原语,一般是指由若干条指令组成的程序段,用来实现某个特定功能,在执行过程中不可被中断。

——百度百科

关中断指的是CPU在处理本次完本次中断任务前不再处理其他中断。

在单CPU系统中,CPU 的读—修改—写原语可以保证是原子的,即执行过程过中不会被中断,所以CPU 通过关中断的方式,从芯片级保证该操作所存取的数据不能被多个内核控制路径同时访问,避免交叉执行。然而,在对称多处理器 (SMP) 环境中,单CPU 涉及读—修改—写原语不再是原子的,因为,在某个CPU 执行读—修改—写指令时有多次总线操作,其他CPU 竞争总线,可导致对同一存储单元的读—写操作与其他CPU 对这一存储单元交叉这时我们就需要用一个称为自旋锁(spin lock)的原始对象为CPU 提供锁定总线的方法

自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置(test-and-set)”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。这说明只有在占用锁的时间极短的情况下,使用自旋锁是合理的,因为此时某个CPU可能正在等待这个自旋锁。当临界区较为短小时,如只是为了保证对数据修改的原子性,常用自旋锁;当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁就不是一个很好的选择,会降低CPU的效率。

这时候,信号量(睡眠锁)的概念就被提出来了。

Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个已被持有的信号量时,信号量会将其推入等待队列,然后让其睡眠。这时处理器获得自由去执行 其它代码。当持有信号量的进程将信号量释放后,在等待队列中的一个任务将被唤醒,从而便可以获得这个信号量。

以一个停车场的运作为例。简单起见,假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了五辆车,看门人允许其中三辆直接进入,然后放下车拦,剩下的车则必须在入口等待,此后来的车也都不得不在入口处等待。这时,有一辆车离开停车场,看门人得知后,打开车拦,放入外面的一辆进去,如果又离开两辆,则又可以放入两辆,如此往复。

在这个停车场系统中,车位是公共资源,每辆车好比一个线程,看门人起的就是信号量的作用。

从编程的角度简单理解,信号量就是flag,一个标记,一个整数或者bool或者list。

经常我们还会看到这样的一种锁:互斥锁,这个锁实现方式与信号量(睡眠锁相似,接口和应用场景有所不同,但实现目的与其类似,很多情况下二者是可以相互替换的。

自旋锁是信号量(睡眠锁、互斥锁的基础,后者综合运用了队列、调度等技术。后面所有的锁都是在这几个名词的基础上组合实现的。

悲观锁与乐观锁:悲观锁与乐观锁并不是特指某个锁而是在并发情况下的两种不同策略。悲观锁(Pessimistic Lock), 就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。乐观锁(Optimistic Lock), 就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!悲观锁阻塞事务,乐观锁回滚重试

公平锁与非公平锁:如果多个线程申请一把公平锁,那么当锁释放的时候,先申请的先得到,非常公平。显然如果是非公平锁,后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的。

还有一些锁,例如读写锁、共享锁、可中断锁、递归锁等都是高级程序语言在基本功能上的场景演绎,都是为了程序并发保驾护航。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多