Go 并发编程

写了 5 年 Java 并发,你手上的工具大概是这样的:

// Java —— 线程池 + Future + BlockingQueue
var executor = Executors.newFixedThreadPool(10);
var future = executor.submit(() -> {
    return remoteService.query();
});
try {
    var result = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true);
}

现在看 Go 的等价写法:

// Go —— goroutine + channel + select
func queryWithTimeout() {
    ch := make(chan string, 1)
    go func() {
        ch <- remoteService.Query()
    }()
    select {
    case result := <-ch:
        fmt.Println(result)
    case <-time.After(5 * time.Second):
        fmt.Println("超时了")
    }
}

没有 Executor ,没有 Future ,没有 BlockingQueuego 关键字一写,协程就启动了。 chan 一建,数据就在协程间流动。 这是 Go 语言最独特的基因

📌 前置知识:本文假定读者了解 Java 线程基础(Thread/Runnable/Executor)和基本的并发概念(竞态条件、互斥锁)。Go 版本为 1.22。

线程 vs 协程 vs 有栈协程:先搞清楚"轻量"是什么意思

在深入 goroutine 之前,先理清"线程"“协程"“有栈协程"这三个概念——它们是理解 goroutine 为什么"轻量"的基础。

特性操作系统线程无栈协程有栈协程(Goroutine)Java 虚拟线程
调度者操作系统内核编译器/状态机(用户态)Go 运行时(用户态 GMP 调度器)JVM(用户态 ForkJoinPool 调度)
栈大小固定 ~1MB(Linux 默认 8MB 虚拟空间)无独立栈,局部变量存储在堆上的状态对象中初始 ~2KB,动态扩缩容(最大可达 1GB)初始 ~200 字节(堆上存储,按需分配)
切换成本用户态 ↔ 内核态上下文切换,约 1 ~ 10μs函数返回+状态机跳转,约几十 ns用户态寄存器保存/恢复,约 200ns用户态,约 200ns ~ 1μs
创建数量/限制/关键能力几百到几千(受内存和调度开销限制)不能在嵌套函数调用中挂起(只能平层 await)轻松几十万到百万;可在嵌套函数调用深处挂起和恢复(有自己的栈)绑在 OS 线程上执行,遇到阻塞操作时才 unmount;goroutine 在 GMP 模型中由 Go 运行时主动抢占调度
代表Java 的 java.lang.Thread,映射到 OS 线程(1:1)JavaScript async/await、Kotlin suspend、Rust asyncGo goroutine、Java Virtual Thread(Project Loom)

关键结论:goroutine 和 Java 21 的 Virtual Thread 本质上都是"有栈协程”——M:N 调度(M 个协程映射到 N 个 OS 线程),用户态切换,初始内存极小 。但它们的调度模型有本质区别:

flowchart TD
    subgraph JavaPlatform["☕ Java 平台线程 vs 虚拟线程"]
        JT(["平台线程 Platform Thread\n1:1 映射 OS 线程\n栈:~1MB\n切换:内核态"])
        JVT(["虚拟线程 Virtual Thread\nM:N 映射 OS 线程\n栈:堆上动态分配\n切换:用户态(unmount/remount)"])
        JVT2["阻塞时自动 unmount\n释放 OS 线程给其他 Virtual Thread\n由 ForkJoinPool 承载"]
    end
    
    subgraph GoPlatform["🐹 Go goroutine"]
        GG(["goroutine\nM:N 映射 OS 线程\n栈:~2KB 起始,动态扩缩\n切换:用户态 GMP 调度"])
        GG2["GMP 模型:\nG = goroutine\nM = OS 线程\nP = 逻辑处理器"]
        GG3["Go 1.14+ 异步抢占\nCPU 密集型不会饿死其他协程"]
    end
    
classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
    
    class JT,JVT,GG root
    class JVT2,GG2,GG3 leaf

Goroutine 快速入门: go 关键字就够了

Go 启动协程只需要在函数调用前加 go

// Go —— 启动 10000 个 goroutine
for i := 0; i < 10000; i++ {
    go func(n int) {
        time.Sleep(1 * time.Second)
        fmt.Println(n)
    }(i)
}

