参考文献

进程之间通信的方式

  • RPC/HTTP/信号量/消息队列/管道/socket

并发编程中核心的问题

  • 分工: 如何高效地拆解任务并分配给线程
    • Java SDK Fork/Join框架就是一种分工模式
  • 同步: 指线程之间如何协作
    • CountDownLatch就是一种典型的同步方式
  • 互斥: 保证同一时刻只允许一个线程访问共享资源
    • 可重入锁则是一种互斥手段

同步

  • 并发编程领域的同步主要指线程之间的协作,指一个线程执行完了一个任务,如何通知后续任务的线程开工
  • 工作中遇到的线程协作问题,基本上都可以描述为这样一个问题:
    • 当某个条件不满足时,线程需要等待;
    • 当某个条件满足时,线程需要被唤醒执行;
  • 解决协作问题的核心技术是"管程"

互斥

  • 实现互斥的核心技术是

从性能角度讲,我们为了提高执行一定计算机任务的效率,所以IO等待时不能让CPU闲置,所以我们应该把任务拆分交替执行,于是有了分时操作系统,出现了并发.后来CPU多核了又有了并行计算.即分工

分工以后我们为了进一步提升效率和更加灵活地达到目的,所有我们要对任务进行组织编排,也就是对线程组织编排.于是线程之间需要通信,于是操作系统提供了一些让进程,线程之间通信的方式.即同步

但是事物总不是完美的,并发和通信带来了较高的编程复杂度,同事也出现了多线程并发操作共享资源的问题.于是对于共享资源需要访问串行化,所以根据现实世界的做法设计了锁,信号量等等.即互斥.

并发与并行

  • 单核CPU下,线程实际上还是串行执行的.操作系统中有一个组件做任务调度器,将CPU的时间片(Windows下时间片最小为15毫秒)分给不同的线程使用,只是由于CPU在线程间(时间片很短)的切换(上下文切换)的非常快,人类感觉是同时运行的.
    • 总结为一句话: 微观串行,宏观并行
    • 一般会将线程轮流使用CPU的做法称为并发(Concurrent);
    • 多核CPU下,每个核Core都可以调度运行线程,这时候线程可以并行(Parallel);
    • 多线程就意味着并发,但是并行只发送在将这些线程于同一时间调度分配到不同CPU核上执行时候,也就是说,并行是并发的一种特定形式.
img
  • 并发(Concurrent)是同一时间应对(dealing with)多件事情的能力;

    • 并发是指如何正确,高效地控制共享资源;
    • 同时处理多个任务,即不必等待一个任务完成就能开始处理其他任务.
    • 并发解决的是阻塞问题,即一个任务必须等待非其可控的外部条件满足才能继续执行,最常见的例子是I/O,一个任务必须要等待输入才能执行(即被阻塞),类似场景称为I/O密集型问题.
  • 并行(Parallel)是同一时间动手做(doing)多件事情的能力;

    • 并行是指如何利用更多的资源来产生更快速的响应.
    • 同时在多处执行多个任务.并行解决的是所谓的计算密集型问题,即通过把任务分成多个部分,并在处理器上执行,从而提升程序运行的速度.

「并发」强调的是可以一起「出『发』」,「并行」强调的是可以一起「执『行』」

  • 顺序: 上一个开始执行的任务完成后,当前任务才能开始执行;
  • 并发: 无论上一个开始执行的任务是否完成,当前任务都可以开始执行;

(也就是说,A B 顺序执行的话,A 一定会比 B 先完成,而并发执行则不一定.)

与可以一起执行的并行(parallel)相对的是不可以一起执行的串行(serial):

  • 串行: 有一个任务执行单元,从物理上就只能一个任务、一个任务地执行

  • 并行: 有多个任务执行单元,从物理上就可以多个任务一起执行

(也就是说,在任意时间点上,串行执行时必然只有一个任务在执行,而并行则不一定.)

综上,并发与并行并不是互斥的概念,只是前者关注的是任务的抽象调度、后者关注的是任务的实际执行.而它们又是相关的,比如并行一定会允许并发.

减少上下文切换的方法

  • 无锁并发编程.多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁.
    • 将任务进行拆分,不同的线程处理不同任务,不同的数据,各个线程互不干涉.
  • CAS算法.Java的Atomic包使用CAS算法来更新数据,而不需要加锁.
  • 使用最小线程.避免创建不需要的线程,比如任务很少,但创建了很多线程来处理,这样会造成大量的线程处于等待状态.
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换.

同步和异步

  • 同步: 需要等待结果返回,才能继续运行;
  • 异步: 无需等待结果返回,就能继续运行;

临界区

  • 在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源.如,同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件.实际上,这些问题只有在一或多个线程向这些资源做了写操作时才有可能发生,只要资源没有发生变化,多个线程读取相同的资源就是安全的.
  • 临界资源: 一次仅允许一个进程使用的资源.例如: 物理设备中的打印机、输入机和进程之间共享的变量、数据.
  • 临界区: 导致竞态条件发生的代码区称作临界区.

