为什么 Jvm 需要有栈协程

发布于 2024-09-26 16:40:15 字数 13626 浏览 38 评论 0

旧有的 servlet 生态的线程模型

首先我们先要聊一聊现在我们用的最多的 servlet 的执行模型是什么:

这个 dispatch 其实就是一个 EventLoop 或者说是一个 selector 来检测注册到其上的链接状态发生的变化

以 Tomcat 为例子,当这个 selector 发现存在一个链接可读时,就会封装一个读取和后续处理的操作丢到 worker 线程中执行,在大部分情况下请求的读取和写出都是绑定到一个线程的,这里我们不讨论很细节的实现,只需要稍微理解一下线程模型即可。

img

即我们可以发现 HttpRequest 的生命周期可以用 ThreadLocal 来代表,不会存在同一个线程交错处理多个请求的情况(排除 servlet3.1 引入的 async-request api 情况,这个我想大部分人也不太会使用)

再结合我们经常使用的 client 的实现来思考,比如基于 socket api 的 bio 实现的 jdbc,哪怕是本质是非阻塞也要封装出同步接口的 lettuce 或者 okhttp3,这些 client 我们在使用时会阻塞住当前的线程。此时为了继续对外提供服务就需要继续加线程,就导致了一个普通的 springboot 服务有时候甚至会使用数百个内核线程在不停的切换,大量的内核线程带来了什么结果?内存占用高,大量的上下文切换导致的性能下降(cache miss 之类的),高昂的锁代价,浪费的 CPU 时钟资源。

我们只能这样做吗?显然不是,我们来看看其他的语言是怎么做的。Go,node.js 之类的的兴起,让更多的开发者发现我们其实只需要少量的内核线程就可以支撑起原来上百线程的并发能力。事实证明,在 web 这种无状态的,IO 用时较多的程序类型只要用少量的(n 个 cup 核心数的线程数目)就可以达成我们的全部需要。

如何在 jdk8 的情况下弥补这一切?

总结一下需求,我们需要一个框架可以当 io 未完毕时线程可以切换走执行其他的任务,等完毕后再执行后续的事情

其实用少量线程支持大量并发的技术栈早已出现,甚至我们在自己部门的仓库里面也能看到这个技术——响应式技术栈,比如说 Spring WebFlux,Vert.x,Quarkus

从下图看 vertx 的综合 benchmark 非常的强

img

后端框架 benchmark

以 Vert.x 为例子,他的代码风格是这样的 本质上就 Future 套 Future,将异步操作串联在一起

private void addOrder(Router router){
        router.post(prefix)
                .handler(AccessBaseSessionHandler.createLeastMode(Roles.USER))
                .handler(BodyHandler.create())
                .handler(ValidationJsonHandler.create(OrderVO.class))
                .handler(rc -> {
                    LoginUserPO loginUserPO = rc.session().get(UserController.userKeyInSession);
                    OrderVO orderVO = rc.get(ValidationJsonHandler.VALUE_KEY);
                    orderService.addNewOrder(orderVO,loginUserPO.getUserId())
                            .map(v -> ResponseEntity.success(orderVO.getOrderId(),200).toJson())
                            .onSuccess(rs -> rc.response().end(rs))
                            .onFailure(rc::fail);
                });
    }
public Future<OrderPO> getOrderByOrderId(Long orderId){
    return mySQLPool.getConnection()
      .compose(sc -> SqlTemplate.forQuery(sc,"SELECT * FROM `order` WHERE order_id=#{id}").mapTo(OrderPORowMapper.INSTANCE).execute(Map.of("id",orderId)).onComplete(ar -> sc.close()))
      .flatMap(rs -> rs.size() == 0 ? Future.failedFuture("无此单号"):Future.succeededFuture(rs.iterator().next()));
    }

在这份代码里面数据库操作的返回值是 Future,这难道是我们通过把 jdbc 操作丢到线程池中跑吗?仔细思考一下 如果是这样那么显然我们既没有减少阻塞时间,也没有降低线程开销。这个地方实际上是利用 netty 按照对应数据库的协议写出了一个新的响应式的数据库访问 client。因此这里没有任何的线程在阻塞,即 DB 处理时间长的瓶颈并不会阻碍我们处理新的请求。相关的可以看看这两个 https://r2dbc.io/ https://vertx.io/docs/

思考这样一个情况,我们的 httpclient,db client,redis client 全是异步实现而且他们公用同一组线程作为 Eventloop,那么这一套异步工具集下来是不是可以有效地提高我们的吞吐量?事实上,golang 的协程网络库就是类似于这样。

