可能是最全的Kotlin協程講解

使用協程已經有較長的時間了,但一直停留在launch、async啓動協程,suspend方法掛起的階段。這段時間系統梳理Kotlin知識時才發現,對協程(僅對Kotlin)還有很多概念不甚瞭解。例如CoroutineScope對協程生命週期的重要性、協程父子結構的作用、結構化併發、一些Kotlin協程中約定俗稱的規定等。

概述

解釋協程

解釋協程這一概念,是個作死的行爲,這裏斗膽一試。

我們嘗試從幾個比較流行的說法來解釋協程到底是個什麼東西,而不是再增加一種讓人猜不透的說法

  1. 協程是輕量級線程(官方表述)

    可以換個說法,協程就是方法調用封裝成類線程的API。方法調用當然比線程切換輕量;而封裝成類線程的API後,它形似線程(可手動啓動、有各種運行狀態、能夠協作工作、能夠併發執行)。因此從這個角度說,它是輕量級線程沒錯。

    當然,協程絕不僅僅是方法調用,因爲方法調用不能在一個方法執行到一半時掛起,之後又在原點恢復。這一點可以使用EventLoop之類的方式實現。想象一下在庫級別將回調風格或Promise/Future風格的異步代碼封裝成同步風格,封裝的結果就非常接近協程了。

    而協程和線程之間的區別,往大了說,那就是普通函數與線程的區別;往小了說,就是EventLoop和線程的區別。他們之間的唯一的關係,僅僅在於協程的代碼是運行在線程中。一個不恰當的類比,人和地球(地球提供生成環境,人在其中生存)

  2. 線程運行在內核態,協程運行在用戶態

    主要明白什麼叫用戶態,我們寫的幾乎所有代碼,都執行在用戶態,協程對於操作系統來說僅僅是第三方提供的庫而已,當然運行在用戶態。而線程是操作系統級別的東西,運行在內核態。

  3. 協程是一個線程框架(扔物線表述)

    對某些語言,比如Kotlin,這樣說是沒有問題的,Kotlin的協程庫可以指定協程運行的線程池,我們只需要操作協程,必要的線程切換操作交給庫,從這個角度來說,協程就是一個線程框架。

    但理論上我們可以在單線程語言如JavaScript、Python上實現協程(事實上他們已經實現了協程),這時我們再叫它線程框架可能就不合適了。

私以爲,協程要從兩方面看

  • 概念上:coroutine(協程)和subroutine(子程序)是一個級別的(從命名上也類似)。子程序是一段具備一定功能的代碼,一個函數、一個方法、一段代碼都算是一個子程序。而協程,顧名思義,就是相互協作的子程序,多個子程序之間通過一定的機制相互關聯、協作地完成某項任務。比如一個協程在執行上可以被分爲多個子程序,每個子程序執行完成後主動掛起,等待合適的時機再恢復;一個協程被掛起時,線程可以執行其它子程序,從而達到線程高利用率的多任務處理目的——協程在一個線程上執行多個任務,而傳統線程只能執行一個任務,從多任務執行的角度,協程自然比線程輕量。

    通過提高線程利用率來提高多任務執行效率,這一點和IO多路複用、Reactor模型等基本思想一致,從這個角度看,協程並不是什麼新東西。

  • 實現上:協程的重點和難點就在於執行到掛起點時掛起和恢復的行爲。它在底層技術實現上和我們常用的異步回調沒有本質的區別,僅僅是根據不同的編程思想封裝成對應的API。

    其具體實現原理我們將在其它文章討論,這裏僅介紹協程概念。

協程解決的問題——以同步的方式寫異步代碼。如果不使用協程,我們目前能夠使用的API形式主要有三種:純回調風格(如AIO)、RxJava、Promise/Future風格,他們普遍存在回調地獄問題,解回調地獄只能通過行數換層數,且對於不熟悉異步風格的程序員來說,能夠看懂較爲複雜的異步代碼就比較費勁。

Kotlin的協程

根據Kotlin協程設計提案,Kotlin協程的設計目標有如下三點

  • 不依賴 Future 之類複雜的庫提供的特定實現
  • 同時涵蓋 “async/await” 用例以及“生成器代碼塊”
  • 使 Kotlin 協程能包裝各種現有的異步 API (如 Java NIO、各種 Future 的實現等)

