kotlin协程-基础概念篇

种一颗树的最好时机是十年前,其次是现在。 学习也一样。 跟着霍老师的《深入理解Kotlin携程》学习一下协程。

协程是什么

当我们说到协程的时候,总会提到它是轻量级的用户态线程,能够挂起恢复 ,也就是允许函数在执行过程中主动让出控制权,并在之后从断点继续执行。 从这里我们可以看出协程的一些特点: 用户态控制: 协程的调度完全由程序自身(开发者)控制,而非操作系统内核,因此切换开销远小于线程。 协作式调度: 协程通过显式的挂起(如yield、await)和恢复操作交出执行权,同一时间只有一个协程在运行,不存在线程的抢占式调度冲突。 状态保存: 协程挂起时会保存自身的执行上下文(如局部变量、程序计数器等),恢复时可从挂起点继续执行,无需重新初始化。

为什么要有协程

其实这个问题和协程解决了什么问题是一样的,个人粗浅的简介,协程解决了如下两个大方面的问题:

异步编程的复杂性

这里复杂性是指我们写代码的复杂性和代码阅读的困难性。 当我们进行网络请求或者其他IO密集型的任务(文件读写、数据库操作)时,通常情况不会 傻傻地等待任务结束,浪费cpu资源,传统的解决方案就是回调函数(callback),但这导致了著名的"回调地狱"(Callback Hell),代码嵌套深,难以阅读和维护。 但协程通过"同步的方式写异步的代码",解决了这个问题。代码看起来是顺序执行的,但实际上是异步非阻塞的。 举个例子

javascript// 复制代码
httpRequest.get("url", function(response) {
    response.on("data", function(data) {
        // 处理数据,如果再嵌套其他异步操作...
    });
});

// 使用 Async/Await(假设语法上允许这么写,只是做个示例)
async function fetchData() {
    let response = await httpRequest.get("url"); // 挂起,等待结果,但不阻塞线程
    let data = await response.json(); // 再次挂起等待
    return process(data);
}

轻量级并发

那么它在哪?

核心比喻:公司与员工

  • 进程 :一个公司。拥有独立的办公场地、仓库、资金(对应:独立的内存空间、系统资源)。
  • 线程 :公司的正式员工
    • 每个员工的招聘和解雇(创建和销毁)需要经过HR和行政的复杂流程,成本高(系统调用,内核介入)。
    • 员工之间的工作切换(上下文切换)需要开会、交接任务,也很耗时(内核态切换,保存大量寄存器状态)。
    • 员工数量太多时,管理开销会变得巨大(系统调度器负载过重)。
  • 协程 :公司为了完成一个特定项目而临时组建的"团队"或"工作小组"
    • 创建和解散:经理一句话就能组建或解散,完全在公司内部完成,成本极低(用户态创建,无需内核参与)。
    • 工作切换:小组内的工作交接非常简单。比如,A说"我做到这里,先等你把数据给我",然后B就开始做。交接只在小组内部记录一下,不需要惊动全公司(用户态切换,保存少量上下文)。
    • 数量:一个公司可以轻松拥有成千上万个这样的临时小组,而正式员工的数量可能只有几百个。

"轻量"的具体体现(技术层面)

基于上面的比喻,我们来看具体"轻"在哪儿:

