《MMO 服务器开发》2.3 双栈对照:goroutine、Java 虚拟线程与 Netty EventLoop

把 Go 的 goroutine、JDK 21 的虚拟线程与 Netty 的 EventLoop 放进同一张表:三者的栈内存模型、调度器和 GC 交互各有什么不同。10 万个执行体的实跑对照给出真实数字,最后落在「什么条件下选哪条路线」的可判定结论上。

2.2 量完了 Go 这一侧。但 MMO 服务端从来不只有一种技术栈:网关可能是 Netty,游戏服可能是 Go,中间还夹着一层 Java 的老服务。同一个「一个连接一个执行体」的想法,在 Go 里叫 goroutine,在 JDK 21 里叫虚拟线程,在 Netty 里则干脆被否掉了——Netty 用少量线程加事件循环,明确拒绝「一个连接一个线程」。

本节不做「翻译版 Go」。三者的分歧不在语法,而在三个地方:栈内存放在哪里、谁来调度、GC 怎么和它们打交道。 这三个分歧决定了它们在压力下的行为完全不同。

本节要回答:10 万个执行体,Go 和 Java 各要付多少钱?什么时候该用事件循环而不是执行体? 结论是:同机实测 10 万个 goroutine 创建到回收约 49–65ms、同时存活时每实例约 2053 字节;10 万虚拟线程在 6 核容器里创建到回收约 84ms、每实例约 1006 字节;两者数量级相同,都远优于平台线程。净差距不在「谁更快」,而在栈的所有权与回收语义。

2.3.1 Go 侧:10 万个 goroutine 的实测

实验分两段,缺一不可:A 段建完就跑、跑完就退,量「创建 + 调度 + 回收」的总耗时;B 段让 10 万个全部停在 channel 上,量「同时活着」的常驻内存。如果只做 A 段,结束时所有栈已经还给 stackpool,此时读 MemStats 只会得到接近 0 的增量——拿它算「每个 goroutine 多少字节」是错的。

下面这段是 B 类(产出数字的基准),go test 可以直接跑:

// file: internal/netio/loadtest/spawn_test.go
// tier: B
// verify: go test -v -run TestSpawn100kGoroutines -timeout 600s ./internal/netio/loadtest/
package loadtest

