欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

深入理解 Kotlin 并发程序》阅读笔记 I

最编程 2024-05-31 09:13:46
...

 第一章~第二章

第一章: 异步程序设计介绍

讨论了异步的概念、异步程序的设计及可以用来简化异步程序设计的常见框架和特性。

  • 本质上,异步和同步这两个概念探讨的是程序的控制流程,异步的同时也经常伴随着并发,但这不是必然的。
  • Kotlin协程是用来简化异步程序设计的,可以在实现任务的异步调用的同时,降低代码的设计复杂度,进而提升代码可读性。

1.1 异步的概念

  • 按照指令执行顺序来分类,指令按顺序执行的情形叫作同步执行,反之则称为异步执行;

  • 一个异步代码如下:

    fun main() { val callback = { println("D") }

    val task = {
        println("C")
        callback()
    }
    
    println("A")
    thread(block = task)
    println("B")
    

    }

  • 实践当中通常会涉及从C到D的过程中的线程切换,主流程执行到B时可能通过执行某种操作开始等待异步任务,直到在D处将异步任务转回主流程。

  • 在实践当中随着代码量的增加,回调不断嵌套,就会出现大家经常提到的“回调地狱”问题。(代码耦合、难以try catch)。

    fun main() { loopMain { runOnIOThread { println("A") delay(1000) { println("B") runOnMainThread { println("C") } } } }} fun loopMain(block: () -> Unit) { Looper.prepareMainLooper() runOnIOThread(block) Looper.loop()}

  • 对于程序设计中复杂的异步事件交互,我们就不得不引入诸如EventBus这样的框架或者生产–消费者模型来统一管理和约束异步交互。

1.2 异步程序设计的关键问题

