一个 Java 开发者的认知升级之旅
为什么一个 4 核 CPU 能轻松跑 10 万个并发任务?为什么 Go 不需要 async/await?这篇文章将通过对比 Java 和 Go 的并发模型,揭示 Goroutine 调度的本质------它不是"轻量级线程",而是一种全新的并发抽象。
开场白
写 Java 十年了,第一次看到 Go 的协程,我的第一反应是:
"不就是轻量级线程吗?线程池加个队列不就完了?"
然后我开了 10 万个协程,程序跑得飞快,内存只占了几 MB。
这完全颠覆了我的认知。
协程真的是"轻量级线程"吗?不,它连"线程的缩小版"都不是。
这篇文章记录了我从"以为懂了"到"真正理解"的过程------以及那些曾经困惑我的核心问题。
先说结论:协程不是线程,是另一种东西
如果你只有 30 秒,记住这几句话就够了:
协程不是"轻量级线程",这个比喻很误导人。
准确的说法是:协程是"可暂停的任务",线程是"执行任务的工人"。
1. 协程不是线程,是"可暂停的任务"
协程 = 一个任务(一段代码、几个函数)
线程 = 执行任务的工人
调度器 = Go runtime 内部的任务分配逻辑
2. 多少核就多少线程
arduino
4 核 CPU → Go 默认开 4 个线程
协程可以开 10 万个 → 因为它只是"任务",不是"工人"
3. 切换成本降低了 1000 倍
线程切换:操作系统调度,1-2 微秒,内核态
协程切换:Go runtime 调度,几纳秒,用户态
所以 Go 能做到:4 个线程在 10 万个任务之间快速切换,成本低到可以忽略。
4. 任务遇到阻塞会"挂起",不会阻塞线程
go
time.Sleep(1秒) // 协程挂起,线程继续干别的
<-channel // 协程挂起,线程继续干别的
读文件/网络 // 协程挂起,线程继续干别的
线程永远不会闲着,永远在执行某个协程。
如果你已经理解了上面这些,后面的内容会帮你理解"为什么"以及"和 Java 有什么区别"。
如果还没完全理解,没关系,继续往下看。
从 Java 视角看 Go:几个常见的认知误区
作为一个 Java 老兵,我脑子里的第一反应是:
误区1:协程 = 线程池里的任务
"协程不就是 Runnable 吗?最后还不是扔到线程池里执行?"
误区2:time.Sleep() = Thread.sleep()
"让线程睡 1 秒呗,这有啥区别?"
误区3:channel = BlockingQueue
"不就是个队列吗?底层肯定用了 AQS 那套。"
听起来很合理,对吧?
但这些理解都不准确。
这些误区源于一个根本问题:我把 Go 的并发模型,硬套进了 Java 的"共享内存 + 锁"框架里。
认知误区 1:协程不是线程的缩小版
我问自己:"协程最终还是要靠线程执行,不就是把线程切更细吗?"
其实不是。协程和线程的关系,不是"大小",是"层次"。
看这张图:
我的第一反应:
"这不可能!4 个线程怎么跑 10 万个任务?肯定是阻塞等待,性能拉胯。"
然后我跑了个压测:
- Java 线程池(1000 线程):内存爆了,CPU 在疯狂切换线程
- Go 协程(10 万个):内存几 MB,CPU 平稳
这让我重新思考:协程根本不是"轻量级线程"。
真相是什么?
Java 的思路:
1 个任务 = 1 个线程(或复用有限的线程)
线程是"工人",任务让工人去干
Go 的思路:
1 个任务 = 1 个协程(可以有无限个)
协程是"任务清单",4 个工人轮流干 10 万个任务
关键区别:
- Java:工人(线程)休息 = 真休息,啥都不干
- Go:任务(协程)暂停 = 工人立刻干别的任务
协程的本质:它是一个可以暂停和恢复的函数。
认知误区 2:time.Sleep() 没让线程睡觉
我写了这段代码:
go
for i := 0; i < 100000; i++ {
go func() {
time.Sleep(time.Second)
fmt.Println("done")
}()
}
我以为:
"10 万个协程都在睡,那不得 10 万个线程都在睡?"
实际情况:
top 一看,只有 4 个线程,CPU 几乎为 0。
这让我好奇:"线程在干嘛?"
真相揭晓
看这个时序图就明白了:
大白话:
- Java 的
Thread.sleep():工人去睡觉了 - Go 的
time.Sleep():任务挂起了,工人继续干别的
所以开 10 万个 sleep 的协程,线程一个都没睡!
对比代码
Java 版:
java
for (int i = 0; i < 100000; i++) {
new Thread(() -> {
Thread.sleep(1000); // 线程真睡了,啥都不干
}).start();
}
// 结果:系统崩溃
Go 版:
go
for i := 0; i < 100000; i++ {
go func() {
time.Sleep(time.Second) // 协程挂起,线程继续工作
}()
}
// 结果:4个线程平稳运行
认知误区 3:channel 不是 BlockingQueue
我看到这段代码:
go
ch := make(chan int)
go func() {
ch <- 42 // 发送
}()
data := <-ch // 接收
我的第一反应:
"这不就是 Java 的 BlockingQueue 吗?put() 和 take() 换个写法而已。"
但仔细了解后发现,工作机制完全不同。
BlockingQueue 是怎么工作的?
java
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
// 线程1
queue.put(42); // 如果满了,线程阻塞,啥都不干
// 线程2
int data = queue.take(); // 如果空了,线程阻塞,啥都不干
关键:线程真的会阻塞,CPU 浪费在那里。
Go 的 channel 是怎么工作的?
线程去干别的] Note2[协程B挂起
线程去干别的] style B fill:#FFE0B2
关键流程:
-
协程A 写 channel,没人接收
- 协程A 挂起(不是线程睡觉!)
- 线程立刻切到其他协程
-
协程B 读 channel,拿到数据
- 协程A 被唤醒,放回就绪队列
- 线程继续调度
整个过程,线程没有浪费一纳秒。
认知误区 4:切换成本完全不是一个量级
我心想:"切换就是切换,保存寄存器、切上下文,都一样贵吧?"
实际上差距巨大。
线程切换(操作系统干的)
markdown
1. 保存当前线程的所有寄存器(几十个)
2. 切换页表(虚拟内存映射)
3. 刷新 TLB(地址转换缓存)
4. 刷新 CPU 缓存
5. 通知调度器
成本:1-2 微秒
协程切换(Go runtime 自己干的)
markdown
1. 保存 3 个寄存器(PC、SP、几个通用寄存器)
2. 就这么多
成本:几纳秒
速度对比:协程切换比线程切换快 1000 倍。
为什么这么快?
- 线程切换在内核态,协程切换在用户态
- 线程切换要操作系统介入,协程切换 Go 自己搞定
- 线程切换要刷新缓存,协程切换啥都不用刷
所以 Go 的调度器到底怎么玩的?
如果你用过 Netty,一秒就能懂。
Netty 的 EventLoop
你可能见过这样的代码:
java
EventLoop loop = new NioEventLoopGroup().next();
loop.execute(() -> {
// 任务1
});
loop.execute(() -> {
// 任务2
});
// EventLoop 在一个线程里不停地:
// 1. 从任务队列取任务
// 2. 执行任务
// 3. 处理 I/O 事件
Go 的调度器就是这个思路!
Go 的 GMP 模型
把 Go 想象成一个高级的 Netty:
三个角色:
- G (Goroutine):要干的活(协程)
- M (Machine):干活的工人(系统线程)
- P (Processor):工头(调度器,默认数量 = CPU 核心数)
工作流程:
每个 P(工头)带着一个 M(工人),不停地:
lua
while (true) {
从队列里拿一个协程 G
让 M 执行 G 的代码
如果 G 遇到:
- time.Sleep() → 挂起 G,继续下一个
- channel 阻塞 → 挂起 G,继续下一个
- 系统调用 → 挂起 G,继续下一个
否则,G 运行完了,继续下一个
}
和 Netty 对比:
| Netty | Go |
|---|---|
| EventLoop 线程 | Go 的 M(线程) |
| 任务队列 | 协程就绪队列 |
| loop.execute(task) | go func() |
| 异步 I/O + 回调 | 协程阻塞 + 自动挂起 |
关键区别:
- Netty:你要手动写回调
- Go:你写同步代码,runtime 自动异步化
实战对比:开 10 万个任务
Java 版(传统线程池)
java
ExecutorService pool = Executors.newFixedThreadPool(1000);
for (int i = 0; i < 100000; i++) {
pool.submit(() -> {
Thread.sleep(1000); // 线程真睡了
System.out.println("done");
});
}
// 结果:
// - 1000 个线程在疯狂切换
// - 内存占用:1000 x 1MB = 1GB
// - CPU 大量时间浪费在上下文切换
Go 版(协程)
go
for i := 0; i < 100000; i++ {
go func() {
time.Sleep(time.Second) // 协程挂起,线程继续干活
fmt.Println("done")
}()
}
// 结果:
// - 4 个线程平稳运行
// - 内存占用:100000 x 2KB = 200MB
// - CPU 几乎不浪费
我第一次看到这个对比,人都傻了。
深入理解:为什么 Go 不需要 async/await?
有一天我在想:"为什么 Go 不需要 async/await?"
Java、JavaScript、C# 都有 async/await,为啥 Go 不需要?
答案是:
因为协程切换太快了,Go 可以在任何阻塞点自动切换,根本不需要程序员显式标记。
其他语言的逻辑:
javascript
// JavaScript
async function fetchData() {
const result = await fetch(url); // 明确标记"这里会等"
return result;
}
为啥要加 async/await?因为线程切换太贵,必须明确告诉编译器"这里可以切走"。
Go 的逻辑:
go
func fetchData() string {
result := fetch(url) // 看起来同步,其实 runtime 自动切换了
return result
}
为啥不需要?因为协程切换成本低到可以忽略,Go runtime 自己决定什么时候切。
这就是设计哲学的差异:
你以为要学新语法(async/await),其实 Go 通过 runtime 调度解决了这个问题。
总结:四个关键认知点
经过这一轮学习,我总结出了这几个关键点:
1. 协程不是线程的小号
线程:
- 操作系统管
- 1-2 MB 内存
- 切换要 1-2 微秒
- 阻塞就真阻塞了
协程:
- Go runtime 管
- 2 KB 内存
- 切换要几纳秒
- 阻塞只是"挂起",线程继续干活
2. time.Sleep() 不是让线程睡
Java 的 Thread.sleep():
线程去睡觉,CPU 浪费
Go 的 time.Sleep():
协程挂起,线程继续干活
3. channel 不是队列
Java 的 BlockingQueue:
线程阻塞等数据
Go 的 channel:
协程挂起,线程继续调度其他协程
4. 切换成本天差地别
线程切换: 1-2 微秒(内核态,刷新缓存)
协程切换: 几纳秒(用户态,不刷新)
快 1000 倍。
用一个生活化的类比理解
Java 并发编程:
你是老板,雇了 1000 个工人。
- 工人睡觉,你得发工资
- 工人打架,你得管
- 工人太多,HR 崩溃
Go 并发编程:
你是老板,列了 10 万个任务。
- Go 只给你 4 个超级工人
- 工人自己安排任务优先级
- 你只管列任务,其他不用操心
三个关键启示
1. 不要用旧框架套新概念
协程不是线程,channel 不是队列,Go 的并发是另一套哲学。
2. 写同步代码,获得异步性能
- 不需要回调
- 不需要 Promise
- 不需要 async/await
3. 通过通信来共享内存
- 不要用锁
- 用 channel 传数据
- 代码更清晰
写给同样在探索 Go 的开发者
如果你刚接触 Go,觉得协程的行为很反直觉,这很正常。
因为我们在用传统线程模型的思维理解 Go。
记住这句话:
"协程不是更轻的线程,而是一种全新的并发抽象------它让 runtime 承担了调度的复杂性,让开发者可以用同步的方式写出高并发的代码。"
试着放下对线程、锁、回调的固有认知,重新理解并发。
你会发现,原来并发可以这么优雅。