2
头图

"Coroutine is a lightweight thread" , I believe you have heard this statement more than once. But do you really understand the meaning? I'm afraid the answer is no. The following content will tell you how the coroutine is run in the Android runtime , what is the relationship between them and threads, and the concurrency problem encountered when using the Java programming language thread model.

coroutine and thread

Coroutines are designed to simplify code that executes asynchronously. For the Android runtime coroutine, the code block of the in a dedicated thread. For example, the Fibonacci operation in the example:

// 在后台线程中运算第十级斐波那契数
someScope.launch(Dispatchers.Default) {
    val fibonacci10 = synchronousFibonacci(10)
    saveFibonacciInMemory(10, fibonacci10)
}

private fun synchronousFibonacci(n: Long): Long { /* ... */ }

The code block of the above async coroutine, will be distributed to the thread pool managed by the coroutine library to execute , which realizes the synchronized and blocked Fibonacci numerical calculation, and stores the result in the memory. The thread pool belongs to Dispatchers.Default. The code block will be executed in a thread in the thread pool at some time in the future, and the specific execution time depends on the strategy of the thread pool.

Please note that since the suspend operation is not included in the above code, it will be executed in the same thread. The coroutine may be executed in different threads, such as moving the execution part to a different dispatcher, or including code with suspended operations in a dispatcher that uses a thread pool.

If you don't use coroutines, you can also use threads to implement similar logic yourself. The code is as follows:

// 创建包含 4 个线程的线程池
val executorService = Executors.newFixedThreadPool(4)
 
// 在其中的一个线程中安排并执行代码
executorService.execute {
    val fibonacci10 = synchronousFibonacci(10)
    saveFibonacciInMemory(10, fibonacci10)
}

Although you can manage the thread pool by we still recommend using coroutine as the preferred asynchronous implementation in Android development. has a built-in cancellation mechanism that can provide more convenient exception capture and structured concurrency. The latter can Reduce the chance of similar memory leaks, and have a higher integration with the Jetpack library.

working principle

What happened from the time you created the coroutine to when the code was executed by the thread? When you use the standard coroutine builder to create a coroutine, you can specify the CoroutineDispatcher that the coroutine runs. If not specified, the system will use Dispatchers.Default default.

CoroutineDispatcher will be responsible for allocating the execution of the coroutine to the specific thread . At the bottom, when CoroutineDispatcher is called, it will call encapsulated Continuation (such as the coroutine here) interceptContinuation method to intercept the coroutine. The process is based on the premise that CoroutineDispatcher implements the CoroutineInterceptor interface.

If you read my previous article about how at the bottom, you should already know that the compiler will create a state machine, and the relevant information about the state machine (such as the next operation to be performed) is Stored in the Continuation object.

Once Continuation objects need to be performed in another Dispatcher, DispatchedContinuation of resumeWith method will be responsible for the coordination process distributed to the appropriate Dispatcher.

In addition, in the implementation of the Java programming language, inherits from the DispatchedContinuation DispatchedTask 160bedd3e89ec4 abstract class, also belongs to an implementation type of the Runnable Therefore, the DispatchedContinuation object can also be executed in a thread. The advantage is that when CoroutineDispatcher is specified, the coroutine will be converted to DispatchedTask and Runnable in the thread as 060bedd3e89ebe.

So when you create a coroutine, how is the dispatch When you use the standard coroutine builder to create a coroutine, you can specify the startup parameter, and its type is CoroutineStart . For example, you can set the coroutine to start when needed, and then set the parameter to CoroutineStart.LAZY . By default, the system uses CoroutineStart.DEFAULT according to CoroutineDispatcher be scheduled execution time.

△ 协程的代码块如何在线程中执行的示意图

△ Schematic diagram of how the code block of the coroutine is executed in the thread

distributor and thread pool

You can use the Executor.asCoroutineDispatcher() extension function to convert the coroutine to CoroutineDispatcher , and then the coroutine can be executed in any thread pool in the application. In addition, you can also use the default Dispatchers coroutine library.

You can see how the createDefaultDispatcher method initializes Dispatchers.Default . By default, the system will use DefaultScheduler . If you look at the implementation code of Dispatcher.IO DefaultScheduler , which supports the creation of at least 64 threads on demand. Dispatchers.Default and Dispatchers.IO are implicitly related, because they use the same thread pool, which brings us to our next topic, what runtime overhead will be caused by using different dispatchers to call withContext?

thread and withContext performance

In the Android runtime, if there are more threads running than the number of cores available in the CPU, switching threads will bring a certain runtime overhead. Context switching is not easy! The operating system needs to save and restore the execution context, and the CPU needs to spend time planning threads in addition to performing actual application functions. In addition, when the code running in the thread is blocked, it will also cause a context switch. If the above problem is for threads, what performance loss will be caused by using withContext in different Dispatchers?

Fortunately, the thread pool will help us solve these complex operations, it will try to perform as many tasks as possible (this is why performing operations in the thread pool is better than manually creating threads). Because the coroutine is scheduled to execute in the thread pool, it will also benefit from it. Based on this, coroutines will not block threads, but they will suspend their work, which is more effective.

