架构反思-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() 上,这一个语句是一个复合操作,它包含几个部分的动作:

  1. 分配对象存储空间
  2. 初始化对象
  3. 把 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 的并发代码并不是容易的事情,需要考虑时间和空间维度上的,整个程序的状态变化,需要投入较多的时间。但是正如在第一个反思中所说的,我们永远无法做到完美,先要处理好重点业务,再优化,要在别人发现系统漏洞之前,先把漏洞给堵上,就已经做得非常不错了。

posted @ 2021-03-27 17:09  drop *  阅读(85)  评论(0编辑  收藏  举报