Java 协程
协程本质上和线程是一类概念,本质上是编程过程中对并发计算任务的一种抽象,只不过协程在调度层面上更轻量。
线程的调度由于是操作系统实施的,有时间片/中断等较为复杂的机制,因此调度点对用户是透明的,可以认为调度理论上可以在任何地方触发。而协程的调度点往往是用户代码显式通过触发的(发生在用户态),需要用户代码自己相互“协作”来完成任务的调度和执行,这也是协程中”协“的来源。
一、Java 线程 & 协程
在 Java 中,每个线程都会映射到一个内核级线程,线程的调度是由操作系统内核负责的;对于协程来说,协程的切换仅仅是应用层的切换,不会深入内核。
二、协程的实现机制
从上下文切换方面,协程可以分为有栈协程和无栈协程。
- 有栈协程。顾名思义,协程方法间的调用是通过栈去实现的,好处是比较符合我们 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)开始执行新线程的指令。
线程上下文切换消耗组成:
- 操作系统保存和恢复上下文所需的开销,这部分开销算在进程本身消耗的 CPU 里
- 单次上下文切换开销主要受寄存器状态,调度策略,硬件性能等因素影响
- 上下文切换次数主要受 I/O 操作,锁和同步机制,系统负载,线程的创建和销毁频率等因素影响。
- 线程调度器进行线程调度的开销,这部分消耗算在操作系统消耗的 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 暂停会有不好的影响。
四、协程的收益
上下文切换开销,单次协程上下文切换耗时约 0.28us (测试数据 0.3us - 6us),约占线程消耗的 3.7%
收益计算,协程的收益主要体现在 CPU 利用率的后半段,可以帮助我们 CPU 利用率达到一个更高的水位而保持同样的稳定性。
协程收益 = 接入前(原利用率下)CPU 核数 - 接入后(现利用率下)CPU 核数