0 Comments

Go 并发编程:goroutine 与 channel 实战

并发是 Go 语言最引以为傲的特性之一。相比传统语言依赖线程、锁和回调的复杂模型,Go 用 goroutine + channel 提供了一套简洁而强大的并发原语。这篇「不要用共享内存来通信,而要用通信来共享内存」正是 Go 并发设计的核心哲学。本文将通过真实场景,带你深入理解 goroutine 与 channel 的配合使用。

一、为什么是 goroutine

操作系统线程是昂贵的资源:每个线程默认占用约 1MB 的栈空间,创建、切换、销毁都要陷入内核态,开销巨大。而 goroutine 是 Go 运行时管理的用户态轻量线程:

  • 初始栈仅约 2KB,并且可以按需增长,最多可达 GB 级别;
  • 创建成本极低,一个普通程序同时运行成千上万个 goroutine 毫不费力;
  • 调度由 Go 运行时负责,采用 GMP 模型(Goroutine、Machine 即 OS 线程、Processor 逻辑处理器),在多个 OS 线程上动态调度;
  • 阻塞不浪费线程,当 goroutine 因 I/O 或 channel 阻塞时,运行时会自动把它挂起并让出线程,去执行其他 goroutine。

启动一个 goroutine 只需一个关键字:

package main

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello, %s\n", name)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go sayHello("goroutine") // 启动一个 goroutine
    sayHello("main")         // 主 goroutine 同步执行
}

输出的顺序是不确定的——这是并发程序的常态。理解这一点,是写好并发代码的第一步。

二、channel:goroutine 之间的通信管道

channel 是 Go 提供的类型安全的消息队列,goroutine 通过它收发数据,天然实现了同步。声明方式:

ch := make(chan int)        // 无缓冲 channel
buffered := make(chan int, 10) // 有缓冲 channel,容量为 10

无缓冲 channel 的同步语义

无缓冲 channel 的发送和接收必须同时就绪,否则会阻塞,这使它天然成为一个「接力棒」:

package main

import "fmt"

func worker(done chan bool) {
    fmt.Println("工作中...")
    done <- true // 发送完成信号(阻塞直到主程序接收)
}

func main() {
    done := make(chan bool)
    go worker(done)
    <-done // 等待 worker 完成
    fmt.Println("任务完成")
}

有缓冲 channel 的解耦能力

有缓冲 channel 允许发送方在缓冲区未满时不必等待接收方,适合「生产-消费」场景:

package main

import (
    "fmt"
    "time"
)

func producer(ch chan<- int) { // 只写 channel
    for i := 1; i <= 5; i++ {
        ch <- i
        fmt.Printf("生产: %d\n", i)
    }
    close(ch) // 生产完毕,关闭 channel
}

func consumer(ch <-chan int) { // 只读 channel
    for v := range ch { // range 会在 channel 关闭后自动结束
        fmt.Printf("消费: %d\n", v)
        time.Sleep(200 * time.Millisecond)
    }
}

func main() {
    ch := make(chan int, 3) // 缓冲区 3
    go producer(ch)
    consumer(ch)
}

注意 channel 的方向类型:chan<- 表示只写,<-chan 表示只读。显式声明方向能在编译期就避免误用,是很好的工程实践。

三、select:多路复用

现实中经常需要同时等待多个 channel 或超时事件,select 语句就是为此设计的,它的行为类似加强版的 switch,但每个 case 都是 channel 操作:

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "来自 ch1"
    }()
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "来自 ch2"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println(msg1)
        case msg2 := <-ch2:
            fmt.Println(msg2)
        case <-time.After(3 * time.Second): // 超时保护
            fmt.Println("超时了")
            return
        }
    }
}

select 有几个关键规则需要牢记:

  1. 有多个 case 就绪时,随机选择一个执行,避免饥饿;
  2. 没有任何 case 就绪时,如果有 default 分支则执行它,否则阻塞;
  3. default 分支可以让 select 变成非阻塞操作,常用于实现「尝试发送」。

