"協程是輕量級的線程",相信你們不止一次聽到這種說法。可是您真的理解其中的含義嗎?恐怕答案是否認的。接下來的內容會告訴你們協程是如何在 Android 運行時中被運行的,它們和線程之間的關係是什麼,以及在使用 Java 編程語言線程模型時所遇到的併發問題。html
協程旨在簡化異步執行的代碼。對於 Android 運行時的協程,lambda 表達式的代碼塊會在專門的線程中執行。例如,示例中的 斐波那契 運算:java
// 在後臺線程中運算第十級斐波那契數 someScope.launch(Dispatchers.Default) { val fibonacci10 = synchronousFibonacci(10) saveFibonacciInMemory(10, fibonacci10) } private fun synchronousFibonacci(n: Long): Long { /* ... */ }
上面 async 協程的代碼塊,會被分發到由協程庫所管理的線程池中執行,實現了同步且阻塞的斐波那契數值運算,而且將結果存入內存,上例中的線程池屬於 Dispatchers.Default。該代碼塊會在將來某些時間在線程池中的某一線程中執行,具體執行時間取決於線程池的策略。git
請注意因爲上述代碼中未包含掛起操做,所以它會在同一個線程中執行。而協程是有可能在不一樣的線程中執行的,好比將執行部分移動到不一樣的分發器,或者在使用線程池的分發器中包含帶有掛起操做的代碼。github
若是不使用協程的話,您還可使用線程自行實現相似的邏輯,代碼以下:編程
// 建立包含 4 個線程的線程池 val executorService = Executors.newFixedThreadPool(4) // 在其中的一個線程中安排並執行代碼 executorService.execute { val fibonacci10 = synchronousFibonacci(10) saveFibonacciInMemory(10, fibonacci10) }
雖然您能夠自行實現線程池的管理,可是咱們仍然推薦使用協程做爲 Android 開發中首選的異步實現方案,它具有內置的取消機制,能夠提供更便捷的異常捕捉和結構式併發,後者能夠減小相似內存泄漏問題的發生概率,而且與 Jetpack 庫集成度更高。api
工做原理緩存
從您建立協程到代碼被線程執行這期間發生了什麼呢?當您使用標準的協程 builder 建立協程時,您能夠指定該協程所運行的 CoroutineDispatcher,若是未指定,系統會默認使用 Dispatchers.Default
。安全
CoroutineDispatcher 會負責將協程的執行分配到具體的線程 。在底層,當 CoroutineDispatcher
被調用時,它會調用封裝了 Continuation (好比這裏的協程) interceptContinuation 方法來攔截協程。該流程是以 CoroutineDispatcher 實現了 CoroutineInterceptor 接口做爲前提。數據結構
若是您閱讀了我以前的關於 協程在底層是如何實現 的文章,您應該已經知道了編譯器會建立狀態機,以及關於狀態機的相關信息 (好比接下來要執行的操做) 是被存儲在 Continuation 對象中。
一旦 Continuation 對象須要在另外的 Dispatcher 中執行,DispatchedContinuation
的 resumeWith 方法會負責將協程分發到合適的 Dispatcher。多線程
此外,在 Java 編程語言的實現中,繼承自 DispatchedTask 抽象類的 DispatchedContinuation 也屬於 Runnable
接口的一種實現類型。所以,DispatchedContinuation
對象也能夠在線程中執行。其中的好處是當指定了 CoroutineDispatcher
時,協程就會轉換爲 DispatchedTask
,而且做爲 Runnable
在線程中執行。
那麼當您建立協程後,dispatch
方法如何被調用呢?當您使用標準的協程 builder 建立協程時,您能夠指定啓動參數,它的類型是 CoroutineStart。例如,您能夠設置協程在須要的時候才啓動,這時能夠將參數設置爲 CoroutineStart.LAZY
。默認狀況下,系統會使用 CoroutineStart.DEFAULT
根據 CoroutineDispatcher
來安排執行時機。
△ 協程的代碼塊如何在線程中執行的示意圖
分發器和線程池
您可使用 Executor.asCoroutineDispatcher() 擴展函數將協程轉換爲 CoroutineDispatcher
後,便可在應用中的任何線程池中執行該協程。此外,您還可使用協程庫默認的 Dispatchers。
您能夠看到 createDefaultDispatcher
方法中是如何初始化 Dispatchers.Default
的。默認狀況下,系統會使用 DefaultScheduler。若是您看一下 Dispatcher.IO 的實現代碼,它也使用了 DefaultScheduler
,支持按需建立至少 64 個線程。Dispatchers.Default
和 Dispatchers.IO 是隱式關聯的,由於它們使用了同一個線程池,這就引出了咱們下一個話題,使用不一樣的分發器調用 withContext 會帶來哪些運行時的開銷呢?
線程和 withContext 的性能表現
在 Android 運行時中,若是運行的線程比 CPU 的可用內核數多,那麼切換線程會帶來必定的運行時開銷。上下文切換 並不輕鬆!操做系統須要保存和恢復執行的上下文,並且 CPU 除了執行實際的應用功能以外,還須要花時間規劃線程。除此以外,當線程中所運行代碼阻塞的時候也會形成上下文切換。若是上述的問題是針對線程的,那麼在不一樣的 Dispatchers 中使用 withContext 會帶來哪些性能上的損失呢?
還好線程池會幫咱們解決這些複雜的操做,它會嘗試儘可能多地執行任務 (這也是爲何在線程池中執行操做要優於手動建立線程)。協程因爲被安排在線程池中執行,因此也會從中受益。基於此,協程不會阻塞線程,它們反而會掛起本身的工做,於是更加有效。
Java 編程語言中默認使用的線程池是 CoroutineScheduler 。它以最高效的方式將協程分發到工做線程。因爲 Dispatchers.Default
和 Dispatchers.IO 使用相同的線程池,在它們之間切換會盡可能避免線程切換。協程庫會優化這些切換調用,保持在同一個分發器和線程上,而且儘可能走捷徑。
因爲 Dispatchers.Main 在帶有 UI 的應用中一般屬於不一樣的線程,因此協程中 Dispatchers.Default和 Dispatchers.Main 之間的切換並不會帶來太大的性能損失,由於協程會掛起 (好比在某個線程中中止執行),而後會被安排在另外的線程中繼續執行。
協程因爲其可以簡單地在不一樣線程上規劃操做,的確使得異步編程更加輕鬆。可是另外一方面,便捷是一把雙刃劍: 因爲協程是運行在 Java 編程語言的線程模型之上,它們難以逃脫線程模型所帶來的併發問題。所以,您須要注意而且儘可能避免該問題。
近年來,像不可變性這樣的策略相對減輕了由線程所引起的問題。然而,有些場景下,不可變性策略也沒法徹底避免問題的出現。全部併發問題的源頭都是狀態管理!尤爲是在一個多線程環境下訪問可變的狀態。
在多線程應用中,操做的執行順序是不可預測的。與編譯器優化操做執行順序不一樣,線程沒法保證以特定的順序執行,而上下文切換會隨時發生。若是在訪問可變狀態時沒有采起必要的防範措施,線程就會訪問到過期的數據,丟失更新,或者遇到 資源競爭 問題等等。
請注意這裏所討論的可變狀態和訪問順序並不只限於 Java 編程語言。它們在其它平臺上一樣會影響協程執行。
使用了協程的應用本質上就是多線程應用。使用了協程而且涉及可變狀態的類必須採起措施使其可控,好比保證協程中的代碼所訪問的數據是最新的。這樣一來,不一樣的線程之間就不會互相干擾。併發問題會引發潛在的 bug,使您很難在應用中調試和定位問題,甚至出現 海森堡 bug。
這一類型的類很是常見。好比該類須要將用戶的登陸信息緩存在內存中,或者當應用在活躍狀態時緩存一些值。若是您稍有大意,那麼併發問題就會乘虛而入!使用 withContext(defaultDispatcher) 的掛起函數沒法保證會在同一個線程中執行。
好比咱們有一個類須要緩存用戶所作的交易。若是緩存沒有被正確訪問,好比下面代碼所示,就會出現併發問題:
class TransactionsRepository( private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default ) { private val transactionsCache = mutableMapOf<User, List<Transaction>() private suspend fun addTransaction(user: User, transaction: Transaction) = // 注意!訪問緩存的操做未被保護! // 會出現併發問題:線程會訪問到過時數據 // 而且出現資源競爭問題 withContext(defaultDispatcher) { if (transactionsCache.contains(user)) { val oldList = transactionsCache[user] val newList = oldList!!.toMutableList() newList.add(transaction) transactionsCache.put(user, newList) } else { transactionsCache.put(user, listOf(transaction)) } } }
即便咱們這裏所討論的是 Kotlin,由 Brian Goetz 所編撰的《Java 併發編程實踐》對於瞭解本文主題和 Java 編程語言系統是很是好的參考材料。此外,Jetbrains 針對 共享可變的狀態和併發 的主題也提供了相關的文檔。
對於如何保護可變狀態,或者找到合適的 同步) 策略,取決於數據自己和相關的操做。本節內容啓發你們注意可能會遇到的併發問題,而不是簡單羅列保護可變狀態的方法和 API。總而言之,這裏爲你們準備了一些提示和 API 能夠幫助你們針對可變變量實現線程安全。
封裝
可變狀態應該屬於並被封裝在類裏。該類應該將狀態的訪問操做集中起來,根據應用場景使用同步策略保護變量的訪問和修改操做。
線程限制
一種方案是將讀取和寫入操做限制在一個線程裏。可使用隊列基於生產者-消費者模式實現對可變狀態的訪問。Jetbrains 對此提供了很棒的 文檔。
避免重複工做
在 Android 運行時中,包含線程安全的數據結構可供您保護可變變量。好比,在計數器示例中,您可使用 AtomicInteger。又好比,要保護上述代碼中的 Map,您可使用 ConcurrentHashMap。ConcurrentHashMap
是線程安全的,而且優化了 map 的讀取和寫入操做的吞吐量。
請注意,線程安全的數據結構並不能解決調用順序問題,它們只是確保內存數據的訪問是原子操做。當邏輯不太複雜的時候,它們能夠避免使用 lock。好比,它們沒法用在上面的 transactionCache 示例中,由於它們之間的操做順序和邏輯須要使用線程並進行訪問保護。
並且,當已修改的對象已經存儲在這些線程安全的數據結構中時,其中的數據須要保持不可變或者受保護狀態來避免資源競爭問題。
自定義方案
若是您有複合的操做須要被同步,@Volatile 和線程安全的數據結構也不會有效果。有可能內置的 @Synchronized 註解的粒度也不足以達到理想效果。
在這些狀況下,您可能須要使用併發工具建立您本身的同步機制,好比 latches、semaphores) 或者 barriers)。其它場景下,您可使用 lock) 和 mutex 無條件地保護多線程訪問。
Kotlin 中的 Mute 包含掛起函數 lock 和 unlock,能夠手動控制保護協程的代碼。而擴展函數 Mutex.withLock 使其更加易用:
class TransactionsRepository( private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default ) { // Mutex 保護可變狀態的緩存 private val cacheMutex = Mutex() private val transactionsCache = mutableMapOf<User, List<Transaction>() private suspend fun addTransaction(user: User, transaction: Transaction) = withContext(defaultDispatcher) { // Mutex 保障了讀寫緩存的線程安全 cacheMutex.withLock { if (transactionsCache.contains(user)) { val oldList = transactionsCache[user] val newList = oldList!!.toMutableList() newList.add(transaction) transactionsCache.put(user, newList) } else { transactionsCache.put(user, listOf(transaction)) } } } }
因爲使用 Mutex 的協程在能夠繼續執行以前會掛起操做,所以要比 Java 編程語言中的 lock 高效不少,由於後者會阻塞整個線程。在協程中請謹慎使用 Java 語言中的同步類,由於它們會阻塞整個協程所處的線程,而且引起 活躍度 問題。
傳入協程中的代碼最終會在一個或者多個線程中執行。一樣的,協程在 Android 運行時的線程模型下依然須要遵循約束條件。因此,使用協程也一樣會出現存在隱患的多線程代碼。因此,在代碼中請謹慎訪問共享的可變狀態。