关于android:Java并发编程Android的UI框架为什么是单线程的

前言

家喻户晓,Android 会在 ViewRootImpl 中调用 checkThread 办法检测是否是在 UI 线程中更新 UI

// 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,不能在子线程中更新 UI1?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大洋

一个线程对共享变量的批改,另外一个线程能够立即看到,称为可见性

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

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

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

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 是多少?

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)

先查看后执行

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

// check
if (condition) {
    // action
}

上面通过提早初始化看一下 “先查看后执行” 问题,提早初始化的目标是将对象的初始化操作推延到理论应用时才进行,同时要确保只被初始化一次3

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

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

三恶 – 有序性

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

在没有同步的状况下,编译器、处理器以及运行时等都有可能对操作的执行程序进行一些意想不到的调整。在不足足够同步的多线程程序中,要想对内存操作的执行程序进行判断,简直无奈失去正确的论断。

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,在没有正确同步的状况下,即便要推断最简略的并发程序的行为也很艰难。很容易设想 PossibleReordering 是如何输入 (1, 0) 或 (0,1) 或 (1,1) 的:线程 A 能够在线程 B 开始之前就执行实现,线程 B 也能够在线程 A 开始之前执行实现,或者二者的操作交替进行。但奇怪的是,PossibleReordering 还能够输入 (0,0) 。因为每个线程中的各个操作之间不存在数据流依赖性,因而这些操作能够乱序执行。(即便这些操作依照程序执行,但在将缓存刷新到主内存的不同时序中也可能呈现这种状况,从线程 B 的角度看,线程 A 中的赋值操作可能以相同的秩序执行。)

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) 。

总结

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

阐明与参考文献


  1. 在子线程中也能更新UI,这里不抬杠 ↩
  2. 摘自《Java并发编程实战-第九章》 ↩
  3. 摘自《Java并发编程实战-第二章》 ↩
  4. 摘自《Java并发编程实战-第三章》 ↩
  5. 摘自《Java并发编程实战-第十六章》 ↩

【腾讯云】轻量 2核2G4M,首年65元

阿里云限时活动-云数据库 RDS MySQL  1核2G配置 1.88/月 速抢

本文由乐趣区整理发布,转载请注明出处,谢谢。

您可能还喜欢...

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据