给汽车软件上个锁
作者 | 吉吉
出品 | 焉知
对汽车软件来说,安全是重中之重的属性。而在多进程和多线程的并行计算环境中,在管理共享资源时,保证竞争状况下的数据一致性是汽车软件中常见的功能安全需求。为满足这个需求,同步机制和锁是我们的一种常用手段。那么这个同步机制和锁又是怎么来理解呢?如果你对它不是很了解,那么接下来我们尝试用通俗易懂的方式来共同探讨一下。高手们请自动略过或多多指教。
什么是锁
进程就像一个房子:它是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。跟房子类似,它是一个实体,具有属于自己的资源。
线程就像房子里面的住户:他是操作系统能够运行调度的最小单位。跟住在房子里的住户类似,它被包含在进程之中,共享着进程内(房子内)的资源。
单线程就像一个人住一个房子:他可以独享房子里面的资源,在房子里面为所欲为,吃饭睡觉上厕所,想怎么来就怎么来,想什么时候来就什么时候来。
多线程就像多个人住一个房子:当房子里多了一个住户,情况就大有不同了。这个时候资源都不是独享的,往往需要为他人考虑。比如早上上厕所的时候,你就要先看看你的太太是否已经在里面了。如果房子里面住的是两个理智的成年人,那么只要注重隐私地敲敲门或者询问一下,就可以解决共享资源的问题了。但我们软件开发中的实际情况和现实生活一样,很多时候并不那么理想,不一定那么的可控。试想房子里面住的不只是你们两夫妻,还有多个小朋友,那么情况是不是就更有趣了?
没错,在这个环境下,你就希望你的房子里面能有一个机制,管理多个住户访问共享资源时的竞争和冲突。比如,你就会给房子里的卫生间装一个锁,有人进去用就锁上,出来就解锁。这样就能解决多个住户同时进去的问题。这就是软件开发里的“锁”,它本质上是一种同步技术,也是一个抽象概念,让多线程在同一时刻只有一个线程能拥有这个抽象的锁。拥有这个锁的线程会告诉其他线程:“我正在碰这个资源,其他不相干的线程别碰!” 因而,锁有两个基本操作:获取和释放,也就是上锁和开锁。
图1:示意图,一个进程作为一个或多个线程的容器
为什么需要锁
上文提及到,锁本质上是为了解决竞争共享资源的问题。那么具体一点怎么来理解呢?
其实对于跑在单核CPU的程序来说,可以不用锁。因为在单核CPU上,线程对应的指令执行都是有先后顺序的,不存在多线程同一时刻操作同一个资源的情况。但现在众多高性能芯片都是多核CPU,支持多线程并发计算的。这个时候如果多个线程同时操作同一个资源,比如内存里的变量,就可能会影响计算结果的正确性。下面我再来看一个例子。
图2:共享内存访问冲突的例子示意图
如上图所示,假设多个线程对应多个银行自动柜员机(A,B,C,D)的操作,它们都能访问银行账户里的余额数据。这些余额数据存放在共享内存区域内。当柜员机A的线程需要给账户1里存100元,而柜员机C同时需要给账户1里面存100元,那么共享内存区里的账户1余额最后会变成多少呢?小学数学告诉我们,应该是250元。但在实际程序中,柜员机A线程的操作可能是读取原来的余额50元,计算存取100元后余额应该是150元,把150元写回账户1的余额变量上。柜员机C线程操作也是一样。如果线程A和C完全同时并发,那么一番猛如虎的操作后,账户1的余额可能最终就是150元。这账户上少钱了,可能是难受得心痛。在汽车软件上,很多数据资源都是涉及行车安全的,一旦出错,则不是心痛这么简单了。
如果加上锁呢?柜员机A的线程在操作存钱时,先把账户1的余额锁定,告诉其他柜员机线程“先别动,等我处理完这笔交易!”然后算完余额150元再写回账户1,然后释放锁。这个时候柜员机C线程看到锁已经释放后,就再读取150元的余额,然后加上新存入的100元,得出新余额250元后再写入共享内存区,这样账户余额就正确了。这就是锁的魅力。
这时候,聪明的你可能就会问:“那如果多个线程同时操作锁,怎么办呢?”这是一个好问题,由于锁是为了解决多线程并发的资源访问冲突问题的,那么锁本身的操作肯定需要足够“底层”和“简单”,而且线程本身会得到锁操作的结果,也就是上锁或者解锁成功与否。所以锁操作需要依赖操作系统来实现,具体的实现行为往往是不能再细分的原子操作。详细原理我们有机会再探讨。而在软件实现层面,我们以Linux为例,再来看看几种实现方式。
Linux中锁的几种实现方式
1. Mutex (Mutual Exclusion, 互斥锁)
-
同一时刻只有一个线程任务可以拥有mutex,就像卫生间里最多只有一个人在里面能操作反锁。
-
只有拥有该mutex的线程可以解锁,就像只有卫生间里反锁的住户才能开锁。
-
简单直接,没有竞争情况,就像卫生间锁和没锁,跟能用不能用,一目了然。
-
如果拥有mutex的线程睡下去或者被抢占了,那么其他线程就进不去该核心区,造成计算资源浪费。就像如果反锁完卫生间的住户在里面睡着了,或者从里面窗户跑飞到外面去,那么其他等待使用卫生间的人就只能干等了。
Semaphore(信号量)
Semaphore是通过维护一个计数量来控制共享资源访问的一种同步原语。根据计数量是1,或者大于1,它主要有两种分类:二元信号量(binary semaphore)和计数信号量(counting semaphore)。它就像在卫生间门上装一个锁,然后在门外面挂上相应数量的钥匙。如果门外面还有钥匙,说明里面还能用,那么住户就会拿起一个钥匙,打开门进去,并把钥匙也一起带进去。如果门外没有钥匙了,就说明已经占满,暂时不能再进去其他人了。Semaphore有这些关键特征:
-
同一时刻可以支持多个线程拥有Semaphore,就像做了小隔间的卫生间里能容纳一个人洗澡,一个人刷牙,在门外面就可以挂两把钥匙了。
-
Semaphore的上锁和解锁是通过Wait和Signal通知机制实现的,就像如果有住户想进卫生间,但是门上已经没有钥匙了,那么这个住户就会在门上留上自己的手机号码。卫生间里有住户出来了,把钥匙挂回到门上的时候,就会看到手机号码,发个短信给门上的号码通知他现在卫生间可以用了。
-
二元信号量和Mutex在功能上比较类似,都是只允许最多一个线程访问资源。但二者不完全相同,区别在于占用的线程解锁资源时,是否会通知其他正在等待的线程。
Spinlock(自旋锁)
自旋锁和Mutex有点像,区别在于如果线程获取不到自旋锁(即共享资源已经被占用),那么尝试获取的线程就会重复循环地等待,也就是“自旋”的含义,就像用户看到卫生间已经被占用,但他会站在门口一直旋转等待开门。线程在尝试获取自旋锁的时候不会睡眠,也就不会有线程上下文的切换,但等待的过程中会一直有CPU消耗。这也很好理解,站在门口旋转等待肯定是能提高卫生间用户切换的效率的,一有人出来就能进去,但是门口旋转等待也是消耗耐心和体力的一个行为。所以这比较适合资源操作很快的场合,就像卫生间里的人能很快出来的场景。
Futex(Fast Userspace Mutexes,用户空间互斥锁)
Futex由内核空间的等待队列组成,该队列与用户空间的原子级别的整数相连。多个进程或线程完全在用户空间对整数进行操作,如果没有竞争发生,就在用户空间更改Futex。如果有竞争发生,才会陷入内核,使用相对昂贵的系统调用。还是以使用卫生间的例子,由于反锁卫生间门(Mutex)是一个很耗资源的操作,那么我们可以以开关门作为第一道锁(即用户空间的操作)。如果没竞争,门是敞开的,那么住户就直接进卫生间并关上门,不用反锁。只有住户看到门已经关上了,才敲门。里面用户听到有人敲门,即有竞争了,再去反锁门。在绝大多数都不需要竞争的场景下,只是开关门就比反锁省事多了。
写在最后
同步机制和锁对于确保数据完整性以及协调多个线程和进程的活动至关重要。上文提到的Mutex、Semaphore、Spinlock和Futex在不同数据量、不同操作耗时的场景下各有着不同的功效。Mutex为确保互斥提供了直接的解决方案,而Futex则通过最大限度地减少内核参与来提高性能。Spinlock为耗时较短的关键程序提供低开销的锁定,而 Semaphore则便于管理对有限数量资源的访问。理解并合理运用这些机制可以帮助我们设计出优雅地处理并发问题的应用程序,避免出现竞争条件和死锁等隐患,保证软件安全性。随着多核处理器和并行计算在汽车电子控制器上的的日益普及,高效同步的重要性怎么强调都不为过。使用合理的锁机制将使软件更加可靠、性能更强,并能充分发挥高性能硬件的潜力。希望本文能为大家简单介绍到锁的基本概念,为大家日后进一步的深入应用先荡起一点水花。
热门文章
更多精华美文扫码阅读
焉知汽车
希骥电池与储能
请先 登录 后再发表评论~