初识协程: 为什么需要它以及如何启动第一个协程

前言

协程是什么?

协程和线程是并列的概念,都用于管理并发。简单来说,协程是 Kotlin 提供的一种并发管理的工具。比如,我们可以使用协程来开启一个后台任务。

不过在 JVM 平台上,Kotlin 协程的底层实际上是通过 Java 的线程来实现的。它在线程的基础上做了一层封装,提供了一套易用的 API 来让我们更好地管理并发的任务。

为什么需要协程?

那么问题来了,为什么不直接使用线程呢?

关键在于:协程能让我们以看似同步代码的线性结构,来编写异步执行的逻辑

比如我们要在后台线程从网络中获取文章和文章的评论,然后在主线程中将响应结果显示在界面(线程切换),传统方式是通过回调来完成的。

当异步任务存在先后依赖时,代码就会进行嵌套,从而形成难以维护的 "回调地狱"。

kotlin 复制代码
fun loadArticleAndCommentsTraditional(articleId: String) {
    fetchArticleAsync(articleId, object : ArticleCallback {
        override fun onSuccess(article: Article) {
            println("文章获取成功: ${article.title}")
            // 第一个回调成功后,发起第二个异步请求
            fetchCommentsAsync(article.id, object : CommentsCallback {
                override fun onSuccess(comments: List<Comment>) {
                    println("评论获取成功,数量: ${comments.size}")
                    displayArticleAndComments(article, comments)
                }

                override fun onError(error: Exception) {
                    println("获取评论失败: ${error.message}")
                    displayError("无法加载评论: ${error.message}")
                }
            })
        }

        override fun onError(error: Exception) {
            println("[Traditional] 获取文章失败: ${error.message}")
            displayError("无法加载文章: ${error.message}")
        }
    })
}

而使用协程,代码只需要这样:

kotlin 复制代码
fun loadArticleAndCommentsCoroutine(articleId: String) {
    appScope.launch { // 在主线程上启动协程
        try {
            val article = fetchArticleCoroutine(articleId) // 调用第一个挂起函数
            println("文章获取成功: ${article.title}")

            val comments = fetchCommentsCoroutine(article.id) // 调用第二个挂起函数
            println("评论获取成功,数量: ${comments.size}")
            
            displayArticleAndComments(article, comments)
        } catch (e: Exception) {
            // 统一进行错误处理
            println("发生错误: ${e.message}")
            displayError("加载失败: ${e.message}")
        }
    }
}

不仅是具有先后顺序的串行任务可以这样做,并行任务也行。比如我们要同时获取用户信息和用户的好友列表,然后将它们的结果进行合并,再更新 UI。

使用协程,只需这样:

kotlin 复制代码
fun fetchUserDataWithCoroutines() {
    viewModelScope.launch {
        // 使用 async 并发启动两个网络请求
        // async 会立即返回一个 Deferred 对象,不会阻塞当前协程
        val userInfoDeferred = async { getUserInfo() }
        val friendsDeferred = async { getUserFriends() }

        // 调用 await() 会挂起当前协程,直到结果返回
        val userInfo = userInfoDeferred.await()
        val friends = friendsDeferred.await()

        // 在主线程,合并结果并更新数据。
        _uiState.value = "协程加载完毕:\n$userInfo\n朋友列表: $friends"
    }
}

整个流程还是很清晰,"用线性结构编写异步代码" 的特性,正是协程核心的优势。并且其他的优势,例如"结构化并发"、"轻量级",也都是围绕这个优势展开的。

协程的启动与线程切换

并发管理从线程的角度上来看,就三件事:切换线程线程间的等待与协作线程安全(互斥锁) 。从协程的角度上来看,也是这三件事,我们先来讲讲切线程

切线程的主要目的通常有两种:

  1. 执行后台任务,避免阻塞当前线程

  2. 切换到 UI 线程更新界面。

协程的切线程:CoroutineScopelaunch

首先,引入协程的依赖:

kotlin 复制代码
// build.gradle.kts
dependencies{
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") // Android应用才需要
}

