Skip to content

30|goroutine 与 channel

运维工作里经常要同时处理多个目标:批量探测主机健康状态、并行请求多个接口、并发下载日志文件。串行执行是一条做完再做下一条,如果每次操作都要等 1 秒,100 个目标就要等 100 秒。并发执行是把任务同时发出去,让它们在网络等待的时间里交错运行,总耗时接近最慢的那个任务而不是所有任务之和。

Go 的并发模型围绕 goroutinechannel 展开。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.WaitGroupchannel 等待任务结束。

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 读取,接收方永远阻塞
  • WaitGroupAddDone 不匹配,Wait 永远等不到

goroutine 泄漏不会立刻暴露问题,但运行时间越长,内存占用越高,最终拖垮进程。排查时用 runtime.NumGoroutine() 观察 goroutine 数量是否持续增长。

channel 方向误用

函数参数声明为 chan<-(只写)但内部尝试读取,编译会报错。但如果声明为双向 chan,调用方传了一个只读或只写的 channel,编译器不会报错,运行时可能 panic。

关闭 nil channel

go
var ch chan int
close(ch) // panic: close of nil channel

nil channel 不能关闭,也不能发送数据,但读取会永远阻塞。某些并发模式里会故意用 nil channel 来禁用某个 case。

五、并发 vs 并行

并发(concurrency)是结构——同时处理多个任务的能力;并行(parallelism)是执行——多个任务真正同时运行。单核 CPU 上,goroutine 通过调度器交替执行,是并发但不是并行;多核 CPU 上,多个 goroutine 可能在不同核心同时运行,既是并发也是并行。

Go 的并发模型让程序结构更容易表达"同时处理多件事",但真正的性能提升还取决于任务类型。计算密集型任务需要多核并行才有收益;IO 密集型任务(网络请求、文件读写)主要靠并发减少等待时间。

六、并发数量控制

goroutine 虽然轻量,但无限创建仍然有问题:内存占用、调度开销、目标服务端压力。第 34 讲会介绍 sync.WaitGroupMutex 和 worker pool 模式,用来控制并发数和共享状态。