1. 创建和销毁的成本:极低
维度 线程 协程
操作者 操作系统内核 用户程序/运行时库
系统调用 需要(如 clone, pthread_create 不需要
内存分配 需要为栈、线程控制块等在内核/用户空间分配内存(通常为 MB 级别,如Linux默认8MB) 通常在堆上预分配或动态分配一小块内存(通常为 KB 级别,如2KB)
速度 慢(毫秒级,ms) 极快(纳秒级,ns,通常比线程快100-1000倍)

结论 :你可以轻松创建数万甚至数十万个协程,而创建同样数量的线程会直接耗尽你机器的内存和CPU资源。

2. 上下文切换的开销:极低

这是协程性能优势最关键的一点。

维度 线程切换 协程切换
切换者 操作系统内核(调度器) 用户程序/运行时库(协程调度器)
模式切换 需要从用户态 切换到内核态,再切回用户态。这个操作本身就有CPU开销。 完全在用户态完成,没有模式切换的开销。
保存的上下文 非常多。包括:通用寄存器、浮点寄存器、状态寄存器、栈指针、程序计数器、内存映射信息等。需要保存整个CPU现场。 非常少 。通常只保存必要的寄存器(如程序计数器、栈指针)和少量局部变量。
缓存影响 可能很大。线程被切换到不同CPU核心时,CPU缓存(L1/L2/L3)可能失效,导致性能下降。 很小。协程通常在同一个线程内切换,对CPU缓存友好。

结论:协程的切换就像是自己收拾一下桌面,准备做另一件事;而线程切换就像是整个公司开会,宣布换一个项目组来用这个办公室。

3. 栈内存管理:灵活且节省
  • 线程 :每个线程都有自己固定大小的栈内存(如8MB)。这块内存在线程创建时就被保留,即使线程只用了很少的栈空间(比如几十KB),这8MB也被占着。
  • 协程 :协程的栈通常是动态增长 的,或者是在堆上分配的一小块内存。开始时可能只有2KB,如果不够用,可以再申请。这使得数千个协程的总内存占用远小于同样数量的线程。
4. 调度方式:协作式 vs 抢占式
  • 线程(抢占式):操作系统调度器会在任何时候(通过时钟中断)强制暂停当前线程,切换到另一个线程。这保证了"公平",但增加了不确定性,并且切换时机不可预测。
  • 协程(协作式) :协程主动 通过 yieldawait 等关键字自愿 交出执行权。调度器只在协程挂起时才会进行切换。
    • "轻"在哪里?
      1. 无需复杂的同步原语:因为切换点是明确的,所以在协程内部,非挂起点的代码段是"原子性"的,不需要加锁来保护。这简化了编程,也避免了锁的开销。
      2. 切换时机可控:调度器只在协程"愿意"的时候才工作,没有强制中断带来的额外上下文保存开销。

总结表格

特性 线程 协程 "轻"在何处
创建/销毁 重量级,需内核介入 轻量级,用户态完成 无系统调用,内存占用小,速度快
上下文切换 重量级,需陷入内核 轻量级,完全在用户态 无模式切换,保存的上下文极少
栈内存 固定大小(MB级) 动态/小块堆内存(KB级) 内存利用率高,总占用小
调度 抢占式,由内核强制 协作式,由协程主动让出 切换时机明确,无需锁,开销可控
数量级 成百上千 成千上万 资源开销小,可创建数量多

小提醒

虽然经常听到"协程是轻量级的线程"这个说法,虽然直观,但容易引起误解。更准确的理解是:

协程是运行在线程之上的、由用户程序控制的、更高级的并发抽象。一个线程可以同时运行多个协程,由协程调度器负责在它们之间进行高效的、用户态的切换。

它的"轻",归根结底是因为它将调度的权力从操作系统内核(重量级)夺回,交给了用户程序的运行时(轻量级),从而避免了所有与内核交互带来的巨大开销。

协程分类

这个对于应用开发者来讲并不重要,了解一下就好

按调用栈分类

这个分类关注的是协程在执行过程中如何管理其栈内存,它直接决定了协程的通用性和实现复杂度。

栈式协程

有的也叫有栈协程

  • 核心特征 :拥有独立的调用栈,类似于线程。
  • 工作方式:当一个栈式协程挂起时,它会将其完整的调用栈(包括嵌套调用的函数链)保存起来。恢复时,整个调用栈被还原,可以从最内层的函数继续执行。
  • 能力:非常强大。可以在任意深度的函数调用中挂起。
  • 比喻:就像用录像机完整地录下了一部话剧的整个排练现场(包括所有演员的位置和状态),随时可以按播放键从中断处继续排练。
  • 优点
    • 使用方便:程序员无需关心当前执行点在哪一层函数,可以任意进行函数调用,并在任何地方挂起。
    • 功能强大:支持通用的、任意嵌套的挂起操作。
  • 缺点
    • 内存开销大:每个协程都需要预分配一块足够大的栈内存以防溢出,即使它大部分时间只使用很少的栈空间。当协程数量极多时,总内存消耗可观。
    • 实现复杂:需要管理栈内存的分配、增长和切换。

无栈协程

  • 核心特征没有独立的调用栈 。其状态通过状态机在堆上显式地存储。
  • 工作方式 :编译器会将一个可挂起的函数(如 async 函数)编译成一个状态机。挂起时,只保存必要的局部变量(通常是 await 表达式之前的那些)和当前状态机的状态(如 label),而不是整个调用栈。恢复时,根据保存的状态跳转到对应的 label 继续执行。
  • 能力 :挂起点是受限的,通常只能在协程体顶层函数中显式地使用 awaityield 挂起。你不能在一个未被声明为 async 的深层函数内部随意挂起。
  • 比喻 :就像一本选择你自己的冒险书。你不需要记住所有页面的状态,只需要一个书签(当前状态)和记录下你之前做过的几个关键选择(局部变量)。翻到指定页码,根据选择继续故事。
  • 优点
    • 极其轻量:内存开销极小,通常只有一个对象的大小,创建和切换开销极低。
    • 实现简单:无需复杂的栈管理逻辑,非常适合在语言库层面实现。
    • 与异步IO天然契合:其"传染性"(async/await语法)迫使程序员显式标记挂起点,使得代码的异步流程非常清晰。
  • 缺点
    • 使用受限 :不能在任意函数调用中挂起,所有可能挂起的调用链都必须被标记为 async 并使用 await。这被称为"颜色问题"。
    • 传染性 :一旦一个函数内部需要挂起,它自身也必须被标记为 async,这会影响到它的所有调用者。

按调度方式分类

这个分类关注的是由谁来决定何时挂起当前协程并切换到另一个。

1. 对称协程

  • 核心特征所有协程是平等的 。任何一个协程都可以主动将执行权直接交给另一个指定的协程。
  • 工作方式 :协程A通过一个类似 transfer(B) 的操作,直接切换到协程B。调度逻辑分散在每个协程中。
  • 比喻 :在一个圆桌会议上,任何一个人发言完毕后,可以指定下一位发言人。
  • 优点
    • 灵活:可以实现非常复杂的控制流。
  • 缺点
    • 控制流复杂:由于调度权分散,程序的控制流可能会变得难以理解和维护,容易形成"意大利面条式"的代码。

2. 非对称协程

  • 核心特征协程之间存在调用关系 ,类似于函数调用。提供了两个核心原语:yield(挂起)和 resume(恢复)。
  • 工作方式 :协程B由协程A(或主程序)resume 启动。当B执行 yield 时,它不会指定切换给谁,而是无条件地将控制权返还给恢复它的那个协程A
  • 关系 :这建立了一种半对称的调用者-被调用者关系yieldresume 是配对的。
  • 比喻老板与员工 。老板(调用者)让员工(协程)去完成一项任务(resume)。员工在过程中可以多次回来向老板汇报进度并等待下一步指示(yield),但员工不能直接把任务甩给另一个同事。
  • 优点
    • 控制流清晰:结构类似于函数调用,非常易于理解和推理。生产者-消费者模型用非对称协程实现起来非常直观。
    • 主流选择:更符合大多数编程场景的直觉。
  • 缺点
    • 灵活性不如对称协程。

分类总结与现实世界的映射

分类维度 类型 核心特征 典型代表
调用栈 栈式协程 有独立栈,可在任意函数深度挂起 Go (Goroutine), C++20
无栈协程 通过状态机实现,挂起点受限 JS/Python/C# (async/await), Rust
调度方式 对称协程 协程间直接、平等地切换 早期 Lua
非对称协程 通过 yield/resume 与调用者交互 大多数现代语言 (Python生成器, JS async/await, Kotlin)

重要提示:这两种分类是正交的。一个协程系统可以是:

  • 栈式 + 非对称 :如 Go (Goroutine的调度是Go运行时负责的,但对程序员呈现的 channel 通信模型更像非对称)。
  • 无栈 + 非对称 :如 JavaScript、Python、C#async/await。这是目前最流行的组合,因为它实现了轻量级和清晰控制流的平衡。
  • 栈式 + 对称:一些研究性的或特定领域的协程实现。

以上就是我们需要关注的协程概念了,下一篇我们来看下协程的基础设施

相关推荐
无知的前端8 小时前
一文精通-Kotlin中双冒号:: 语法使用
android·kotlin
Huang兄8 小时前
kotlin协程-基础设施篇-协程创建与启动:SafeContinuation
kotlin
Merrick11 小时前
从 Java 到 Kotlin 的入门学习
kotlin
阿健君13 小时前
Java/Kotlin 泛型
kotlin
来来走走15 小时前
kotlin学习 基础知识一览
android·开发语言·kotlin
雨白20 小时前
StateFlow 与 SharedFlow:在协程中管理状态与事件
android·kotlin
tangweiguo030519871 天前
ProcessLifecycleOwner 完全指南:优雅监听应用前后台状态
android·kotlin
消失的旧时光-19431 天前
Kotlin reified泛型 和 Java 泛型 区别
java·kotlin·数据
雨白2 天前
Flow 的异常处理与执行控制
android·kotlin