结果传递

  • 不同于同步调用,由于异步调用是立即返回的,因此被调方的逻辑通常存在两种情形:

    • 结果尚未就绪,进入任务执行的状态,待结果就绪后通过回调传递给调用方(见下方代码:2);
    • 结果已经就绪,可以立即提供结果(见下方代码:1)。

    fun asyncBitmap( url: String, callback: (Bitmap) -> Unit ): Bitmap? { return when (val bitmap = Cache.get(url)) { null -> { // 2 thread { download(url) .also { Cache.put(url, it) } .also(callback) } null } else -> bitmap // 1 } }

调用他的地方如下:

val val bitmap = asyncBitmap(
    "https://www.bennyhuo.com/assets/avatar.jpg"
) {
    show(it) // 异步请求
}
if (bitmap != null) {
    show(it) // 直接返回
}
  • 结果传递更为常见的做法是,在结果就绪的情况下仍然立即以回调的形式传递给调用方,以保证结果传递方式的透明性。
  • Kotlin协程的挂起函数(suspend function)本质上就采取了这个异步返回值的设计思路。

异常处理

异常也是函数调用结果的一种,既然asyncBitmap本身也可能抛出异常,那我们完全可以对抛出的异常与返回的结果一视同仁。如果有一些手段能帮我们把异常的处理合并,我们处理起来就会相对轻松一些。仔细对比图1-1和图1-2,同样存在异步逻辑,只不过后者的异步的调用流程通过编译器或者其他手段简化成了“同步化”的调用,因此前者需要分别处理A到B和C到D处的异常,而后者对整体流程做一次处理即可,复杂度明显降低。

一旦产生异步调用,异常处理就会变得复杂。而异步逻辑同步化正是kotlin协程要解决的问题。

取消响应

  • 异步任务必须要像风筝一样,在需要的时候能够由外部主动收回。
  • 取消响应中的响应是很关键的一点,需要异步任务主动配合取消,如果它不配合,那么外部也就没有办法,只能听之任之了。

复杂分支

我们可以为同步的逻辑添加分支甚至循环操作,但对于异步的逻辑而言,想要做到这一点就相对困难了。

fun loopOnAsyncCalls() {
    val countDownLatch = CountDownLatch(urls.size)
    val map = urls.map { it to EMPTY_BITMAP }
        .toMap(ConcurrentHashMap<String, Bitmap>())
    urls.map { url ->
        asyncBitmap(url, onSuccess = {
            map[url] = it
            countDownLatch.countDown() // ②
        }, onError = {
            showError(it)
            countDownLatch.countDown() // ③
        })
    }
    countDownLatch.await()  // ①
    val bitmaps = map.values
}

这段程序会在①的位置阻塞,直到所有回调的②或③位置执行之后才会继续执行。

1.3 常见异步程序设计思路

如果有某种手段能够将异步回调流程与主流程整合起来,让代码看起来如同同步调用一般,异步程序的设计复杂度就会大大降低,基于这样的API我们也就能够更加轻松地设计出强大的程序。

Future

  • Future是JDK 1.5版本时就引入的接口。它有一个get方法,能够同步阻塞地返回Future对应的异步任务的结果。

    fun bitmapFuture(url: String): Future { return ioExecutor.submit(Callable { download(url) }) }

    fun main() { val bitmaps = urls.map { bitmapFuture(it) }.map { it.get() } }

这段代码是用一串url异步请求并得到一串对应的bitmap,而且顺序保持一致,因为get只在结果就绪时才会返回。但是弊端是get是阻塞的,调用其中一个get,当前调用也被阻塞了,在所有get返回之前,当前的调用流程会一直限制在这段逻辑里。

CompletableFuture

从某种意义上来讲,通过阻塞当前调用来等待异步结果,让异步的逻辑变得不像“异步”了,是因为我们还得同步地等待结果。JDK 1.8又新增了一个CompletableFuture类,它实现了Future接口,通过它我们可以拿到异步任务的结果。

fun bitmapCompletableFuture(url: String): CompletableFuture<Bitmap> =
    CompletableFuture.supplyAsync {
        download(url)
    }
 
fun callCompletableFuture() {
    urls.map {
        bitmapCompletableFuture(it)
    }.let { futureList ->
        CompletableFuture.allOf(*futureList.toTypedArray())
            .thenApply {
                futureList.map { it.get() }
            }
    }.thenAccept { bitmaps ->
        println(bitmaps.size)
    }.join()
}

我们通过urls得到bitmap,并thenAccept只会在结果就绪时回调,因此不会阻塞整体代码执行流程。但是让结果的获取脱离了主调流程。

Promise与async/await

  • Promise是一个异步任务,它存在挂起、完成、拒绝三个状态,当它处在完成状态时,结果通过调用then方法的参数进行回调;出现异常拒绝时,通过catch方法传入的参数来捕获拒绝的原因。
  • async和await很好地兼顾了异步任务执行和同步语法结构的需求。Kotlin对async/await的支持稍微有些不同,它没有引入这两个关键字就实现了这一功能。

响应式编程

主要关注的是数据流的变换和流转,因此它更注重描述数据输入和输出之间的关系。

Kotlin协程

一个关键字suspend来表示挂起点,包含了异步调用和回调两层含义。

  • 所有异步回调对于当前调用流程来讲都是一个挂起点,在这个挂起点我们可以做的事情非常多,既可以像async/await那样异步回调,又可以添加调度器来处理线程切换,还可以作为协程取消响应的位置。
  • kotlin实现异步示意图

第二章 协程的基本概念

2.1 协程究竟是什么

协程的概念最核心的点就是函数或者一段程序能够被挂起,稍后再在挂起的位置恢复。挂起和恢复是开发者的程序逻辑自己控制的,协程是通过主动挂起出让运行权来实现协作的,因此它本质上就是在讨论程序控制流程的机制,这是最核心的点,任何场景下探讨协程都能落脚到挂起和恢复。

协程与线程最大的区别在于,从任务的角度来看,线程一旦开始执行就不会暂停,直到任务结束,这个过程都是连续的。线程之间是抢占式的调度,因此不存在协作问题。我们再来理一理协程的概念。

  • 挂起恢复。
  • 程序自己处理挂起恢复。
  • 程序自己处理挂起恢复来实现程序执行流程的协作调度。

2.2 协程的分类

按调用栈分类

指的就是普通函数调用栈,是一种用来保存函数调用时的状态信息的数据结构,协程的实现也可以按照是否开辟相应的调用栈来分类。

  • 有栈协程(Stackful Coroutine):每一个协程都有自己的调用栈,有点类似于线程的调用栈,这种情况下的协程实现其实很大程度上接近线程,主要的不同体现在调度上。可以在任意函数调用层级的任意位置挂起,并转移调度权。
  • 无栈协程(Stackless Coroutine):协程没有自己的调用栈,挂起点的状态通过状态机或者闭包等语法来实现,内存方面比较有优势。Kotlin的协程通常被认为是一种无栈协程的实现。但Kotlin的协程可以在挂起函数范围内的任意调用层次挂起(suspend函数嵌套调用的方式)。

按调度方式分类

  • 对称协程(Symmetric Coroutine):任何一个协程都是相互独立且平等的,调度权可以在任意协程之间转移。
    • 非常接近线程;
  • 非对称协程(Asymmetric Coroutine):协程出让调度权的目标只能是它的调用者,即协程之间存在调用和被调用关系。
    • 常见语言对协程的实现大多是非对称实现

2.3 协程的实现举例

Python的Generator

  • 典型的无栈协程的实现
  • 通过yield来挂起当前Generator函数的执行,通过next来恢复参数对应的Generator执行
  • 不支持嵌套

Lua标准库的协程实现

  • 非对称有栈协程
  • 协程几个概念:
    • 协程的执行体,即我们常提到的协程体,主要是指启动协程时对应的函数。
    • 协程的控制实例,我们可以通过协程创建时返回的实例控制协程的调用流转,我们将该对象的类型称为协程的描述类。
    • 协程的状态,在调用流程转移前后,协程的状态会发生相应的变化。

Go的go routine

如果我们有多个go routine对channel进行读写,或者有多个channel供多个goroutine读写,那么这时的读写操作实际上就是在go routine之间平等地转移调度权,因此可以认为go routine是对称的协程实现。

推荐阅读