首页 > 基础资料 博客日记
【并发编程】java中的协程
2024-08-17 06:00:06基础资料围观181次
目录
出现的原因
随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。 比如,互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不同机器上的大量服务共同协作来实现, ,也就是我们常说的微服务, 这种服务细分的架构在减少单个服务复杂度、增加复用性的同时, 也不可避免地增加了服务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。
Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾, 1:1 的内核线程模型是如今 Java 虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂, 系统能容纳的线程数量也很有限。 前处理一个请求可以允许花费很长时间在单体应用中, 具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下, 用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销, 这就会造成严重的浪费。
另外我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来,但其中的切换损耗也是相当可观的。
这样的话,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。
其次, Go 语言等支持用户线程等新型语言给 Java 带来了巨大的压力, 也使得 Java 引入用户线程成为了一个绕不开的话题。
协程简介
为什么用户线程又被称为协程呢?我们知道, 内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是, 一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上, 则可以通过很多手段来缩减这些开销。
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名—— “协程”(Coroutine) 完整地做调用栈的保护、 恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。
协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB ,此外内核数据结构(Kernel Data Structures) 还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支 持协程的应用中, 同时并存的协程数量可数以十万计。
协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。
总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)。
纤程-Java 中的协程
在 JVM 的实现上,以 HotSpot 为例, 协程的实现会有些额外的限制, Java 调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正常切换协程而不影响整个线程? 另外, 如果协程中遇传统的线程同步措施会怎样? 譬如 Kotlin 提供的协程实现, 一旦遭遇 synchronize 关键字, 那挂起来的仍将是整个线程。
所以 Java 开发组就 Java 中协程的实现也做了很多努力, OpenJDK 在 2018 年 创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber) ”这个名字。
Loom 项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。
根据 Loom 团队在 2018 年公布的他们对 Jetty 基于纤程改造后的测试结果, 同样在 5000QPS 的压力下, 以容量为 400 的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比, 前者的请求响应延迟在 10000 至 20000 毫秒之间, 而后者的延迟普遍在 200 毫秒以下,
目前 Java 中比较出名的协程库是 Quasar[ˈkweɪzɑː(r)](Loom 项目的 Leader 就 是 Quasar 的作者 Ron Pressler), Quasar 的实现原理是字节码注入,在字节码 层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚拟机的现场保护虽然能够工作,但影响性能。
Quasar 实战
Quasar 的使用其实并不复杂,首先引入 Maven 依赖
<dependency>
<groupId>co.paralleluniverse</groupId>
<artifactId>quasar-core</artifactId>
<version>0.8.0</version>
</dependency>
在具体的业务场景上, 我们模拟调用某个远程的服务, 假设远程服务处理耗 时需要 1S,使用休眠 1S 来代替。为了比较, 用多线程和协程分别调用这个服务 10000 次,来看看两者所需的耗时。
Quasar 的:
public class FiberExample {
public static void main(String[] args) throws Exception{
CountDownLatch count = new CountDownLatch(10000);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
IntStream.range(0,10000).forEach(i-> new Fiber() {
@Override
protected String run() throws SuspendExecution, InterruptedException {
//Quasar中Thread和Fiber都被称为Strand,Fiber不能调用Thread.sleep休眠
Strand.sleep(1000 );
count.countDown();
return "aa";
}
}.start());
count.await();
stopWatch.stop();
System.out.println("结束了: " + stopWatch.prettyPrint());
}
}
在执行 Quasar 的代码前,还需要配置 VM 参数(Quasar 的实现原理是字节 码注入,所以,在运行应用前,需要配置好 quasar-core 的 java agent 地址)
-javaagent:D:\Maven\repository\co\paralleluniverse\quasar-core\0.8.0\quasar-core-0.8.0.jar
线程的:
public class Standard {
public static void main(String[] args) throws Exception{
CountDownLatch count = new CountDownLatch(10000);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
ExecutorService executorService = Executors.newCachedThreadPool();
// ExecutorService executorService = Executors.newFixedThreadPool(200);
IntStream.range(0,10000).forEach(i-> executorService.submit(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException ex) { }
count.countDown();
}));
count.await();
stopWatch.stop();
System.out.println("结束了: " + stopWatch.prettyPrint());
executorService.shutdownNow();
}
}
从代码层面来看, 两者的代码高度相似, 忽略两者的公共部分, 代码不同的地方也就 2 、3 行。
其中的 Fiber 就是 Quasar 为我们提供的协程相关的类,可以类比为 Java 中的 Thread 类。
上面的代码现在看不懂不要紧, 随着后面并发编程知识的继续学习, 这些代码是很容易理解的,现在只要知道这些代码的业务意义即可: 调用远程服务10000 次,每次耗时 1S,然后统计总耗时。
看看执行的结果:
Quasar 的:
线程的:
可以看到性能的提升还是非常明显的。而且上面多线程编程时, 并没有指定线程池的大小, 在实际开发中是绝不允许的。一般我们会设置一个固定大小的线程池, 因为线程资源是宝贵, 线程多了费内存还会带来线程切换的开销。上面的场景在设置 200 个固定大小线程池时(Executors.newFixedThreadPool(200)),在本机的测试结果达到了 50 多秒,几乎是数量级的增加。
由这个结果也可以看到协程在需要处理大量 IO 的情况下非常具有优势, 基于固定的几个线程调度, 可以轻松实现百万级的协程处理, 而且内存消耗非常平稳。
更多 Quasar 的使用方法和技巧,请大家自行挖掘和学习。
JDK19 的虚拟线程
2022 年 9 月 22 日,JDK19(非 LTS 版本) 正式发布,引入了协程,并称为 轻量级虚拟线程。但是这个特性目前还是预览版, 还不能引入生成环境。
因为环境所限,暂不提供实际的范例,只讲述基本用法和原理。
要使用的话,需要通过
使用 javac --release 19 --enable-preview XXX.java 编译程序,并使用 java --enable-preview XXX 运行该程序
在具体使用上和原来的 Thread API 差别不大:java.lang.Thread.Builder,可以 创建和启动虚拟线程,例如:
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
// Thread.ofPlatform() 则创建传统意义的实际
或者
Thread.startVirtualThread(Runnable)
并通过 Executors.newVirtualThreadPerTaskExecutor()提供了虚拟线程池功能。
在具体实现上, 虚拟线程当然是基于用户线程模式实现的,JDK 的调度程序不直接将虚拟线程分配给处理器, 而是将虚拟线程分配给实际线程,是一个 M: N 调度,具体的调度程序由已有的 ForkJoinPool 提供支持。
但是虚拟线程不是协同调度的, JDK 的虚拟线程调度程序通过将虚拟线程挂载到平台线程上来分配要在平台线程上执行的虚拟线程。在运行一些代码之后, 虚拟线程可以从其载体卸载。此时平台线程是空闲的, 因此调度程序可以在其上挂载不同的虚拟线程,从而使其再次成为载体。
通常,当虚拟线程阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take ())时,它将卸载。当阻塞操作准备完成时(例如,在套接字上已经接收到字节) , 它将虚拟线程提交回调度程序, 调度程序将在运营商上挂载虚拟线程以恢复执行。 虚拟线程的挂载和卸载频繁且透明,并且不会阻塞任何 OS 线程。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!
标签: