Go语言:goroutine与channel

goroutine 和 channel 是 Go 并发模型的两个核心原语。goroutine 负责并发执行,channel 负责通信和同步。本文通过实例讲解它们的用法和注意事项。

goroutine:轻量级线程

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

package main

import (
    "fmt"
    "time"
)

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

func main() {
    go sayHello("goroutine-1")
    go sayHello("goroutine-2")
    sayHello("main")
}

goroutine 和 OS 线程的区别:

  • 初始栈空间只有 2KB(线程通常 1MB),可以轻松创建数十万个
  • 由 Go runtime 的调度器管理(GMP 模型),不是直接映射到 OS 线程
  • 切换成本远低于线程上下文切换

channel 的创建和使用

channel 是 goroutine 之间的通信管道,类型安全:

// 创建
ch := make(chan int)    // 无缓冲 channel
ch := make(chan int, 5) // 缓冲大小为 5 的 channel

// 发送
ch <- 42

// 接收
val := <-ch

// 关闭
close(ch)

基本示例:

func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
        fmt.Printf("sent: %d\n", i)
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for val := range ch { // range 会在 channel 关闭后退出
        fmt.Printf("received: %d\n", val)
    }
}

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

chan<- int 表示只写 channel,<-chan int 表示只读 channel。在函数签名中使用方向限定可以防止误用。

Unbuffered vs Buffered

无缓冲 channelmake(chan int)):发送和接收必须同时就绪,否则阻塞。天然的同步机制。

ch := make(chan int)

go func() {
    ch <- 1 // 阻塞,直到有人接收
    fmt.Println("sent")
}()

time.Sleep(time.Second)
val := <-ch // 此时发送方才能继续
fmt.Println("received:", val)

有缓冲 channelmake(chan int, n)):缓冲区未满时发送不阻塞,缓冲区为空时接收阻塞。

ch := make(chan int, 3)
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
ch <- 3 // 不阻塞
// ch <- 4 // 阻塞!缓冲区满了

fmt.Println(<-ch) // 1

选择建议:默认用无缓冲(明确的同步语义),只在需要解耦生产速度和消费速度时用有缓冲。

select 多路复用

select 同时监听多个 channel,哪个就绪就执行哪个:

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

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "from ch1"
    }()

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch2 <- "from ch2"
    }()

    // 接收两次
    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)
        case msg := <-ch2:
            fmt.Println(msg)
        }
    }
}

select 配合 time.After 实现超时控制:

select {
case result := <-ch:
    fmt.Println("got result:", result)
case <-time.After(3 * time.Second):
    fmt.Println("timeout!")
}

配合 default 实现非阻塞操作:

select {
case val := <-ch:
    fmt.Println("received:", val)
default:
    fmt.Println("no data available")
}

WaitGroup

sync.WaitGroup 用于等待一组 goroutine 全部完成:

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 完成时计数减 1
    fmt.Printf("Worker %d starting\n", id)
    // 模拟工作
    time.Sleep(time.Duration(id) * 100 * time.Millisecond)
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // 计数加 1
        go worker(i, &wg)
    }

    wg.Wait() // 阻塞直到计数归零
    fmt.Println("All workers done")
}

注意:wg.Add(1) 必须在 go 之前调用,不能放在 goroutine 内部,否则可能 main goroutine 先执行到 Wait() 时计数还是 0。

实战:并发爬虫限速

结合 channel 和 WaitGroup 实现带并发数限制的任务执行:

func main() {
    urls := []string{
        "https://example.com/1",
        "https://example.com/2",
        "https://example.com/3",
        // ...
    }

    concurrency := 3
    sem := make(chan struct{}, concurrency) // 信号量
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            sem <- struct{}{}        // 获取令牌(满了就阻塞)
            defer func() { <-sem }() // 释放令牌

            resp, err := http.Get(u)
            if err != nil {
                fmt.Println("error:", err)
                return
            }
            defer resp.Body.Close()
            fmt.Printf("%s -> %d\n", u, resp.StatusCode)
        }(url)
    }

    wg.Wait()
}

用 buffered channel 作为信号量,限制同时运行的 goroutine 数量,这是 Go 中非常常见的模式。

常见陷阱

1. goroutine 泄漏

// 没人接收,goroutine 永远阻塞在发送
go func() {
    ch <- result // 如果 ch 没有接收者,这个 goroutine 永远不会退出
}()

context.Contextselect + done channel 来确保 goroutine 可以退出。

2. 在已关闭的 channel 上发送

close(ch)
ch <- 1 // panic: send on closed channel

只由发送方 close channel,接收方不要 close。

3. 闭包捕获循环变量

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // 大概率全部打印 5
    }()
}
// 正确做法:传参
for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n)
    }(i)
}

小结

Go 的并发哲学是 "Don't communicate by sharing memory; share memory by communicating"。goroutine 负责执行,channel 负责通信,select 负责调度,WaitGroup 负责同步。掌握这四个工具,就能应对大部分并发场景。