在协程中,切线程非常简单,只需这样即可:

kotlin 复制代码
val scope = CoroutineScope(context = EmptyCoroutineContext)
scope.launch {
    // ... 执行后台任务 ... 
}

CoroutineScope (协程作用域) 的作用和线程中的 ExecutorService 类似,但它的功能更广泛。它定义了协程的生命周期和上下文。

创建该对象时,需要传入一个 CoroutineContext 来提供了启动协程需要的上下文信息,这里我们填入的是 EmptyCoroutineContext,表示不提供任何特定的配置,在这种情况下,协程默认会使用 Dispatchers.Default 线程池来执行任务。

调用 CoroutineScope.launch 函数即可启动一个新的协程,它不会返回结果(只管"发射")。效果和 Executor.execute 方法类似,会将代码块放到指定的线程或线程池中执行。

协程中的线程管理

协程中管理线程的工具叫做 ContinuationInterceptor,意思是在协程执行过程中拦截并切换线程。协程具体在哪个线程上执行,是由它的实现类 CoroutineDispatcher (协程调度器) 决定的,它内部提供了全局的线程池来管理任务。

协程提供了四个可用的 CoroutineDispatcher

kotlin 复制代码
public actual object Dispatchers {
    @JvmStatic
    public actual val Default: CoroutineDispatcher = DefaultScheduler

    @JvmStatic
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher

    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined

    @JvmStatic
    public val IO: CoroutineDispatcher get() = DefaultIoScheduler
    
    // ...
}
  • Dispatchers.Default

    如果 CoroutineScopelaunch 没有指定 CoroutineDispatcher,默认启动的协程会使用 Dispatchers.Default 来调度任务。

    它适合处理计算密集型任务,比如图片压缩、加/解密运算。

    线程池的大小与 CPU 核心数相同,因为这类任务会占满 CPU,过多的线程反而会因切换开销降低效率。

  • Dispatchers.IO

    它适合处理 I/O 密集型任务,比如文件读写、网络请求。I/O 就是与内存外的外部设备(磁盘、网络)进行交互。

    线程池默认 64 个线程,因为在执行这类任务时,CPU 大部分时间都在等待磁盘或网络返回数据,并不在进行计算。因此,一个更大的线程池能让更多的 IO 任务同时等待,最终极大提升并发吞吐量(空闲的 CPU 完全有能力调度管理超过其核心数的线程)。

  • Dispatchers.Main

    它会将任务调度到 UI 线程执行。

    注意:在后端程序中使用它会出错,因为服务器不存在切到主线程的需求。

  • Dispatchers.Unconfined

    它是一个不限制的 Dispatcher,不进行的任何线程管理:

    • 协程启动时,会在当前线程中直接执行代码。

    • 协程内部,从挂起函数恢复时,不会切回原来的线程。

    挂起函数:是一种可以在协程内部,再次切换线程的函数。

怎么设置 Dispatcher 呢?

你可以在创建 CoroutineScope 时指定,比如:

kotlin 复制代码
val ioScope = CoroutineScope(Dispatchers.IO)
ioScope.launch {
    // 协程默认都在 Dispatchers.IO 的线程上运行
    println("IO Scope: ${Thread.currentThread().name}")
}

你也可以在 launch 时指定:

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch(Dispatchers.Main) {
    // 协程在主线程上运行
    println("Main Dispatcher Launch: ${Thread.currentThread().name}")
}

如果两者都设置了,launchDispatcher 会覆盖 CoroutineScope 中设置的。

如果你需要自定义线程池作为 Dispatcher,你可以这样:

kotlin 复制代码
val fixedThreadPoolContext =
    newFixedThreadPoolContext(nThreads = 10, name = "Fixed-Thread") // 固定大小的线程池
val fixedScope = CoroutineScope(context = fixedThreadPoolContext)
fixedScope.launch {
    println("Fixed Thread Pool Dispatcher Launch: ${Thread.currentThread().name}")
}
// 在确认不再需要时关闭
fixedThreadPoolContext.close()