可以認爲,Kotlin是想在自己的代碼環境中用協程消除傳統的異步API,以原語的方式提供。

本文介紹

上面介紹了協程的基本概念和Kotlin協程的設計目的,接下來介紹Kotlin中協程的使用方法、核心組件、核心概念以及常見使用約定。有關實現原理,尚未探索,計劃另開文章詳述。

使用協程

啓動

協程需要運行在協程上下文環境,在非協程環境中憑空啓動協程,有三種方式

  • runBlocking{}

    啓動一個新協程,並阻塞當前線程,直到其內部所有邏輯及子協程邏輯全部執行完成。

    該方法的設計目的是讓suspend風格編寫的庫能夠在常規阻塞代碼中使用,常在main方法和測試中使用。

  • GlobalScope.launch{}

    在應用範圍內啓動一個新協程,協程的生命週期與應用程序一致。這樣啓動的協程並不能使線程保活,就像守護線程。

    由於這樣啓動的協程存在啓動協程的組件已被銷燬但協程還存在的情況,極限情況下可能導致資源耗盡,因此並不推薦這樣啓動,尤其是在客戶端這種需要頻繁創建銷燬組件的場景。

  • 實現CoroutineScope + launch{}

    這是在應用中最推薦使用的協程使用方式——爲自己的組件實現CoroutieScope接口,在需要的地方使用launch{}方法啓動協程。使得協程和該組件生命週期綁定,組件銷燬時,協程一併銷燬。從而實現安全可靠地協程調用。

在一個協程中啓動子協程,一般來說有兩種方式

  • launch{}

    異步啓動一個子協程

  • async{}

    異步啓動一個子協程,並返回Deffer對象,可通過調用Deffer.await()方法等待該子協程執行完成並獲取結果,常用於併發執行-同步等待的情況

一個合適的例子

class TtpServiceImpl(val vertx: Vertx): TtpService, CoroutineScope {
    override val coroutineContext: CoroutineContext by lazy { vertx.dispatcher() }
    
    override fun getContentList(resultHandler: Handler<AsyncResult<OperationResponse>>){
        launch{
            val deffer1 = async{ awaitResult<List<JsonObject>>{ dbService.getContentList(it) } }
            val deffer2 = async{ awaitResult<List<JsonObject>>{ dbService.getAuthorList(it) } }
            val contents = deffer1.await()
            val authors = deffer2.await()
            val reuslt = contents.map{ content -> 
                content.put("author", authors.filter{ ... }.first())
            }
            resultHandler.succeed(reuslt)
        }
    }
}

取消

launch{}返回Job,async{}返回Deffer,Job和Deffer都有cancel()方法,用於取消協程。

從協程內部看取消的效果

  • 標準庫的掛起方法會拋出CancellationException異常。
  • 用戶自定義的常規邏輯並不會收到影響,除非我們手動檢測isActive標誌。

上面兩個特性和線程的interrupt機制非常類似,理解起來並不難。

val job = launch {
    // 如果這裏不檢測isActive標記,協程就不會被正常cancel,而是執行直到正常結束
    while (isActive) { 
        ......
    }
}
job.cancelAndJoin() // 取消該作業並等待它結束

瞭解協程的啓動和取消,對於最基本的使用已經足夠了。不過爲了更加安全放心地使用,需要更加深入地瞭解,我們從核心組件說起。

異常

Kotlin協程的異常有兩種

  • 因協程取消,協程內部suspend方法拋出的CancellationException
  • 常規異常,這類異常,有兩種異常傳播機制
    • launch:將異常自動向父協程拋出,將會導致父協程退出
    • async: 將異常暴露給用戶(通過捕獲deffer.await()拋出的異常)

這裏借用官方例子講解

fun main() = runBlocking {
    val job = GlobalScope.launch { // root coroutine with launch
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // 我們將在控制檯打印 Thread.defaultUncaughtExceptionHandler
    }
    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async { // root coroutine with async
        println("Throwing exception from async")
        throw ArithmeticException() // 沒有打印任何東西,依賴用戶去調用等待
    }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}

輸出結果

Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException

注意,例子是在GlobalScope.launch{}中拋異常,不會導致父協程退出。

全局異常處理

指定全局異常處理器,省時省力。

  override fun getContentList(resultHandler: Handler<AsyncResult<OperationResponse>>) {
    launch(CoroutineExceptionHandler { _, e ->
      logger.error("Exception when get content list.", e)
      resultHandler.fail()
    }) {
            val deffer1 = async{ awaitResult<List<JsonObject>>{ dbService.getContentList(it) } }
            val deffer2 = async{ awaitResult<List<JsonObject>>{ dbService.getAuthorList(it) } }
            val contents = deffer1.await()
            val authors = deffer2.await()
            val reuslt = contents.map{ content -> 
                content.put("author", authors.filter{ ... }.first())
            }
            resultHandler.succeed(reuslt)
    }
}

核心組件

Kotlin的協程實現是以附加庫kotlinx-coroutines-core的形式提供的,但其實協程的接口定義在覈心庫kotlin-stdlib-common的kotlin.coroutines中。

協程上下文

顧名思義,協程上下文表示協程的運行環境,包括協程調度器、代表協程本身的Job、協程名稱、協程ID等。通過CoroutineContext定義,CoroutineContext被定義爲一個帶索引的集合,集合的元素爲Element,上面所提到調度器、Job等都實現了Eelement接口。

由於CoroutineContext被定義爲集合,因此在實際使用時可以自由組合加減各種上下文元素。

啓動子協程時,子協程默認會繼承除Job外的所有父協程上下文元素,創建新的Job,並將父Job設置爲當前Job的父親。

啓動子協程時,可以指定協程上下文元素,如果父上下文中存在該元素則覆蓋,不存在則添加。

    override fun getContentList(resultHandler: Handler<AsyncResult<OperationResponse>>){
        // 自定義新協程名稱
        launch(CoroutineName("customName")){
            ... ...
        }
    }

調度器

調度器是協程上下文中衆多元素中最重要的一個,通過CoroutineDispatcher定義,它控制了協程以何種策略分配到哪些線程上運行。這裏介紹幾種常見的調度器

  • Dispatcher.Default

    默認調度器。它使用JVM的共享線程池,該調度器的最大併發度是CPU的核心數,默認爲2

  • Dispatcher.Unconfined

    非受限調度器,它不會將操作限制在任何線程上執行——在發起協程的線程上執行第一個掛起點之前的操作,在掛起點恢復後由對應的掛起函數決定接下來在哪個線程上執行。

  • Dispathcer.IO

    IO調度器,他將阻塞的IO任務分流到一個共享的線程池中,使得不阻塞當前線程。該線程池大小爲環境變量kotlinx.coroutines.io.parallelism的值,默認是64或核心數的較大者。

    該調度器和Dispatchers.Default共享線程,因此使用withContext(Dispatchers.IO)創建新的協程不一定會導致線程的切換。

  • Dispathcer.Main

    該調度器限制所有執行都在UI主線程,它是專門用於UI的,並且會隨着平臺的不同而不同

    • 對於JS或Native,其效果等同於Dispatchers.Default
    • 對於JVM,它是Android的主線程、JavaFx或者Swing EDT的dispatcher之一。

    並且爲了使用該調度器,還必須增加對應的組件

    • kotlinx-coroutines-android
    • kotlinx-coroutines-javafx
    • kotlinx-coroutines-swing
  • 其它

    在其它支持協程的第三方庫中,也存在對應的調度器,如Vertx的vertx.dispatcher(),它將協程分配到vertx的EventLoop線程池執行。

注意,由於上下文具有繼承關係,因此啓動子協程時不顯式指定調度器時,子協程和父協程是使用相同調度器的。

Job

Job也是上下文元素,它代表協程本身。Job能夠被組織成父子層次結構,並具有如下重要特性。

  • 父Job退出,所有子job會馬上退出
  • 子job拋出除CancellationException(意味着正常取消)意外的異常會導致父Job馬上退出

類似Thread,一個Job可能存在多種狀態

State [isActive] [isCompleted] [isCancelled]
New (optional initial state) false false false
Active (default initial state) true false false
Completing (transient state) true false false
Cancelling (transient state) false false true
Cancelled (final state) false true true
Completed (final state) false true false

我們直接使用launch獲取到的job已經處於Active裝填,啓動時加上LAZY參數時則得到New狀態的Active。

各狀態轉換關係如下,注意,Completing只是一個內部狀態,外部觀察還是Active狀態。

在這裏插入圖片描述

要區分是主動取消還是異常導致一個協程退出,可以getCancellationException()查看退出原因。

作用域

協程作用域——CoroutineScope,用於管理協程,管理的內容有

  • 啓動協程的方式 - 它定義了launch、async、withContext等協程啓動方法(以extention的方式),並在這些方法內定義了啓動子協程時上下文的繼承方式。
  • 管理協程生命週期 - 它定義了cancel()方法,用於取消當前作用域,同時取消作用域內所有協程。

區分作用域和上下文

從類定義看,CoroutineScope和CoroutineContext非常類似,最終目的都是協程上下文,但正如Kotlin協程負責人Roman Elizarov在Coroutine Context and Scope中所說,二者的區別只在於使用目的的不同——作用域用於管理協程;而上下文只是一個記錄協程運行環境的集合。他們的關係如下。
在這裏插入圖片描述

Roman Elizarov的文章說得可能不是很明晰,我認爲比較容易理解的說法是

  • CoroutineScope規範了CoroutineContext的繼承和管理方式。

約定和經驗

避免使用GlobalScope.launch

GlobalScope是實現了CoroutineScope的單例對象,含有一個空的上下文對象

// GlobalScope的定義
public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

這意味着它的生命週期與整個應用綁定,並且永遠不會被主動取消。這樣啓動的協程只有兩個歸宿:

  • 協程正常執行完成
  • 協程內部發生錯誤,導致協程因異常自動取消

這是危險的。考慮極端情況:

  1. 在一個實例方法中使用GlobalScope.launch啓動了一個CPU密集型協程,且執行時間較長
  2. 在啓動協程後,該實例方法因異常退出,所屬對象也被銷燬
  3. 反覆多次出現步驟1\2

這樣導致的結果是啓動了超多CPU密集型任務,最終導致應用卡頓,甚至資源耗盡。

解決方案是避免使用GlobalScope。正確的做法是將自己的組件實現CoroutineScope,並在組件銷燬時調用作用域的cancel()方法。實現方式多使用委託。

// 官方例子
class MyActivity : AppCompatActivity(), CoroutineScope by MainScope() {
    override fun onDestroy() {
         cancel() // cancel is extension on CoroutineScope
    }
    ... ...
}
// vertx例子
abstract class CoroutineVerticle : Verticle, CoroutineScope {
  // 默認上下文使用context.dispatcher()
  override val coroutineContext: CoroutineContext by lazy { context.dispatcher() }
  ... ...
}

結構化併發

首先從結構化併發這一概念說起(參考這篇文章)。

非結構化併發

與結構化併發相對的是非結構化併發,即傳統的異步框架和異步庫。這種異步框架在工作時,可能會在函數中啓動一個新的協程,或新註冊一個回調函數,當函數調用返回時,從語義上,函數貌似返回了,但實際上它仍然在後臺運行(對於啓動的協程,他會運行直到結束;對於註冊的回調函數,仍然屬於原函數的一部分,會在將來執行),如果不瞭解其中的因果關係,就不知道它什麼時候結束,這違反了因果關係

正是由於異步框架的非結構化併發的缺點,導致出現背壓這樣更加複雜的副產物。儘管它們能夠使程序正常工作,但會很麻煩,違反編程直覺——你不是在編程,而是在使用一種工具。

一個非結構化併發的邏輯流如同go語句產生的效果。
在這裏插入圖片描述
結構化併發

結構化併發,就是將多個分開的併發路徑最終再次連接起來,使得符合因果關係,在意義上類比將麪條代碼始作俑者go語句從編程語言中剔除。
在這裏插入圖片描述
實現結構化併發需要運行環境,用於包裝真正的異步操作,並暴露取消機制、異常傳播機制等API。

Kotlin如何實現結構化併發