竞态条件

  • 当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件.

检查再执行 check-then-act

大多数竞态条件的特点: 使用潜在的过期观察值来作为决策或执行计算,这种竞态条件被称为检查再执行(check-then-act).

  • 你观察到一些事情为真(文件X不存在),然后(then)基于你的观察去执行一些动作(创建文件X);但是事实上,从观察到执行操作的这段时间内,观察结果可能已经无效了(有人在此期间创建了文件X),从而引发错误(非预期的异常,重写数据或破坏文件).

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class LazyInitRace {
    private ExpensiveObject instance = null;

    public ExpensiveObject getInstance(){
    // check-then-act
    if (instance == null){
    instance = new ExpensiveObject();
    }
    return instance;
    }
    }

读-改-写

  • 自增操作

缺少即加入 put-if-absent

1
2
3
4
5
if (map.contain(key)) {
map.get(key)
} else {
map.put(key, object)
}

进程-线程-管程

  • 进程(Process): 进程是程序的一次执行实例.它是操作系统进行资源分配和调度的基本单位.每个进程都有独立的内存空间和系统资源,包括代码、数据、堆栈、文件描述符等.进程之间相互独立,通过进程间通信(IPC)机制来进行数据交换和协作.每个进程都运行在自己的地址空间中,因此进程切换的开销较大.
  • 线程(Thread): 线程是进程的执行单元,是操作系统进行调度的基本单位.一个进程可以包含多个线程,共享同一进程的资源.线程之间共享进程的内存空间,包括代码、数据和堆栈.不同线程之间可以并发地执行,可以同时访问共享内存,提高程序的性能.线程的切换开销较小,但线程间的共享资源需要进行同步和互斥控制.
  • 管程(Monitor): 管程是一种并发编程的抽象概念,用于管理共享资源的访问.它结合了数据结构和同步机制,提供了一种方式来确保多个线程对共享数据的安全访问.管程提供了一组操作(程序代码)和一个内部锁,用于保护共享数据的一致性和互斥访问.线程在进入管程时会自动获取内部锁,执行对共享变量的操作,然后释放锁,以便其他线程可以进入管程执行.
    • 管程的目标是简化并发编程,通过封装共享数据和同步机制,使得编写线程安全的代码更加容易.它提供了对共享资源的控制和协作机制,确保多个线程之间的互斥、同步和通信.
  • 进程是操作系统资源管理的基本单位,线程是进程的执行单元,而管程是一种用于管理共享资源的抽象概念.它们各自在不同的层次上描述了并发执行和资源管理的概念和机制.

为什么需要多线程

  • CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

    • CPU 增加了缓存,以均衡与内存的速度差异;// 导致 可见性问题

    • 操作系统增加了进程、线程,以分时复用(线程切换)CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致 原子性问题

    • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用.// 导致 有序性问题

并发出现问题的根源:并发三要素

  • 可见性: CPU缓存引起

    • 当一个线程修改了共享变量的值时,其他线程能够立即看到这个修改.CPU缓存会导致可见性问题,因为每个线程都可能会有一个本地缓存,当一个线程修改了共享变量时,其他线程可能无法立即看到这个修改,因为它们还在使用本地缓存中的旧值.
  • 原子性: 分时复用引起

    • 一个操作要么全部执行成功,要么全部失败回滚.分时复用会导致原子性问题,因为多个线程可能会共享同一个资源,例如一个计数器,如果多个线程同时对这个计数器进行修改,可能会导致计数器值不正确.
  • 有序性: 重排序引起

    • 程序的执行顺序必须按照代码的顺序执行.重排序会导致有序性问题,因为编译器和处理器可能会对指令进行重排序,以优化程序性能.在某些情况下,这种重排序可能会导致程序出错.

针对并发三要素的处理方案–JMM

可见性

  • Java提供了volatile关键字来保证可见性.
  • 通过synchronizedLock也能够保证可见性,synchronizedLock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中.因此可以保证可见性.

原子性

  • Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock来实现.由于synchronizedLock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性.

有序性

  • 在Java里面,可以通过volatile关键字来保证一定的“有序性”.另外可以通过synchronizedLock来保证有序性,很显然,synchronizedLock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性.当然JMM是通过Happens-Before 规则来保证有序性的.

线程安全的实现方法

互斥同步

  • synchronized和各种Lock

非阻塞同步

  • CAS
  • J.U.C 包里面的原子类
  • J.U.C 包提供了一个带有标记的原子引用类AtomicStampedReference,解决ABA问题

无同步方案

  • 要保证线程安全,并不是一定就要进行同步.如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性.
  • 线程本地存储(Thread Local Storage)

不可变类

不可变对象(Immutable Object): 对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化.

  • 定义一个不可变类,一个不可变类,必须要满足以下4个条件:
    • 确保类是final的,不允许被其他类继承;
    • 确保所有的成员变量(字段)是final的,这样的话,它们只能在构造方法中初始化值,并且不会再随后被修改.
    • 不要提供任何setter方法
    • 如果要修改类的状态,必须返回一个新的对象.