当前位置: 首页 > 科技观察

Java不支持协程?那是你不知道Quasar的原因!

时间:2023-03-13 04:26:27 科技观察

在编程语言的圈子里,各种语言之间的比较似乎从来没有停止过,就像早期的“PHP是世界上最好的语言”一样。钓鱼的时候看到很多文章说“Golang的性能打败Java”。作为一个写了几年java的javaer,我怎么受得了这个?于是在网上看了一些比较golang和java的文章,其中java的痛点,也就是golang被吹上天的地方,就是对多线程并发的支持上去了。先看一段描述:Go在语言层面原生支持并发,简单易用。Go语言的并发基于轻量级的线程Goroutine,创建成本非常低。单个Go应用也可以充分利用CPU多核编写高并发服务器软件。简单,执行性能好。很多时候,不需要考虑锁机制以及由此带来的各种问题。看到这里,我的心瞬间就凉了下来,每一个字都真的是刺痛了我的心。虽然java中的JUC包已经为我们封装了很多并发工具,但是在实际的高并发环境中,我们仍然需要考虑各种锁的使用,以及服务器性能瓶颈、限流熔断等诸多问题。回到正题,上面说的goroutine是什么?其实轻量线程goroutine也可以称为协程。得益于go中的调度器和GMP模型,go程序会智能地将任务合理分配到各个CPU上。好吧,其实上面这段话我是看不懂的。向写Go的哥们请教了。总之,Go的并发性能非常好。但这不是我们要讲的重点。今天我们将讨论如何在Java中使用协程。什么是协程?我们知道,线程在阻塞态和可运行态之间的切换,以及线程间的上下文切换都会造成性能损失。为了解决这些问题,引入了协程的概念,就像允许一个进程中存在多个线程一样,一个线程中也可以存在多个协程。那么,使用协程有什么好处呢?一是执行效率高。线程切换由操作系统内核执行,消耗资源较多。协程由程序控制,在用户态执行,不需要从用户态切换到内核态。我们也可以理解为协程是一种进程自己调度任务的调度模式,所以协程之间的切换开销要高很多。小于线程切换。第二,节约资源。因为协程本质上是通过分时复用单个线程,所以可以节省一定的资源。类似于线程的五种状态切换,协程之间也有状态切换。下图展示了协程调度器内部的任务流程。综合以上几个角度来看,相对于原生支持协程的Go,Java在多线程并发方面确实是脆弱的。不过在Java官方的jdk中虽然不能直接使用协程,但是还有其他开源框架是通过动态修改字节码的方式来实现协程的,比如我们接下来要学习的Quasar。Quasar使用Quasar是一个开源的Java协程框架。通过使用Javainstrument技术修改字节码,可以保存和恢复方法暂停前后的jvm栈帧,同时增加了方法内部已经执行过的字节码位置。以状态机的形式记录下来,下次继续执行时可以直接跳转到最新的位置。Quasar项目最后一次更新是在2018年,版本一直停留在0.8.0,但是我直接用这个版本的时候报错:这个错误的大概意思就是这个class文件是用更高版本编译的jdk,所以你是低版本当然不能在jdk上运行。这里主版本号54对应的是jdk10,我用的是jdk8。只好降级,尝试低版本。果然0.7.10可以用了:准备好了,下面就写几个例子来感受下协程的魅力吧。1.运行时间下面我们来模拟一个简单的场景。假设我们有一个平均执行时间为1秒的任务。我们来测试使用线程和协程执行10,000个并发任务需要多少时间。首先通过线程调用,直接使用Executors线程池:publicstaticvoidmain(String[]args)throwsInterruptedException{CountDownLatchcountDownLatch=newCountDownLatch(10000);长启动=System.currentTimeMillis();ExecutorServiceexecutor=Executors.newCachedThreadPool();for(inti=0;i<10000;i++){executor.submit(()->{try{TimeUnit.SECONDS.sleep(1);}catch(InterruptedExceptione){e.printStackTrace();}countDownLatch。倒数();});}countDownLatch.await();长端=System.currentTimeMillis();System.out.println("Threaduse:"+(end-start)+"ms");}查看运行时间:好了,我们在Quasar中用协程运行上面一样的流程。这里我们要用到Quasar中的Fiber,可以翻译成coroutine或者fiber。Fiber创建的类型可以分为以下两类:publicFiber(Stringname,FiberSchedulerscheduler,intstackSize,SuspendableRunnabletarget);publicFiber(Stringname,FiberSchedulerscheduler,intstackSize,SuspendableCallabletarget);没有返回值的SuspendableRunnable或者有返回值的SuspendableCallable都可以在Fiber中运行。java中Runnable和Callable的区别,看名字的区别就知道了。其他参数可以省略,name为协程名称,scheduler为调度器,默认使用FiberForkJoinScheduler,stackSize指定用于保存fiber调用栈信息的栈大小。在下面的代码中,使用了Fiber.sleep()方法来让协程休眠,这与Thread.sleep()非常相似。publicstaticvoidmain(String[]args)throwsInterruptedException{CountDownLatchcountDownLatch=newCountDownLatch(10000);长启动=System.currentTimeMillis();for(inti=0;i<10000;i++){newFiber<>(newSuspendableRunnable(){@OverridepublicIntegerrun()throwsSuspendExecution,InterruptedException{Fiber.sleep(1000);countDownLatch.countDown();}})。开始();}countDownLatch.await();长端=系统。当前时间毫秒();System.out.println("Fiberuse:"+(end-start)+"ms");}直接运行,报警告:QUASARWARNING:QuasarJavaAgentisn'trunning。如果您正在使用另一种检测方法,您可以忽略此消息;否则,请参阅Quasar文档中的入门部分。请记住,Quasar的有效性原理是基于Java仪器技术的,因此这里我们需要添加一个Agent代理。在本地maven仓库找到已经下载好的jar包,在VM选项中添加参数:-javaagent:E:\Apache\maven-repository\co\paralleluniverse\quasar-core\0.7.10\quasar-core-0.7.10这次运行.jar时没有警告。查看运行时间:运行时间只有使用线程池时的一半多一点,确实可以大大缩短程序的运行效率。2.内存占用测试完运行时间,我们来测试一下运行内存占用的对比。尝试通过以下代码在本地启动100万个线程:publicstaticvoidmain(String[]args){for(inti=0;i<1000000;i++){newThread(()->{try{Thread.sleep(100000);}catch(InterruptedExceptione){e.printStackTrace();}}).start();}}本来以为会报OutOfMemoryError,没想到的是我的电脑直接卡死了。。。而且一次都没有,试了几次最后都卡死了不得不重启电脑。好吧,我选择放弃,那我们试试启动100万个Fiber协程吧。publicstaticvoidmain(String[]args)throwsException{CountDownLatchcountDownLatch=newCountDownLatch(10000);对于(inti=0;i<1000000;i++){intfinalI=i;newFiber<>((SuspendableCallable)()->{Fiber.sleep(100000);countDownLatch.countDown();returnfinalI;}).start();}countDownLatch.await();System.out.println("end");}program可以正常执行,看来使用的内存确实比线程少很多。上面我特意把每个协程的结束时间都弄的很长,这样我们可以用JavaVisualVM查看运行过程中的内存使用情况:可以看到在使用FiberMemory的情况下只用了1G多一点,平均100万个协程,意味着每个Fiber只占用1Kb左右的内存空间,相对于Thread线程来说真的是非常轻量级了。从上图我们也可以看到有很多ForkJoinPools在运行,它们分别起到什么作用呢?前面我们说过,协程是由程序控制切换到用户态,而Quasar中的调度器是一个或多个ForkJoinPools,用于调度Fiber。3.原理与应用这里简单介绍一下Quasar的原理。该框架将在编译时扫描代码。如果方法被@Suspendable注解,或者抛出SuspendExecution,或者在配置文件META-INF/suspendables中指定了方法,那么Quasar会修改生成的字节码,在parksuspend方法前后插入一些字节码。这些字节码会记录此时协程的执行状态,比如相关的局部变量和操作数栈,然后通过抛出异常的方式将cpu的控制权从当前协程转移到controller。这时controller可以调度另一个协程运行,通过之前插入的字节码恢复当前协程的执行状态,让程序继续正常执行。回顾前面例子中的SuspendableRunnable和SuspendableCallable,它们的run方法都抛出SuspendExecution。事实上,这并不是一个真正的例外。它只是作为标识suspend方法的语句,实际运行中不会抛出。当我们创建一个Fiber并调用其中的其他方法时,如果我们想要Quasar的调度器介入,我们必须在使用时逐层抛出这个异常或者添加注解。看一个简单的代码写法的例子:println(content);}}).start();}privateStringsendRequest()throwsSuspendExecution{returnrealSendRequest();}privateStringrealSendRequest()throwsSuspendExecution{HttpResponseresponse=HttpRequest.get("http://127.0.0.1:6879/名称").execute();字符串内容=response.body();returncontent;}需要注意的是,如果Exception在方法内部已经被try/catch捕获到,需要再次手动抛出SuspendExecution异常。小结本文介绍了Quasar框架的简单使用。其具体实现原理比较复杂,这里暂且不展开讨论。后面会单独分析。另外,很多其他框架已经集成了Quasar,比如ParallelUniverse下的Comsat项目,可以提供HTTP、DB访问等功能。虽然现在想在Java中使用协程只能使用这样的第三方框架,但是不要气馁。OpenJDK16中增加了一个名为ProjectLoom的项目,你可以在OpenJDKWiki上看到它会使用Fiber轻量级用户态线程,从jvm层面彻底改变多线程技术,使用全新的编程模型来制作适用于高吞吐量业务场景的轻量级线程并发。