Java 并发基础 - 协程模型

304

一、引入

现在 Java 作为 Web 后台服务主力军之一,面临的服务需求压力也不同于以往,业务量不断增长,业务复杂度不断变高,一次业务请求往往对应着分布式部署的节点服务协同调度

而现在主流的 Java 虚拟机采用的是内核线程实现的 1 : 1 方式 实现,天然的切换、调度成本高,旧时代的一个请求可以花费很长时间的单体应用无伤大雅,而现在的请求对应着分布式节点的协作处理,可能带来更多的时间花销

就算是使用线程池,频繁的线程切换也会带来很大的消耗,如何高效地处理这种高并发的请求?

有一种线程模型已经做到了,也就是用户线程方式实现的线程模型,也就是 1 : n 方式

二、协程

2.1 线程上下文

程序 = 数据 + 代码,而代码执行,必须需要该程序上下文数据的支撑

  • 程序员角度来看:上下文是方法调用过程种的各种局部变量与资源
  • 线程角度来看:上下文是调用栈种存储的各类信息
  • 操作系统和硬件角度来看:上下文是存储在内存、高速缓存和寄存器种的一个个具体二进制数,具体的数据都有:
    1. P 寄存器,用来存储 CPU 要读取指令的地址
    2. ESP 寄存器:指向当前线程栈的栈顶位置
    3. 其他通用寄存器的内容:包括代表函数参数的 rdi、rsi 等等
    4. 线程栈中的内存内容

2.2 线程调度开销

内核线程方式实现的线程,在线程调度开销来源于:

  1. 响应中断,执行系统态切换
  2. 保护和恢复执行现场
  3. 上下文切换

用户线程方式实现的线程,可以大大减少上下文切换的开销,但不免还是会有系统调用,那么还是会进行保护和恢复现场等操作,这个过程其实是一系列数据在各种寄存器、缓存种来回拷贝,是一个性能代价不小的操作

那么,如果把保护和恢复线程这些性能代价比较高的操作由程序员在用户态完成呢?

解决这个问题,也就是启发协程的一个关键点

2.3 发展历史

2.3.1 栈纠缠

早期串行系统(例如,DOS),天生内核不支持多线程,老大爷们搞了一个骚操作,用来自己手动实现多线程,今天人们称之为栈纠缠(Stack Twine)

栈纠缠,由用户自己模拟多线程,自己保护恢复现场的工作模式,大概的原理思路是在内存里划出一片空间来模拟调用栈:让不同方法调用按一定规则压栈、弹栈,不破坏这片栈空间即可

这样看,多段代码执行就像相互纠缠一样

2.3.2 协同式调度的用户线程

后期的操作系统,开始提供多线程的支持(内核线程),但靠自己模拟多线程的做法依然存在,于是被演化为用户线程继续存在,并且早期的用户线程都是被设计程协同式调度的,所以它有了一个别名,协程(Coroutine),由于是协同调度,因此一般没有线程同步问题

2.3.3 协程

所以其实,协程,就是协同式调度的用户线程,但又要注意的一点是协程和用户线程模型里面的 用户线程 的区别是什么?

  • 协程就是会完整地自己做保护现场、恢复现场这些操作
  • 用户线程模型中的用户线程是不会这么做的,所以说就算不采用内核线程模型实现线程,使用用户线程模型,那么每个用户线程还是得要有自己的线程栈,保护上下文数据,还是得线程切换,这时一部分操作还是得通过内核的系统调用来实现
  • 协程和用户线程的关系,可以说协程就是用户线程模型里面用户线程具体的一种优秀的实现,这种实现的线程调度,不需要切换到内核态

协程也是有调用栈的,这个堆栈是在堆内存申请的,由于早期协程,会完整地做调用栈的保护、恢复工作(对 EIP 寄存器指针指向的指令地址的控制、线程栈的内容控制等),所以,今天称该种协程为 有栈协程(Stackfull Coroutine)

后期发展,更是演化出了 无栈协程(Stackless Coroutine)

无栈协程,本质上是一种有限状态机,状态保存在闭包里,所以比有栈协程恢复调用栈要轻量地多,但局限于状态机,功能也会更少,各种编程语言的的 awaitasyncyield 这类关键字,其实就是无栈协程的应用

因为有了调用栈,所以有栈协程的协程在调度的时候,任意一个协程在调度时,可以存放在调用栈也就是成为一个栈帧,也就是该协程把自己挂起了,而挂起后,任意一个协程都可以恢复到压栈时的状态

但无栈协程是基于状态机的,只能挂起顶层状态,其他的状态可能受影响,而不是自己自愿挂起的

2.4 本质

协程的本质到底是什么?从不同角度来看

  • 线程模型实现角度来看,协程属于用户线程的一种具体的实现,是一种实现机制,既然讨论的是用户线程,那么混合式实现和用户线程模型两种线程模型都可以做协程的实现
  • 代码角度来看,协程是各种代码片段的协同式调度,代码片段就像定义了切入点一样,什么时候切换到这个片段,由编程语言实现的协程机制实现决定。这个角度来看,协程是一个函数,一个方法调用,是一个方法执行点的入口
  • 调用单元角度来看,协程基于线程的一个运行单元,在该线程上进行代码片段的协同式调度,,一般来说多个协程由一个线程支持,但也不是一定只有一个线程
  • 执行过程角度来看,协程是串行地执行各个代码片段的一个执行过程

2.5 线程与协程

2.5.1 栈大小

先拿有栈协程来说,由于普通的线程需要执行上下文切换,需要用一个线程栈来保存这些数据,还有 CPU 缓存数据之类的,这些数据还是比较多的,JVM 默认的 HOT SPOT 在 LINUX 的线程栈大小就是 1 MB

但是协程就不需要了,直接把自己的状态和数据压栈到调用栈即可,所以一个协程也就几 KB 大小,如果是无栈协程,那没了调用栈,更小了!所以协程轻轻松松十万,百万的并发,普通的线程并发难以做到

2.5.2 切换代价

协程与协程间的调度是协同式调度,切换也就是调用栈压栈和弹栈的过程(无栈协程代价更小,暂且不讨论),代价极其小,但是线程调度切换的时候是很大的,依赖于线程调度器,往往进行系统调用

2.5.3 性能

计算器一切的计算都是跑在 CPU 上面的(或者现在也没那么一定,也可能跑在 GPU 上了 😅),讨论性能一定要看什么场景:IO 密集计算 or CPU 密集计算?

总览文章,可以发现协程其实可以说就是基于一个线程做的运行,说白了是单线程运行的,那么也就意味着只有一个 CPU 能被利用到,所以往往在 CPU 密集型计算场景下,协程性能远不如多线程理想,但在 IO 密集型计算场景下,协程的性能要有于多线程,因为可以减少线程上下文切换带来的损耗

三、纤程

OpenJDK 在 2018 年创建了 Loom 项目,未来 Java 新的并发编程机制没意外也会采用这个纤程这个名称作为其一部分

这是一种有栈协程

3.1 执行过程(Continuation)

用于维护执行现场,保护,恢复上下文数据

3.2 调度器(Scheduler)

用于负责编排所有执行的代码顺序,Loom 中默认的调度器是 JDK 7 加入的,用于任务分解的 Fork / Join Pool

四、协程库

现 Java 编程也可以采用 Quasar 协程库,不依赖于 JVM,基于字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复,字节码注入,性能较差,对即时编译器干扰比较大

参考