最近发现大家都在聊虚拟线程,那我也蹭蹭热度

645 阅读10分钟

前言

java过去的三十年中, java程序员几乎都依赖线程解决各种高并发问题

特别是在各大主流编程语言引进协程的大环境下, java继续保持一个请求一个线程的独特风格未免也太过于保守且不合群

有人会说了, 那我java不也有CompletableFuture或者线程池+阻塞队列吗?

答: 那我就就跟你聊聊, 协程能带来什么好处, 为什么我们java需要协程

为什么需要虚拟线程?

我们先聊聊线程的问题

线程是昂贵的

首先我们必须明确一点, java中线程是非常昂贵的

在我们最常使用的linux系统中, 我们叫线程为LWP, 也就是轻量级进程

每个线程一般需要级别的内存

假设一个平均延迟为50毫秒的应用程序通过同时处理10个请求实现每秒处理200个请求的吞吐量。

为了使该应用程序扩展到每秒处理2000个请求的吞吐量,它将需要同时处理100个请求。

如果每个请求的持续时间内都使用一个线程(每个请求一个操作系统线程),那么在其他资源(如CPU或网络连接)耗尽之前,线程的数量通常成为限制因素。即使线程被池化,应用程序的吞吐量也会受到限制,因为池化可以避免启动新线程的高成本,但不能增加总线程数。

线程池+阻塞队列的问题

如果程序使用Executors.newFixedThreadPool(200), 也就是200个平台线程供所有10000个任务

try (var executor = Executors.newFixedThreadPool(200)) {
  IntStream.range(0, 10_000).forEach(i -> {
    executor.submit(() -> {
      Thread.sleep(Duration.ofSeconds(1));
      return i;
    });
  });
} // executor.close()

此时许多任务会存储到阻塞队列, 然后慢慢的按照一定顺序运行,程序将花费很长时间才能完成。

另外,如果示例程序中的10_000更改为1_000_000,那么程序将提交1,000,000个任务,

我们计算下线程池模式的代价是怎样?

答:

  1. 200个线程, 假设每个线程占用内存1m, 那么内存占用200m(这里只是做了个假设)
  2. 10000个任务, 不管丢给多少个线程, 每个任务都要休眠1秒, 而200个线程, 速度每秒处理200个任务, 计算一下, 10000 / 200 = 50
  3. 200个线程都需要上下文切换, 每次都要在用户层和内核层之间切换, 保存状态和恢复状态, 非常的慢
  4. 过度的线程上下文切换回影响CPU高速缓存的命中率

那么虚拟线程的表现又如何?

以下是一个创建大量虚拟线程的示例程序。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  IntStream.range(0, 10_000).forEach(i -> {
    executor.submit(() -> {
      Thread.sleep(Duration.ofSeconds(1));
      return i;
    });
  });
} // executor.close()

只是休眠一秒钟现代硬件可以轻松支持10000个虚拟线程同时运行这样的代码。

虚拟线程在充分预热之后可以实现每秒约10,000个任务的吞吐量。此外,如果示例程序中的10,000被改为1,000,000,那么程序将提交1,000,000个任务,创建1,000,000个虚拟线程并发地运行,并在充分预热之后实现每秒约1,000,000个任务的吞吐量

在幕后,JDK可能只在少量的OS线程上运行代码,可能只有一个。

虚拟线程是什么?

你可以认为虚拟线程拥有一个专门处理IO阻塞的工厂。

当线程在执行,遇到阻塞操作时,就会将该阻塞操作丢给IO阻塞工厂,让IO阻塞工厂去运作,去监控阻塞是否完成。而线程可以去执行别CPU计算型任务。

小白:照你这么说,那阻塞的IO到底是由谁去执行呢?
小黑:IO操作大多数情况下都不需要CPU去主动运行,比如磁盘操作一般是由dma进行操作的。dma最终只是告诉CPU任务完成了,读到了哪些数据。
小黑:再比如网络IO操作,我们只需要把数据发送给网卡就行了,网卡会为我们自动将数据发出去,并自动将数据接收回来,然后网卡会将收到的数据告诉CPU的。
小黑:所以实际的收发过程并不需要CPU参与操作。所以在很多IO操作的过程中,CPU并不需要深度参与,整个过程都会有一个第三方的类CPU设备在进行专门的操作处理。

所以我们可以得出结论:

虚拟线程场景下,IO不消耗OS线程, CPU计算操作消耗OS线程

那么现在问题来了,IO阻塞工厂的工作机制是怎样的?

IO事件通知机制

我们需要事先知道一件事:

虚拟线程会将表面阻塞的函数底层被虚拟线程库替换为非阻塞函数, 比如上面的Thread.sleep函数, 底层将被提交为非阻塞的函数

URLData getURL(URL url) throws IOException {
  try (InputStream in = url.openStream()) {//blocking call
    return new URLData(url, in.readAllBytes());
  }
}

如果你去跟代码你会发现: VirtualThreads.park