Kotlin協程通過CoroutineScope實現結構化併發。因爲作用域具有如下特性

  • 能夠控制內部協程的生命週期
  • 可以取消內部所有協程
  • 所有子協程完成後作用域才結束

爲了讓我們能夠對一部分代碼塊實現結構化併發,Kotlin提供了coroutineScope{}方法(官方聲稱其目的在於並行分解,即將一個長耗時任務分解成多個併發的短耗時任務,並在最終組裝,正是利用了作用域的結構化併發特性,才能夠實現並行分解)

// 常用的示意例子,這裏用的是async,實際上用launch時,coroutineScope也會等待其結束後再返回。
suspend fun loadAndCombine(name1: String, name2: String): Image = coroutineScope {
    val deferred1 = async { loadImage(name1) }
    val deferred2 = async { loadImage(name2) }
    combineImages(deferred1.await(), deferred2.await())
}

此外,從結構化併發的概念上看,runBlocking{}也能夠結構化併發。

suspend方法和CoroutineScope擴展方法的取捨

Kotlin中,有兩個約定俗成的東西

  1. 每一個聲明爲CoroutineScope的擴展方法的方法,都會馬上返回,但是會併發地執行擴展方法指定的內容

    這也是runBlocking不是CoroutineScope的擴展方法的原因之一

  2. 每一個僅聲明爲suspend的方法,會等待其內部邏輯完成後再返回給調用者

但如果擁有如下簽名的方法會怎麼樣呢?它是suspend方法,同時也是CoroutineScope的擴展方法。調用者並不知道在方法內是否會啓動新的協程,憑空給代碼增加了複雜度,因此不推薦使用。

suspend fun CoroutineScope.obfuscate(data: Data)

suspend方法就應該在所有任務都完成後再返回。如果在suspend方法內部有需要併發執行的內容,那就應該等待他們都完成後再返回,此時可以使用coroutineScope{},而不是在方法簽名上加上CoroutineScope擴展。

區分與對比

Kotlin中,有幾種方式能夠啓動協程,或者看似能夠啓動協程,這裏列舉

  • launch{}

    CoroutineScope的擴展方法,啓動一個協程,不阻塞當前協程,並返回新協程的Job。

  • async{}

    CoroutineScope的擴展方法,啓動一個協程,不阻塞當前協程,返回一個Deffer,除包裝了未來的結果外,其餘特性與launch{}一致

  • withContext(){}

    一個suspend方法,在給定的上下文執行給定掛起塊並返回結果,它並不啓動協程,只會(可能會)導致線程的切換。用它執行的掛起塊中的上下文是當前協程的上下文和由它執行的上下文的合併結果。

    withContext的目的不在於啓動子協程,它最初用於將長耗時操作從UI線程切走,完事再切回來。

    前面我們說過,協程取消後,位於協程中的標準庫的suspend函數會拋出CancellationException,withContext也不例外。

  • coroutineScope{}

    一個suspend方法,創建一個新的作用域,並在該作用域內執行指定代碼塊,它並不啓動協程。其存在的目的是進行符合結構化併發的並行分解(即,將長耗時任務拆分爲併發的多個短耗時任務,並等待所有併發任務完成後再返回)。

  • runBlocking{}

    是一個裸方法,創建一個協程,並阻塞當前線程,直到協程執行完畢。前面說過,這裏不再贅述。

Kotlin協程的其它功能

異步流

首先說,Kotlin中的異步流和RxJava中的流在概念上非常類似,可以被歸爲響應式流。並且Kotlin也提供響應的庫將它轉換爲其它響應式流

  • kotlinx-coroutines-reactive 用於Reactive Streams
  • kotlinx-coroutines-reactor 用於Project Reactor
  • kotlinx-coroutines-rx2 用於RxJava2

爲什麼需要異步流

一個掛起函數能夠異步地返回單一的值,如果我們要異步返回多個值並針對每個都做處理呢?這也是一般響應式流遇到的場景,在Kotlin中,異步流用來解決它。

已有現成的響應式流,爲啥還有異步流?

從功能上說,現有的響應式流庫能夠解決問題,但在Kotlin中不夠優雅。Flow的設計目標是擁有儘可能精簡的設計,能夠完美融合到Kotlin的協程API中。

