异步并发问题的解决思路
1 基本概念
1.1 并发和同步
并发:操组之间互相无法感知。通常用来描述“存在多个操作同时访问同一个状态”的异步场景。
同步:使用某种机制(通常泛指“锁”或“信号量”)将并发操作变得受控制。
1.2 什么是Happened-before?
Happened-before是Leslie Lamport提出的一种用来描述两个事件先后发生顺序的论述(参考: Lamport逻辑时钟1)。Happened-before中强调,如果A happened-before B
,那么A
必然对B
可见。
后来这一种论述被广泛应用于编程语言并发模型的实现。如Java Memory Model的synchronized、volatile、java.util.concurrent,其他语言的atomic标准库基本上也都支持。
譬如我们可以说,synchronized unlock happened-before lock
,意思是上一个unlock
临界区(即被机制保护的部分)的操作结果,必然对当前lock
临界区可见。背后的含义是unlock
临界区和lock
临界区不存在并发性,即它们是并发安全的。
volatile
也有同样效果,但理解起来更为复杂一些。当我们说volatile write happened-before read
时,表面上可以简单理解为是上一个write
结果必然对当前read
可见。
然而volatile
是通过一种由CPU架构提供名为内存栅栏/内存屏障(写屏障、读屏障)2 的技术来实现的。写屏障能够同时确保write
之前的操作对read
可见。而读屏障则能够确保read
之后的操作避免指令重排序(一种提高代码执行效率的机制)。
// 理解例子
class Example {
int a = 0;
volatile int b = 0;
// 1)先执行
void write() {
a = 1; // 普通写
b = 1; // volatile写,会插入“写屏障”
}
// 2)后执行
void read() {
if (b == 1) { // volatile读,会插入“读屏障”
assert(a == 1); // 因为“写屏障”结果为:true
}
}
}
2 安全隐患
更新覆盖:一个操作的更新了覆盖另一个操作的更新,导致部分更改丢失。
脏读:读取到另一个操组尚未提交(即中间状态)的状态
- ⚠️ 虽然一些编程语言有自己的内存模型。如Java Memory Model,线程有自己的工作内存(一种抽象概念),但它依然无法避免脏读。譬如第一次读操作必然从主存中读取,然而主存若没有相关机制保护的话,就无法解决异步并发所带来的安全隐患
3 解决思路
3.1 隔离
- 使用锁(访问互斥)或信号量(限制操作数) 对临界区进行并发同步控制
- 期间还可以通过细化锁策略(段锁、读写锁)或消除锁语义(由运行时提供) 来提高并发效率
- 将状态放入线程变量(ThreadLocal)或域值(ScopeValue,Java 20)* 中,通过隐藏其可见性来避免并发
3.2 不可变
- 用final修饰属性
- 忽略操作或直接抛异常
- 写时复制。即修改操作仅在副本上进行。通常还会将修改后的副本返回给客户端
- 读操作防别名Bug3。即读操作直接返回副本
3.3 无状态
- 对象没有保持状态的属性,如果有那么属性本身也应该是无状态的。或者属性本身就是不可变的。