java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:60)//this line parks the virtual thread
java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:184)
java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:212)
java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:356)//JVM runtime will replace an actual read() into read from java nio package 
java.base/java.io.InputStream.readAllBytes(InputStream.java:346)

也就是说最终他会被虚拟线程库拦截, 然后判断它是否是阻塞IO, 如果是将阻塞IO替换NIO, 然后使用epollIOCP或者kqueue

主要还是看你系统如何实现更好咯

不过我看到有人说用了linuxio_uring, 但这是有前提的, linux内核版本要高

那如果是异步IO呢?

答: 虚拟线程中运行的异步IO无需特殊处理

总结

虚拟线程仅在执行CPU计算时消耗OS线程

使用虚拟线程需要注意的问题

虚拟线程的ThreadLocal

虚拟线程可以有近乎无限个, 每一个虚拟线程都会有一个ThreadLocal

我们需要慎重考虑在虚拟线程中使用ThreadLocal

不要池化虚拟线程

不要池化虚拟线程, 不要对虚拟线程进行池化。为每个应用程序任务创建一个虚拟线程。虚拟线程的生命周期短暂,具有较浅的调用堆栈。它们不需要线程池的额外开销或功能。

计算密集型操作不需要使用虚拟线程

虚拟线程的好处体现在IO密集型, 对于CPU密集型不推荐使用虚拟线程

虚拟线程本身也是有代价的

当然这点是存在争议的, 我这里说的是 内核线程数+1 个线程的情况下进行计算操作

运行虚拟线程的线程可能被固定在某些代码上

线程可以装载虚拟线程, 也可以从线程中卸载虚拟线程, 也就是我们说的切协程

我们知道, 虚拟线程需要线程在背后进行运行, 所以对于装载和卸载虚拟线程的问题还是需要考虑的

JDK中有一些阻塞操作不会卸载虚拟线程,从而阻塞了背后系统线程。

这是因为要么在操作系统层面(例如,许多文件系统操作)要么在JDK层面(例如,Object.wait())存在一些限制。

这些阻塞操作的实现将通过临时扩展调度器的并行性来补偿对操作系统线程的占用。

因此,调度器的 ForkJoinPool 中的平台线程数量可能在短时间内超过可用处理器的数量。

可以通过系统属性 jdk.virtualThreadScheduler.maxPoolSize 来调整调度器可用于的最大平台线程数。

有两种情况下,虚拟线程在阻塞操作期间无法被卸载,因为它被固定在其载体上:

  1. 当它在synchronizedsynchronized方法内部执行代码
  2. 当它执行native方法或外部函数时。

native方法的线程被固定无法解决, 但如果是synchronized则可以使用java.util.concurrent.locks.ReentrantLock

并且在未来openjdk开发人员考虑让synchronized也不会出现固定平台线程的情况

虚拟线程栈和垃圾收集器的问题

虚拟线程是有栈协程, 虚拟线程需要虚拟线程栈, 虚拟线程栈以块对象的形式存放在jvm的堆中

Java虚拟机(JVM)中,每个线程都有自己的线程栈,这个线程栈是用来存储线程运行时的信息的。但是,对于虚拟线程来说,它们的线程栈并不是垃圾收集(GC)的根,也就是说,垃圾收集器在进行垃圾收集时,并不会去检查虚拟线程栈中的引用。

这意味着,如果一个虚拟线程被阻塞了,比如说在等待队列(BlockingQueue)的take()方法,并且没有其他线程可以获取到这个虚拟线程或者队列的引用,那么这个虚拟线程就可能会被垃圾收集器回收。因为这个虚拟线程已经无法被唤醒或者中断了。

当然,如果一个虚拟线程正在运行,或者它被阻塞了但是有可能被唤醒,那么它就不会被垃圾收集器回收。

另外,虚拟线程还有一个限制,那就是**G1垃圾收集器不支持处理过大的堆栈块对象**。如果一个虚拟线程的堆栈大小达到了一半的区域大小(可能小到512KB),那么就可能会抛出一个StackOverflowError异常。

关于async/await

java不能像 c# 那样直接用 asyncawait 糖, 真的很可惜, 直到我发现openjdk开发人员不使用async/await的理由非常离谱, 大意是:

有人提议将无栈协程(例如async/await)这种语法添加到Java语言中。这种做法比实现用户模式线程更容易,而且提供了一种统一的构造来表示操作序列的上下文。

然而,这种新的构造与线程是分开的,虽然在许多方面与线程相似,但在一些微妙的方式上存在区别。它会API划分为面向线程和面向协程的两部分,并且需要将这种新的类线程构造引入到平台及其工具的所有层面这需要更长的时间才能被生态系统接受,并且与用户模式线程相比,可能不会与平台那么优雅和协调

大多数已经采用async/await语法协程的语言之所以这样做,是因为无法实现用户模式线程(例如Kotlin),具有遗留的语义保证(例如固有的单线程JavaScript)或者是具有特定语言的技术约束(例如C++)。这些限制在Java中并不适用。

说的好像有人还沉醉在 new Thread() 似的, 很多面向线程的API就没多少人用