锁,知其然知其所以然

Taken by iCola

今天,从一个小问题聊起。

假设你账户上原来有100元钱,你用微信支付100元,与此同时你女票用支付宝给你转100元零花钱,你帐户的余额有没有可能变成200元或者0元? (贫穷果真限制想象力,也就敢假设100元)

你肯定会说,不可能。

但是,不妨一起YY一下。 支付和转入发生在同一时间点,在这个时间点上,微信和支付宝都看到你账户有100元钱,微信这边用帐户的100块钱减去消费掉的100块钱,给银行系统返回0元的余额。 而支付宝这边却是这么看的,帐户原来有100元,加上存入的100元,给系统返回200元的余额。 那么神奇的事情发生了,如果微信先返回0给系统,支付宝后返回200给系统,那么最终的帐户余额会是200元。 反之,则是0元。

当然,现实生活中不可能出现这种低级的问题。 因为有无数种简单方法可以避免发生这种问题。 比如说,银行系统可以直接采用排队的策略,先来的先服务,后来的就得等着。 这样,当微信发起支付请求时,微信知道余额是100元,这会儿即便有支付宝来存钱,也得在微信后面排队。 等到微信把帐户的100元支付出去后,银行系统余额是0元。 微信的业务办完以后,开始处理支付宝的存钱业务,这时候支付宝读取余额就是0元了,存入100后变成了100元。 当然,支付宝先来存钱,微信再来支付,也会是同样的结果。

但是,如果在这之间又来个XX支付查询余额怎么办? 难道要等着微信处理完,再等支付宝处理完,最后才能读取余额吗? 极端一点,查个余额,加载的圈圈转半小时,用户能忍?

当然,以上只是拿支付系统举个例子。想要说明的是,很多事情其实可以并行处理,而不是简单低效的串行处理。 去银行办业务的人应该深有体会。

在计算机的世界里,现代化的处理器一般都拥有多个核心,如果业务处理都这样排队的话,很多核心就白白浪费了。 这里吐槽一下很多手机厂商,一味追求四核、八核的CPU,而系统调度能力根本就发挥不出多核心的作用,大部分时候,手机里只有一两个核在工作。 这也是为什么早期安卓手机四核卡顿,而苹果手机双核却很流畅度的原因之一。

正式进入正题,为了榨干CPU的性能,很多任务的执行都是并行的。单核心的电脑也可以表现出并行的效果,单核CPU可以分出一点时间播放音乐,然后分出一点时间渲染网页,单核CPU只是在不同的任务间不停切换罢了。在人的时间观念里,就好像这些任务是同时执行的。这里涉及 时间片 (Timeslice or Quantum)的概念,老规矩,以后有时间可以聊一聊。

在计算机的世界里, 并行 (Concurrent)无处不在。但是 并行不可避免的会遇到这样的问题:同一个文件,一个线程或者CPU核要修改文件内容,而另一个线程或者核却要删除这个文件。 想象一下,你骑着单车往前跑,突然有人打了个响指,单车瞬间消失了,你说气人不。 因此,想要做到安全的并行处理,就必须有一种同步机制,保证不同CPU核心处理同一个数据资源时,不会出现不可测的结果。

常用的一种机制叫做 锁或者互斥量 (Lock or Mutex)。很多文章一般讲到锁,都会提到一堆API,亦或者讲一堆术语。

  • 共享锁/ 互斥锁

  • 乐观锁/ 悲观锁

  • 读者锁/写者锁

  • 自旋锁

  • 可重入锁

  • 公平锁

  • 原子操作

  • 信号量

  • 大内核锁

  • 线程锁

  • 文件锁

巴拉巴拉一堆。 我想说,知道这些乱七八糟的概念没有什么意义。 如果你知道锁的原理和作用,这些概念基本就是废话了。 因此,我想说, 知道所有道理,真的可以为所欲为