使用異步流

  val flow = flow {
    // 耗時操作1
    delay(1000)
    emit(12)
    // 耗時操作2
    delay(1000)
    emit(13)
  }

  runBlocking {
    flow.collect { println(it) }
  }
  • flow接收的lambda表達式是一個協程環境,裏面的操作實在一個協程中執行
  • collect是收集操作,只有收集時纔會真的去執行流中定義的邏輯
  • flow也可以被取消

更多詳情,移步官方手冊

通道

通道(Channel)用於在多個協程之間傳輸數據。Channel是和BlockingQueue非常相似的概念。不同的是寫入和讀取數據用的是異步的send和recieve

這裏展示簡單的使用

  • 直接通過Channel構造函數

    val channel = Channel<Int>()
    launch {
        for (x in 1..5) channel.send(x * x)
        channel.close()
    }
    for (y in channel) println(y)
    println("Done!")
    
  • 使用produce構建器

    val channel = produce {
        send(12)
        send(13)
    }
    
    for (value in channel) {
        println(value)
    }
    

在通道沒有數據時,調用recieve會導致協程掛起;在通道緩衝滿時,調用send會導致協程掛起。

通道遵循FIFO原則,先發出的消息會先被獲取。

監督

常規的Job當子協程拋異常時,父協程也會被退出。有時不想要這種情況發生,可以使用監督。使用方式是將SupervisorJob在協程啓動時當上下文元素傳入。

協程+通道實現actor

Kotlin的協程本質上說是更好用的線程的封裝,因此還是會有共享的可變狀態的併發問題。解決方式無非幾種

  • 共享變量使用併發數據結構,如Atomic數據類
  • 限制訪問共享變量的協程在單線程上執行
  • 對共享變量的訪問加鎖
  • 使用actor模式,將共享變量封裝在actor中,通過actor的消息郵箱將併發變串行

Kotlin的actor是由一個協程、協程封裝的狀態、一個它與其它協程通信的通道組成。

和scala一樣,正確使用actor的方式是先創建消息類,然後定義actor,然後發送消息

// 計數器 Actor 的各種類型
sealed class CounterMsg
object IncCounter : CounterMsg() // 遞增計數器的單向消息
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() // 攜帶回復的請求

fun main() = runBlocking<Unit> {
  val counter = actor<CounterMsg> {
    var counter = 0 // actor 狀態
    for (msg in channel) { // 即將到來消息的迭代器
      when (msg) {
        is IncCounter -> counter++
        is GetCounter -> msg.response.complete(counter)
      }
    }
  }
  withContext(Dispatchers.Default) {
    counter.send(IncCounter)
  }
  // 發送一條消息以用來從一個 actor 中獲取計數值
  val response = CompletableDeferred<Int>()
  counter.send(GetCounter(response))
  println("Counter = ${response.await()}")
  counter.close() // 關閉該actor
}

actor能夠併發安全的原因是它將並發過來的請求存儲在通道中,再一個一個地處理。達到了不需要加鎖的串行調用。比直接併發安全,比加鎖高效。

總結

本文詳述了Kotlin協程的基本用法及重要組件的運行機制,對它們有了足夠詳細的瞭解和清晰的認識有助於我們寫出正確的Kotlin協程代碼。當然實際使用還需要根據具體情況選擇恰當的API。文章的最後大致介紹了Kotlin協程中的其它功能,雖然不常用到,但瞭解總是沒錯的,萬一有用呢。

文章中偏重講解,示例代碼較少,且部分實例代碼來自官方,部分來自自己,並不一定能夠直接運行,重在展示用法,閱讀時注意區分。

寫本文前打了很多草稿,如有興趣翻閱,可以看這裏

參考資料

  1. 協程 - 維基百科
  2. [碼上開學]協程系列視頻(扔物線,共3集)
  3. Kotlin協程設計提案(翻譯版)
  4. Kotlin協程官方手冊
  5. Kotlin核心組件Javadoc
  6. Coroutine Context and Scope - Roman Elizarov
  7. The reason to avoid GlobalScope - Roman Elizarov
  8. Structed concurrency - Roman Elizarov
  9. Structed concurrency - Somebody
  10. Explicit Concurrency - Roman Elizarov
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章