怎样安全的并发编程

2024-02-29
11分钟阅读时长

引言

说到并发,首先会想到多线程。若只关注多线程如何使用,却对并发编程没有深入的了解,很容易在代码里挖坑,变成这个段子的模样:“从前有个程序员遇到了一个性能问题。他想,没事,我懂,用线程就好了。现他有在个两题了问“。

为了避免这种情况,我们该思考“为什么需要多线程,为什么 js 里没有多线程?”等诸类问题。将多线程看成是并发的一种手段,也就是让我们往下面 (the lower)走一点,越过线程理解并发编程。

如果逻辑控制流在时间上重叠,那么它们就是并发的(concurrent) —— 《CSAPP》

理论指导实践

分析 thread(线程)coroutine(协程)reactive(反应式)event/task queue(任务队列) 等各种并发手段。不讨论它们的用法、优劣,而是关注它们的的相似之处——它们实际都是通过编排、调度逻辑控制流实现的并发。所以只需要搞清楚它们是如何调度执行单元的,就掌握了核心科技

逻辑控制流,底层一点的理解是硬件电路形成的一组逻辑。应用级一点的解释是一个可以被执行的内容,可能是线程、协程、一个可观察对象 Observable、Subscription、FutureTask、Event Callback…… 等等。不过用 CSAPP 书中的逻辑控制流表示一个可执行内容有点极简,所以下文就称之为任务执行单元吧,代表一个可被执行的片段(you know it)。

抢占式调度

首先是最常见的抢占式调度,执行单元间呈竞争关系,A 任务与 B 任务互相争夺执行机会。最常见的例子是“操作系统内核利用 CPU 时钟中断,达成多线程并发“。每次中断都代表某个线程抢到了 CPU 时间片”。因为 CPU 中断是纳秒级的,实际效果是内核在飞快的切换执行单元以交错执行。提供一种所有执行单元在并行的假象。在多核心 CPU 下,内核的抢占式调度也足以实现真正的并行

抢占式调度下,执行单元无法确定自己什么时候会被执行,且任何时刻都可能会被中断执行。反过来说,只要我们基于此特性,处理好执行单元的竞争与中断,就可以实现一个抢占式调度器。也可以看出抢占式调度的好处是不会存在独占情况——某个执行单元永远占用着 CPU。因为每个执行单元都有被执行的机会,就像在等红绿灯一样。

协作式调度

协作式调度可以引出一大堆技术,例如 IO 多路复用迭代器事件驱动 等等。

它们都有一个点——主动让渡控制权,或者说主动挂起的(释放并等待)。A 任务与 B 任务可以在合适的时机主动让渡出控制权。特点是持有控制权的任务主动中断,~~抛开现实不谈(例如 CPU 中断),~~这也突出了协作式调度的优点与理念——“调度不会影响顺序性“。因为我们是主动让出的,继续执行时可以找到让渡时的节点来保证顺序性,也可以理解为调度器会帮我们将执行单元恢复到让渡前一刻的状态,然后就像没让渡过一样继续执行。

协作式调度,执行单元知道自己什么时候会让出,但同时对程序员也是无感知的,因为当再次拿到控制权时,协作式调度器可以保持顺序性1。这个特性提高了程序员们的并发编程体验。于是出现了很多协作式调度框架。抽象的角度看,不论是 Reactive Stream,还是 coroutine ,在我看来都是协作式调度的不同实现。它们都有着执行单元可以主动挂起的特性,与其它执行单元协作式的完成逻辑。

实践中的问题

是时候为线程正名一下了,虽然前面段落回避提及线程,但现在开始线程必不可少,因为线程作为系统内核最小的调度单位,实现并行基本2离不开线程。协程、反应式等用户态的并发模型到底还是跑在线程上的。

单线程的话,就不存在着并发编程中的问题,无非就是线程安全了。原因只有并发环境下访问共享可变的状态一种。但为什么共享状态这个操作会引起问题?因为数据读写不一致。为什么会读写不一致?需要搞清楚并发下计算机是如何读写状态的。

专业一点的说共享状态就是竞态条件。也说明两个执行单元可能有某种依赖关系,它们需要协商好谁可以使用这个状态。

缓存一致性

假设计算机的内存非常快非常大,那我们就不需要担心缓存一致性问题了,为什么?因为每次读取状态,都是最新的状态,这是纳秒级实时读写。(最后说一次,时间要加速了),这是美好的未来。可现实世界的计算机是有极限的(我不做电子计算机了!JOJO!),计算机的妥协设计是每个 CPU 核心都有一块独立的非常快,但非常小的内存,称之为高速缓存;再加一块速度尚可(远不及 CPU 计算速度),但非常大的内存,它就是我们的内存条,称之为主内存