首先,明确锁的作用和原理。 锁的作用就是保护资源,保证当前加锁的线程能够安全的占有资源。锁的原理其实很直观,一个共享资源放在箱子里面,当A进程想要获取这部分资源时,读取资源之后,就把箱子锁起来,之后当B进程想要获取这个资源时,发现箱子锁着,就只能等着A来解锁,或者先去做别的,过一会再来看看箱子有没有解锁,如果解锁了,就可以获取资源,同时也把箱子锁起来。

是的,锁的作用和原理就是这么简单。 非要说锁有什么复杂性,那就得说如何使用锁了。

一般来说,使用锁也很简单。 无论你 什么语言,加解锁基本都是如下这种范式。


Lock r.
Do something.

Unlock r.

那么,锁的使用到底还有什么值得讨论的呢?

1、单核CPU的系统是否需要加锁?

答案是,需要。 如果不同的程序都可能读写同一个资源,那就需要对该资源加锁。 因为CPU有时间片的机制,也就是多个程序可以认为是并行的,因此需要加锁,否则文件的内容就会变得不确定。

退一步,即便一个资源只有一个程序读写,有时候也需要加锁。 程序本身可能响应中断(这里暂不考虑系统调度),而中断处理会读写这部分资源,那就需要加锁。 中断 (Interrupt Request)可以简单理解成一个霸道的不用排队的人,中断触发时,CPU会立即保存当前执行的事务,然后跳转到中断处理函数,执行相关操作。 如果不加锁,就可能出现这样的情况:程序本身正在读取一个文件,此时来了个中断,中断处理函数里面删除了资源,中断返回时,程序拿着的资源已经不存在了,这将导致错误处理。

说到这,很容易想到,单核CPU的系统中,加锁本身是不是只要关掉中断就可以了? 是的,单核CPU的锁其实就是关闭了中断。 关中断,也就意味着不会有硬件触发的特殊处理流程,也不会有调度器进行任务抢占。 所以,只要关中断的程序本身不去触发任务切换,那么关中断后的处理都在临界区,是可以保证安全读写的。

2、多核CPU一定要加锁吗?

答案是,不一定。 单线程独占且不会被中断中处理函数处理的资源,就可以不加锁。

3、多核CPU可以通过关中断实现加锁吗?

答案也是不一定。

一个单线程独占的资源,且线程本身被强行绑定到某一个CPU核心上,这样加锁只要关掉所属CPU的中断就可以了。

如果不涉及中断处理,只涉及多核心多线程,加锁无法通过高级语言实现,而需要底层的原子操作来实现,一般使用汇编直接操作CPU的某些指令。

如果一个复杂程序,同时涉及多核心、多线程以及中断,加锁的操作则不仅需要关中断,避免中断内访问资源,还需要通过汇编锁定临界区(可以理解为加锁和解锁之间的代码片段),避免其他CPU核心或者线程访问资源。

4、加锁对程序本身到底有什么影响呢?

这个世界,凡事必有成本。 加锁的作用是保证系统资源的安全操作。 但是,加锁带来的问题也很多。

首先,加锁增加了非程序功能的额外操作,这部分开销往往需要几百个CPU周期,复杂程序涉及的加锁流程会相当多,累积起来的开销是不容忽视的。

其次,加锁会导致其他线程阻塞,或者导致中断响应延迟。 这是由加锁的原理决定的。 关中断的锁自然会导致中断受影响,而加锁又会创建临界区,同一时刻只有一个线程获得锁,这就导致其他想要获取锁的线程挂住,处理不得当,就会浪费太多的CPU资源。

再其次, 活锁 (Livelock)问题。 当多个线程同时去获取某个锁时,不防称之为LockA。 如果此时某个线程长时间或者高频率霸占LockA,其余线程不停尝试获取LockA,却总是获取不到,也就导致这些线程不能访问被LockA保护的资源,这就可能产生很多神奇的问题。

