查看原文
其他

锁,知其然知其所以然

我是可乐 可乐 2022-09-06
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是个比较有趣的东西,抽空聊一聊。


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


推荐:

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

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



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存