Go 并发编程实战:从 goroutine 到高并发模式

By | 2026年9月16日

为什么 Go 的并发如此特别?

在传统语言中,并发编程往往意味着复杂的线程管理和锁竞争。而 Go 从语言层面内置了并发原语:goroutine 和 channel,让并发变得轻量且优雅。但这也带来了新的挑战:如何正确同步、如何避免 goroutine 泄漏、如何选择锁还是 channel?

本文将通过一个完整的实战案例——并发爬虫(模拟),带你掌握 Go 并发编程的核心技能。

环境准备

  • Go 1.20+(本文使用 Go 1.21)
  • 任意编辑器

创建一个新目录并初始化模块:


mkdir go-concurrency-demo && cd go-concurrency-demo
go mod init demo

第一步:理解 goroutine 与 WaitGroup

goroutine 是由 Go 运行时管理的轻量级线程,启动一个 goroutine 只需 go 关键字。

但主 goroutine 不会等待其他 goroutine 结束,因此我们需要 sync.WaitGroup 来同步。


package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 确保计数器减一
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1) // 必须在启动 goroutine 前调用
        go worker(i, &wg)
    }

    wg.Wait() // 阻塞直到所有 worker 完成
    fmt.Println("All workers finished")
}

注意:wg.Add(1) 必须在 go 语句之前调用,否则可能主 goroutine 已经执行到 wg.Wait() 而 worker 还没启动,导致 panic。

第二步:channel 基础与方向

Channel 是 goroutine 之间的通信管道。默认是双向的,但可以指定方向提高代码安全性。


func producer(ch chan<- int) { // 只发送
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // 发送方关闭 channel
}

func consumer(ch <-chan int) { // 只接收
    for v := range ch {
        fmt.Println("Received:", v)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

💡 最佳实践:永远由发送方关闭 channel,接收方不要关闭。向已关闭的 channel 发送数据会 panic。

第三步:带缓冲的 channel 与 select

无缓冲 channel 是同步的,发送和接收必须同时准备好。带缓冲的 channel 可以异步,但要注意缓冲区满时发送会阻塞。

select 可以同时等待多个 channel 操作,类似 switch 但用于 channel。


func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "from ch1"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "from ch2"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println(msg1)
        case msg2 := <-ch2:
            fmt.Println(msg2)
        }
    }
}

注意:select 如果没有 default 分支,会一直阻塞直到某个 case 就绪。如果所有 channel 都阻塞且没有 default,则 select 永久阻塞。

第四步:互斥锁与竞态检测

当多个 goroutine 访问共享变量时,必须加锁。Go 提供了 sync.Mutex 和 sync.RWMutex。


var counter int
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        mu.Lock()
        counter++
        mu.Unlock()
    }
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Println("Counter:", counter) // 应为 10000
}

检测竞态:使用 go run -race main.go 可以检测数据竞争,强烈建议在测试时开启。

第五步:实战——生产者-消费者模型

这是一个经典并发模式,使用带缓冲 channel 解耦生产者和消费者。


package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

func producer(id int, ch chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        num := rand.Intn(100)
        fmt.Printf("Producer %d produced: %d\n", id, num)
        ch <- num
        time.Sleep(time.Millisecond * 200)
    }
}

func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    for num := range ch {
        fmt.Printf("Consumer %d consumed: %d\n", id, num)
        time.Sleep(time.Millisecond * 300)
    }
}

func main() {
    ch := make(chan int, 10) // 缓冲区大小为 10
    var wg sync.WaitGroup

    // 启动 2 个生产者
    for i := 1; i <= 2; i++ {
        wg.Add(1)
        go producer(i, ch, &wg)
    }

    // 启动 3 个消费者
    var consumerWg sync.WaitGroup
    for i := 1; i <= 3; i++ {
        consumerWg.Add(1)
        go consumer(i, ch, &consumerWg)
    }

    // 等待所有生产者完成,然后关闭 channel
    wg.Wait()
    close(ch)

    // 等待所有消费者完成
    consumerWg.Wait()
    fmt.Println("All done")
}

关键点:

  • 生产者完成后关闭 channel,消费者通过 range 自动检测关闭。
  • 使用两个 WaitGroup 分别管理生产者和消费者,避免过早关闭 channel。

第六步:超时控制与 context

并发操作经常需要超时控制。context 包是标准做法。


func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    result := make(chan string)
    go func() {
        time.Sleep(3 * time.Second) // 模拟耗时操作
        result <- "done"
    }()

    select {
    case res := <-result:
        fmt.Println(res)
    case <-ctx.Done():
        fmt.Println("Timeout:", ctx.Err())
    }
}

💡 最佳实践:将 ctx 作为函数的第一个参数传递,并在所有阻塞操作中监听 ctx.Done()。

常见坑与避坑指南

  1. goroutine 泄漏:启动的 goroutine 如果永远阻塞在 channel 上,会导致内存泄漏。使用 context 或 select 加超时来避免。
  2. 死锁:例如两个 goroutine 互相等待对方发送数据。使用 go run -race 和仔细设计 channel 方向来预防。
  3. 关闭已关闭的 channel:会 panic。只由发送方关闭,且确保只关闭一次。
  4. 锁的粒度:锁的临界区应尽可能小,但也要避免过度加锁导致性能下降。

总结

本文从 goroutine 和 WaitGroup 出发,逐步深入到 channel、select、锁、context,最后实现了一个完整的生产者-消费者模型。掌握这些核心概念后,你可以应对大多数并发场景。

下一步方向:

  • 学习 sync.Pool 减少内存分配
  • 了解 errgroup 包管理 goroutine 错误
  • 研究 Go 调度器原理(GMP 模型)

并发编程的核心是共享内存通过通信来实现,牢记这一哲学,你的 Go 程序会更加健壮。