一、到底什么是协程
我们以一串代码引入:
fun main(){
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
//假装获取网络请求
val result = getResult()
Log.d("MainActivity!", "Result: $result, Thread: ${Thread.currentThread().name}")
}
Log.d("MainActivity!", "Thread: ${Thread.currentThread().name}")
}
//挂起函数
private suspend fun getResult() :String{
withContext(Dispatchers.IO){
delay(100)
}
return "Hello World"
}
首先,在主线程上启动了一个协程,调用 getResult() 来模拟一个网络请求,getResult() 通过 withContext 切换到 IO 线程进行延时操作。接着,在主线程启动另一个协程,打印当前线程的名字。
看一下输出结果:
这段代码就是简单的协程 。 注意一下顺序,都在主线程中,写在前面的代码反而在后面执行了,是不是很神奇在同一个线程中,前面的代码居然暂停了一会儿,然后又突然回来了。
第一次接触协程的,乍一看,肯定是一脸懵逼的。怎么就切换线程了?result 要等待返回结果,为什么不阻塞主线程?这里面的逻辑到底是怎么样的?这些疑问先放着,等读完这篇文章,就恍然大悟了。
result 在主线程,getResult() 在IO 线程中执行,这个就是协程的强大之处:以看似同步的代码写出异步的操作。
Kotlin协程其实就是异步代码的编程模型。与传统的线程和回调相比,它们提供了一种更轻量、更易于理解和维护的方式来处理并发和异步操作。
- 轻量级 线程:协程看起来像是线程,但它们不是。协程是轻量级的,因为它们在底层线程之上运行,可以挂起和恢复,而不会阻塞线程。
- 挂起函数:Kotlin中的挂起函数可以暂停执行,而不会阻塞其所在的线程。当协程执行到挂起函数时,它会挂起协程的执行,释放线程以供其他协程使用。当挂起函数恢复时,协程可以在保存的状态下继续执行。
- 非阻塞:协程在等待某些操作完成时不会阻塞线程,而是会挂起协程自身,让线程可以去执行其他任务。
- 结构化并发:协程提供了一种结构化的方式来处理并发,这意味着你可以创建父子关系的协程,当父协程被取消时,所有子协程也会被取消。
二、非阻塞式挂起
- 挂起:说白了就是这个协程从正在执行它的线程上脱离了,稍后会被切回来(恢复)。也就是说将程序执行流程转移到了其他线程,当前线程并未被阻塞,它先去干别的事情,稍后会继续执行(恢复)。
- 挂起的东西就是挂起函数体也叫协程体。(这里指的就是一串代码)
- 挂起函数:suspend关键字修饰的函数。suspend 本质就是一个标识符,没有实际的作用。
- 挂起函数只能在协程里或者在另一个挂起函数里才能被调用。
runBlocking, launch 和 async除外,它们是特殊的函数,它们会在当前线程创建一个新的协程作用域,因此可以在其内部调用挂起函数,这里先不细说,后面会提到。
- 非阻塞式挂起:就是以看似阻塞的代码,写出非阻塞的效果。
以我们上面的代码为例:
=左边:当前线程=右边:切换之后的线程- 每一次从
当前线程到切换后线程,都是一次协程挂起(suspend) - 这个时候当前的协程,也就是 launch 里面的代码挂起了,暂停了。
- 每一次从
切换后线程到当前线程,都是一次协程恢复(resume)。 - 当到达该恢复的时间点,这里是当网络请求结束,刚刚挂起的协程就要继续执行了。
- 为什么挂起函数要在协程里或者挂起函数里才能被调用呢?
这样保证了挂起函数终归在协程中被调用。这样也就保证了,恢复这一步骤的实现,也就是说这个协程堵住了,它才需要恢复啊。
三、挂起之后怎么办?
- 上面说了,一个协程被挂起之后,也就是将程序执行流程转移到了其他线程,那其他线程呢?怎么指定呢?
这些系统都不知道,所以每开启一个协程都要和他说,这个协程是在哪条线程或线程池上执行的。
调度器CoroutineDispatcher 是 Kotlin 协程框架中的一个核心抽象,它负责调度协程的执行,即决定协程中的代码应该在哪个线程或线程池上运行。CoroutineDispatcher 是协程上下文(CoroutineContext)的一个元素,它定义了协程的并发性管理策略。
CoroutineContext就是类似于 Android 程序的 context 上下文。
- 挂起?挂到哪里了?
协程会被挂起在它所在的协程作用域 (CoroutineScope) 中,确保它们在生命周期结束时得到适当的处理。
如果我们启动了多个协程但是没有一个可以对其进行统一管理的途径的话,就会导致我们的代码臃肿杂乱,甚至发生内存泄露或者任务泄露。为了确保所有的协程都会被追踪,Kotlin 不允许在没有 CoroutineScope 的情况下启动协程。所以我们就说挂起在了一个协程作用域CoroutineScope。
以上得出两个结论就是:协程是需要调度器CoroutineDispatcher和 协程作用域CoroutineScope 的。
四、顶层函数创建协程
协程与协程之间的关系可以简单概括为:挂起函数要在协程里或者挂起函数里才能被调用。
那总得有一个入口,联系起协程和主线程(也就是在主线程中使用协程)。
CoroutineBuilder 协程构建器 ,是协程框架的一部分,提供了启动、管理和控制协程的功能。
上面指出了一个概念:CoroutineScope,任何一个协程都有自己的作用域,所以协程的开启肯定是在CoroutineScope上的。那我们就看一下 kotlin 提供了哪些启动协程的方法。
1.launch 方法
launch 是CoroutineScope 类的成员方法,所以我们总要提供一个CoroutineScope 吧。
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
那就创建一下CoroutineScope 实例,但是他需要一个CoroutineContext 上下文。
有一个叫 GlobalScope 的可以直接使用。
GlobalScope 属于 全局作用域,这意味着通过 GlobalScope 启动的协程的生命周期只受整个应用程序的生命周期的限制,只要整个应用程序还在运行且协程的任务还未结束,协程就可以一直运行。
GlobalScope.launch {
}
日常开发中应该谨慎使用 GlobalScope。
推荐的使用 Dispatchers 提供的 CoroutineContext。
CoroutineScope(context: CoroutineContext) 允许你通过传入一个 CoroutineContext 来创建一个新的协程作用域。传入 Dispatchers.Default 时,实际上是传递了一个包含调度器 Dispatcher.Default 的 CoroutineContext。
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
val scope = CoroutineScope(Dispatchers.Unconfined)
常用的 Dispatchers
Dispatchers.Default
- 用途:适用于计算密集型任务(CPU 密集型)。这是默认的调度器,它会使用一个共享的线程池,通常有与可用 CPU 核心数相同的线程数。
- 特点:它适合做需要大量计算的任务(例如,处理大量数据、计算密集型算法等)。
Dispatchers.Default会根据系统的 CPU 核心数来自动选择合适的线程池大小。
val scope = CoroutineScope(Dispatchers.Default)
2. Dispatchers.IO
- 用途:适用于 I/O 密集型任务(例如,网络请求、文件读写、数据库操作等)。它会为协程提供一个单独的线程池,专门用于执行 I/O 操作。
- 特点:
Dispatchers.IO会创建一个较大的线程池(远大于Dispatchers.Default的线程池),因为 I/O 操作通常会涉及大量等待,比如等待网络响应或磁盘访问。
val scope = CoroutineScope(Dispatchers.IO)
3. Dispatchers.Main
- 用途:适用于 UI 线程。通常在 Android 或桌面应用中使用,用于在主线程(UI 线程)中执行任务。
- 特点:
Dispatchers.Main会确保协程在主线程上运行,因此你可以在协程中更新 UI 元素,处理用户交互等操作。在 Android 中,Dispatchers.Main也与lifecycleScope和viewModelScope结合使用。
val scope = CoroutineScope(Dispatchers.Main)
4. Dispatchers.Unconfined
- 用途:这个调度器不会指定协程在哪个特定的线程中运行,而是在调用挂起函数时继续在原线程执行,直到协程挂起。
- 特点:
Dispatchers.Unconfined在某些情况下可能不符合预期的行为,因为它不保证协程总是在同一个线程上执行。如果协程从一个线程挂起,并且在另一个线程恢复,可能会导致线程切换的意外情况。它通常用于测试或在不关心线程的情况下使用。
val scope = CoroutineScope(Dispatchers.Unconfined)
某些 ktx 库为某些生命周期类提供了自己的 CoroutineScope,例如,ViewModel 有 viewModelScope,Lifecycle 有 lifecycleScope。
在 Android 中使用 lifecycleScope 或 viewModelScope
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 使用 lifecycleScope 启动协程
lifecycleScope.launch {
delay(1000)
println("协程执行完毕")
}
}
}
说了这么多,launch 的特点是怎么样的呢?
launch用于启动一个新的协程,通常在指定的作用域或调度器中运行。它不挂起调用协程,与调用者并发运行。
协程的launch启动是异步的,但启动本身并不会阻塞主线程。在 scope.launch 调用后,协程被调度器安排到适当的线程(如主线程或其他调度器),但是这一过程不会立刻执行协程代码,而是立刻返回并继续执行后续的主线程代码。
2.runBlocking
可以使用 runBlocking 这个顶层函数(不是挂起函数)来启动协程,执行体就包含了一个隐式的 CoroutineScope
public fun <T> runBlocking(context: CoroutineContext =
EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T
runBlocking 内部 启动的协程是非阻塞式的,但runBlocking 本身 会阻塞其所在线程。通常用于在普通函数或测试代码中启动协程,确保协程执行完成后再继续。不推荐在生产代码中使用。
runBlocking {
Log.d("MainActivity!", "runBlocking")
delay(3000)
Log.d("MainActivity!", "runBlocking end")
}
Log.d("MainActivity!", "runBlocking out")
但是 runBlocking 退出的条件是:内部隐式的 CoroutineScope 里面的协程都执行完毕。这与内部不在同一个作用域内的没有关系。
runBlocking {
launch {
repeat(5){
Log.d("MainActivity!", "launch $it")
delay(100)
}
}
GlobalScope.launch{
repeat(10){
Log.d("MainActivity!", "GlobalScope launch $it")
delay(100)
}
}
}
Log.d("MainActivity!", "runBlocking out")
3.async
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T>
async 用于启动协程并期望其返回一个结果。
- 返回类型:返回一个
Deferred<T>对象,Deferred是Job的子类,表示一个有返回值的异步任务,可以通过await()获取结果。 - 用法:适用于需要异步计算并获取返回结果的场景。
private fun main() = runBlocking {
val result = async {
// 异步计算并返回结果
delay(1000)
Log.i("MainActivity!", "Hello, World!")
86
}
Log.i("MainActivity!", "The result is ${result.await()}")
}
在这个例子中,async 启动了一个协程,它返回一个整数 86。我们通过 await() 来获取结果。
await() 会挂起当前协程:
await()是一个挂起函数,它会挂起当前协程直到Deferred完成。因此,它可以用来等待异步任务的结果。
Deferred 只会计算一次:
Deferred对象的结果只能计算一次,因此,如果你多次调用await(),它会直接返回缓存的结果,而不会重新执行协程。
避免直接阻塞主线程:
await()只能在挂起函数中使用,不能直接在主线程中调用。如果你需要在主线程(例如main函数)中使用await(),必须将代码放在runBlocking或其他协程作用域中。
异常处理:
- 如果在
async启动的协程中抛出异常,调用await()时会将该异常重新抛出。
4.三者比较
四、协程之间交互(挂起)
上面的回答解决了怎么在从 ”零“开启一个协程,当然上面的这些方法在协程之间交互的时候依然可以使用,只要有作用域。接下来就说说用于”协程之间交互“的方法。都说是协程中使用了,下面的函数全是挂起函数,这也很好理解,也就是挂起函数将当前协程挂起。
1.withContext
它会挂起当前协程,直到代码执行完毕并返回结果。
withContext 用来 切换执行上下文,但并不会启动新的协程,它只是挂起当前协程并执行指定的任务。 等到新上下文中的代码执行完毕之后,恢复当前协程继续执行后续代码。
withContext(Dispatchers.Unconfined){}
2.coroutineScope
coroutineScope是一个挂起函数,它在当前协程的上下文中创建一个独立的协程作用域,并且确保在该作用域内启动的所有协程都执行完毕后才会继续后续的操作。
private fun main() = runBlocking {
coroutineScope {
launch {
delay(1000)
Log.d("MainActivity!", "Task 1")
}
delay(100)
Log.d("MainActivity!", "Task 2")
}
launch {
delay(100)
Log.d("MainActivity!", "Task 3")
}
Log.d("MainActivity!", "over")
}
3.两者比较
4.总结一下
本片文章提到了有关协程的两个关键的概念:挂起和创建。
它们是理解协程如何工作的关键。我们可以通过理解这两个概念来更好地控制并发、异步任务和线程调度。
1. 协程的创建 (Coroutine Creation)
上面一共提到了五个方法。哪几个方法是能够创建协程的?
只有 launch,runBlocking 和 async 是创建了新的协程。
协程创建是指在程序中启动一个新的协程,它的行为类似于线程,但比线程更轻量、更加高效。协程是在一个协程作用域 (CoroutineScope) 内创建的。常用的创建协程的方法就说上面提到的那三个。
协程的创建过程:
- 调用
launch或async方法时,协程会被创建并调度到指定的线程或调度器上。例如,Dispatchers.Main会将协程调度到主线程,Dispatchers.IO会将协程调度到 IO 线程。 - 这些协程都是 轻量级的,与传统线程相比,它们会更加节省资源,允许在短时间内启动大量协程。
2. 协程的挂起 (Coroutine Suspension)
协程的挂起是指在协程内部调用挂起函数时,当前协程会被暂停,直到挂起的任务完成,然后再继续执行。这与传统的线程阻塞不同,协程的挂起不会阻塞实际的线程,其他协程仍然可以继续执行。