天天看點

Kotlin 協程系列一:Coroutine基礎

本系列為翻譯和解讀

Kotlin

協程的官方文檔,對應官方文檔版本1.5.3 時間是2021-10

約定:全部的正文均對應文檔原文,個人解讀以引用的形式插入

官方文檔連結 https://kotlinlang.org/docs/coroutines-guide.html

如果對協程完全沒任何概念,強烈推薦先看這篇文章 https://xie.infoq.cn/article/351ddc94200d03948c41fbabd

如果你想寫代碼實操,可以參考這個配置環境 https://openxu.blog.csdn.net/article/details/116999821

你的第一個協程程式

一個協程對象是一個可暫停的計算過程。它在概念上和線程很相似,也就是說協程也是讓一個代碼塊和其他代碼同時運作。然而,協程并不會和任何特定的線程綁定。它可能在某個線程上暫停運作,然後在另一個線程上恢複運作

協程可以被認為是輕量級的線程,但是務必注意二者有大量的重要的不同之處,這使協程的使用迥異于線程

這段話非常重要,因為中文翻譯協程有“輕量級線程”的說法,很容易誤導了解為一個線程上分出很多協程。但事實上協程和線程是沒有任何這種“繼承”關系的,它是一個獨立的架構。官方文檔也強調那隻是個比喻。

協程是運作于線程上的,一個線程可以運作多個(可以是幾千上萬個)協程。線程的排程行為是由 OS 來操縱的,而協程的排程行為是可以由開發者來指定并由編譯器來實作的。

運作下面的代碼來觀察你的第一個協程

fun main() = runBlocking { 
	// 該閉包内的this指向一個CoroutineScope類型的對象
    launch { // 啟動一個子協程
        delay(1000L) // 不阻塞地延時1s (機關 ms)
        println("World!") // 延時後列印
    }
    println("Hello") // 主協程在子協程延時的同時在繼續運作
}
           

你将看到結果

Hello
World!
           

讓我們分析一下這段代碼的功能

lanuch

是一個協程構造器。它能啟動一個協程,同時使剩餘的代碼繼續獨立的運作。這是為什麼

Hello

先被列印

delay

是一個特殊的挂起函數。它能在一個特定的時間點暫定協程的運作。挂起一個協程并不會阻塞該協程所線上程的運作,此時線程可以去運作其他的協程代碼

runBlocking

也是一個協程構造器,它連接配接了非協程的代碼如上述的

fun main()

和協程代碼如

runBlocking{}

大括号中的内容。

IDE

會在

runBlocking

後提示

this: CoroutineScope

如果你删掉或者忘了加

runBlocking

,你會在

launch

函數上看到一個

error

。因為

lanuch

隻能聲明在

CoroutineScope

Unresolved reference: launch
           
這裡的

CoroutineScope

是協程作用域,就先了解成協程特定的上下文環境,協程隻能運作在這種環境裡。而協程構造器就是提供這種環境

runBlocking

這個名字意味着,目前運作的協程會被下面的代碼阻塞(在這個例子中是主線程),直到代碼塊中的全部協程都運作結束。你将經常看到在應用程式的最頂層使用

runBlocking

,而在具體的邏輯代碼中很少使用,因為線程是昂貴的資源,阻塞線程是低效且不推薦的

結構化并發

協程遵循結構化并發的原理,這意味着新的協程隻能在特定的協程作用域中被啟動,協程作用域就限制了協程的生命周期。上面的例子中

runBlocking

建構了協程作用域,這是為什麼主函數一直等待到

World!

被列印後才結束運作

在真實的應用中,我們會啟動非常多的協程。結構化并發能保證他們不會丢失和洩露。一個外部的作用域不會結束運作直到它的所有子協程運作結束。結構化并發也能保證任何代碼錯誤都能正确無遺漏地被上報

這段話很抽象,但核心思想是之是以有協程作用域這種文法,是為了追蹤所有協程對象,避免記憶體洩漏