性能好就代表一切吗?或者响应式存在什么问题

从 C10K 角度来看,nio 确实是一个很好的解决方案,Tomcat 底层也是基于 nio,但是为什么到业务处理层我们还是同步的呢?或者说为什么业务层不也使用异步响应式思想呢?

我这里给出一个比较常见的响应式操作,开启事务然后查询最后回滚

img

堆栈

首先响应式是基于事件的,在 api 的表现上就是 write(buffer,callbcak),一旦业务复杂起来回调地狱势必会出现,哪怕我们将其用 promise/future 改造也只是将回调打平了而已其实没有解决实际问题,同时回调还存在一个问题——会丢失大量堆栈信息,仅仅保留那些被捕获进来的状态。

这一点很好理解,当你给这个一时半会没法完成的 IO 事件挂一个回调后,程序此时就执行完了 OutFunction 函数,因此退栈了,等他的 IO 完成后发现有个事件该执行了(runnable.run)就去执行,此时原来的栈已经推掉了,你没法在回调的堆栈里面看到原来的 stack trace 了

img

我们丢失了堆栈即意味着丢失了函数的嵌套关系,就很难找到到底是谁调用了这个函数,是哪一个放置了回调,这一点在出问题要排查时是非常致命的,ps:你仔细观察栈顶的函数名,实际上我们可以通过生成的 lambda 名来找一找,不过这是特殊情况了。

再比如说思考这样一个代码,当第二行出现问题时,我没法从堆栈的信息里面获取到前后的操作详情

future.map(l -> {})
      .flatmap(l -> {})

调试

请看如下的代码

img

一旦回调嵌套回调出现问题你很难去了解函数之间的调用关系,这一点对 debug 是致命的缺陷,因此你在 idea 里面 debug 的时候不得不把有先后关系的回调里面打满断点然后利用执行到断点的方式去 debug,而不能打一个断点向下执行

生态兼容性

这里直接给一个结论,完全无法无缝兼容。

首先是线程模型完全不一致

img

请求 A 到达服务器,解析后开始处理业务逻辑,该查数据库了,此时向数据库发送请求,由于数据库 client 是非阻塞异步的,此时请求 A 对应的数据库响应还未返回没有触发后续事件,相当于请求 A 被“挂 起”了,此时 eventloop 就可以接收请求 B,一直执行到请求数据库,若此时请求 A 的数据库响应已经到达 则触发了后续事件,eventloop 再“恢复”请求 A 的处理直到写出请求 A 的响应 类似于一种交错处理,在每一个异步点挂起当前的请求(异步点就是那些需要发起异步方法的,比如请 求一个远端数据,或者线程池跑一个长时间任务,差不多就是一个方法返回 future 就是 异步方法 ) 此时不同的任务交替跑在 java 线程上面,此时 ThreadLocal 就失效了,MDC 这种依赖于 ThreadLocal 的就完全没办法使用了。

即我们建立在单线程处理情况假设上的一些无侵入传参生态就完全失败了

而为他带来性能提升的核心准则——不要阻塞事件循环——同时也使其与原有的同步生态隔离开来,这是两套完全不同的代码风格,这是很难以共存的,我们只能去复用很少一部分 java 的第三方包生态 很多中间件的 SDK 需要重写。这就是 java 后端性能提升的面对的问题,或许你用 netty 再加上 graalvm aot 支持可以建立一个性能很不错的网关,但是你用那些去写业务,很多东西都需要从 0 开始做起,这一点就是很多人提到的维护性问题。我已经不止一次看到有些同学在回调中直接去调用一个阻塞 api 了。

概念众多且不便于书写

基于回调进行处理,其实类似于人肉进行 cps 变换,开发的便利性就会急剧下降。而从控制流角度来看,你想象一下,你调用多个异步操作,是不是从你的主控制流 fork 出来多个并发控制流?这些多出来的控制流是不太可控的,如果这些 fork 出来的控制流也会 fork 出新的控制流呢?如果此时还涉及到资源的释放呢?(请参考结构化并发)

比如说 onSuccess,OnFailure 这种函数就是在模拟 if..else,recoverWith 模拟 try..catch,在命令式代码中都很好书写,但是一旦开始用函数来模拟就非常难以理解和掌控了。本来若我们自己掌控不住代码还可以通过静态分析工具来帮助我们,但是切换到响应式模式,主流的静态分析工具也没法发挥作用。