对比 Java 创建 10000 个线程——如果不加线程池,直接 new Thread() 会导致内存爆炸(每个线程 1MB 栈,10000 个 = 10GB)。即使用线程池,最多也就几百个线程并行。

10000 个 goroutine 占多少内存?初始每个 2KB 栈 × 10000 = 约 20MB。 这还是极端情况,正常情况下 goroutine 用完就回收了

// Java —— 创建大量线程 ≈ 自爆
// 10000 个线程 × 1MB 栈 = 约 10GB 内存
// 只能用线程池限制:
var executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10000; i++) {
    final int n = i;
    executor.submit(() -> {
        Thread.sleep(1000);
        System.out.println(n);
        return null;
    });
}

⚠️ 新手提示: go func(n int) { ... }(i) 里的 (i) 是把 i 作为参数传给匿名函数。如果直接引用外层的 i (闭包),所有 goroutine 看到的可能是同一个值(Go 1.22 之前循环变量共享地址的经典坑,1.22 已修复)。

Channel:goroutine 之间的"管道”

Java 里线程间通信靠共享变量 + 锁,或者 BlockingQueue 。Go 靠 channel。

// Go —— 创建和使用 channel
ch := make(chan int)      // 无缓冲 channel(同步)
ch := make(chan int, 10)  // 有缓冲 channel(缓冲 10 个)

// 发送
ch <- 42

// 接收
value := <-ch

// 关闭(发送方关闭,接收方可以检测到关闭)
close(ch)

无缓冲 vs 有缓冲

无缓冲(同步)有缓冲(异步)
声明ch := make(chan int)ch := make(chan int, 10)
发送方阻塞直到接收方就绪缓冲未满时不阻塞
接收方阻塞直到发送方发送缓冲为空时阻塞
特性✅ 保证同步,不需要额外通知⚠️ 类似 Java BlockingQueue(10)= LinkedBlockingQueue(10)

常见 Channel 模式

模式 1:Worker Pool(工作池)

func workerPool() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // 启动 3 个 worker goroutine
    for w := 0; w < 3; w++ {
        go func(id int) {
            for job := range jobs { // range 会一直读直到 channel 关闭
                results <- job * 2
            }
        }(w)
    }

    // 发送 5 个任务
    for j := 0; j < 5; j++ {
        jobs <- j
    }
    close(jobs) // 关闭 jobs,workers 的 range 循环退出

    // 收集结果
    for i := 0; i < 5; i++ {
        <-results
    }
}

对比 Java 的 ExecutorService + CompletionService 写法——同样的逻辑,Go 只需要 channel 和 go 关键字。

模式 2:Fan-Out / Fan-In(扇出/扇入)

func fanOutFanIn() {
    input := make(chan int)
    
    // Fan-Out:一个输入 → 多个 worker
    outputs := make([]chan int, 3)
    for i := 0; i < 3; i++ {
        outputs[i] = make(chan int)
        go func(id int, out chan int) {
            for v := range input {
                out <- v * v
            }
            close(out)
        }(i, outputs[i])
    }
    
    // Fan-In:多个 worker 输出 → 一个结果 channel
    result := merge(outputs...)
    // merge 函数启动 goroutine 把多个 channel 汇总到一个
}

Select:多路复用,Go 的"epoll 语法糖"

select 是 Go 并发最精妙的设计之一——同时监听多个 channel,哪个先就绪就执行哪个

select {
case msg1 := <-ch1:
    fmt.Println("ch1 就绪:", msg1)
case msg2 := <-ch2:
    fmt.Println("ch2 就绪:", msg2)
case <-time.After(3 * time.Second):
    fmt.Println("超时 3 秒")
default:
    fmt.Println("都没就绪,不阻塞")
}

Java 里实现同等功能需要用 CompletableFuture.anyOf() 或者轮询 BlockingQueue.poll(timeout)

// Java —— 多路复用 ≈ CompletableFuture.anyOf
var f1 = CompletableFuture.supplyAsync(() -> service1.query());
var f2 = CompletableFuture.supplyAsync(() -> service2.query());
var result = CompletableFuture.anyOf(f1, f2)
        .get(3, TimeUnit.SECONDS);

