Appearance
30|goroutine 与 channel
运维工作里经常要同时处理多个目标:批量探测主机健康状态、并行请求多个接口、并发下载日志文件。串行执行是一条做完再做下一条,如果每次操作都要等 1 秒,100 个目标就要等 100 秒。并发执行是把任务同时发出去,让它们在网络等待的时间里交错运行,总耗时接近最慢的那个任务而不是所有任务之和。
Go 的并发模型围绕 goroutine 和 channel 展开。goroutine 是轻量执行单元,channel 是 goroutine 之间的通信管道。Go 的并发哲学是"通过通信共享内存,而不是通过共享内存通信"。
一、goroutine
在函数调用前加 go 关键字,就会在一个新的 goroutine 里执行:
go
package main
import (
"fmt"
"time"
)
func check(name string) {
time.Sleep(1 * time.Second)
fmt.Println(name, "ok")
}
func main() {
go check("web01")
go check("web02")
time.Sleep(2 * time.Second)
fmt.Println("done")
}go check("web01") 启动任务后立刻返回,主流程不会等 check 执行完成。示例里的 time.Sleep 只是临时让主 goroutine 停住,方便看到输出。真实程序里需要用 sync.WaitGroup 或 channel 等待任务结束。
main 函数返回后,进程会直接退出,未执行完的 goroutine 也会被强制终止。因此并发任务必须有明确的等待机制,不能只把函数前面加一个 go 就算完成。
goroutine 的栈初始很小(通常 2KB),按需增长,比操作系统线程轻量得多。一台普通机器上可以同时运行成千上万个 goroutine,但并发数不等于同时执行数——Go 调度器会把 goroutine 调度到少量操作系统线程上执行。
二、channel
channel 是 goroutine 之间传递数据的管道。批量巡检时,任务函数把结果写入 channel,主流程从 channel 里汇总。
无缓冲 channel
go
package main
import (
"fmt"
"time"
)
type Result struct {
Host string
OK bool
}
func check(host string, results chan<- Result) {
time.Sleep(500 * time.Millisecond)
results <- Result{Host: host, OK: true}
}
func main() {
hosts := []string{"web01", "web02", "db01"}
results := make(chan Result)
for _, host := range hosts {
go check(host, results)
}
for range hosts {
result := <-results
fmt.Println(result.Host, result.OK)
}
}无缓冲 channel 的发送和接收互相等待:发送方执行 results <- value 时,如果没有接收方在读,发送方会阻塞;接收方执行 <-results 时,如果还没有发送方写入,接收方也会阻塞。这种同步特性让无缓冲 channel 天然适合"握手"场景——发送方确认接收方已经准备好接收数据。
chan<- Result 表示只写 channel,<-chan Result 表示只读 channel。方向写清楚后,函数签名本身就约束了使用方式,减少误用。
有缓冲 channel
go
results := make(chan Result, 10)缓冲区能短暂存放结果。没有接收方时,写满缓冲区后发送方仍然会阻塞。channel 不是无限队列,批量任务里仍然要控制并发数量和结果消费速度。
三、关闭 channel
生产方全部结束后,可以关闭 channel。读取方用 range 读取,直到 channel 被关闭:
go
package main
import (
"fmt"
"sync"
)
type Result struct {
Host string
OK bool
}
func main() {
hosts := []string{"web01", "web02", "db01"}
results := make(chan Result)
var wg sync.WaitGroup
for _, host := range hosts {
wg.Add(1)
go func(name string) {
defer wg.Done()
results <- Result{Host: name, OK: true}
}(host)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println(result.Host, result.OK)
}
}关闭 channel 表示"后面不会再有新值"。已经写入 channel 的值仍然可以继续读完,读完后 range 自动结束。
通常由发送方关闭 channel。接收方不知道后面还有没有发送者,贸然关闭容易触发 panic: send on closed channel。channel 也不需要每发送一个值就关闭一次,一个结果队列通常只在所有发送方结束后关闭一次。
向已关闭的 channel 发送数据会 panic,从已关闭的 channel 读取会返回零值。用"逗号 ok"语法判断 channel 是否已关闭:
go value,
if !ok {
fmt.Println("channel closed")
break
}四、常见并发问题
goroutine 泄漏
启动的 goroutine 永远没机会结束,就是泄漏。常见原因:
- 向没有接收方的 channel 发送,发送方永远阻塞
- 从不会关闭的 channel 读取,接收方永远阻塞
WaitGroup的Add和Done不匹配,Wait永远等不到
goroutine 泄漏不会立刻暴露问题,但运行时间越长,内存占用越高,最终拖垮进程。排查时用 runtime.NumGoroutine() 观察 goroutine 数量是否持续增长。
channel 方向误用
函数参数声明为 chan<-(只写)但内部尝试读取,编译会报错。但如果声明为双向 chan,调用方传了一个只读或只写的 channel,编译器不会报错,运行时可能 panic。
关闭 nil channel
go
var ch chan int
close(ch) // panic: close of nil channelnil channel 不能关闭,也不能发送数据,但读取会永远阻塞。某些并发模式里会故意用 nil channel 来禁用某个 case。
五、并发 vs 并行
并发(concurrency)是结构——同时处理多个任务的能力;并行(parallelism)是执行——多个任务真正同时运行。单核 CPU 上,goroutine 通过调度器交替执行,是并发但不是并行;多核 CPU 上,多个 goroutine 可能在不同核心同时运行,既是并发也是并行。
Go 的并发模型让程序结构更容易表达"同时处理多件事",但真正的性能提升还取决于任务类型。计算密集型任务需要多核并行才有收益;IO 密集型任务(网络请求、文件读写)主要靠并发减少等待时间。
六、并发数量控制
goroutine 虽然轻量,但无限创建仍然有问题:内存占用、调度开销、目标服务端压力。第 34 讲会介绍 sync.WaitGroup、Mutex 和 worker pool 模式,用来控制并发数和共享状态。