val singleThreadContext = newSingleThreadContext(name = "Single-Thread") // 单线程的线程池
val singleScope = CoroutineScope(context = singleThreadContext)
singleScope.launch {
    println("Single Thread Dispatcher Launch: ${Thread.currentThread().name}")
}
// 在确认不再需要时关闭
singleThreadContext.close()

这两个函数都加上了 @DelicateCoroutinesApi 注解,告诉我们需要小心使用。因为它内部会创建线程池,我们需要手动管理它的生命周期,在不用时调用 close() 方法关闭线程池。

注意:在实际使用中,应该在确认不再需要该线程池时,再调用 close(),否则某些协程任务将不被执行。

挂起函数

协程相比线程最大的优势就是挂起函数(Suspend Functions),在我们进行线程切换后,还能切回原来的线程,继续执行后续的代码。

例如:我们要在网络请求后,回到主线程更新 UI,协程只需这样:

kotlin 复制代码
interface UserService {
    @GET("user/{id}")
    suspend fun getUserInfo(@Path(value = "id") id: Int): User // 挂起函数
}

CoroutineScope(context = Dispatchers.Main).launch {
    // 挂起,在后台执行网络请求
    val user = userService.getUserInfo(1)
    // 恢复,回到主线程更新 UI
    showUserInfo(user)
}

为什么在同一个协程中,两行代码却执行在不同的线程?

关键在于 getUserInfo 函数声明中的 suspend 关键字,一个函数使用 suspend 关键字后,就会变为挂起函数,suspend 有暂停、挂起的意思。

调用该函数时,该函数所在的协程会被挂起,也就是当前协程与主线程脱离了,不再占用着主线程,让它可以继续响应用户操作,比如被其他协程使用。

挂起函数会在指定的后台线程执行,比如当前的网络请求会在 Retrofit 内部的后台线程中执行。当网络请求结束后,协程又会恢复。会自动切回该协程所在的线程(主线程),然后执行后续代码(更新 UI)。

不过要注意,suspend 只是一个标记,告诉编译器这是可被挂起的函数。真正的后台线程切换,是由函数的具体实现来完成的。suspend 机制让后台切换过程对于调用者来说是透明的,让代码逻辑非常连贯。

挂起函数只能在协程代码块或是另一个挂起函数中调用,否则会报错。

这也是十分合理的,挂起函数的意义在于让当前协程暂时脱离它所在的线程去执行耗时操作,等操作完成后,协程会自动切回原来的线程继续执行。如果在协程外部调用,是没有意义的。

Android 中使用协程

在真实的客户端项目中,协程使用的推荐模式是:在主线程启动协程,在协程内部通过调用挂起函数切到后台执行耗时操作,挂起函数执行完成后(协程回到主线程),我们安全地更新 UI

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Main)
scope.launch { // 从主线程开始
    val userData = fetchUserData() // 挂起函数,内部会切换到 IO 线程
    binding.userNameTextView.text = userData.name // 回到主线程更新UI
}

suspend fun fetchUserData(): User {
    return withContext(Dispatchers.IO) {
        // ... 执行网络请求 ...
        User("Fetched User")
    }
}

并且在 Android 项目中,我们不会像之前那样,手动创建 CoroutineScope,而是会使用 Jetpack KTX 库中预置的 CoroutineScope

kotlin 复制代码
// LifecycleOwner.kt
public interface LifecycleOwner {
    public val lifecycle: Lifecycle
}

public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
    get() = lifecycle.coroutineScope

我们可以在 LifecycleOwner 接口的实现类 ComponentActivityFragment 中,使用 lifecycleScope 扩展属性。比如,我们可以在 MainActivity 中直接使用 lifecycleScope.launch { ... }

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            // ... 执行耗时操作 ...
            // 更新 UI
        }
    }
}

lifecycleScope 会和所属的 LifecycleOwner 的生命周期绑定,当 LifecycleOwner 销毁时,lifecycleScope 所启动的协程也会被自动取消。