select 的几个关键细节:

  • 分支:如果所有 case 都没就绪,不阻塞,直接走 default
  • 超时:最常用的超时控制模式
  • 随机公平:多个 case 同时就绪时随机选一个,防止某个 case 被饿死
flowchart TD
    Select["📡 select 多路复用"] --> Case1["case <-ch1\nch1 可读?"]
    Select --> Case2["case <-ch2\nch2 可读?"]
    Select --> Case3["case <-timeout\n超时了?"]
    Select --> Default["default\n都不行,直接跳过"]
    
    Case1 --> Action1["执行 ch1 分支"]
    Case2 --> Action2["执行 ch2 分支"]
    Case3 --> Action3["执行超时分支"]
    Default --> Action4["执行 default"]
    
classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef branch fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#fde68a,font-weight:bold;
classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
    
    class Select root
    class Case1,Case2,Case3,Default branch
    class Action1,Action2,Action3,Action4 leaf

GMP 调度器:goroutine 为什么能跑百万并发

goroutine 的调度由 Go 运行时的 GMP 模型 负责:

组件全称含义
GGoroutine一个 goroutine,包含栈、指令指针、状态
MMachine一个 OS 线程,goroutine 运行在 M 之上
PProcessor逻辑处理器,持有 G 的本地运行队列,数量由 GOMAXPROCS 决定
flowchart TD
    subgraph GMP["🔧 GMP 调度模型"]
        P1["P0(逻辑处理器)"] --> LRQ1["本地 G 队列\n[G1→G2→G3]"]
        P2["P1(逻辑处理器)"] --> LRQ2["本地 G 队列\n[G4→G5]"]
        GRQ["全局 G 队列\n[G6→G7→G8]"]
    end
    
    M1["M0(OS 线程)"] -.->|绑定| P1
    M2["M1(OS 线程)"] -.->|绑定| P2
    
    LRQ1 -.->|调度| M1
    LRQ2 -.->|调度| M2
    
    P1 -.->|work stealing\n本地队列空了| P2
    P1 -.->|从全局队列取| GRQ
    
classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef branch fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#fde68a,font-weight:bold;
classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
    
    class P1,P2 branch
    class M1,M2 root
    class LRQ1,LRQ2,GRQ leaf
    class GMP highlight

核心机制:

  • GOMAXPROCS :决定 P 的数量,默认等于 CPU 核心数。一个 P 同一时刻只能运行一个 G(在一个 M 上)
  • Work Stealing:一个 P 的本地队列空了,会从其他 P 的队列"偷"一半的 G 过来
  • 抢占调度(Go 1.14+):goroutine 运行超过 10ms 会被强制抢占,确保 CPU 密集型任务不会饿死其他协程
  • 阻塞处理:G 进行系统调用(如读文件)导致 M 阻塞时,P 会 换一个 M 继续调度其他 G。旧的 M 等系统调用返回后再把 G 放回队列

⚠️ 新手提示: GOMAXPROCS 不是越大越好。设置成 CPU 核数即可——过多的 P 会导致更多的上下文切换,反而降低吞吐。

CSP 模型 vs Java 并发工具的对应关系

CSP(Communicating Sequential Processes,通信顺序进程) 是 Go 并发的理论基础。核心思想:不要通过共享内存来通信,而要通过通信来共享内存

Go CSP 概念Java 对应关键差异
go func()new Thread(runnable).start() / executor.submit()goroutine 比线程轻量 1000 倍
chan (无缓冲)SynchronousQueue语法层面支持,不需要 import java.util.concurrent
chan (有缓冲)LinkedBlockingQueue / ArrayBlockingQueueGo 的 chan 可以关闭,接收方可以检测关闭
selectCompletableFuture.anyOf() + poll(timeout)Go 的 select 是语言内置,不需要嵌套回调
close(chan)无直接对应Go 中关闭 channel 是广播信号,通知所有接收方
sync.Mutexsynchronized / ReentrantLockGo 中锁是最后的备选方案,优先 channel
sync.WaitGroupCountDownLatch语义完全一致
context.Context无直接对应Go 中传递取消信号、超时、截止时间的标准方式

几个值得注意的差异:

1. Channel 可以关闭,BlockingQueue 不能

ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // 关闭后不能再发送,但可以继续接收
for v := range ch {
    fmt.Println(v) // 1, 2 —— 接收完所有缓冲数据后 range 自动退出
}

