0%

Java并发编程-Android的UI框架为什么是单线程的?

Java并发编程-Android的UI框架为什么是单线程的?

前言

众所周知,Android 会在 ViewRootImpl 中调用 checkThread 方法检测是否是在 UI 线程中更新 UI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ViewRootImpl.java

final Thread mThread;

public ViewRootImpl(Context context, Display display) {
mThread = Thread.currentThread();
}

void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}

为什么 Android 只能在 UI 线程中更新 UI,不能在子线程中更新 UI[1]?Android 为什么不使用多线程更新 UI呢?

GUI 框架为什么是单线程的[2]

早期的 GUI 应用程序都是单线程的,并且 GUI 事件在 ”主事件循环“ 中进行处理。当前的 GUI 框架则使用了一种略有不同的模型:在该模型中创建一个专门事件分发线程(Event Dispatch Thread,EDT)来处理 GUI 事件

单线程的 GUI 框架并不仅限于在 Java 中,在 Qt、NexiStep、MacOS Cocoa、X Windows 以及其他环境中的 GUI 框架都是单线程的。许多人曾经尝试过编写多线程的 GUI 框架,但最终都由于静态条件和死锁导致的稳定性问题而又重新回到单线程的事件队列模型:采用一个专门的线程从队列中抽取事件,并将它们转发到应用程序定义的事件处理器。

在多线程的 GUI 框架中更容易发生死锁问题,其部分原因在于,在输入事件的处理过程中与 GUI 组件的面向对象模型之间会存在错误的交互。用户引发的动作将通过一种类似于 “气泡上升” 的方式从操作系统传递给应用程序:操作系统首先检测到一次鼠标点击,然后通过工具包将其转化为 “鼠标点击” 事件,该事件最终被转换为一个更高层事件(例如 “鼠标左键被按下” 事件)转发给应用程序的监听器。另一方面,应用程序引发的动作又会以 “气泡下沉” 的方式从应用程序返回给操作系统。例如,在应用程序中引发修改某个组件背景色的请求,该请求将被转发给某个特定的组件,并最终转发给操作系统进行绘制。因此,一方面这组操作将以完全相反的顺序来访问相同的 GUI 对象;另一方面又要确保每个对象都是线程安全的,从而导致不一致的锁定顺序,并引发死锁。

另一个在多线程 GUI 框架中导致死锁的原因就是 “模型 — 视图 — 控制 (MVC)” 这种设计模式的广泛应用。通过将用户的交互分解到模型、视图和控制等模块中,能极大的简化 GUI 应用程序的实现,但这却也进一步增加了出现不一致锁定顺序的风险。

单线程的 GUI 框架通过线程封闭机制来实现线程安全性。所有 GUI 对象,包括可视化组件和数据模型等,都只能在事件线程中访问。当然,这只是将确保线程安全性的一部分工作交给应用程序的开发人员来负责,他们必须确认这些对象被正确的封闭在事件线程中。

从以上文档中不难发现,早期前辈们尝试过多线程的 GUI 框架,最终都以 “失败” 告终而又回归到单线程的事件队列模型

原因大致总结为:在多线程中操作 GUI 对象,会有线程安全问题

线程安全三大恶

何为线程安全性[3]

要对线程安全性给出一个确切的定义是非常复杂的。定义越正式,就越复杂,不仅很难提供有实际意义的指导建议,而且也很难从直观上去理解。因此,下面给出了一些非正式的描述,看上去令人困惑。在互联网上可以搜到许多 “定义”,例如:

  • 可以在多个线程中调用,并且在线程之间不会出现错误的交互。
  • 可以同时被多个线程调用,而调用者无需执行额外的动作。

看看这些定义,难怪我们会对线程安全性感到困惑。它们听起来非常像 “如果某个类可以在多个线程中安全的使用,那么它就是一个线程安全的类”。对于这种说法,虽然没有太多的争议,但同样不会带来太多的帮助。我们如何区分线程安全的类以及非线程安全的类?进一步说,“安全” 的含义是什么?