import (
	"runtime"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

const spawnN = 100_000

// task 是一份「小任务」:定量纯计算,不含任何阻塞。这样量出来的是
// 「创建 + 调度 + 回收 N 个执行体」本身的成本,而不是任务里干了多少活。
// Java 侧 Spawn100k.java 用同一个常数,两边才可比。
//
//go:noinline
func task(seed uint64) uint64 {
	x := seed | 1
	for i := 0; i < 2000; i++ {
		x = x*6364136223846793005 + 1442695040888963407
	}
	return x
}

func mib(b uint64) float64 { return float64(b) / (1 << 20) }

// TestSpawn100kGoroutines 分两段量 10 万个 goroutine:
//
//	A 段——建完就跑、跑完就退,量「创建 + 调度 + 回收」的总耗时;
//	B 段——10 万个全部停在 channel 上,量「同时活着」的常驻内存。
//
// 两段必须分开。A 段结束时所有栈已经还给 stackpool,此时读 MemStats
// 只会得到接近 0 的增量,拿它去算「每个 goroutine 多少字节」是错的。
func TestSpawn100kGoroutines(t *testing.T) {
	if testing.Short() {
		t.Skip("短模式跳过 10 万 goroutine 实验")
	}
	t.Logf("GOMAXPROCS=%d NumCPU=%d", runtime.GOMAXPROCS(0), runtime.NumCPU())

	// 热身:喂热栈缓存池与 P 的本地缓存,否则第一轮的
	// stackalloc / mallocgc 会被算进「创建成本」。
	var warm sync.WaitGroup
	for i := 0; i < spawnN/10; i++ {
		warm.Add(1)
		go func(i int) { defer warm.Done(); task(uint64(i)) }(i)
	}
	warm.Wait()
	runtime.GC()

	// ---- A 段:创建 → 执行 → 回收 ----
	var sum atomic.Uint64
	var wg sync.WaitGroup
	var beforeA runtime.MemStats
	runtime.ReadMemStats(&beforeA)

	start := time.Now()
	for i := 0; i < spawnN; i++ {
		wg.Add(1)
		go func(i int) { defer wg.Done(); sum.Add(task(uint64(i))) }(i)
	}
	wg.Wait()
	elapsed := time.Since(start)

	var afterA runtime.MemStats
	runtime.ReadMemStats(&afterA)
	t.Logf("A 段 n=%d 总耗时=%v 单实例=%v TotalAlloc=+%.1f MiB", spawnN,
		elapsed.Round(time.Millisecond), (elapsed / time.Duration(spawnN)).Round(time.Nanosecond),
		mib(afterA.TotalAlloc-beforeA.TotalAlloc))

	// ---- B 段:10 万个全部活着,停在 gate 上 ----
	runtime.GC()
	var beforeB runtime.MemStats
	runtime.ReadMemStats(&beforeB)
	base := runtime.NumGoroutine()

	gate := make(chan struct{})
	var ready, done sync.WaitGroup
	ready.Add(spawnN)
	done.Add(spawnN)
	for i := 0; i < spawnN; i++ {
		go func() { defer done.Done(); ready.Done(); <-gate }()
	}
	ready.Wait()
	time.Sleep(300 * time.Millisecond) // 等最后一批栈落定再采样

	var afterB runtime.MemStats
	runtime.ReadMemStats(&afterB)
	live := runtime.NumGoroutine()
	stack := int64(afterB.StackInuse) - int64(beforeB.StackInuse)
	heap := int64(afterB.HeapAlloc) - int64(beforeB.HeapAlloc)
	t.Logf("B 段 n=%d NumGoroutine=%d(基线 %d) StackInuse=+%.1f MiB HeapAlloc=+%.1f MiB 每实例约 %.0f B",
		spawnN, live, base, mib(uint64(stack)), mib(uint64(heap)), float64(stack)/spawnN)

	close(gate)
	done.Wait()
	runtime.GC()
	var afterC runtime.MemStats
	runtime.ReadMemStats(&afterC)
	t.Logf("C 段 回收后 NumGoroutine=%d StackInuse=%.1f MiB", runtime.NumGoroutine(), mib(afterC.StackInuse))

	if live-base < spawnN*99/100 || stack <= 0 || sum.Load() == 0 {
		t.Fatalf("观测异常: live=%d stack=%d sum=%d", live, stack, sum.Load())
	}
}

本机实跑输出:

GOMAXPROCS=10 NumCPU=10
A 段 n=100000 总耗时=49ms 单实例=485ns TotalAlloc=+5.3 MiB
B 段 n=100000 NumGoroutine=100002(基线 2) StackInuse=+195.8 MiB HeapAlloc=+58.5 MiB 每实例约 2053 B
C 段 回收后 NumGoroutine=2 StackInuse=3.7 MiB

三行读出来三件事:

  • 每实例约 2053 字节,与 stackMin = 2048 逐字对上。 停在 channel 上的 goroutine 用的就是最小栈,一个字节都没多长。
  • C 段是这套内存模型的关键证据。 10 万个 goroutine 退出后 StackInuse 从 195.8 MiB 掉回 3.7 MiB——栈没有被 free 给操作系统,而是还给了 runtime 的 stackpool 复用。这意味着「起 10 万个 goroutine 再退掉」不会造成地址空间的反复申请释放。
  • A 段 49ms 是「创建 + 跑 2000 轮 LCG + 回收」的总时间,TotalAlloc 只涨了 5.3 MiB——因为栈是从 stackpool 里复用出来的,不走 mallocgc。

2.3.2 Java 侧:10 万虚拟线程的实测

Java 侧做同一件事,用 JDK 21 的 Executors.newVirtualThreadPerTaskExecutor()。注意运行环境:Java 代码跑在 Docker 里(Colima 虚拟机,6 个可用处理器、10 GiB 内存),而 Go 那组跑在宿主机上(10 个逻辑核)。两者的绝对耗时不可直接比较,可比较的是每实例的量级。

按本卷约定,Java 代码必须落到 $HOME 下(Colima 只把 $HOME 挂进虚拟机,/tmp 挂不进去),验证命令如下:

cp java/Spawn100k.java "$HOME/.aegis-java/ch02/" && \
docker run --rm -v "$HOME/.aegis-java/ch02:/w" -w /w eclipse-temurin:21-jdk \
  sh -c "javac Spawn100k.java && java Spawn100k"
// file: java/Spawn100k.java
// tier: L2J
// verify: cp java/Spawn100k.java "$HOME/.aegis-java/ch02/" && docker run --rm -v "$HOME/.aegis-java/ch02:/w" -w /w eclipse-temurin:21-jdk sh -c "javac Spawn100k.java && java Spawn100k"
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;

/**
 * 2.3 节 Java 侧对照:JDK 21 虚拟线程。与 Go 侧 TestSpawn100kGoroutines 共用
 * 同一个「小任务」常数(2000 轮 LCG),A/B 两段的语义与 Go 侧完全对齐。
 */
public class Spawn100k {
    static final int N = 100_000;
    static final int PLATFORM_N = 10_000; // 平台线程只敢跑到 1 万,理由见正文
    static final int WARMUP = 10_000;

    /** 与 Go 侧 task() 逐字对应的定量小任务。 */
    static long task(long seed) {
        long x = seed | 1L;
        for (int i = 0; i < 2000; i++) {
            x = x * 6364136223846793005L + 1442695040888963407L;
        }
        return x;
    }

    static long usedHeap() {
        Runtime rt = Runtime.getRuntime();
        return rt.totalMemory() - rt.freeMemory();
    }

    static ExecutorService pool(boolean virtual) {
        return virtual ? Executors.newVirtualThreadPerTaskExecutor()
                       : Executors.newCachedThreadPool();
    }

    /** A 段:创建 → 执行 → 回收。 */
    static void phaseCreateRun(String kind, int n, boolean virtual) throws Exception {
        AtomicLong sum = new AtomicLong();
        CountDownLatch latch = new CountDownLatch(n);
        long t0 = System.nanoTime();
        try (ExecutorService ex = pool(virtual)) {
            for (int i = 0; i < n; i++) {
                final long id = i;
                ex.submit(() -> { sum.addAndGet(task(id)); latch.countDown(); });
            }
            latch.await();
        }
        long ns = System.nanoTime() - t0;
        System.out.printf(Locale.ROOT, "A 段 [%s] n=%-7d 总耗时=%9.3f ms 单实例=%7.3f us sum!=0=%b%n",
            kind, n, ns / 1e6, ns / 1000.0 / n, sum.get() != 0L);
    }

    /** B 段:N 个执行体全部活着,停在 latch 上。 */
    static void phaseResident(String kind, int n, boolean virtual) throws Exception {
        System.gc();
        Thread.sleep(300);
        long before = usedHeap();
        CountDownLatch gate = new CountDownLatch(1);
        CountDownLatch ready = new CountDownLatch(n);
        CountDownLatch done = new CountDownLatch(n);
        try (ExecutorService ex = pool(virtual)) {
            for (int i = 0; i < n; i++) {
                ex.submit(() -> { ready.countDown(); gate.await(); done.countDown(); return null; });
            }
            ready.await();
            Thread.sleep(500);
            System.gc(); // 与 Go 侧一样先 GC 再采样:量「占住的」,不是「没人引用的垃圾」
            Thread.sleep(300);
            long after = usedHeap();
            System.out.printf(Locale.ROOT, "B 段 [%s] n=%-7d 堆增量=%7.1f MiB 每实例约 %5.0f B 平台线程数=%d%n",
                kind, n, (after - before) / 1048576.0, (double) (after - before) / n, Thread.activeCount());
            gate.countDown();
            done.await();
        }
    }

    public static void main(String[] args) throws Exception {
        Runtime rt = Runtime.getRuntime();
        System.out.printf(Locale.ROOT, "JVM %s | 可用处理器=%d | 最大堆=%.0f MiB%n",
            System.getProperty("java.version"), rt.availableProcessors(), rt.maxMemory() / 1048576.0);
        phaseCreateRun("warmup", WARMUP, true); // 先热 JIT,否则解释执行的开销会混进 A 段耗时
        phaseCreateRun("warmup", WARMUP, false);
        phaseCreateRun("virtual ", N, true);
        phaseCreateRun("platform", PLATFORM_N, false);
        phaseResident("virtual ", N, true);
        phaseResident("platform", PLATFORM_N, false);
    }
}

Docker 里实跑输出:

JVM 21.0.12.1 | 可用处理器=6 | 最大堆=2482 MiB
A 段 [virtual ] n=100000  总耗时=   84.094 ms 单实例=  0.841 us sum!=0=true
A 段 [platform] n=10000   总耗时=   36.030 ms 单实例=  3.603 us sum!=0=true
B 段 [virtual ] n=100000  堆增量=   96.0 MiB 每实例约  1006 B 平台线程数=1
B 段 [platform] n=10000   堆增量=    4.6 MiB 每实例约   479 B 平台线程数=10001

四个数字要连起来读:

  • 虚拟线程 10 万个,Thread.activeCount() 仍然是 1。 这是虚拟线程的定义性证据:它们不是操作系统线程,activeCount() 数不到。
  • 每实例约 1006 字节,比 goroutine 的 2053 字节还小。 虚拟线程的栈同样在堆上、同样按需增长,初始块比 Go 的 2KB 更小。
  • 平台线程那组每实例只有 479 字节——但这个数字是假的。 它只统计了 Java 堆,而平台线程的栈在 native 内存里,默认 1 MiB 一块,根本不进堆。10 万个平台线程意味着约 98 GiB 的栈地址空间保留,这才是「平台线程撑不住高并发」的真正原因,而不是那 479 字节。
  • 平台线程的 A 段单实例耗时是虚拟线程的约 4 倍(3.603µs 对 0.841µs,同一容器内)。差值就是创建内核调度实体的成本。

2.3.3 三条路线的分歧点与选型

把三者放进同一张表。注意「栈在哪」这一行——它才是所有其他差异的源头:

关注点Go goroutineJDK 21 虚拟线程Netty EventLoop
执行体形态goroutine(有栈)虚拟线程(有栈)回调(无独立栈)
栈放在哪堆,2KB 起、按需增长堆,初始块约 1KB共享 EventLoop 线程的栈
栈由谁回收GC + stackpool 复用GC + 分块回收不适用(无独立栈)
调度器GMP,抢占式 + work stealingForkJoinPool 作为 carrier,阻塞时 unmount固定线程数,事件驱动
阻塞的代价gopark 挂起 goroutine,M 让出unmount 虚拟线程,carrier 让出不允许阻塞,一阻塞整个 EventLoop 停摆
实测每实例内存约 2053 B(宿主 10 核)约 1006 B(容器 6 核)不适用

三条可判定的选型规则:

  1. 如果业务代码里会出现「同步调用下游」——查库、调 RPC、读文件——选有栈的执行体(goroutine 或虚拟线程)。 这类调用一旦写进 Netty 的 ChannelHandler,就会阻塞整个 EventLoop 上绑定的成百上千条连接。Netty 的答案是「再开一个业务线程池」,但那等于把执行体模型手工拼回来。
  2. 如果每条连接的逻辑极其简单、且回调链能写成纯状态机——选 EventLoop。 典型是纯转发网关、协议代理、以及像 6.1 的 WebSocket 握手这种一次性计算。它的优势是内存里没有 N 份栈,且缓存局部性极好。
  3. 如果团队是 Java 且 JDK 版本 ≥ 21——优先虚拟线程,而不是 Netty。 虚拟线程把「一个连接一个执行体」的写法还给了 Java,且不需要引入任何框架;Netty 的价值退回到它真正的强项:零拷贝 ByteBuf(见 4.2)和成熟的协议栈。

一条必须记住的边界:虚拟线程解决的是「线程太贵」,不解决「阻塞本身是浪费」。 无论是 goroutine 还是虚拟线程,一次阻塞调用依然要走一次完整的挂起—唤醒路径(gopark/goready,或 unmount/mount)。2.2 量到的「2000 并发时吞吐从 4159 掉到 1008 往返/秒」在两套栈上都成立。换运行时能改变常数,改不了 Little 定律。

本节小结

  • 三者的分歧只有一个源头:栈放在哪里。 Go 和虚拟线程把栈放堆上,所以能开十万个;平台线程和 Netty 不能,所以一个只能开几千、另一个干脆不要栈。
  • 10 万个执行体的实测:goroutine 每实例约 2053 字节、虚拟线程约 1006 字节,量级相同。 净差距不在「谁更快」,而在回收语义——goroutine 退出后栈回到 stackpool(StackInuse 从 195.8 MiB 掉回 3.7 MiB),虚拟线程的栈则交给 GC。
  • 别拿平台线程的堆占用当内存成本。 那 479 字节不含 native 栈;默认 1 MiB 一块,10 万条就是约 98 GiB。这是「平台线程撑不住高并发」的真实原因。
  • 选型判据是「代码里会不会出现同步调用下游」,不是性能。 会,就用有栈执行体;不会,且逻辑是纯状态机,就用 EventLoop。
  • 同机相对量级,绝对数值不可移植。 Go 组跑在 10 核宿主机、Java 组跑在 6 核容器,且宿主机负载均值 57–66;两边都只能横着比量级,不能竖着比快慢。

下一章离开并发模型,进入协议:一条 TCP 流怎么切出一个个消息。

阅读导航:上一节:2.2 goroutine-per-connection 与 Go 调度器 · 下一节:3.1 消息头与命令号 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标