Go 并发编程
goroutine、channel 与同步原语,深入 GMP 调度模型,并与 Java 线程 / Kotlin 协程对照。
一、并发模型总览
1.1 并发与并行
- 并发(concurrency):同时处理多件事的结构设计,是程序结构问题。
- 并行(parallelism):同时执行多件事,是运行问题,依赖多核硬件。
Go 的设计理念是"你只管写出并发结构(goroutine + channel),调度器在可能时自动并行执行"。这句话翻译过来:Go 帮你做了 Java 中线程池 + Future + 回调的组合工作。
1.2 从 OS 线程到 goroutine
Java 中一个 new Thread(...).start() 背后是 1:1 的 OS 线程:内核态创建、默认栈 1MB、上下文切换要进内核。而 goroutine 是 Go 运行时自己管理的用户态协程:
| 维度 | Java 线程 | goroutine |
|---|---|---|
| 归属 | OS / JVM | Go runtime |
| 初始栈 | 约 1MB(固定) | 2KB(可动态扩缩到 1GB) |
| 创建成本 | 高(走内核) | 低(用户态,纳秒级) |
| 切换成本 | 内核上下文切换 | 用户态,微秒级以下 |
| 规模上限 | 数千到数万 | 轻松百万级 |
go 关键字一条语句就启动一个 goroutine,无返回值、无 join 句柄(同步交给 channel / WaitGroup):
go func() {
fmt.Println("in goroutine")
}()go func() {
fmt.Println("in goroutine")
}()1.3 并发编程三原则(Go 官方格言)
- Do not communicate by sharing memory; instead, share memory by communicating.(不要通过共享内存来通信,而要通过通信来共享内存。)
- goroutine 不是免费的,但非常便宜,可大量使用。
- 不要启动你不知道何时结束的 goroutine(防泄漏)。
二、goroutine 深入
2.1 栈管理:小栈起步,动态增长
每个 goroutine 初始栈仅 2KB,按需增长(连续栈机制:扩容时分配 2 倍新栈并拷贝),用完收缩。百万 goroutine 的峰值内存约 2GB 仅是理论上限,实际远小于 Java 百万线程(1TB 栈)的荒谬程度。
2.2 goroutine 泄漏:最贵的 Bug
泄漏 = goroutine 永久阻塞,其栈与引用对象永不回收。三大来源与对策:
| 泄漏来源 | 例子 | 对策 |
|---|---|---|
| channel 没人收 | ch <- x 永远阻塞 | 用 select + ctx / timeout |
| 忘记关闭通知 | 消费者死等 range ch | 生产者 close 或改用 ctx |
| 锁未释放 / 阻塞调用 | Mutex 忘记 Unlock | defer 解锁 + go vet 检查 |
排查手段:runtime.NumGoroutine() 打点监控;go tool pprof 抓 goroutine 堆栈;go test -run xxx 配合 goleak(Uber)在测试结尾断言无泄漏。
2.3 runtime 控制:Gosched 与 GOMAXPROCS
runtime.GOMAXPROCS(0) // 查询;设置默认不推荐,交给默认值(= 核数)
runtime.Gosched() // 让出 P,一般不需要手动调用
runtime.NumGoroutine()// 当前 goroutine 数(监控用)runtime.GOMAXPROCS(0) // 查询;设置默认不推荐,交给默认值(= 核数)
runtime.Gosched() // 让出 P,一般不需要手动调用
runtime.NumGoroutine()// 当前 goroutine 数(监控用)三、channel:通信即同步
3.1 channel 基础
channel 是带类型的管道,创建用 make(引用类型):
ch := make(chan int) // 无缓冲
ch2 := make(chan int, 10) // 有缓冲,容量 10
ch <- 1 // 发送
v := <-ch // 接收
v, ok := <-ch // 带 ok 判断通道是否已关闭
close(ch) // 关闭ch := make(chan int) // 无缓冲
ch2 := make(chan int, 10) // 有缓冲,容量 10
ch <- 1 // 发送
v := <-ch // 接收
v, ok := <-ch // 带 ok 判断通道是否已关闭
close(ch) // 关闭发送与接收都是阻塞语义,正好天然实现了同步(Java 的 SynchronousQueue 是它的带类型亲戚)。
3.2 有缓冲与无缓冲
| 类型 | 发送时机 | 效果 |
|---|---|---|
| 无缓冲 | 必须有接收者就绪 | 发送者与接收者同时就绪才交接(同步) |
| 有缓冲 | 缓冲未满即可发 | 发送者不阻塞,起到队列 / 限流作用 |
无缓冲 channel 常用来做两个 goroutine 间的握手信号,有缓冲 channel 则是简易队列。缓冲容量填满后继续发送会阻塞,这就是天然背压(backpressure)。
3.3 关闭与 range
发送方负责 close,接收方用 for range 优雅消费完剩余值:
func produce(ch chan<- int) { // chan<- int 单向:只写
for i := 0; i < 5; i++ { ch <- i }
close(ch) // 发送方关闭,之后接收方 range 自然结束
}
func main() {
ch := make(chan int, 3)
go produce(ch)
for v := range ch { // 读到关闭为止,等价于每轮检查 ok
fmt.Println(v)
}
}func produce(ch chan<- int) { // chan<- int 单向:只写
for i := 0; i < 5; i++ { ch <- i }
close(ch) // 发送方关闭,之后接收方 range 自然结束
}
func main() {
ch := make(chan int, 3)
go produce(ch)
for v := range ch { // 读到关闭为止,等价于每轮检查 ok
fmt.Println(v)
}
}规则:永远只在发送方关闭 channel;接收方不能也不应关闭。 关闭后接收会立即拿到零值,配合 v, ok := <-ch 区分。
3.4 单向 channel
chan<- T(只写)与 <-chan T(只读)用于函数签名,限制使用权限,编译期防呆。双向 channel 可隐式转为单向(反之不行),如上面示例的 produce(ch chan<- int)。
3.5 select:多路复用
select 同时等待多个 channel,谁先就绪执行谁(随机公平选择),是 Go 并发的瑞士军刀:
select {
case v := <-jobs:
process(v)
case <-time.After(3 * time.Second):
fmt.Println("超时,放弃等待")
case <-ctx.Done():
fmt.Println("被取消:", ctx.Err())
default: // 非阻塞分支,没有就绪的立即走这里
fmt.Println("暂无任务")
}select {
case v := <-jobs:
process(v)
case <-time.After(3 * time.Second):
fmt.Println("超时,放弃等待")
case <-ctx.Done():
fmt.Println("被取消:", ctx.Err())
default: // 非阻塞分支,没有就绪的立即走这里
fmt.Println("暂无任务")
}- 多个 case 同时就绪时 select 随机选一个(公平性)。
- 没有 default 且无就绪 case 时会阻塞。
for { select {} }是事件循环惯用结构。
3.6 channel 高频坑
- 对已关闭 channel 发送会 panic:
send on closed channel。 - 重复 close 会 panic:用 sync.Once 包裹 close。
- 关闭后接收立即返回零值:业务上必须用
ok判断而非判断零值。 - nil channel 收发都会永久阻塞(可用于 select 中临时"禁用"某分支)。
- 忘记 close 不会 panic,但会让
range死等——确认消费方语义再决定是否 close。
四、sync 包:显式同步原语
4.1 WaitGroup:等待一组 goroutine
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait() // 阻塞直到全部 Donevar wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait() // 阻塞直到全部 DoneAdd必须在Wait之前(或并发执行 goroutine 前)调用完毕。Done等价于Add(-1);计数器不能为负,否则 panic。WaitGroup不能复制(拷贝后计数器丢失),传递要用指针。
4.2 Mutex 与 RWMutex
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // defer 保证解锁,配合 recover 兜底
c.n++
}type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // defer 保证解锁,配合 recover 兜底
c.n++
}sync.Mutex:互斥锁(Javasynchronized/ReentrantLock)。不可重入:同一 goroutine 重复 Lock 会死锁。sync.RWMutex:读写锁,多读单写(JavaReentrantReadWriteLock)。读锁可并发,写锁独占;写锁优先于读锁(防止写饥饿)。- 锁拷贝即灾难:Mutex 嵌入 struct 后,该 struct 只能以指针传递。
4.3 Once:只执行一次
var (
once sync.Once
pool *sql.DB
)
func getDB() *sql.DB {
once.Do(func() { pool = connect() }) // 并发下也仅执行一次
return pool
}var (
once sync.Once
pool *sql.DB
)
func getDB() *sql.DB {
once.Do(func() { pool = connect() }) // 并发下也仅执行一次
return pool
}标准库内部(如 sync.Map 的初始化)大量使用 Once;它保证函数要么完整执行要么不执行,是"懒加载单例"的安全写法(Java 对应双重检查锁的繁琐)。
4.4 Cond:条件变量
Cond 让 goroutine 等待"条件成立",比轮询省 CPU:
c := sync.NewCond(&sync.Mutex{})
ready := false
go func() {
c.L.Lock()
for !ready { c.Wait() } // Wait 内部:解锁 -> 挂起 -> 唤醒后重新加锁
c.L.Unlock()
fmt.Println("开始干活")
}()
// 主流程准备就绪
c.L.Lock()
ready = true
c.Broadcast() // 唤醒全部;Signal 只唤醒一个
c.L.Unlock()c := sync.NewCond(&sync.Mutex{})
ready := false
go func() {
c.L.Lock()
for !ready { c.Wait() } // Wait 内部:解锁 -> 挂起 -> 唤醒后重新加锁
c.L.Unlock()
fmt.Println("开始干活")
}()
// 主流程准备就绪
c.L.Lock()
ready = true
c.Broadcast() // 唤醒全部;Signal 只唤醒一个
c.L.Unlock()坑:条件判断必须用 for 而非 if(防止虚假唤醒 / 错过信号,与 Java await 规范一致);否则可能出现被唤醒后条件仍不成立。
4.5 Map:并发安全的 map
var m sync.Map
m.Store("key", 1)
v, ok := m.Load("key")
m.LoadOrStore("key", 2) // 不存在才写入(原子)
m.Range(func(k, v any) bool { // 遍历
return true // false 停止
})var m sync.Map
m.Store("key", 1)
v, ok := m.Load("key")
m.LoadOrStore("key", 2) // 不存在才写入(原子)
m.Range(func(k, v any) bool { // 遍历
return true // false 停止
})适用场景有限:读多写少的"写时复制型"缓存、只增不减的注册表。普通业务 map 请用 Mutex + 原生 map,性能与类型安全都更好(sync.Map 无泛型,Go 1.24 仍无内置泛型版本,社区用泛型封装)。
4.6 Pool:对象复用池
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 1024) },
}
func handle() {
b := bufPool.Get().([]byte) // 取(可能新建)
b = append(b, "data"...)
// 用完放回
bufPool.Put(b[:0])
}var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 1024) },
}
func handle() {
b := bufPool.Get().([]byte) // 取(可能新建)
b = append(b, "data"...)
// 用完放回
bufPool.Put(b[:0])
}- 专为降低高频临时对象 GC 压力设计(典型:json 编解码缓冲区、bytes.Buffer)。
- Pool 中的对象可能被 GC 随时回收,不能假设取出的对象一定在。
- 放回前必须重置状态,否则脏数据复用。
4.7 atomic:无锁原子操作
var n atomic.Int64 // Go 1.19+ 泛型风格原子类型
n.Add(1)
v := n.Load()
n.Store(10)
n.CompareAndSwap(10, 11) // CASvar n atomic.Int64 // Go 1.19+ 泛型风格原子类型
n.Add(1)
v := n.Load()
n.Store(10)
n.CompareAndSwap(10, 11) // CASatomic 适用于:计数器、标志位、状态机等简单共享变量。与 Java AtomicInteger / volatile 对应,但没有 volatile 关键字——Go 里所有并发共享读写都必须用原子或锁或 channel,否则就是数据竞争。
五、GMP 调度模型
5.1 G / M / P 是什么
| 符号 | 全称 | 职责 |
|---|---|---|
| G | Goroutine | 一个待执行的用户协程(含栈、状态) |
| M | Machine | 工作线程,真正执行 Go 代码的 OS 线程 |
| P | Processor | 调度上下文,持有本地可运行 G 队列(容量 256)与执行资源 |
运行时维护一张全局就绪队列 + 每个 P 一个本地队列。规则:
- 一个 M 要执行 G 必须先绑定一个 P(M:P = N:1 关系,P 数量 = GOMAXPROCS)。
- 新建 G 优先进当前 P 的本地队列,本地满则放全局队列。
- P 本地队列空了:先偷全局队列,再去其它 P 的本地队列偷一半(work stealing)。
- M 阻塞(如 syscall):P 让给其它 M 继续跑,避免核空闲。
5.2 调度流程与抢占
触发调度的事件:go 语句新建 G、GC、系统调用返回、锁竞争、channel 操作、以及抢占。Go 1.14 引入基于信号的异步抢占:sysmon 监控线程发现某 G 运行超过 10ms,就发送信号强制它让出,防止死循环卡死整个进程(Java 的线程调度由内核抢占,Go 是协作 + 信号抢占的混合)。
5.3 为什么百万 goroutine 可行
- 栈极小(2KB 起步)且共享 M 而非独占线程。
- 切换在用户态完成,不陷入内核,不涉及 CPU 亲和性丢失。
- 网络 I/O 由 netpoller(epoll / IOCP)接管,G 阻塞在 I/O 时真正让出 M,唤醒后重新入队——这是"同步写法 + 异步性能"的根基。
5.4 GOMAXPROCS 与容器陷阱
GOMAXPROCS 默认等于 runtime.NumCPU(),但Go 不会自动感知容器 CPU 配额(cgroup),在 4 核限制的容器里跑在 32 核宿主机上时,会创建 32 个 P,线程数与调度开销虚高,延迟劣化。
解法:接入 go get go.uber.org/automaxprocs 并在 main 里 _ = automaxprocs.Set(),或手动 GOMAXPROCS=4。K8s 场景务必处理。
六、channel 与锁的选型
6.1 两种并发哲学
- CSP(channel):并发单元之间不共享状态,通过消息传递协作;适合"流水线 / 生产者-消费者 / 事件驱动"。
- 共享内存 + 锁(Mutex):保护一段临界区;适合"多个 goroutine 更新同一结构"(计数器、缓存、余额)。
Go 格言"通过通信共享内存"是倾向性建议而非禁令——sync.Mutex 保护 map 是标准做法,硬用 channel 反而别扭。
6.2 选型决策表
| 场景 | 首选 | 理由 |
|---|---|---|
| 任务分发 / 流水线 | channel | 天然队列 + 背压 |
| 结果汇聚(多路返回) | channel / WaitGroup | 扇入扇出 |
| 保护 map / 结构体字段 | Mutex / RWMutex | 简单直接 |
| 只执行一次的初始化 | sync.Once | 语义精确 |
| 高并发计数器 | atomic | 无锁最快 |
| 通知多个等待者 | Cond / channel close | close 广播是 Go 惯用法 |
| 跨 goroutine 取消 | context | 官方标准 |
6.3 性能认知
channel 底层也是"锁 + 队列"实现(hchan 内部有 mutex),不是魔法。但 Go 编译器与运行时对其做了深度优化(无竞争时的快速路径)。经验结论:channel 性能足够业务使用;只有在纳秒级热路径才需要考虑锁粒度与 atomic。判断标准是读代码的清晰度优先,过早优化是万恶之源。
七、内存模型与数据竞争
7.1 Go 内存模型:happens-before
Go 内存模型与 Java 类似,定义同步操作间的 happens-before 关系:
| 操作 | 保证 |
|---|---|
| goroutine 创建 | go f() 之前的语句 happens-before f 执行 |
| channel 发送 | 发送 happens-before 对应接收完成 |
| channel 关闭 | close 之前发生的 happens-before 从 channel 读到零值返回 |
| 无缓冲 channel 接收 | 接收完成 happens-before 对应发送返回 |
| Mutex | Unlock happens-before 后续 Lock 成功 |
| WaitGroup | Done happens-before Wait 返回 |
没有这些关系保护的两个 goroutine 读写同一变量,就是数据竞争(data race),行为未定义。
7.2 数据竞争示例
var count int
func main() {
for i := 0; i < 1000; i++ {
go func() { count++ }() // 多个 goroutine 并发读写 count
}
time.Sleep(time.Second)
fmt.Println(count) // 结果不确定,可能不是 1000
}var count int
func main() {
for i := 0; i < 1000; i++ {
go func() { count++ }() // 多个 goroutine 并发读写 count
}
time.Sleep(time.Second)
fmt.Println(count) // 结果不确定,可能不是 1000
}count++ 是"读-改-写"三步,交错执行会丢更新。go build 不会报错,运行时也不一定 panic,危害极大。
7.3 race 检测器:必开
go test -race ./... # 测试时检测
go run -race main.go # 运行检测(性能下降约 5-10 倍,仅开发用)go test -race ./... # 测试时检测
go run -race main.go # 运行检测(性能下降约 5-10 倍,仅开发用)race 检测器(基于 ThreadSanitizer)能定位"哪个 goroutine 在哪个文件第几行竞争",开发与 CI 中常开:
==================
WARNING: DATA RACE
Read at 0x... by goroutine 7:
main.main.func1() main.go:9 +0x...
Previous write at 0x... by goroutine 5:
main.main.func1() main.go:9
====================================
WARNING: DATA RACE
Read at 0x... by goroutine 7:
main.main.func1() main.go:9 +0x...
Previous write at 0x... by goroutine 5:
main.main.func1() main.go:9
==================修复方案按序选择:channel 传递 > atomic > 锁 > 重新设计(消除共享)。
7.4 与 Java 内存模型对照
Java 有 volatile、final 特殊语义与 JMM 的可见性规则,Go 没有 volatile——因为 Go 的并发同步手段更少且更统一:要么用 channel/锁/原子,要么就是 bug。Java 的 synchronized 可重入、volatile 只保证可见性,Go 的 Mutex 不可重入且总是"锁 = 全屏障"。整体上 Go 更简单粗暴,犯错空间更小。
八、常见并发模式
8.1 Worker Pool:固定并发度消费任务
func workerPool(jobs <-chan int, size int) {
var wg sync.WaitGroup
for i := 0; i < size; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs {
fmt.Printf("worker %d 处理 %d\n", id, j)
}
}(i)
}
wg.Wait()
}
func main() {
jobs := make(chan int, 100)
go func() {
defer close(jobs)
for i := 0; i < 100; i++ { jobs <- i }
}()
workerPool(jobs, 10) // 固定 10 个并发消费者
}func workerPool(jobs <-chan int, size int) {
var wg sync.WaitGroup
for i := 0; i < size; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs {
fmt.Printf("worker %d 处理 %d\n", id, j)
}
}(i)
}
wg.Wait()
}
func main() {
jobs := make(chan int, 100)
go func() {
defer close(jobs)
for i := 0; i < 100; i++ { jobs <- i }
}()
workerPool(jobs, 10) // 固定 10 个并发消费者
}要点:任务关闭后 range 结束,wg.Wait 保证全部处理完再返回。
8.2 扇出(Fan-out)与扇入(Fan-in)
// 扇出:一个源 -> 多个 worker;扇入:多个 worker 结果 -> 一个汇聚 channel
func merge(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(chs))
for _, c := range chs {
go func(c <-chan int) {
defer wg.Done()
for v := range c { out <- v }
}(c)
}
go func() { wg.Wait(); close(out) }() // 全部结束后关闭汇聚 channel
return out
}// 扇出:一个源 -> 多个 worker;扇入:多个 worker 结果 -> 一个汇聚 channel
func merge(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(chs))
for _, c := range chs {
go func(c <-chan int) {
defer wg.Done()
for v := range c { out <- v }
}(c)
}
go func() { wg.Wait(); close(out) }() // 全部结束后关闭汇聚 channel
return out
}这是"分治 + 汇总"的通用骨架,注意关闭时机:close(out) 必须在所有生产者退出后执行。
8.3 超时控制
ch := make(chan result)
go func() { ch <- doSlow() }()
select {
case r := <-ch:
fmt.Println("成功:", r)
case <-time.After(2 * time.Second):
fmt.Println("超时")
}ch := make(chan result)
go func() { ch <- doSlow() }()
select {
case r := <-ch:
fmt.Println("成功:", r)
case <-time.After(2 * time.Second):
fmt.Println("超时")
}注意 time.After 每次 select 都会新建 timer,高频循环里应改用 time.NewTimer 并在退出分支 Stop,避免 timer 泄漏堆积。
8.4 context 取消:从根上停掉整棵树
func run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 本函数返回时通知所有子任务停止
results := make(chan int)
go func() { // 子任务监听取消
select {
case results <- heavyWork():
case <-ctx.Done():
return
}
}()
select {
case v := <-results:
fmt.Println(v)
case <-ctx.Done():
return ctx.Err() // context canceled
}
return nil
}func run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 本函数返回时通知所有子任务停止
results := make(chan int)
go func() { // 子任务监听取消
select {
case results <- heavyWork():
case <-ctx.Done():
return
}
}()
select {
case v := <-results:
fmt.Println(v)
case <-ctx.Done():
return ctx.Err() // context canceled
}
return nil
}生产规范:所有 goroutine 的启动处都应思考"它如何退出",三条路——channel 关闭、ctx.Done、或自然结束。
8.5 信号量限流
sem := make(chan struct{}, 5) // 缓冲 struct{} 即信号量
for _, task := range tasks {
sem <- struct{}{} // 超过 5 个并发则阻塞等待
go func(t Task) {
defer func() { <-sem }()
t.Run()
}(task)
}sem := make(chan struct{}, 5) // 缓冲 struct{} 即信号量
for _, task := range tasks {
sem <- struct{}{} // 超过 5 个并发则阻塞等待
go func(t Task) {
defer func() { <-sem }()
t.Run()
}(task)
}struct{} 零字节,是"纯信号"channel 的标准写法。也可用 golang.org/x/sync/semaphore 获得权重信号量。
8.6 重试与退避
func retry(attempts int, fn func() error) error {
for i := 0; i < attempts; i++ {
if err := fn(); err == nil {
return nil
} else if i == attempts-1 {
return err
}
time.Sleep(time.Duration(1<<i) * 100 * time.Millisecond) // 指数退避 100ms,200ms,400ms
}
return nil
}func retry(attempts int, fn func() error) error {
for i := 0; i < attempts; i++ {
if err := fn(); err == nil {
return nil
} else if i == attempts-1 {
return err
}
time.Sleep(time.Duration(1<<i) * 100 * time.Millisecond) // 指数退避 100ms,200ms,400ms
}
return nil
}生产建议配合 jitter(随机抖动)避免惊群,可直接用 github.com/cenkalti/backoff。
九、与 Java / Kotlin 并发模型对比
9.1 三者的"轻量并发"方案
| 维度 | goroutine | Java 虚拟线程(Loom) | Kotlin 协程 |
|---|---|---|---|
| 语言 | Go 原生 | JDK 21+ | 库(kotlinx.coroutines) |
| 栈 | 2KB 动态 | 可挂起堆栈 | 状态机(无独立栈) |
| 调度 | 运行时 M:N | JVM 调度 | 线程池 + 调度器 |
| 通信 | channel / 锁 | 锁 / 并发集合 | channel / Flow / Mutex |
| 取消 | context | 中断(不可靠) | 结构化并发 + Job |
| 学习曲线 | 平缓(一条语言特性) | 需要理解 JMM | 需要理解挂起机制 |
三者本质都是"把阻塞换成交出控制权",让线程空转时间变成有效吞吐。Java 虚拟线程对标 goroutine 最直接;Kotlin 协程通过挂起函数把异步写得像同步,比 Go 的显式 goroutine 更隐蔽但也更容易失控。
9.2 同步原语对照
| Go | Java | 说明 |
|---|---|---|
sync.Mutex | synchronized / ReentrantLock | Go 不可重入 |
sync.RWMutex | ReentrantReadWriteLock | 多读单写 |
sync.WaitGroup | CountDownLatch / CyclicBarrier | 等待一组完成 |
sync.Once | 双重检查锁 | 只执行一次 |
sync.Cond | Condition | 条件等待 |
channel | BlockingQueue / SynchronousQueue | 消息传递 |
atomic | AtomicLong / volatile | 原子操作 |
sync.Map | ConcurrentHashMap | 并发容器(场景窄) |
context | CompletableFuture 取消 / 线程中断 | 取消传播 |
| goroutine + channel | ExecutorService + Future | 任务编排 |
9.3 设计哲学总结
- Java:以锁 + 并发集合为中心,强调线程安全容器;并发工具从 JUC 选择组合。
- Go:以 goroutine + channel 为中心,鼓励"每个独立任务一个 goroutine,用管道串联";锁只兜底临界区。
- Kotlin:结构化并发,suspend 函数链式表达异步流程,最接近命令式业务代码。
没有银弹:Go 的写法在大流量网关、代理类服务中开发效率极高;Java/Kotlin 在复杂业务状态机与既有 Spring 生态里更顺手。转换心态的钥匙是——别把一个 goroutine 当线程用,把它当"廉价的独立任务"用。
本篇已覆盖 Go 并发全貌。下一步落地到 Web 开发:Go Gin 框架 与 Go Web 项目实践。
Cao Note