持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情
首先我们需要搞清楚到底什么是并发,它在系统中又是以何种形式存在的。
并发是如何产生的
在操作系统中,一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行,这种情形叫并发。但是,在任一个时刻只有一个程序在处理器上运行。 从这个过程中我们大致可以了解到,并发主要和处理器(CPU)有关,当同时有多个运行中的程序需要占用处理器资源,就形成了并发。我们总结了并发的两种场景,第一种场景是多个进程使用同一个处理器内核资源,第二种场景是多个进程使用不同的处理器内核资源。
并发会带来什么问题
针对上面介绍的并发两种场景,会有不同的问题。我们先来分析第一种场景,多个进程同时使用同一个处理器核(core)资源。我们知道一个处理器核在同一时刻只能被一个进程占用,那么,从微观角度讲真正的并发应该不存在,应该不会有任何问题才对呀?很遗憾,事实情况并非如此,为了防止CPU资源被同一个进程长期占用,大部分硬件都会提供时钟中断机制,在中断发生的时候,会进行进程的切换,当前进程会让出CPU,并且让其他进程能获得CPU的机会。因为进程切换的存在,假如共享同一个内存变量,就会存在代码临界区,比如i++操作,就不能保证原子性。因为i++其实分为两个步骤:
- add i
- set i
假设i=0,当进程1执行完add i后,就发生了切换。进程2重新开始执行add i,那么2个进程都执行完i++之后,结果i的值还是1。 所以,在这种情况下,并发带来的问题就是进程切换造成的代码临界区。 我们来分析并发的第二种场景,多个进程同时使用多个CPU核。在这种情况下,会引发两种问题。第一种问题和多个进程使用1个CPU核引发的问题一样,由于先天就是多个核并行执行多个进程的程序,假如共享同一个变量操作,必然会存在代码临界区。
第二种问题因为CPU每个核都维护了一个L2 cache(二级缓存),其目的是为了减少与内存之间的交互,提升数据的访问速度。但是这样,就会造成主存中的数据复制存在多份在各自的L2 cache中,导致数据不一致。这就是CPU二级缓存和内存之间的可见性问题。
如何解决并发带来的问题
上节分析了并发带来的问题,归根结底就2类: ·代码临界区的问题。 ·主存可见性的问题。 下面我们分别来介绍这两类问题的解决方案。 先说代码临界区问题。孙子曰:“百战百胜,非善之善者也;不战而屈人之兵,善之善者也。”也就是说最好的战争方式,就是不要发动战争,通过谋略让对手投降。杀敌一千,自损八百,很是划不来。所以,处理代码临界区的问题也是一样,最好的方式就是消除临界区。很多时候,临界区是由于自己考虑不周到,代码编写方式不正确造成的,只要设计得当,是有可能消除的。 不过凡事无绝对,假如不能消除临界区,那么我们只能硬着头皮想办法对付了。前面我们分析临界区出现问题是因为多个进程同时进入了临界区,造成了逻辑的混乱。所以,我们可以把临界区作为一个整体,让多个进程串行通过临界区,达到保护临界区的目的。这样的机制我们就叫做同步。同步在技术上一般都是通过锁机制来解决的,后面我们会具体分析Linux中的不同锁实现方式。 另外像i++这样的操作,一般都会在硬件级别提供原子操作指令作为解决方案,本章我们也会介绍原子变量的实现方法,一般都会通过cmpxgl这样原子指令来支持。 接着来看主存可见性的问题。多个进程依赖同一个内存变量,那么为了保证可见性,可以通过让L2 cache强制失效,都去主存中取数据。有时候编译器为了提升程序执行效率,都会对编译后的代码进行优化,让某些指令在上下文中的结果依赖L2 cache,我们可以通过内存屏障等方式,去除编译器优化,本章后面会具体介绍这种方法。