虚拟线程速览

Posted on Sep 26, 2023

为了解决什么问题?

围绕 I/O 为核心的单机任务,难点之一是权衡应对负载增加的能力和开发的难易程度,从 网络·NIO 中我们可以看到,Blocking I/O 优点是概念简单,缺点是在 I/O 操作完成之前,线程将无法执行任何其他操作,另外一点容易忽视的是它经过 Java 堆复制数据;Java NIO 劣势是概念较复杂(考虑异步容易出错),优势是支持非阻塞 I/O 和分配直接内存。一般情况下,基于 NIO 的服务比基于 BIO 的服务性能表现更好,但阻塞式 I/O 对广大开发者的心智负担更低。

使用非阻塞 I/O 模型难以避免引入异步编程或响应式编程,而这些范式对于开发者来说是一种挑战,因此许多主流语言都提供了将单线程阻塞式代码转化为异步、非阻塞式代码的语法糖,比如 C#/JavaScript 的 async/await

navigation-trace-async-program

  • 让出(yield)控制权给调用者线程。
  • 尝试隐藏异步调用与同步调用的差异,使其看起来像同步代码那样简单。
  • 是通过编译器自动将 async 方法转换为状态机来实现的。

详情参考 Task asynchronous programming model,随着越来越多的异步“传染”程序代码,性能提高代价可能是越来越难以推理代码。由于各种各样的原因,Java 官方没走 async/await 的道路,而是选择了类似于 Go/Kotlin 协程 的“绿色线程”。所谓“绿色”是相对于 OS 线程而言,其中 Java 的线程瓶颈尤为严重,已知 1 线程需要大小 1MB 的栈,那么 10000 线程大约消费 10 GB 内存,这类来自 OS 的瓶颈促使开发人员考虑线程池技术。随着负载的增加,Java 线程池的扩展又是一大挑战,官方正式推出的挑战者是虚拟线程

“每线程一请求”的崛起?

ExecutorService executor = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("my-thread", 0).factory());

@Override
public void run() {
    try (ServerSocket serverSocket = new ServerSocket(port)) {
        while (!Thread.interrupted()) {
            Socket socket = serverSocket.accept();
            executor.submit(() -> handleRequest(socket));
        }
    } catch (IOException e) {
        // ...
    } finally {
        executor.close();
    }
}

在开始之前需要先澄清若干概念:

  • 操作系统线程(OS thread)。由操作系统管理的数据结构。
  • 平台线程(Platform thread)。在 Java 19 之前,Thread 类的每个实例都是一个平台线程,是操作系统线程的包装器。创建一个平台线程就会创建一个操作系统线程,阻塞一个平台线程就会阻塞一个操作系统线程。
  • 虚拟线程(Virtual thread)。由 JVM 管理的轻量级线程。它们扩展了线程类,但不与特定的操作系统线程绑定。因此,虚拟线程的调度由 JVM 负责。
  • 载体线程(Carrier thread)。用于运行虚拟线程的平台线程称为载体线程。它不是一个有别于 Thread 或 VirtualThread 的类,而是一个功能名称。

JVM 级别的虚拟线程调度器对于虚拟线程应用 M : N 调度(M:N scheduling),M 表示较多虚拟线程数,N 表示较少平台线程数,并且 JVM 在可垃圾回收的堆中使用 Java 对象来表示虚拟线程的栈帧。

jvts

调度器调度虚拟线程时给虚拟线程挂载(mount) 平台线程,执行某些代码时卸载(unmount)平台线程(通常发生在执行阻塞式 I/O 操作),将它释放到 ForkJoinPool 维护的线程池,虚拟线程会被阻塞在 I/O 操作直到完成时才被调度器重新调度。

  • 阻塞或增加平台线程 ❌
    • 某些方法(例如 Object.wait())会触发捕获载体线程(capture the carrier thread),引起载体线程 ⬆️
  • 阻塞或增加虚拟线程 ✅
    • 固定虚拟线程(pinned thead):不卸载直到在 native method 或 synchronized code 返回,期间载体线程⬇️

虚拟线程 API 设计倾向于减轻开发人员心智负担,将虚拟线程复杂度转移到平台和库,但使用时仍然有不少注意事项:

  • 以“每线程一请求”方式编写简单的同步代码,采用阻塞式 I/O API
  • 将每个并发任务表示为一个虚拟线程,切勿池化虚拟线程
  • 使用 Semaphore 限制并发数。
  • 不要在 ThreadLocal 中缓存昂贵的可重用对象。
  • 避免长时间或频繁固定虚拟线程。

本文首发于 https://h2cone.github.io/

参考资料