在线程安全性的定义中,最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。

正确性的含义是:某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结果。由于我们通常不会编写详细的规范,那么如何知道这些类是否正确呢?我们无法知道,但这并不妨碍我们在确信 “类的代码能工作” 后使用它们。这种 “代码可信性” 非常接近于我们对正确性的理解,因此我们可以将单线程的正确性近似定义为 “所见即所知(we know it when we see it)”。在对 “正确性” 给出一个较为清晰的定义后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定不会是线程安全的。

大恶 - 可见性

可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。[4]

在上个世纪的单核时代,所有的线程都在唯一的一颗 CPU 上执行,CPU 缓存与内存之间的就像两口子,你的就是我的,我的就是你的,不分彼此😂

因为只有一颗 CPU,也只有一个 CPU 缓存,所以一个线程花了大洋,对另一个线程来说,它一定能看到还剩多少大洋。例如在下面的图中,内存中一共有100大洋,如果线程A花了20大洋,线程B想再挥霍时,只能挥霍80大洋

image-20220516195337629

一个线程对共享变量的修改,另外一个线程可以立刻看到,称为可见性

在21世纪的多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存之间的事就不好掰扯了,就像古代的皇帝(内存)有后宫佳丽三千(CPU),皇帝跟所有佳丽们说咱国库充足:白银100两,佳丽们都知道了国库有100两白银,皇后想花80两买面膜(操作 CPU-1 缓存),贵妃想花70两买BB霜(操作 CPU-2 缓存),过段时间皇帝一看(CPU 缓存同步到内存),国库还有30两白银。例如在下面的图中,内存有100两白银,线程A花了80两,然后同步到内存,皇帝看了国库还有20两,线程B花了70两,然后同步到内存,国库变成30两了?啧啧,这国库白银越花越多😂

image-20220516203133183

这里想说明线程A对共享变量的操作对于线程B来说是不可见的

接下来用一段代码来看一下可见性问题,下面代码[4:1]中说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程和读线程都将访问共享变量 ready 和 number。主线程启动读线程,然后将 number 设为 45,并将 ready 设为 true。读线程一直循环直到发现 ready 的值变为 true,然后输出 number 的值。虽然程序看起来会输出 45,但事实上很可能输出 0,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的 ready 值和 number 值对于读线程来说是可见的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class VisibilityTest {

private static boolean ready;
private static int number;

private static class ReaderThread extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println("number = " + number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
ready = true;
number = 45;
}
}

二恶 - 原子性

与可见性类似,原子性也是一种复杂的属性,因为原子性中的错误也是会违背我们的直觉。

原子性:是指一个或多个指令(操作)在 CPU 执行过程中不被中断、不可分割的特性,这里强调在 CPU 指令(操作)的执行过程中,表示原子性是在 CPU 指令(操作)层面而不是语言层面

下面介绍两种常见的原子性错误形式

读取 - 修改 - 写入

接下来用一段代码看一下 “读取 - 修改 - 写入” 问题,下面代码由 Kotlin 编写,在 reduceCount 方法中循环 100000 次执行 count-- 操作,两个线程执行 reduceCount 方法,可以先想下程序运行后输出的 count 是多少?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fun main() {

val thread1 = thread {
reduceCount()
}

val thread2 = thread {
reduceCount()
}

thread1.join()
thread2.join()

println("count = $count")
}

// 20万
var count: Long = 200000L

private fun reduceCount() {
// 循环10万次
for (i in 1..100000) {
count--
}
}

直观上感觉 count 应该是 0,但是程序输出的 count 是位于 0 至 100000 之间的随机数,这是为什么呢?

虽然递减操作 count-- 是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。

实际上,它包含了三个独立的操作:

  • 读取 count 的值
  • 将值加 1
  • 然后将第二步计算结果写入 count

