写过 JAVA 并发代码的同学对 synchronized 关键字一定是熟的不能再熟了,其基于对象头部的 monitor 实现了对代码块的加锁,使一段代码变为线程不可重入的。
synchronized 与操作系统层的 lock 与 unlock 机制非常类似,多线程通过一个共享变量通信,这个共享变量标志着一段代码是否正在被一段线程执行着,如果已有线程在执行这段代码了,那么其它线程便等待在这个信号量上,直到其执行完毕并重置信号量。
操作系统层级实现 lock/unlock 有两种方式,一是单核环境下通过禁止中断来防止时钟中断打断自己的执行,从而使 lock/unlock 之间代码的执行是不被打断的,单核环境下这也意味着上锁的代码不会有其它线程的重入,保证了上锁代码的线程排它性。
但是这种模式仅适合单核处理器的情况,如果是多核处理器,即使本线程所在处理器禁止了中断,其它线程依然可以运行在其它处理器上来执行上锁的代码,代码还是可重入的。所以多线程情况下我们使用内存中的共享变量来进行线程间的通信,信号量像一扇门,线程进去时关门,出来时开门,只有门是开着的线程才可以进入上锁代码区。这样一来我们必须保证多线程情况下每个线程对共享变量“门”的操作的正确性。对于 X86 架构来说,可以借助大名鼎鼎的 test&set 原语来对共享变量进行操作,因为这是处理器级别的原语,执行过程是不可打断的,保证了单核心环境下的线程排它性。再结合内存的总线锁保证多核对 “门” 的访问的排它性,即可保证多线程环境下对锁操作的线程排它性。
使用“门”来保证线程排它性也有两种方式,一种是繁忙等待,一个线程发现“门”是关着的,便在while中不断检查门的状态只到其开启,显然这种方案会导致大量的计算资源浪费在循环中。
另一种方式是非繁忙等待,我们为“门”设置一个等待队列,线程发现“门”是关闭的则进入阻塞状态,并加入等待队列。等待队列中的线程因为是阻塞的,不会在等待过程中占用计算资源,处理器可以被解放出来去做更多有意义的事情。“门”内的线程执行“开门”动作时,从等待队列中挑一个或几个线程唤醒,让他们继续执行或竞争。大部分情况下我们采用的都是非繁忙等待的方式。
将上述描述中的门换做对象头的monitor,lock/unlock 换做更高抽象层级的moitor-enter/monitor-exit,则可以直接用来描述 synchronized 的原理了。当然,这些高层的抽象的实现是基于上述底层的抽象的。
对于线程安全的定义,因为其描述主体的不同而不同。比如一个变量是线程安全的,还是一段代码是线程安全的,它们的定义一定是不同的。《Java并发编程》中对线程安全的定义也只是在多线程情况下可以保证程序的正确性,但是正确性的概念却非常模糊(可能因为我看的是翻译版?)。
没找到一个明确的定义,我只能尝试总结一下个人在进行多线程编程时的思路。
在进行多线程编程时,如果线程间不需要进行通信,各个线程独立执行,彼此间没有共享的数据,则不需要考虑线程安全问题,它们本身就是一些彼此独立的单线程罢了。
但是一旦线程间需要协作,则必须通过共享的数据进行通信,也就产生了数据依赖。我们必须保证无序执行的线程们对这些依赖的访问的正确性。基于对共享数据访问正确性的保证,推演出线程间的互斥关系。
同时在定义共享数据时,我们一定是基于A与B是如何基于共享数据协作来定义的,那么这便是A与B线程的同步关系。
这里面最难的是互斥关系的定义,同步关系是我们对线程协作方式的设计,而互斥关系是要保证多线程对共享数据访问的正确性。因此互斥关系需要我们从程序动作的依赖关系,语言/操作系统甚至是硬件层的特性来综合的考虑。
比如一个“门”的实现,我们对门的动作会有三种:1.检查门的状态,2.开门,3.关门。
很显然,关门必须时线程排它的,如果有两个线程同时关门,则无法保证加锁区域的线程排它性。而开门不需要,因为只有门内的线程才可以开门,在我们的设计中同一时间只会有一个线程在门内。
检查门的状态与开门是存在依赖关系的,因为我们必须基于1的结果来判断3是否执行。所以1与3应该是一组线程排它的动作。一个线程一旦执行了1,那么其它线程对1与3都不能执行。
如果A执行1,然后去开门,门还未被打开时间片用完被挂起,此时B来检查门的状态,发现门还是开启的,那么A与B会同时进入同步代码区,程序并没有按我们既定的语义正确执行。
所以互斥关系是1与2应该被我们设计成一个操作4:检查门的状态,如果是开启的则关门进入同步代码区,否则移步到门的等待队列并阻塞。并且对与动作4,各线程间是互斥的。
1与2应该是一个原子操作,必须全部执行完,并且执行过程中不能有其它线程打断和进入造成共享数据的污染(原子性)。
互斥关系被推演出来了,我们再考虑一个问题,如果A已经执行了动作4检查并关门进入了同步代码区,但是门的状态驻留在CPU1的写缓冲区中,没有刷新到缓存,那么此时CPU2执行线程B则会发现门还是开的,AB同时进入了同步代码区。这是基于处理器的硬件结构来考虑的,因为 B 执行操作4对于 A 执行操作4是有数据依赖的(反过来A对B也有),所以不管A还是B,我们必须保证其对操作4的执行结果一定对另一方可见才能保证程序的正确运行。X86架构下的读写屏障可以帮我们实现这一点,在JAVA中体现为volatile或synchronized。(可见性)
我们再看开门的动作,在代码上体现为 1.开门 --》2.唤醒等待队列的线程。代码写的很漂亮,但执行时很可能不是这样执行的。编译器在编译代码时,因为开门/唤醒等待队列中的线程不存在数据依赖关系,因此编译器很可能将其顺序打乱,因为对于单线程来讲先1还是先2并不影响单线程的执行结果。对于处理器来说也一样,等待队列所在的缓存如果处于busy,为了提高流水线的吞吐量减少cache wait,处理器很可能先将 2 送入流水线。
虽然单线程情况下无论先执行1还是先执行2,结果都一样。单对于多线程来说情况并没有这么乐观,用等待队列中线程的视角看这个线程,它仿佛是个神经病,到底先执行1还是先执行2根本让人摸不透。如果先执行2,等待的线程被唤醒,此时1还没执行,门还是关的,于是被唤醒的线程又去睡觉了。此时再执行1,门虽然开了,却没有线程试图进入同步代码区,以为它们都在睡觉,并且是没有人叫醒的那种。
所以动作1,2在单线程情况下虽然不存在数据依赖,但是在多线程情况下存在依赖。2的执行必须依赖1的执行才能使程序正确的运行,但是编译器和处理器却不能感知到这一点,这需要我们自己去保证,1执行完2才可以执行。处理器层面实现这一点还是靠内存屏障,Java层面则还是volatile和synchronized(有序性)
再看一下我们设计的流程:
1. 我要编写一个多线程的程序,需要线程间的协作。
2. 从功能层面设计各个线程的协作方式,从而定义它们通信使用的共享变量。
3. 为了保证对多线程共享变量访问的正确性,让它们按我们既定的逻辑进行协作,推演线程间的互斥关系。
4. 推演互斥关系的过程中我们发现哪些动作是不可分割的,哪些动作是互斥的。而依据就是多线程乱序的执行这些动作,如果可分割或者不互斥,那么多线程不能按我们既定的逻辑进行协作。
5. 我们还需要找到多线程情况下,动作间的依赖关系,保证多线程情况下被依赖的动作对其它线程的可见性和有序性。
关键词:同步互斥关系,多线程环境下动作的数据依赖,动作的原子性。
其中,互斥关系的推演以及原子性的定义是最复杂的,题目多解,没有固定解法,特别考验内功与经验。