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
无缓冲 channel(make(chan int)):发送和接收必须同时就绪,否则阻塞。天然的同步机制。
ch := make(chan int)
go func() {
ch <- 1 // 阻塞,直到有人接收
fmt.Println("sent")
}()
time.Sleep(time.Second)
val := <-ch // 此时发送方才能继续
fmt.Println("received:", val)
有缓冲 channel(make(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.Context 或 select + 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 负责同步。掌握这四个工具,就能应对大部分并发场景。