两块内存特性互补,让数据读写不至于拖慢 CPU 计算。妥协的代价就是数据一致性问题,或者说数据可见性问题。因为 cpu 运行一个线程时,需要先从主内存读取数据拷贝到高速缓存里,之后就是 cpu 与高速缓存的时间了,期间 CPU 会适时的将高速缓存里的堆积数据刷写到主内存中。问题出在线程间可以共享数据,会牵扯到数据同步最小的分布式了吧?),有数据同步就不可避免的有一致性问题了,与分布式、数据库领域的数据一致性大同小异。

高级语言为了避免我们太操心这些事情,抽象了运行时内存区域(堆栈、常量区、方法区…),然后设计了内存模型,负责线程间通信,保证线程间数据同步,线程空间隔离。让多线程容易使用,程序员们要操心的事情变少了(不用和系统底层打交道),但也也让线程间通信变的陷阱重重。

读写有序性

不考虑性能,假设不存在中间缓存,每个 CPU 核心实时读写主内存,可以保证所有线程共享数据的一致性。但这样能解决线程安全问题吗?还是不行,因为计算机并不会 line by line 的执行代码,因为计算机/虚拟机会在不影响语义的情况下,优化代码的执行顺序。也就是优化后应该与不优化执行的结果一致,称之为重排序

想象多个线程只共享一个变量时,我们的假设确实有用——指令重排序对我们来说不会是问题。但多个线程共享多个变量时,指令重排序的情况就很复杂了。线程是独立的,无法确保另一个线程的重排序会不会影响。举个简单的例子,方便理解:

var value: Int = 0;
var flag: Boolean = false;
​
fun init() {
	value = 8;
	flag = true;
}
​
fun getValue() {
	if (flag) {
		println(value);
	}
}


fun main() {
	// thread 1 可能会先执行 flag = true,后执行 value = 8
	thread { init(); }
	// 在这钟情况下,thread 2 则有可能 print 0;
	thread { getValue() }
}

如果没有重排序,因为我们知道线程就算是抢占的,交错执行。但也不会造成 print 0 的情况,因为赋值 value = 8原子操作(下面介绍)。2 个线程,2 个共享变量,非常简单代码,使我的大脑宕机,爱来自…..

真实的代码会更复杂,可见编译器和 CPU 很难确保重排序优化在多线程多共享的情况下不会出现异外结果。就像这个理论不该出现的 print 0,即使我们理解线程的交错执行,且阅读并人脑编译了代码可能的执行过程,但因为指令重排序的存在,这一切变得混沌。

原子一致性

进一步假设~~(现在是幻想时间)~~,不存在指令重排序,不存在缓存一致性问题,相当于 javavolatile 关键字的效果。线程安全问题还会存在吗?还是会存在,因为程序不仅有指令、数据,还有算法逻辑)。

编排一系列指令形成逻辑,可以称之为算法或操作(算法帅一点,所以下文统称算法)。既然是一系列指令,那么每条指令都有可能会被系统内核中断或被其它并行的线程影响,导致算法结果不符合预期。而原子性的百科定义是:

原文:线性一致性(Linearizability),或称原子一致性严格一致性指的是程序在执行的历史中在存在可线性化点 P 的执行模型,这意味着一个操作将在程序的调用和返回之间的某个点 P 起作用。这里“起作用”的意思是被系统中并发运行的所有其他线程所感知。

大概意思是如果某个指令被执行,其它线程一定会知道这个指令被某个线程执行了,就像单线程一样,等待执行的代码行可以感知到已执行的代码行。或者常见的理解基于「原子是不可再分割的最小物质」并发原子性就是「不可再打断的最小操作序列」。这个解释与数据库的原子性「要么全都成功,要么全都失败」异曲同工,当然细说还是有区别的,例如并发的原子性不会 rollback

我的解释是,如果一个算法执行过程中,即使被内核中断切换了线程,或存在共享状态的并行线程,也不会被影响结果。就可以说这个算法是线程安全的,也可以理解为算法不会被打断,具有原子性。

原子性我认为是比较容易理解的,因为非原子性算法造成的影响用户态可以很明显的感知到。例如两个线程同时执行 i++ ,最后 i 的结果通常会小于预期值。

无论如何,这是最后一步了,不会继续假设了。只要保证原子性,就可以使线程同步,线程同步了,线程安全问题自然烟消云散。

高速的安全并发

步入正题,前文可得,只要我们灵活运用三大特性即可避免线程安全问题。不过我们并发初衷可不是为了安全,而是 fot the speed!速度!

《Java 并发编程实践》中写过,如何修复线程安全问题:

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复该问题:

  • 不在线程之间共享该状态变量
  • 将该状态变量修改为不可变的变量
  • 再访问该状态变量时使用同步

串行编程

先看最简单的一种:在访问该状态变量时使用同步,也就是串行化线程了。

前文也提到「只要保证了原子性,就可以使线程同步」。可以总结出两种为线程添加原子性的主要方式:

  • 原子指令:CPU 指令级别的原子操作。如大名鼎鼎的 CAS - 比较并较换 指令。
  • 信号量:是一种底层思想,以信号量的不变性实现出原子指令线程锁

