深入淺出協程、線程和并發問題
"協程是輕量級的線程",相信大家不止一次聽到這種說法。但是您真的理解其中的含義嗎?恐怕答案是否定的。接下來的內容會告訴大家 協程是如何在 Android 運行時中被運行的 ,它們和線程之間的關系是什么,以及在使用 Java 編程語言線程模型時所遇到的 并發問題 。
協程和線程
協程旨在簡化異步執行的代碼。對于 Android 運行時的協程, lambda 表達式的代碼塊會在專門的線程中執行 。例如,示例中的斐波那契 運算:
- // 在后臺線程中運算第十級斐波那契數
- someScope.launch(Dispatchers.Default) {
- val fibonacci10 = synchronousFibonacci(10)
- saveFibonacciInMemory(10, fibonacci10)
- }
- private fun synchronousFibonacci(n: Long): Long { /* ... */ }
上面 async 協程的代碼塊, 會被分發到由協程庫所管理的線程池中執行 ,實現了同步且阻塞的斐波那契數值運算,并且將結果存入內存,上例中的線程池屬于 Dispatchers.Default。該代碼塊會在未來某些時間在線程池中的某一線程中執行,具體執行時間取決于線程池的策略。
請注意由于上述代碼中未包含掛起操作,因此它會在同一個線程中執行。而協程是有可能在不同的線程中執行的,比如將執行部分移動到不同的分發器,或者在使用線程池的分發器中包含帶有掛起操作的代碼。
如果不使用協程的話,您還可以使用線程自行實現類似的邏輯,代碼如下:
- // 創建包含 4 個線程的線程池
- val executorService = Executors.newFixedThreadPool(4)
- // 在其中的一個線程中安排并執行代碼
- executorService.execute {
- val fibonacci10 = synchronousFibonacci(10)
- saveFibonacciInMemory(10, fibonacci10)
- }
雖然您可以自行實現線程池的管理, 但是我們仍然推薦使用協程作為 Android 開發中首選的異步實現方案 ,它具備內置的取消機制,可以提供更便捷的異常捕捉和結構式并發,后者可以減少類似內存泄漏問題的發生幾率,并且與 Jetpack 庫集成度更高。
工作原理
從您創建協程到代碼被線程執行這期間發生了什么呢?當您使用標準的協程 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 會帶來哪些性能上的損失呢?
還好線程池會幫我們解決這些復雜的操作,它會嘗試盡量多地執行任務 (這也是為什么在線程池中執行操作要優于手動創建線程)。協程由于被安排在線程池中執行,所以也會從中受益?;诖耍瑓f程不會阻塞線程,它們反而會掛起自己的工作,因而更加有效。
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 可以幫助大家針對可變變量實現線程安全。
封裝
可變狀態應該屬于并被封裝在類里。該類應該將狀態的訪問操作集中起來,根據應用場景使用同步策略保護變量的訪問和修改操作。
線程限制
一種方案是將讀取和寫入操作限制在一個線程里??梢允褂藐犃谢谏a者-消費者模式實現對可變狀態的訪問。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 運行時的線程模型下依然需要遵循約束條件。所以,使用協程也同樣會出現存在隱患的多線程代碼。所以,在代碼中請謹慎訪問共享的可變狀態。