并发的问题在哪里?(什么让多线程和单线程程序不同)
并发(多线程共同运行)相比与单线程而言,区别在于CPU的执行上。在单个CPU上,①单线程程序运行时独占CPU资源,独占对同一个数据结构或者资源的更改的权限,而在多线程情况下,多个线程同时拥有对同一个数据结构或者资源的修改的权限,线程执行快慢不同,随着时间推移很可能多个线程竞争导致数据结构状态不一致、资源利用率低甚至受损导致程序出错,②此外还有一个线程修改到一半被另外一个线程打断导致出错的问题;在多个CPU上(现代计算机多是多核处理器),除了单个CPU要面临的问题,③还要面临多个CPU之间保持数据一致性的问题。虽然按照传统的计算机模型,无论是几核CPU,它们都共享同一块内存空间,所以在内存中的数据始终是一致的,但是在实际应用中,为了确保CPU执行的效率问题,每个CPU都设有不止一级的缓存预先保留可能会用到的数据,在写入时也有写缓冲区缓存对数据的修改。这种情况下,一个CPU对数据的读取和写入可能不能及时的通知到另外的CPU,导致多CPU协同出错。
如何解决并发的问题?
对于可能并发修改数据结构或者资源的问题:
这种情况称为“临界区问题”,临界区问题的解决方法是对关键数据加锁或者使用信号量来同步(让多个线程的无序并发修改变为有序的可安排的修改)。具体到实现上,需要额外的锁(我更喜欢叫做令牌)来完成。被锁住的代码区域只能当前线程解锁后才能被其他线程访问。那么如果加锁、解锁的步骤被其他线程打断怎么办?这就需要下面的解决方法。对于线程操作到一半被另一个线程打断的问题:
这个问题涉及到两个小问题,其中最直接的,是操作原子性的问题。也就是说一个操作,根本不能被打断,一定要被CPU一口气执行完毕才可以。如上一个问题所说,这个情况需要加锁,加锁也是好几个操作呀,说到底如何保持原子性呢?
这需要在CPU的指令层面实现,在CPU的指令中实现一个lock指令前缀,当执行由这个前缀标注的指令时的时候,确保CPU原子地完成。其实这个指令抽象到上层编程语言,只是一个compareAndSwap函数(CAS),通过对这个函数的巧妙运用实现了复杂的加锁,解锁操作。
除了原子性问题,还有一个问题,成为有序性问题。这个问题是,一个操作,其实原本是可以打断的,但是指令实行导致状态不一致,因为打断的时机不对,导致出错了。
详细说来,程序代码,最后交给CPU执行起来,是需要转化为CPU可以识别的指令(一系列的二进制串)的,在转化过程中,为了提高效率,可能会对代码进行重新排序(在编译阶段的重排序生成CPU指令的处理器重排序)。重排序问题会导致明明在代码中顺序为一,二,三的语句真正执行起来变成了二,一,三的问题,当然对于存在数据依赖的代码,其先后顺序肯定不会颠倒,但是这个保证是针对单线程而言的,多线程情况下,重排序并不知道哪个数据会成为另一个线程的依赖数据。这就导致,在多线程下,一个线程明明看到另一个线程的二操作完成了,因此推断其一操作也完成了,所以这个线程自己要依靠一操作的结果执行什么操作,但却发现一操作还没发生,导致这个线程执行出错。
这种重排序引起的问题,解决第一需要编写人员首先找到那些影响多线程协同的变量或代码块,对它们进行特殊处理;第二,就是这个特殊处理,需要CPU指令层面的实现,这里需要的是“内存屏障”,内存屏障是指特殊的指令,它们会使屏障内的数据一定是在某些操作之前(或之后)完成了,并且对,也就是说会禁止重排序的发生。内存屏障分多种,对应不同的要求,也对应禁止不同的重排序。对于多CPU数据一致性的问题:
多CPU的数据一致性问题,也可以叫做数据可见性的问题,就是一个CPU修改了数据,一定要在某个其他CPU需要使用这个数据的时刻,对其他CPU可见的问题。这个问题的解决,其实在上一个问题的解决方法中已经提到了,就是“内存屏障”,内存屏障可以从CPU的层面保证,数据的更改,一定在其他某个操作完成之前(或之后)就已经完成,并且对其他CPU可见。
这里继续深化下,CPU如何实现的这个保证。这说来还是两个字:“加锁”。这次的锁是加在了内存总线上,也就是CPU和内存通信的物理连接上,总线锁可以保证,对某个数据的修改一定是单个CPU的行为,修改会直接反映到内存中,并且修改后所有CPU内的该数据的缓存失效,都要再次到内存中重新读取一次。后来,因为总线锁太“重”了(为了一个数据锁住了所有内存空间),又引入了缓存锁,CPU只锁住自己的缓存,修改完后,通过CPU之间的缓存一致性协议,其他CPU获得当前该数据缓存失效的信息,重新读取数据。
总结
通过上面的描述,不难发现,并发的问题,简洁地说是原子性、可见性和有序性。并发的解决如果用两个字来概括的话,就是“加锁”。加锁的底层依托,是CPU层面的CAS操作、和内存屏障,而这二者继续往下深入,都会归结到CPU的lock指令,使用总线锁和缓存锁。