原子指令

原子指令比较容易理解,就像定理一样。从 CPU 硬件级别限制了此指令不会被中断,一但执行,不可取消。

常见的 CAS 原子指令就是实现各种乐观锁的关键,因为 CAS 指令不可被中断,才能保证乐观锁自旋的检测与更新是线程安全的。例如 AtomicInteger

原子指令在性能损耗上大大小于整块代码加锁,但使用场景上也比较受限。~~因为不如加锁一把梭简单。~~对于一段多线程代码,需要人脑编译来判断“仅依靠原子指令是否可以保证线程安全“,说多线程优化通常就是在说这个,依靠原子指令让你的线程锁(阻塞)变少。就像乐观锁做的一样,通过 CAS 指令,来应对多读少写的场景。或者直接改变你的算法逻辑

信号量

以提供互斥为目的的二元信号量常常也称为互斥锁(mutex)。 ——《CSAPP》

同步线程都知道用线程锁,常用的线程锁基于信号量的思想。使用二元信号量变量实现互斥锁的例子:

  • 执行线程前,首先获取信号量
    • 如果信号量为 0 ,则挂起线程,等待重启,重启后继续判断信号量。
    • 如果信号量为 1 ,则立即返回,并 - 1,这将导致其它线程挂起,本线程执行。
  • 执行线程后(包括被中断),必须释放信号量:
    • 将信号量 + 1,并尝试重启一个因为此信号量挂起的线程。

然后我的建议是不要在信号量、互斥量、自旋锁、乐观锁、悲观锁、互斥锁等等这些术语上浪费太多脑筋。信号量是底层的一种思想,各种锁都是基于这个思想实现的,只是互斥的级别不同,根据锁的用途对锁进行了概念上的分类。不用在名字上过于讲究。

不可变变量

将该状态变量修改为不可变的变量,很容易理解的一种方式。说起来也很简单:你的共享变量不可变了,相当于只允许读取,必然就不涉及同步问题了。

做起来的话会很艰难,每个线程的数据都像快照一样。数据一致性的控制权回到了你的手里。需要你来编排数据的“流向”,进入什么数据,出来的会是什么,线程没有了副作用,线程的输出你可以预测,就像一条功能明确的加工流水线。

副作用就是指不会对外界产生影响

不可变变量,函数式的思想,一种优雅至极的方案,但会让你束手束脚,不过如果完美贯彻函数式,应该会很流畅,不过需要很高的脑力吧… 不过尽量让变量不可变,从而让函数保持无副作用,是一种好习惯。

分布式事务

再来看好像最简单的一种:不在线程之间共享该状态变量作者本意应该不是让我们放弃。

其实联想一下,不共享状态,每个线程对于变量的当前正确值是没有感知的,把每个线程看作一个分布式节点,像不像分布式里的数据一致性问题?那瞬间就变的很复杂了。

不共享状态,但我们又需要访问该状态。只要我们可以接受短暂的线程不安全,退一步,就会有很多方案。可以参考分布式事务的一些成熟做法,例如最终一致性两阶段提交等。~~不过这已经算脱离了线程安全话题了,算是数据设计了。~~简单的介绍一下吧,其实纯编程实现没太大意义,通常结合数据库、消息队列等中间件来实现。

  • 最终一致性:两个线程执行时,拿到的是共享变量的副本,各自执行完成后,可能还有定时器在不停的纠正数据。
  • 两阶段提交:每个线程执行完毕后,发出准备提交的通知。主线程收到所有线程都准备就绪后,允许各个线程进行提交。然后各个线程开始提交。
  • 多版本并发控制

本质上我们就是需要一些保险手段,在尽可能不影响线程效率的情况下,保障数据不出错。

参考


  1. 可能会对 rx 的顺序性提出质疑,这里非指过程式一样的代码的编写顺序,例如容易理解的协程的顺序性,而是“可以较为容易”的预测代码执行的顺序。例如 rxobserable.flatmap().reduce().publishOn().map().tap() 例子,还是可以预测出这段代码的整体顺序性的,只是操作符联合起来会很复杂,让人难以理解,不过还是可以说 rx 是有一定顺序性的,毕竟本质上是一个流处理,流的流转过程就是顺序。 ↩︎

  2. 如果不把异步 IO 看作一个特殊的“线程”,那将 IO 读写操作交由内核调度,注册回调后继续干活,变相实现了一个线程逻辑计算的同时,其它 IO 硬件也正在读写数据(如网卡),实现并行处理:一个 IO 硬件,一个 CPU。虽然多数场景我们都是要挂起线程,等待 IO 硬件响应的。再展开就是 IO 模型的话题了,本文不过多讨论,不过也可以看出 IO 模型与并发编程的关系密不可分。 ↩︎