浅谈并发编程

225 阅读5分钟

为什么要并发编程

CPU、内存、I/O设备这三者的速度差异。可以形象地描述为:CPU是天上一天,内存是地上一年;内存是天上一天,I/O设备是地上十年。根据木桶理论,程序整体的性能取决于最慢的操作即I/O设备,单方面提高CPU性能是无效的。 为了合理利用CPU的高性能,平衡三者的速度差异,并发编程起到了决定性的作用。主要体现在:操作系统增加了进程、线程、以分时复用CPU,进而均衡CPU与I/O设备的速度差异。

还有是CPU发展迅速,已经有8核、16核、32核等多核CPU。为了提高计算速度可以利用CPU多核并行运行,从而提高程序整体的运行效率。

并发编程解决的核心问题

并发编程可以总结为三个核心问题:分工、同步、互斥。分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一个时刻只允许一个线程访问共享资源。

Java SDK并发包内容很多都是按照这三个维度组织的,Fork/Join框架就是一种分工模式,CountDownLatch就是一种典型的同步方式、而可重入锁则是一种互斥手段。

并发编程Bug的源头

1. 缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性。多核时代,每个CPU都有自己的缓存,CPU缓存与内存的数据一致性就没有那么容易解决了。java中volatile关键字就是解决这个问题,保证CPU缓存和内存数据的一致性。

2. 线程切换带来的原子性问题

一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU能够保证的原子操作是CPU指令级别的,而不是高级语言的操作符。如高级语言中的i++,就对应了多个CPU指令,不是原子性的,如果两个线程分别进行读写,就可能产生偏差。

3. 编译优化带来的有序性问题

有序性是指程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序。 Java领域一个经典的案例就是利用双重检查创建单例对象,例如下面代码:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假设两个线程A、B同时调用getInstance()方法,它们会同时发现instance == null, 于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功,另外一个线程则会处于等待状态;线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程B检查instance == null时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。 但实际上这个getInstance()方法并不完美。在new 操作时,编译优化后的执行路径是这样的:

  1. 分配一块内存M
  2. 将M的地址赋值给instance变量
  3. 最后在内存M上初始化Singleton对象 假设A线程先执行getInstance()方法, 当执行指令2时恰好发生了线程切换,线程B也执行getInstance()方法,线程B在执行第一个判断时就会发现instance!=null, 所以直接返回instance, 而instance是没有初始化的,此时就会触发空指针异常。

并发设计模式

  • Immutability模式:将一个类所有属性都设置成final,并且只允许存在只读方法,那么这个类基本就具备不可变性了。更严格的做法是这个类本身就是final
  • 线程本地存储模式:没有共享,就没有伤害。局部变量可以做到避免共享,Java语言提供ThreadLocal也可以做到。
  • Thread-Per-Message模式:每一个任务分配一个独立的线程进行处理。
  • Worker Thread模式:Java语言提供的线程池,避免重复创建,销毁线程。
  • 生产者和消费者模式:支持异步,并且能够平衡生产者和消费者的速度差异。

Golang并发模型

Golang的协程为一种轻量级线程。从操作系统的角度来看,线程是内核态中调度的,而协程是在用户态调度的,所以相对线程来说,协程切换的成本更低。协程也有自己的栈,但是相比线程栈来说要小得多,典型线程栈大小为1M左右,协程栈只有几K。

Golang-CSP并发模型:不要以共享内存方式通信,要以通信方式共享内存。Golang中协程之间通信推荐是使用channel。channel的容量可以是0,容量为0在Golang为无缓冲channel,容量大于0则称为有缓存的channel。无缓冲channel类似于Java的SynchronousQueue, 主要用于两个协程之间做数据交换


// 创建一个容量为4的channel 
ch := make(chan int, 4)
// 创建4个协程,作为生产者
for i := 0; i < 4; i++ {
  go func() {
    ch <- 7
  }()
}
// 创建4个协程,作为消费者
for i := 0; i < 4; i++ {
    go func() {
      o := <-ch
      fmt.Println("received:", o)
    }()
}

Golang 中的 channel 是支持双向传输的,所谓双向传输,指的是一个协程既可以通过它发送数据,也可以通过它接收数据。