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 goroutine | JDK 21 虚拟线程 | Netty EventLoop |
|---|---|---|---|
| 执行体形态 | goroutine(有栈) | 虚拟线程(有栈) | 回调(无独立栈) |
| 栈放在哪 | 堆,2KB 起、按需增长 | 堆,初始块约 1KB | 共享 EventLoop 线程的栈 |
| 栈由谁回收 | GC + stackpool 复用 | GC + 分块回收 | 不适用(无独立栈) |
| 调度器 | GMP,抢占式 + work stealing | ForkJoinPool 作为 carrier,阻塞时 unmount | 固定线程数,事件驱动 |
| 阻塞的代价 | gopark 挂起 goroutine,M 让出 | unmount 虚拟线程,carrier 让出 | 不允许阻塞,一阻塞整个 EventLoop 停摆 |
| 实测每实例内存 | 约 2053 B(宿主 10 核) | 约 1006 B(容器 6 核) | 不适用 |
三条可判定的选型规则:
- 如果业务代码里会出现「同步调用下游」——查库、调 RPC、读文件——选有栈的执行体(goroutine 或虚拟线程)。 这类调用一旦写进 Netty 的
ChannelHandler,就会阻塞整个 EventLoop 上绑定的成百上千条连接。Netty 的答案是「再开一个业务线程池」,但那等于把执行体模型手工拼回来。 - 如果每条连接的逻辑极其简单、且回调链能写成纯状态机——选 EventLoop。 典型是纯转发网关、协议代理、以及像 6.1 的 WebSocket 握手这种一次性计算。它的优势是内存里没有 N 份栈,且缓存局部性极好。
- 如果团队是 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 消息头与命令号 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。