The thread pool used by default in the Java programming language is CoroutineScheduler . It distributes the coroutine to the worker thread in the most efficient way. Since Dispatchers.Default and Dispatchers.IO use the same thread pool, switching between them will try to avoid thread switching. The coroutine library will optimize these switching calls, keep them on the same dispatcher and thread, and try to take shortcuts.

Since Dispatchers.Main usually belongs to different threads in applications with UI, the switch between Dispatchers.Default and Dispatchers.Main in the coroutine will not bring too much performance loss, because the coroutine will hang ( For example, stop execution in a thread), and then will be scheduled to continue execution in another thread.

Concurrency issues in the

Coroutines do make asynchronous programming easier because they can simply plan operations on different threads. But on the other hand, convenience is a double-edged sword: Because coroutines run on the threading model of the Java programming language, it is difficult for them to escape the concurrency problems caused by the threading model . Therefore, you need to pay attention and try to avoid this problem.

In recent years, strategies such as immutability have relatively alleviated the problems caused by threads. However, in some scenarios, the immutability strategy cannot completely avoid the problem. The source of all concurrency problems is state management! Especially in a multi-threaded environment to access variable state .

In a multithreaded application, the execution order of operations is unpredictable. Unlike the execution order of compiler optimization operations, threads cannot be guaranteed to execute in a specific order, and context switches can happen at any time. If the necessary precautions are not taken when accessing the mutable state, the thread will access outdated data, lose updates, or encounter resource competition issues, and so on.

Please note that the variable state and access sequence discussed here are not limited to the Java programming language. They also affect the execution of coroutines on other platforms.

Applications that use coroutines are essentially multi-threaded applications. uses a coroutine and classes involving variable states must take measures to make it controllable , such as ensuring that the data accessed by the code in the coroutine is up to date. In this way, different threads will not interfere with each other. Concurrency issues can cause potential bugs, making it difficult for you to debug and locate the problem in the application, and even Heisenberg bug .

This type of class is very common. For example, this class needs to cache the user's login information in memory, or cache some values when the application is active. If you are a little careless, then the concurrency problem will take advantage of it! The suspend function using withContext(defaultDispatcher) cannot be guaranteed to be executed in the same thread.

For example, we have a class that needs to cache transactions made by users. If the cache is not accessed correctly, as shown in the following code, there will be concurrency problems:

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))
      }
    }
}
Even if we are talking about Kotlin here, "Java Concurrent Programming Practice" compiled by Brian Goetz is a very good reference material for understanding the subject of this article and the Java programming language system. In addition, Jetbrains for variable share of state and concurrency theme also provides relevant documents.

protect variable state

How to protect the variable state, or find a suitable synchronization ) strategy, depends on the data itself and related operations. The content in this section inspires everyone to pay attention to concurrency issues that may be encountered, rather than simply listing methods and APIs to protect mutable state. All in all, here are some tips and APIs for you to help you achieve thread safety for mutable variables.

package

Mutable state should belong to and be encapsulated in the class. This class should centralize state access operations, and use synchronization strategies to protect variable access and modification operations based on application scenarios.

thread limit

One solution is to limit read and write operations to one thread. You can use queues to achieve access to variable states based on the producer-consumer model. Jetbrains provides a great document .

avoid duplication of work

In the Android runtime, data structures that contain thread safety allow you to protect mutable variables. For example, in the counter example, you can use AtomicInteger . For another example, to protect the Map in the above code, you can use ConcurrentHashMap . ConcurrentHashMap is thread-safe and optimizes the throughput of map read and write operations.

Please note that thread-safe data structures do not solve the problem of calling order, they just ensure that access to memory data is atomic. When the logic is not too complicated, they can avoid the use of lock. For example, they cannot be used in the transactionCache example above, because the order of operations and logic between them requires the use of threads and access protection.

Moreover, when the modified objects have been stored in these thread-safe data structures, the data in them needs to be kept immutable or protected to avoid resource contention problems.

custom solution

If you have complex operations that need to be synchronized, @Volatile and thread-safe data structures will not be effective. It is possible @Synchronized annotation is not enough to achieve the desired effect.

In these cases, you may need to use concurrency tools to create your own synchronization mechanism, such as latches , semaphores ) or barriers ). In other scenarios, you can use lock ) and mutex to unconditionally protect multi-threaded access.

The Mute Kotlin contains the suspend function lock and unlock , which can manually control the code of the protection coroutine. The extension function Mutex.withLock makes it easier to use:

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))
        }
      }
    }
}

Because the coroutine using Mutex will suspend the operation before it can continue, it is much more efficient than the lock in the Java programming language, because the latter will block the entire thread. Please be careful to use the synchronization classes in the Java language in the coroutine, because they will block the thread where the entire coroutine is located and cause the activity problem.

The code passed into the coroutine will eventually be executed in one or more threads. Similarly, the coroutine still needs to follow the constraints under the threading model of the Android runtime. Therefore, the use of coroutines will also have hidden multi-threaded code. Therefore, please be careful to access shared mutable state in your code.


Android开发者
404 声望2k 粉丝

Android 最新开发技术更新,包括 Kotlin、Android Studio、Jetpack 和 Android 最新系统技术特性分享。更多内容,请关注 官方 Android 开发者文档。