08 | 锁:并发操作中,解决数据同步的四种方法
LMOS
该思维导图由 AI 生成,仅供参考
你好,我是 LMOS。
我们在前面的课程中探索了,开发操作系统要了解的最核心的硬件——CPU、MMU、Cache、内存,知道了它们的工作原理。在程序运行中,它们起到了至关重要的作用。
在开发我们自己的操作系统以前,还不能一开始就把机器跑起来,而是先要弄清楚数据同步的问题。如果不解决掉数据同步的问题,后面机器跑起来,就会出现很多不可预知的结果。
通过这节课,我会给你讲清楚为什么在并发操作里,很可能得不到预期的访问数据,还会带你分析这个问题的原因以及解决方法。有了这样一个研究、解决问题的过程,对最重要的几种锁(原子变量,关中断,信号量,自旋锁),你就能做到心中有数了。
非预期结果的全局变量
来看看下面的代码,描述的是一个线程中的函数和中断处理函数,它们分别对一个全局变量执行加 1 操作,代码如下。
首先我们梳理一下编译器的翻译过程,通常编译器会把 a++ 语句翻译成这 3 条指令。
1. 把 a 加载某个寄存器中。
2. 这个寄存器加 1。
3. 把这个寄存器写回内存。
那么不难推断,可能导致结果不确定的情况是这样的:thread_func 函数还没运行完第 2 条指令时,中断就来了。
因此,CPU 转而处理中断,也就是开始运行 interrupt_handle 函数,这个函数运行完 a=1,CPU 还会回去继续运行第 3 条指令,此时 a 依然是 1,这显然是错的。
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
- 深入了解
- 翻译
- 解释
- 总结
本文深入探讨了并发操作中解决数据同步的四种方法:原子操作、中断控制、自旋锁和信号量。通过具体的例子和代码示例,作者详细介绍了这些方法的原理和实现方式。首先,通过一个全局变量的例子说明了在并发操作中可能出现的非预期结果。然后,详细介绍了原子操作的实现方法,以及在x86平台上实现原子操作的代码示例。接着,介绍了中断控制的方法,并给出了在关闭、开启中断过程中的代码示例。随后,作者讲解了自旋锁的原理和实现方式,以及在中断环境下使用自旋锁的方法。最后,提到了信号量的概念和使用场景。整体而言,本文通过清晰的技术指导,帮助读者快速了解并发操作中数据同步问题的解决方法,为并发编程提供了有益的参考。
仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《操作系统实战 45 讲》,新⼈⾸单¥68
《操作系统实战 45 讲》,新⼈⾸单¥68
立即购买
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
登录 后留言
全部留言(104)
- 最新
- 精选
- xiaoq置顶# 单CPU下 业务函数和中断函数会存在并发访问同一资源 1. 对于简单资源(原型变量?),可以把访问资源变成原子操作,使用带lock前缀的`addl subl incl decl`原子指令; 2. 对于复杂资源(复合类型变量),可以把访问资源处业务函数关掉中断,保证此处是串行访问资源;需要解决嵌套问题,通过在关中断前使用pushfl、popl保存之前的中断状态,在下一次开启中断时恢复该状态; # 多CPU下 除了每个CPU存在业务函数和中断函数并发问题,还存在不同CPU之间并发问题; 在保证单CPU使用同步的情况下,还需要保证多个CPU同步; 1. 简单资源的原子访问操作:个人理解是因为锁了总线,所以单个、多个CPU均适用; 2. 自旋锁:关键指令xchg,确保 read(if) & set 在多个CPU之间是原子的 # 信号量 在复杂的上下文中保护多个复合资源 使用spinlock和wait_queue以及resource_count实现等待、互斥、唤醒 使用方法 // spinlock spinlock_t spinlock; spinlock_init(&spinlock) // cpu转圈圈,直到获取锁 spinlock_lock(&spinlock) // dosomething() spinlock_unlock(&spinlock) // semaphore sem_t sem; sem_init(&sem); // 线程会休眠 直到拿到信号量 sem_down(&sem); // dosomething() sem_up(&sem);
作者回复: 对正确的
2022-04-252 - lifetime置顶看到楼上的各位都发表了那么多的总结,感觉你们都太厉害了! 我也跟着你们的脚步,谈谈我对这节课的理解: 1、对原子的理解: 抽象理解为一条指令,要么执行完成,要么没有执行 2、对中断的理解: 单个CPU中多个进程并发执行,是靠使用中断进行切换,关闭中断后,只有一个进程在执行这块临界区代码,其他进程无法切换执行,执行完这块代码后,再打开中断,再去切换到别的进程执行 3、对自旋锁的理解: 自旋锁解决多个CPU,多个进程并行执行的情况; 多个进程对同一个物理内存地址进行访问,先访问并判断为0的进程,进程加锁设置为1,执行临界区代码; 其他的进程陷入访问,判断为1的流程中,死循环; 执行完临界区的进程将地址设置为0,其他进程再去争抢 4、对信号量的理解: 信号量解决自旋锁中其他得不到执行的进程一直在轮询的问题,这个一直轮询会导致CPU无法切换到其他不需要执行该临界区的进程执行,效率低下; 所以引入能睡眠的机制,得不到的进程不让他们继续等了,先睡觉,负责其他进程执行的CPU去切换到别的进程执行; 等执行完临界区的进程OK后,再把这些睡觉的进程唤醒,他们再争抢 理解能力有限,只明白这么多,如果有不对的,麻烦指正!
作者回复: 对头
2021-08-1813 - Freddy置顶本节是关于共享数据的并发修改问题,总结了不同场景下的使用方式: 当共享数据是单体变量时,可以尝试使用原子操作指令; 当共享数据是复杂的数据结构时: 当是单CPU环境时,只有中断和业务进程两个代码操作流,此时我们可以手动控制CPU中断关闭/开启,要注意解决CPU中断关闭/开启的嵌套调用问题; 当是多CPU环境时,就不能同时控制多个CPU的中断了,此时我们用到了自旋锁;多个CPU进程竞争自旋锁,成功加锁的进程,可以执行自己的业务流程;这里要注意的是要保证自旋锁流程中的读取锁变量和判断并加锁的操作是原子执行的; 在多CPU环境时,没有获取自旋锁的CPU,就会一直在循环读取锁变量和判断是否加锁的流程当中,浪费了CPU资源,为了解决这个问题,引入了信号量; 首先,各个进程会去竞争信号量; 没有获取信号量的进程放入等待队列,这样该进程所在的CPU就可以去执行其他业务进程了; 获取信号量的进程执行完后,会释放信号量后,同时会去唤醒在等待队列中的进程,这样等待的进程就会再次去竞争信号量; 思考: 1.那一个进程中的多个线程并发修改共享数据的模型,应该也是同样的道理吧?? 2.锁,应该是操作的前提条件,有了锁才能去执行业务代码; 但对原子操作来说,好像是加锁和业务操作一起执行了。
作者回复: 你好,总结到位
2021-05-28212 - noisyes置顶真的是把锁的前生今世讲得明明白白,让我有种豁然开朗的感觉!可能到最后可能无法独自实现一个操作系统,但是真的能从底层的角度重新认识操作系统!
编辑回复: 小编回复,很高兴这个专栏能够帮到你,后面更精彩,敬请期待。还有,只要有兴趣,跟着课程走,是可以跑起来操作系统的,别慌。
2021-05-276 - 惜心(伟祺)置顶这个问题抽象下就是在如何在并行执行中做到串联有序 解决思路就两个: 1.在结果端(确保内存一致) 2.在过程端(确保cpu计算中不被打断) 原子性就是确保内存一致,但是因为cpu计算需要时间所以只能保证一个单位的绝对一致 至于锁、信号都是在对上面两种思路的组合,妙的是会出现跨代组合来实现更多样化的应用 老师从根本上分析问题,由简到繁,一下抓住根本和牛逼 其实操作系统就是对简单的功能的组合重复实现无限复杂操作 这种思路和软件开发是一致的,用一种语言API,各种的组合重复利用时间空间的重复解决各种复杂问题
作者回复: 很好,学到了
2021-05-2712 - 嗣树置顶记录下我学习本节内容的一些思考: 1. 学习到这里还没有引入抢占这个概念,所以讨论环境中默认进程上下文不会发生抢占,同样也没有进程切换这些东西。 其实讲进程上下文也不对,我们都还没有引入这些东西,暂且凑合这么用吧。 2. 首先讨论了单 cpu 情况下,保证数据一致性的方式,这一时期我们主要防中断: - 对于单个变量我们实现了原子变量(由硬件提供支持),中断是不能打断这个操作的 - 对于复杂变量的操作,这个时候中断可以乱入了,我们通过关中断来保证单 cpu 下这些数据的正确 3. 到了多 cpu 时代,不止有中断这个小三,还有隔壁老王(其他核),关中断已经不管用了, 为了数据的一致需要大家在操作前都走自旋流程,自旋需要新的原子操作支持(xchg),到此我们解决了老王带来的问题。 4. 但是我们还需要面对中断的问题,自旋锁的实现是带条件的死循环,这也引入了一个问题:死锁。 cpu 间的互相抢锁最多抢不到等一会,但是 cpu 和本地中断之间就不同了。 当本 cpu 占有了锁,此时打来中断,假如中断中也要抢这把锁,那他抢不到嘛,只好死给你看咯。 而中断也可以嵌套,这种情况也可能死。所以对自旋锁升级,添加了关中断的操作。 5. 最后老师介绍了信号量,这里其实已经带入了调度的概念。 6. 最后在思考一下抢占和中断优先级带来的问题,其实也还是死锁的问题。 上面我们设想了两种死锁的情况。我们泛化一下,其实中断可以看作比普通进程优先级更高的进程, 只要是构成这种 低级持锁,高级来抢 的局面都可能死锁。而抢占和中断优先级创造了更多的阶级, 也就产生了更多的可能。所以 Linux 中自旋锁的实现第一步是关抢占。 错误或疏漏的地方还请指正,抱拳了老铁。
作者回复: 你好,你学的很认真,学的很通透,总结很到位
2021-05-26439 - neohope置顶当前版本还有几个问题还没有解决,希望后面课程有进一步详解: 1、跨用户进程时,如何共享内核的同一个锁或信号量 2、没有提供锁的可重入不可重入的限制 3、锁自旋时不会让渡CPU时间 4、暂时没有提供公平锁算法 5、暂时没有提供乐观锁算法 基于本节,其实大家可以尝试一下: 1、信号量如何提供最大资源数限制 2、信号量如何提供扣除多个资源的支持 3、如何实现互斥量这一类数据结构呢 4、如何实现读写锁这一类数据结构呢 锁一般用来做线程间或进程间的互斥操作 信号量一般用来做线程间或进程间资源同步操作,比如资源的占用和释放等
作者回复: 是的,我们后面会有介绍 的
2021-05-2636 - pedro置顶今天的专栏可谓是精彩至极! 锁是解决并发同步问题的关键,从本文来看,锁有两个核心点,一个是原子操作,另一个则是中断; 通过原子操作来实现临界区标志位的改变,关闭中断来避免CPU中途离开导致数据同步失败问题。 自旋锁(spinlock)是锁的最小原型,其它锁都是以它为基础来实现的,自旋锁的实现也颇为简单,只需一个简单的原子标志位就可以实现了,当然还要妥善管理中断。 在 xv6 中,对锁的实现只有两种,一种是刚才提到的 spinlock,而另外一种则是 sleeplock,spinlock 不会让出 CPU 执行权,而 sleeplock 则是在 spinlock 的基础上,增加 sleep 功能,即如果一个执行体(线程或者进程)加锁失败,就会进入休眠状态,让出 CPU 执行权,让其它的任务也能得以执行。 本文中的信号量(sem)也是 sleeplock 的一种,sem 的实现更为精致,通过等待队列来记录加锁失败的执行体,并后续通过一定的策略来选择唤醒,这也是很多编程语言中信号量的实现方式。 当然不同的语言会有不同的优化,比如 go 的 Mutex 是非公平的唤醒机制,但是针对非公平的场景,又设有饥饿补偿,总之本文中实现的 sem 几乎是任何信号量(锁)实现的基础蓝本。 对于思考题答案,这里就顺便贴一下吧,如果有啥问题,欢迎大家交流指正: spinlock_t lock; x86_spin_lock_init(&lock); // 加锁,如果加锁成功则进入下面代码执行 // 否则,一直自旋,不断检查 lock 值为否为 0 x86_spin_lock_disable_irq(&lock); // 处理一些数据同步、协同场景 doing_something(); // 解锁 x86_spin_unlock_enabled_irq(&lock); sem_t sem; x86_sem_init(&sem); // 加锁,减少信号量,如果信号量已经为 0 // 则加锁失败,当前线程会改变为 sleeping 状态 // 并让出 CPU 执行权 krlsem_down(&sem); // 处理一些数据同步、协同场景 doing_something(); // 解锁,增加信号量,唤醒等待队列中的其它线程(若存在) krlsem_up(&sem);
作者回复: 我非常喜欢你这样的读者,你总是能抓住问题的本质,继续保持,为你点赞
2021-05-268140 - 不一样的烟火锁用来保证资源的使用不被打断,打断的情况包括中断,其他cpu执行流,其他线程 保护的情况有原子操作,关中断,自旋锁,信号量 原子操作好比瞬时动作,动作只有一个,不被打断 关中断好比学习的时候关闭电话,不被分心,专心学习 自旋锁好比上厕所的时候锁上门,其他人只能在外面团团转,干着急,其他事儿干不了 信号量好比正在忙,门口挂个闲人免进,等自己办完事儿再通知他过来,他可以先去处理其他事情
作者回复: 是的 是的
2021-07-11212 - 黎原子变量,中断,自旋锁,信号量,层层递进,都是在前一个技术的某些场景无法满足的情况下,更高级复杂的解决方案,当然也是基于前面的基础的封装
作者回复: 你这角度也很透彻,很好
2021-05-2611
收起评论