有一些库不只是简单的的回调便利化,还引入了一堆比较学院派的概念来模拟更多的结构,比如说 project reactor,reactiveX,Mutiny!等,你需要理解各种稀奇古怪的操作符,上下游等概念才能比较有把握的去写出正确代码。我并不否认这些库在被压,容错中的优雅实现,但是我们的原则应该是用 20%的理解就可以应对 80%的代码,实际上这些库带来了很大的理解成本。

kotlin 是不是可以来拯救世界呢?

众所周知,kotlin 号称 better java,同样也是我最喜欢的 jvm 语言,它有个重量级特性——coroutine,我们都知道 go 的 goroutine 实际上是一种 runtime 提供的功能,jvm 显然没有对应的功能,kotlin-coroutine 实际上是一种语法糖——CPS 变化的语法糖,即一种无栈协程的实现

看这个代码,全程都是同步的 甚至可以 try..catch..

  suspend fun selectMessageRecordBySender(senderId:Int):List<MessageRecord>{
    try{
      val connection = pool.connection.await()

      val res = SqlTemplate.forQuery(connection,"SELECT * FROM message_record WHERE sender = #{sender}")
        .collecting(MessageRecord.collector)
        .execute(mapOf("sender" to senderId))
        .await()
       return res.value()
    }catch(t : Throwable){
        throw wrap(t)
    }
  }

甚至在 idea 里面可以串行的形式断点调试 https://kotlinlang.org/docs/debug-coroutines-with-idea.html

是不是感觉 这就是最终结果了?响应式框架+kt coroutine 就可以完全胜任任务了?

错了!我们先来看看他的原理

堆栈?

首先 suspend 的本质,就是 CallBack

img

等等 continuation 又是什么?它就是代表程序剩下的部分

img

实际上来讲它等价于

getUserInfo(new CallBack() {
    @Overridepublic void onSuccess(String user) {
        if (user != null) {
            System.out.println(user);
            getFriendList(user, new CallBack() {
                @Overridepublic void onSuccess(String friendList) {
                    if (friendList != null) {
                        System.out.println(friendList);
                        getFeedList(friendList, new CallBack() {
                            @Overridepublic void onSuccess(String feed) {
                                if (feed != null) {
                                    System.out.println(feed);
                                }
                            }
                        });
                    }
                }
            });
        }
    }
});

这些是编译器帮我们做的脏活而已,其本质还是回调,因此我们之前的问题还是没有解决——堆栈还是会丢失

染色?

接着就是另外的问题了,suspend 函数只能被 suspend 函数调用,也就是说它具有传染性,一直到顶层都需要是 suspend 的函数,然后相当于污染了整条调用链路,如果一门新语言,从标准库到上层,都是全 suspend 的还好一点,但是对于有些历史包袱的语言,有些库已经是非 suspend 的,这个染色的处理就很难受。

同时 Future 也是这个问题,所有返回的值不再是一个普通的值了,而是一个 Future,需要用 map 函数解出来。一层一层往上染色,整个调用链路都变成 Future 的。

简单来说 kt 只是解决了表面的异步转同步的问题,而非解决核心问题

触手可及但是不够好的未来——loom

这些响应式 api 被创造出来不是因为它们更容易编写和理解,甚至它们实际上更难以弄明白;不是因为它们更容易调试或分析——甚至会更困难(它们甚至不会产生有意义的堆栈跟踪);并不是因为他们的代码结合比同步的 api 好——他们的结合不那么优雅;不是因为它们更适合语言中的其他部分,或者与现有代码集成得很好,而是因为并行性的软件单元——线程——的实现从内存和性能的角度来看是不够的。由于抽象的运行时性能问题,一个好的、自然的抽象被抛弃,而倾向于一个不那么自然的抽象,这是一个可悲的现状。

为了改变这一切,Project loom——即将在 jdk19 preview 的特性(2022 年 7 月 24 日)——为 jvm 提供以少数内核线程支持海量用户态线程的有栈协程实现。

它解决了什么问题?

通过引入 runtime 支持的 Continuation 结构,重写网络库并且提供 java.lang.Thread 的子类 VitrualThread,做到了只要简单替换线程池实现就可以获得类似于 go 但是是协作式的用户态线程的能力,没有函数染色的副作用,从而直接解决了生态不兼容的问题,同时也给予了旧有代码升级最小化改动的帮助。

