Java 协程

目录

协程本质上和线程是一类概念,本质上是编程过程中对并发计算任务的一种抽象,只不过协程在调度层面上更轻量。

线程的调度由于是操作系统实施的,有时间片/中断等较为复杂的机制,因此调度点对用户是透明的,可以认为调度理论上可以在任何地方触发。而协程的调度点往往是用户代码显式通过触发的(发生在用户态),需要用户代码自己相互“协作”来完成任务的调度和执行,这也是协程中”协“的来源。

一、Java 线程 & 协程

在 Java 中,每个线程都会映射到一个内核级线程,线程的调度是由操作系统内核负责的;对于协程来说,协程的切换仅仅是应用层的切换,不会深入内核。

Java 线程、协程与OS线程映射关系

二、协程的实现机制

从上下文切换方面,协程可以分为有栈协程和无栈协程。

  • 有栈协程。顾名思义,协程方法间的调用是通过栈去实现的,好处是比较符合我们 Java 程序员的开发习惯,写起代码和线程区别不大。缺点是创建、保存、恢复栈的时候,会需要有一定的 CPU 和内存消耗。
  • 无栈协程。无栈协程的方法调用没有通过栈去保存调用关系,而是通过状态机去保存。好处是不需要依赖栈,会减少一些 CPU 和内存操作,缺点是易用性没那么高,我们开发调试会比较困难。

从调度方面,协程可以分为对称协程和非对称协程。

  • 对称协程,指的是任意两个协程之间可以互相切换。使用起来更加直观,比较灵活,切换效率比较高。
  • 非对称协程,一个协程只能将控制权交还给它的调用者。控制流较为清晰,调试和错误追踪相对简单,性能相对较差。

三、协程有哪些优势

3.1 降低损耗

协程无论在创建、销毁、调度等资源开销都远小于线程,因为协程的操作不涉及操作系统的资源分配和调度。

内存占用

协程通常占用更少的内存,因为协程的栈大小比线程小的多。线程在现代操作系统中通常是固定大小(如 1MB),而协程可以小到几 KB(Java G1 512KB)。这使得在同样的条件下,协程能以更高的密度运行。

上下文切换

线程的上下文切换需要系统调用陷入内核。

上下文切换(context-switch)分为两类,一类是主动切换(nr_voluntary_switches),典型的场景是锁、信号量、IO、Thread.yeild();另一类是被动切换,典型场景是线程一直在 CPU 计算(busy loop),当一个时间片被用完时候发生上下文切换。

线程上下文切换的步骤:

1、保持当前线程的上下文

  • 寄存器状态:保存所有通用寄存器、浮点寄存器、状态寄存器等;
  • 程序计数器(PC):保存当前执行的指令地址;
  • 堆栈指针(SP):保存当前堆栈指针的位置;
  • 其他处理器状态:包括条件码、控制寄存器等。

2、选择新的线程

  • 调度器:操作系统的恶调度器选择一个新的线程来运行。

3、加载新线程的上下文

  • 读取新线程的上下文,把步骤 1 保存的状态恢复。

4、更新内存和缓存状态

  • 缓存一致性:确保缓存与内存的一致性,可能需要刷新或无效缓存行。
  • 内存映射:更新内存管理单元(MMU)以映射新的线程的地址空间。

5、恢复执行

  • 处理器从程序计数器(PC)开始执行新线程的指令。

线程上下文切换消耗组成:

  1. 操作系统保存和恢复上下文所需的开销,这部分开销算在进程本身消耗的 CPU 里
    • 单次上下文切换开销主要受寄存器状态,调度策略,硬件性能等因素影响
    • 上下文切换次数主要受 I/O 操作,锁和同步机制,系统负载,线程的创建和销毁频率等因素影响。
  2. 线程调度器进行线程调度的开销,这部分消耗算在操作系统消耗的 CPU 里

线程上下文切换

协程上下文切换过程:

1、保存当前协程状态

  • 保存当前协程的程序计数器(PC)和栈指针(SP)。
  • 保存其他必要的局部变量和状态信息。

2、选择下一个协程

  • 协程主动让出控制权,调度器在协程主动挂起时进行切换。

3、恢复目标协程状态

  • 恢复目标协程的栈指针(SP);
  • 恢复目标协程的程序计数器(PC);
  • 恢复其他必要的局部变量和状态信息。

4、继续执行目标协程

  • 跳转到目标协程的程序计数器(PC),继续执行目标协程的代码。

协程的单次上下文切换不需要内核线程调度器参与,仅需要进行少量的软件上下文保存及一些简单的内存管理操作。

协程上下文切换

3.2 提升并发度

突破 thread-per-request 的限制。

对于 Java 线程模型来讲,一个请求每次只能用一个独立的线程处理,且线程对请求的处理是串行的,一个线程同时只能处理一个请求,如果请求进行一些系统调用,那么线程就会被阻塞。对于协程而言,多个协程共用一个线程,且可以线程内部自主切换,从而实现一个线程处理多个请求。

线程、协程处理请求

衡量一个系统的效率通常几个重要参数:QPS(TPS)、并发数、响应时间。

  • QPS(TPS):Query Per Second,每秒钟 request/事务数量
  • 并发数:系统同时处理的 request/事务数
  • 响应时间:一般取平均响应时间

理解了上面三个要素的意义之后,就能推算出它们之间的关系:

  • QPS(TPS) = 并发数/平均响应时间
  • 并发数 = QPS*平均响应时间

通过公式可以得到提升吞吐量,要么提高并发量(度),要么降低响应时间

3.3 局限性

  • 不适合 CPU 密集型服务:CPU 密集型服务总体上下文切换次数较小,无法体现协程的优势;
  • 不支持高效抢占:协程设计哲学是基于协作式多任务处理,而非抢占式。这意味着协程的执行流程是否继续、暂停或结束,通常由协程自身控制,而不是由外部调度器强制抢占;
  • 协程栈对于 GC 暂停的影响:由于协程栈本质上是 GC Root 的一部分,因此可能对 GC 暂停会有不好的影响。

四、协程的收益

N4dA3D

上下文切换开销,单次协程上下文切换耗时约 0.28us (测试数据 0.3us - 6us),约占线程消耗的 3.7%

收益计算,协程的收益主要体现在 CPU 利用率的后半段,可以帮助我们 CPU 利用率达到一个更高的水位而保持同样的稳定性。

协程收益 = 接入前(原利用率下)CPU 核数 - 接入后(现利用率下)CPU 核数

参考

  1. https://openjdk.org/jeps/425
  2. https://waylau.com/jep-425-virtual-threads-preview/
  3. https://zhuanlan.zhihu.com/p/446993465
  4. https://zhuanlan.zhihu.com/p/535658398
  5. https://github.com/dragonwell-project/dragonwell8/wiki/Wisp%E6%96%87%E6%A1%A3
  6. https://developer.aliyun.com/article/738762
  7. Little 定律