四、实战:并发爬虫 + worker pool

把上面的概念串起来,实现一个经典的并发爬虫,用固定数量的 worker 消费任务队列:

package main

import (
    "fmt"
    "net/http"
    "sync"
)

type result struct {
    url    string
    status int
    err    error
}

func fetch(url string) result {
    resp, err := http.Get(url)
    if err != nil {
        return result{url: url, err: err}
    }
    defer resp.Body.Close()
    return result{url: url, status: resp.StatusCode}
}

func worker(jobs <-chan string, results chan<- result) {
    for url := range jobs {
        results <- fetch(url)
    }
}

func main() {
    urls := []string{
        "https://golang.org",
        "https://github.com",
        "https://pinvery.com",
        "https://godoc.org",
        "https://pkg.go.dev",
    }

    jobs := make(chan string, len(urls))
    results := make(chan result, len(urls))

    const workerCount = 3
    for i := 0; i < workerCount; i++ {
        go worker(jobs, results)
    }

    for _, u := range urls {
        jobs <- u
    }
    close(jobs) // 关闭 jobs,通知 worker 没有更多任务

    for range urls {
        r := <-results
        if r.err != nil {
            fmt.Printf("%s -> 错误: %v\n", r.url, r.err)
        } else {
            fmt.Printf("%s -> %d\n", r.url, r.status)
        }
    }
}

close(jobs) 是关键一步:关闭后,for url := range jobs 会在取完所有任务后自动退出,从而优雅地结束所有 worker。这比手动发送 quit 信号更简洁可靠。

五、sync.WaitGroup 与常见陷阱

当需要等待一组 goroutine 全部完成时,sync.WaitGroup 是标准做法:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1) // 递增计数
        go func(id int) {
            defer wg.Done() // 完成后递减
            fmt.Printf("任务 %d 完成\n", id)
        }(i) // 注意参数传递
    }

    wg.Wait() // 阻塞直到计数归零
    fmt.Println("全部完成")
}

这段代码里有一个极其经典的陷阱——循环变量捕获。如果写成 go func() { fmt.Println(i) }() 而不把 i 作为参数传入,所有 goroutine 很可能会打印同一个值(旧的 Go 版本更是必然如此)。虽然 Go 1.22 之后修正了循环变量语义,但显式传参仍然是更稳妥、更清晰的写法。

其他常见陷阱:

  • 忘记 close channel,导致 range 或接收方永久阻塞;
  • 向已关闭的 channel 发送会 panic,所以关闭的责任应交给唯一的发送方;
  • 死锁:channel 操作没配对好,运行时检测到所有 goroutine 都阻塞时会抛出 fatal error: all goroutines are asleep - deadlock!

六、最佳实践小结

  1. 谁创建,谁关闭——channel 的关闭权应清晰归属,通常由发送方关闭;
  2. select 处理超时和退出,避免 goroutine 泄漏(一直阻塞的 goroutine 永远不会被回收);
  3. 明确 channel 的方向,用 chan<- / <-chan 让类型系统帮你把关;
  4. 优先用 range 遍历 channel,配合 close 表达「没有更多数据」;
  5. 并发并非越多越好——面对大量小任务时,用 worker pool 限制并发度往往比无脑 go 更高效;
  6. -race 检测数据竞争go test -racego run -race main.go 是排查并发的利器。

总结

goroutine 和 channel 让并发编程从「管理线程和锁」的泥潭中解放出来,把关注点重新拉回到「任务之间的协作与通信」上。掌握 channel 的同步语义、select 的多路复用、close 的优雅退出以及 WaitGroup 的等待机制,你就已经拥有了编写健壮并发 Go 程序的核心能力。

并发是一把双刃剑:它带来性能,也带来隐蔽的 bug。好在 Go 提供了 -race 检测器和清晰的心智模型,剩下要做的,就是多写、多测、多思考。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注