对于上面的三个操作来说,count 的初始值为 200000,那么在某些情况下,两个线程读到的值都为 200000,接着执行递减操作,并且都将 count 的值设为 199999。这显然不是我们期望的结果。这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,这种情况称为:竞态条件 (Race Condition)

先检查后执行

先检查后执行是最常见的竞态条件类型,即通过一个可能失效的观测结果来决定下一步的动作。先检查后执行问题中常见的情况就是下面形式的代码:

1
2
3
4
// check
if (condition) {
// action
}

下面通过延迟初始化看一下 “先检查后执行” 问题,延迟初始化的目的是将对象的初始化操作推迟到实际使用时才进行,同时要确保只被初始化一次[3:1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LazyInitRace {

private LazyInitRace instance = null;

public LazyInitRace getInstance() {
// 先检查instace是否已经被初始化,如果已经初始化则返回现有的实例
if (instance == null) {
// 否则将创建一个新的实例,返回一个实例引用
instance = new LazyInitRace();
}

return instance;
}

private LazyInitRace() {
}
}

在 LazyInitRace 中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程 A 和线程 B 同时执行 getInstance。线程 A 看到 instance 为空,因而创建一个新的 LazyInitRace 实例。线程 B 同样需要判断 instance 是否为空。此时的 instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及线程 A 需要花多长时间来初始化 LazyInitRace 并设置 instance。如果当线程 B 检查时,instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。[3:2]

例如在下图中,线程A 执行完 “检查” 阶段后做线程调度(切换),线程 A 和线程 B 按图中序列执行,最终发现两个线程都创建了一个新的 LazyInitRace 实例,但这不是期望的结果。

image-20220517165708450

三恶 - 有序性

在可见性章节中的示例代码中有一种情况:number 很可能输出 0,因为读线程可能看到了写入 ready 的值,但却没有看到之后写入 number 的值,这种现象被称为 “重排序 (Reordering)”。

在没有同步的情况下,编译器、处理器以及运行时等都有可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class VisibilityTest {

private static boolean ready;
private static int number;

private static class ReaderThread extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println("number = " + number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
ready = true;
number = 45;
}
}

有序性指的是程序按照代码的先后顺序执行

下面再来个代码示例[5]来说明有序性问题,在程序 PossibleReordering 中说明了[5:1],在没有正确同步的情况下,即使要推断最简单的并发程序的行为也很困难。很容易想象 PossibleReordering 是如何输出 (1, 0) 或 (0,1) 或 (1,1) 的:线程 A 可以在线程 B 开始之前就执行完成,线程 B 也可以在线程 A 开始之前执行完成,或者二者的操作交替进行。但奇怪的是,PossibleReordering 还可以输出 (0,0) 。由于每个线程中的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。(即使这些操作按照顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况,从线程 B 的角度看,线程 A 中的赋值操作可能以相反的次序执行。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class PossibleReordering {
private static int x = 0;
private static int y = 0;

private static int a = 0;
private static int b = 0;

public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
a = 1;
x = b;
});

Thread thread2 = new Thread(() -> {
b = 1;
y = a;
});

thread1.start();
thread2.start();

thread1.join();
thread2.join();

System.out.println("( " + x + ", " + y + " )");
}
}

下图给出了一种可能由重排序导致的交替执行方式,在这种情况中会输出 (0,0) 。

image-20220517181932852

总结

本文从多线程并发编程中的线程安全角度解读了 Android UI 框架为什么是单线程的,猜想 Android UI 框架设计人员也是汲取了前人多线程中线程安全问题,遂采取单线程的封闭机制来实现线程安全性。

说明与参考文献


  1. 在子线程中也能更新UI,这里不抬杠 ↩︎

  2. 摘自《Java并发编程实战-第九章》 ↩︎

  3. 摘自《Java并发编程实战-第二章》 ↩︎ ↩︎ ↩︎

  4. 摘自《Java并发编程实战-第三章》 ↩︎ ↩︎

  5. 摘自《Java并发编程实战-第十六章》 ↩︎ ↩︎

欢迎关注我的其它发布渠道