从前我们需要自己手写 EventLoop,费劲地重新实现一遍协议解析只是为了提供更好的性能条件来做迁移,现在 只要开启一个虚拟线程 就像是 goalng 写一个 go 关键字一样简单(甚至于你可以用 kotlin 模拟出一个 go 关键字 goroutine.kt ),旧有生态的 bio 原地从阻塞内核线程升级到阻塞用户态线程,再也不需要开那么多内核线程来处理并发了。

Thread.startVirtualThread(() -> {
    System.out.println("Hello, Loom!");
});

Thread::currentThread,LockSupport::park,LockSupport::unpark,Thread::sleep,也对此做了适配,这意味着我们那些基于 J.U.C 包的并发工具仍旧可以使用。

羡慕 go 的 channel?J.U.C 的 BlockingQueue 作为对标完全没有问题

关键要点:

  • 虚拟线程就是 Thread ——无论是在代码中,runtime 中,调试器中还是在 profiler 中
  • 虚拟线程不是对内核线程的包装,而是一个 Java 实例
  • 创建一个虚拟线程是非常廉价的,——您可以拥有数百万个并且无需池化它
  • 阻塞一个虚拟线程是非常廉价的,——您可以随意使用同步代码
  • 无需在编程语言层面做任何更改
  • 可插拔的调度器可以为异步编程提供更好的灵活性

等等?为异步编程提供更好的灵活性?loom 能为异步编程做什么?

只要简单为它写个封装器就可以方便地在同步生态里面使用异步代码,轻松异步转同步而无需引入其他的库,甚至相对于原有的异步操作开火车,这种性能损耗非常少——而且堆栈连续。

public Future<String> asyncFunction(){...}
public String asyncFunctionWrapper(){
    var t = Thread.currentThead();
    var f = asyncFunction.onComplete(v -> LockSupport.unpark(t));
    LockSupport.park(t);
    if(f.success()) return f.get();
    throw f.cause();
}
//运行在虚拟线程中
public void fun(){
   var s = asyncFunctionWrapper();
   var s1 = asyncFunctionWrapper();
}

不够好是什么意思?

先引入一个 loom 中的概念。pin

如果虚拟线程被挂载到载体线程上,且处于无法卸载的状态,我们就说它被“pin”到它的载体线程上。如果一个虚拟线程在 pin 时阻塞了,它就阻塞了它的载体。这种行为仍然是正确的,但是在虚拟线程阻塞期间,它会持有工作线程,使得其他虚拟线程无法使用它。

在当前的 Loom 实现中,虚拟线程可以被固定在两种情况下:当堆栈上有一个本机帧时——当 Java 代码调用本机代码(JNI),然后调用回 Java 时——以及在一个 sychronized 块或方法中。在这些情况下,阻塞虚拟线程将阻塞承载它的物理线程。一旦本机调用完成或监视器释放( synchronized 块/方法退出),线程就被解除锁定。

那我不用不就好了?而且原来的网络 IO 中的 sychronized 也被重写了,这有什么问题?

来看一个我们经常使用的 jdbc 的实现——MySQL-connectorJ 的堆栈检测。

com.mysql.cj 开头的堆栈的栈底有一个 sychronized 关键字加持的方法以防止多个线程读取同一个 socket,因此在这里我们的线程就 pin 住了需要等待 IO 结束,这样又退回到原来的内核线程实现了

img

除了 jdbc,spring 内嵌的 Tomcat 也有这个问题

Thread[#44,ForkJoinPool-1-worker-1,5,CarrierThreads]
   ....
    com.example.demo.DemoApplication.hello(DemoApplication.java:37)
    java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
    java.base/java.lang.reflect.Method.invoke(Method.java:578)
    org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) <== monitors:1
   .....

java 的有栈协程非常美好 很可惜当前的应用无法无缝迁移,这一点就是为什么我说 loom 是触手可及但是不够好,加点私货:Tomcat 这个确实有解决方法 参考 Project Loom 与 SpringBoot - 掘金 我的这篇文章。

总结

我现在可以回答题目的问题了 我借用官方文档的一句话来说——

Project Loom aims to drastically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications that make the best use of available hardware.

Project Loom 旨在大幅减少编写、维护和观察高吞吐量并发应用程序的工作量,以便于充分利用可用硬件

相关资料阅读

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

假扮的天使

暂无简介

文章
评论
28 人气
更多

推荐作者

櫻之舞

文章 0 评论 0

弥枳

文章 0 评论 0

m2429

文章 0 评论 0

野却迷人

文章 0 评论 0

我怀念的。

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文