架构反思-03-并发是很复杂的事情
架构反思-03-并发是很复杂的事情
多核给程序带了更强的处理能力,本质上来说,这是一种横向扩展的方法。同样地,它也要面对任何横向扩展的方法都必须面对的问题,事情划分和合并,以及这个过程中所必须处理的同步、一致性等事务性问题。
并发是一件很复杂的事情,这不仅体现在大型的场景上,如业务层面的,多机集群共同支撑红包业务,中间件层面的,分布式消息队列的消息顺序问题,甚至就在单机上简单程序的数据通信,都会有想不到的并发问题。
以一个最最简单的单例模式来看看,Java 代码
public class Foo {
private static Foo foo;
private Foo() {
// 初始化代码
}
public static Foo getInstance() {
if (foo == null) {
foo = new Foo();
}
return foo;
}
}
在单线程下没有什么问题,但到多线程下,大家都能看出在 foo == null
的判断会出现读写上时间顺序问题,一般用锁做个保障,但是锁整个方法开销太大,又引入了二次确认的机制,如下
public static Foo getInstance() {
if (foo == null) {
synchonized(Foo.class) {
if (foo == null) {
foo = new Foo();
}
}
}
return foo;
}
一般开发能想到这个层面,就已经非常不错了,但是还存在问题。问题出现在 foo = new Foo()
上,这一个语句是一个复合操作,它包含几个部分的动作:
- 分配对象存储空间
- 初始化对象
- 把 foo 设置为对象引用
出于性能的需要,现代的 cpu 都采用多级指令流水。为使得 cpu 性能最大化,在不影响单个线程的执行结果前提下,会对这些操作进行重排序(编译器层面、CPU硬件层面)。那么,如果初始化是个比较耗时的过程,就会出现步骤3
先于步骤2
执行,导致在其它线程上,已看到 foo 有指向明确对象,但是由于对象未有正确初始化,出现逻辑错误。比如:
public class Foo {
private static Foo foo;
private int count = 0;
private Foo() {
for (int i=0; i<100000000; i++) {
if ((i & 0x1) == 0) {
count += 1; // 偶数计数
}
}
}
public static Foo getInstance() {
if (foo == null) {
synchonized(Foo.class) {
if (foo == null) {
foo = new Foo(); // 指令重排,foo 已完成赋值,初始化未完成
}
}
}
return foo;
}
public int getCount() {
return count; // 如果 count 尚未计数完成,得到错误的数据
}
}
必须使用某种手段,阻止重新排序,如给 foo 加个 volatile ,或者直接在类加载时就直接进行初始化。如
public class Foo {
private volatile static Foo foo; // volatile 的写会加内存屏障,阻止写之前的指令重排
}
或
public class Foo {
private static Foo foo;
static {
foo = new Foo(); // 静态块中初始化
}
}
rust 在这么多语言中是比较特别的存在,在语言机制的层面通过强制的线程安全机制,禁止简单的内存共享,使用移动语义直接把简单变量的所有权都移交到其它线程去了,只有支持 sync 语义的类型才能进行跨越线程的共享。在极大地避免了并发安全问题时,同时也带来非常陡峭的学习曲线,需要从业务到实际的运行模型都充分分析后,才能写好程序。golang 也提倡通过通道 chan 的方式进行通信,而不要简单地使用共享方式。
写出无 bug 的并发代码并不是容易的事情,需要考虑时间和空间维度上的,整个程序的状态变化,需要投入较多的时间。但是正如在第一个反思中所说的,我们永远无法做到完美,先要处理好重点业务,再优化,要在别人发现系统漏洞之前,先把漏洞给堵上,就已经做得非常不错了。