Java 的 BlockingQueue 没有"关闭"的概念——要通知消费者"没了",需要额外发一个 poison pill(毒丸)信号。

2. sync.Mutex 更像 synchronized 而非 ReentrantLock

Go 的 sync.Mutex 不可重入!同一个 goroutine 不能多次 Lock 同一个 Mutex,会导致死锁。这和 Java 的 ReentrantLock 不一样。Go 的设计理念是:如果你需要重入锁,说明你的设计有问题。

3. context.Context 是 Go 独有的并发控制原语

func queryWithContext(ctx context.Context, url string) error {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req)
    // 如果 ctx 被取消或超时,请求会自动中断
    return err
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
queryWithContext(ctx, "https://example.com")

Context 传递取消信号、超时、截止时间——沿着调用链向下传播。Java 里实现类似功能需要 Future.cancel() + Thread.interrupt() ,但这两个机制都不保证一定能中断任务。Go 的 Context 是 协作式的——被调用方需要主动检查 ctx.Done() ,但调用方可以确定地发出取消信号。

Java Virtual Thread vs Goroutine:深度对比

Java 21 的 Virtual Thread(虚拟线程)和 goroutine 在"轻量级用户态线程"这个概念上非常接近,但实现路径完全不同:

维度Java Virtual Thread(Java 21)Go Goroutine
引入时间2023 年(Java 21,正式 GA)2009 年(Go 1.0 就内置)
栈管理堆上分配,按需扩展,初始约 200 字节初始 2KB,动态扩缩
调度模型M:N,由 ForkJoinPool 承载M:N,GMP 调度器,有 Work Stealing
阻塞处理自动 unmount OS 线程,释放给其他 VTG 阻塞时 P 换 M 继续工作
抢占协作式(synchronized/monitor 持有时不释放)异步抢占(Go 1.14+,10ms 时间片)
API 改动几乎不需要改动—— new Thread()Thread.startVirtualThread()语言内置 go 关键字
生态成熟度较新,很多库还在适配(synchronized pinning 问题)15 年生态,完全成熟

最大的差异在于 抢占时机 。Java Virtual Thread 在遇到 synchronized 块或 JNI 调用时 不能 unmount,会导致 OS 线程被 pin(固定)。Go 的 goroutine 在 Go 1.14 之后有了异步抢占,即使是在 CPU 密集型循环中也能被调度器中断。

// Go —— 这个死循环不会占着 OS 线程不放
go func() {
    for {
        // 1.14+ 后会被 GMP 调度器强制抢占
        calculatePi() // CPU 密集型
    }
}()
// Java —— Virtual Thread 在 CPU 密集型循环中可能不释放 OS 线程
Thread.startVirtualThread(() -> {
    while (true) {
        calculatePi(); // synchronized 内会 pin 住 OS 线程
    }
});

总结

从 Java 线程池到 Go 的 goroutine,核心转变不是语法,而是 思维模型

  • Java 的世界观:线程是稀缺资源,线程池控制并发数, BlockingQueue 传递数据, Future 等待结果
  • Go 的世界观:goroutine 便宜得像不要钱,channel 传递数据顺便同步,select 多路复用轻松超时控制
场景Java 方案Go 方案
启动并发任务executor.submit(task)go task()
等待结果future.get(timeout)<-ch + time.After
多路复用CompletableFuture.anyOfselect
超时控制future.get(timeout)select { case <-ch: case <-time.After: }
取消任务future.cancel(true) (不保证)context.CancelFunc() (协作式)
线程/协程同步CountDownLatchsync.WaitGroup
互斥锁synchronized / ReentrantLocksync.Mutex (不可重入!)
生产者-消费者BlockingQueuechan (可关闭,range 遍历)

一句话:Java 并发靠 java.util.concurrent 包,Go 并发靠语言关键字(go/chan/select) 。前者是库的胜利,后者是语言设计的胜利。

📖 下一步阅读:goroutine 跑起来了,但真正的挑战是网络——Go 网络编程与 IO 模型。net/http 怎么比 Netty 还快?netpoll 到底是什么?Go 的 IO 多路复用和 Java NIO 有什么本质区别?下一篇讲清楚。


参考资源