提取函數與重構

讓我們把

launch { ... }

中的代碼提取出來形成一個函數。當你在上述例子中試圖提取函數時,你需要在新函數前加上

suspend

修飾符。這是你的第一個挂起函數。挂起函數在協程中可以像普通函數一樣使用,但是它額外的能力是可以調用其他挂起函數,來暫停目前協程的運作。比如

doWorld

中調用

delay

fun main() = runBlocking { // this: CoroutineScope
    launch { doWorld() }
    println("Hello")
}

suspend fun doWorld() {
    delay(1000L)
    println("World!")
}
           

作用域構造器

除了使用官方庫的構造器建立協程作用域,還可以用

coroutineScope

聲明你自己的作用域。它能建立一個協程作用域,該作用域會等待其中的所有子協程運作完成後才結束運作

你會發現

runBlocking

coroutineScope

看起來很像,但注意它們主要的差別在于前者在等待子協程運作的同時會阻塞目前的線程,而後者在等待子協程運作的時候,會釋放目前線程去運作其他代碼。由于這個差別,前者隻是個普通函數,後者則是個挂起函數

你可以從任意挂起函數中使用

coroutineScope

。例如:

fun main() = runBlocking {
    doWorld()
}

suspend fun doWorld() = coroutineScope {  // this: CoroutineScope
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello")
}
           
Hello
World!
           
注意這裡,

runBlocking

建立了一個協程作用域記作A,

doWorld

是A中的一個挂起函數。這個挂起函數裡用

coroutineScope

又建立了一個作用域記作B,函數體裡用

lanuch

又建立了一個作用域記作C,

delay

是C中的挂起函數

是以

delay

挂起了協程C,

doWorld

挂起了協程A。因為

runBlocking

是阻塞挂起,是以在

doWorld

沒有運作結束前不會結束主函數。因為

coroutineScope

是非阻塞挂起,是以在C被挂起後,繼續運作B中的剩餘代碼

作用域構造器與并發

協程作用域構造器還可以用在任意挂起函數的内部,來執行并發的操作。讓我們在

doWorld

函數内部啟動兩個協程

// 順序執行:先是 doWorld ,然後是列印"Done"
fun main() = runBlocking {
    doWorld()
    println("Done")
}

// 并發執行:同時執行兩個子協程
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
    launch {
        delay(2000L)
        println("World 2")
    }
    launch {
        delay(1000L)
        println("World 1")
    }
    println("Hello")
}
           

兩個由

launch { ... }

啟動的子協程是并發執行的,是以從最開始開始計時,先列印出

Hello

,然後第1 s 後列印出

World 1

,第2 s 後列印出

World 2

,這兩個子協程運作結束後,

doWorld

所在的協程作用域才運作結束,傳回到

runBlocking

中列印

Done

Hello
World 1
World 2
Done
           

一個顯式的Job

協程構造器

lanuch

會傳回一個

Job

類型的對象,這是一個句柄,指向被啟動的協程。它可以用來顯式聲明等待該協程的運作結束。例如,你可以顯式聲明等待子協程完成後,再列印

Done

val job = launch { // 啟動一個協程,并用job作為指向它的引用
    delay(1000L)
    println("World!")
}
println("Hello")
job.join() // 顯式聲明等待該協程運作結束,結束後才運作後面的代碼
println("Done") 
           
Hello
World!
Done
           

協程是輕量的

運作如下代碼

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(100_000) { // launch a lot of coroutines
        launch {
            delay(5000L)
            print(".")
        }
    }
}
           

它啟動了10萬個協程,5 s後每個協程列印一個點号

嘗試用線程去實作,去掉

runBlocking

,用

thread

替代

launch

,用

Thread.sleep

替代

delay

。大機率你的代碼會報記憶體溢出錯誤

因為啟動一個協程是建立一些對象,而啟動一個線程是配置設定一大堆記憶體!