在多线程编程中,安全是我们考虑的最重要的因素。通常程序员都会使用锁来满足安全要求,但是只用锁并不能写出良好的多线程代码,因此我们有必要更深入一点,对线程安全策略进行更加全面的了解。首先谈谈影响线程安全的因素:
影响线程安全的因素
有三个因素影响到了多线程下的安全性:原子性、可见性和指令顺序
- 一个原子操作是单独的、不可分割的。但是高级语言中的大多数语句,包括一些简单的读写语句,都不具有原子性,最常见的例子是++,同样,long类型的读写在许多操作系统中也不是原子性操作,因为它牵涉到了2个32位的原子操作,如果此时另一个线程去读取内存,就可能出现高位和低位一个老值一个新值的情况。
- 可见性和指令顺序是两个有关联的问题,在夏天是个好季节的"Loads are not reorderd with other loads" is a FACT!! 再续:.NET MM IS BROKEN中有详细的描述,花开花落的CLR 2.0 Memory Model也是个不错的参考。简单地说,在多核系统中,CPU单元不会在每次读写操作时都去刷新主内存区域,如果在一个线程的写操作完成但刷新主内存之前,另一个线程读到了主内存中的同一个变量值,程序的正确性就得不到保证。编译器和处理器对指令顺序的优化也有类似的影响。
请看下面的代码:
Short变量的读写能满足原子性要求,但是在没有使用同步或InterLock类的情况下,不能保证可见性要求,因此仍不是线程安全的。
实现线程安全的策略
从状态机的角度来看,每个对象都具有若干个一致性状态,在运行过程中,所有的公共方法执行前后,对象都不应违反一致性约束,这样的代码我们通常认为是安全的。比如对于基于链表的队列,队列尾节点的Next变量总是指向null,不论在出列/入列完成前后,这一约束都应该满足。
满足对象的一致性约束的两类策略
- 使用独占技术来避免另一个线程读写到对象的中间状态,这是最常见的策略。
- 允许不同线程访问到对象的不一致状态,但是线程必须能够检测这些状态,并修正它们对安全性的影响。这种策略常见于各类非阻塞算法中,在多处理器系统中有独特的优势,但是只适合特定的场合。
以基于链表的队列为例来说明这2类策略:
对上面的过程,基于锁的策略将使用类似下面的代码来保证另一个线程不能访问到图2的状态
如果使用非阻塞算法,当一个线程执行到图2后被中断,而另一个入列的线程发现队列已经处于图2的状态时,将会试图更新Trail到图3的状态,然后再执行增加的操作,具体的代码可参考这里。不论是锁还是非阻塞算法,都能正确维护对象的一致性状态。
实现独占技术的策略
实现独占技术有三种基本的策略:
- 使用不变对象,一个不变对象肯定是线程安全的,也不会受到可见性和指令顺序优化的影响。最近比较火热的Erlang语言就把这种策略发挥到了极致,变量一旦赋值就不能再改变。
- 使用锁和相关机制保证代码执行过程中对象只能被一个线程访问,这个是用得最多的。
- 限制对象只能被一个线程所访问,这种策略依赖数据封装和隐藏技术,需要在设计上进行仔细地考虑。
三种策略中,锁是最常用的独占机制,但是锁也会带来额外的开销和可扩展性问题。基于锁的算法很容易具有不佳的可扩展性,因为它的实质是将并行化过程改为串行化,很显然当几个并发线程需要排队执行时,增加CPU单元是没有意义的。因此在多核时代,我们应该对不变对象和限制访问技术给予更多的关注。
待续。。。