最后,常见的 死锁 (Deadlock)问题。 多个线程,同时存在多个锁的情况下,加锁不当很容易产生死锁问题,直观的表现就是系统卡住了。 比如Windows出现的死机现象。

死锁是怎么产生的呢? 假设有两个线程A和B,他们都可以使用锁Lock1和Lock2。

  • 在某个时间点,A获取了Lock1,B获取了Lock2。

  • 然后呢,A又想去获取Lock2,此时由于Lock2已经被B占有了,因此A会被阻塞,等着B释放Lock2。 这个时候,线程A已经挂住了。

  • 但是这会儿还不能算是死锁,因为此时B线程还是可以正常执行的,如果此时B释放Lock2,A就可以获得Lock2,A线程和B线程也都可以正常运行。

  • 但是,如果此时B线程又去获取Lock1,那么有趣的事情发生了。 A线程挂住等待B释放Lock2,而B线程挂住等待Lock1,简直完美,A和B都挂住了,这就是死锁了。

死锁问题大多是时序问题 ,往往只有按照特定的顺序执行一系列操作才会出现问题,而这一类问题往往是最难定位的。

5、如何解决锁的这些问题呢?

首先,锁对于性能的消耗是没有办法完全消除的,除非不使用锁。 优化锁的使用,只能从锁的尺度和频度综合考虑,这需要 折衷 (Tradeoff)。 如果锁定的临界区过大,这样可以减少加锁本身的开销,但是会增加占有锁的时间,也就很可能阻塞其他线程的处理。 反之,尽量减小临界区,一个流程可能就需要在多个地方加锁,这会增加加锁本身的开销。

提高性能的另一个方法是,合理分析程序本身读写资源的情况,设计读优先或者写优先的 读写锁 (Rwlock)。 这里读取资源指的是只读取一块内存里的内容,而不会对内容做任何修改或删除操作,而写资源则包括修改和删除资源的操作。 这样就可以把锁设计成读锁和写锁。 写锁是绝对互斥的,一个时间点只能有一个写锁,不能有其他线程获取写锁或者读锁,因为写锁持有者是会修改或者删除资源的。 而当一个资源被读锁持有时,则完全不影响其他线程继续对资源加读锁,这就像你可以同时从多个途径读取帐户余额一样。 但是如果有一个线程想要获取写锁,就必须等待所有的读锁释放。

当然,很多情况下,还可以把锁和条件变量结合起来使用。

对于活锁的问题,一些关键流程,需要尽快获取锁,可以考虑使用 自旋锁 (Spin lock)。 自旋其实就是循环的意思,自旋锁会一直死循环的去获取锁,一旦锁被释放,马上就可以获取锁。 当然,也可以约定一套重试机制,通过机制确保线程可以获得锁。

解决死锁问题,最根本的还是在编程的层面。 程序员自身必须清楚锁的原理,确保加解锁的对称性。 对于多个锁可以划分层级,比如想要获取Lock2,就必须先获取Lock1。 当然,没有人能保证程序加锁没有bug,所以还可以从硬件层面做这些检查,比如看门狗检测线程调度切换,亦或者CPU死循环检查等。

说了这么多,不难看出,多核心、多线程的运行环境中,锁的使用是不可避免的。 但是锁的性能问题和功能问题也是不容忽视的。 是否还有其他保障资源安全的方法呢? 一定程度上讲是有的,比如原子变量,线程特有数据,CPU层面的支持,以及 RCU (Read-copy-update)。 其中,RCU是个比较有趣的东西,抽空聊一聊。

很多时候,我们太习惯于拿着结论直接应用到生活和学习中。虽然大部分时间可以表现得很好,但是时间久了,非但难以精进,更会被某些错误的结论引入歧途。所以, 知其然,更要知其所以然。

推荐:

推荐阅读:我是一个程序员、   鸿蒙系统的微内核是什么

喜欢就点『 好看 』或者『 分享 』吧!