另外,lifecycleScope 的调度器使用的是 Dispatchers.Main.immediate,它和 Dispatchers.Main 都表示主线程,但性能有优化。区别在于:

  • Dispatchers.Main:无论当前在哪个线程,总是会通过 Handler.post 将任务推到主线程的消息队列中去执行。

  • Dispatchers.Main.immediate:会检查当前是否已在主线程,如果是,会立即同步执行任务,避免出入队的开销;如果不是,则会像 Dispatchers.Main 一样分发任务。

因此,Dispatchers.Main.immediate 是一个性能优化的主线程调度器。

当然,数据的处理更多发生在 ViewModel,而 lifecycle-viewmodel-ktx 库提供了 viewModelScope。它的生命周期也与 ViewModel 的生命周期绑定,同样,它默认的 Dispatcher 也是 Dispatchers.Main.immediate。(虽然 ViewModel 并不直接操作 UI,但它持有的数据通常用于更新 UI)

"轻量级线程"

Kotlin 官方文档中有提到:"协程是轻量级的线程"。官方给出了一个示例,用来展示协程比线程更加节省资源:

kotlin 复制代码
// 协程版本:轻松运行
fun main() = runBlocking { // runBlocking 创建一个主线程的协程作用域并阻塞当前线程直到其内部协程完成
    repeat(50_000) { // 启动 50000 个协程
        launch {
            delay(5000L) // 每个协程延迟5秒
            print(".")
        }
    }
}
kotlin 复制代码
// 线程版本:导致内存溢出
fun main() {
    repeat(50_000) { // 尝试启动 50000 个线程
        thread {
            Thread.sleep(5000L) // 每个线程阻塞5秒
            print(".")
        }
    }
}

使用线程的话,会造成 OOM,貌似协程确实更加轻量?

其实,在协程的例子中,runBlocking 通常创建的是单线程的调度器,所以这 50_000 个协程并不是在 50_000 个线程上运行,而是只由一个线程通过时间分片来调度。并且,delay() 是一个非阻塞式的挂起函数,它不会卡住线程,所以能让单线程调度多个延时任务。

而在线程的例子中,代码确实会尝试创建 50_000 个操作系统线程,并且 Thread.sleep(5000L) 会阻塞每一个线程 5 秒,这当然会耗尽系统资源。

最后,对于延时任务,正确的做法应该使用 ScheduledExecutorService

kotlin 复制代码
// 正确的线程版本:同样轻松运行
fun main() {
    val scheduler = Executors.newSingleThreadScheduledExecutor()
    repeat(50_000) {
        scheduler.schedule({
            print(".")
        }, 5, TimeUnit.SECONDS)
    }
    // 释放资源 
    // scheduler.close() 或 scheduler.shutdown()
}

可以看到,只要有正确使用线程 API,也能实现协程效果并且不会内存溢出。所以,官方的示例并不公平。

协程之所以轻量,是因为我们使用协程,能够很容易地编写出高性能的、非阻塞式的并发代码。

相关推荐
文阿花3 小时前
flutter 3.22+ Android集成高德Flutter地图自定义Marker显示
android·flutter
豆豆豆大王3 小时前
Android studio图像视图和相对布局知识点
android·ide·android studio
heeheeai4 小时前
Kotlinx Serialization 指南
kotlin·序列化
我命由我123454 小时前
Android 实例 - Android 圆形蒙版(Android 圆形蒙版实现、圆形蒙版解读)
android·java·java-ee·android studio·安卓·android-studio·android runtime
天若有情6735 小时前
【Android】Android项目目录结构及其作用
android
灿烂阳光g5 小时前
Android Automotive OS架构
android
一碗情深6 小时前
Android 开发环境解析:从SDK、NDK到版本兼容性指南
android·安卓·sdk·ndk
00后程序员张6 小时前
App 上架全流程指南,iOS 应用发布步骤、ipa 文件上传工具、TestFlight 分发与 App Store 审核经验分享
android·ios·小程序·https·uni-app·iphone·webview
2501_916013747 小时前
iOS App 上架流程详解,苹果应用发布步骤、App Store 审核规则、ipa 文件上传与测试分发实战经验
android·ios·小程序·https·uni-app·iphone·webview