Go 并发入门:从 goroutine、channel 到工作池

刚开始学 Go 的并发时,最容易产生一种错觉:

复制代码
只要在函数前面加一个 go,程序就会变快。

这句话只对了一小半。

go 关键字确实可以启动一个 goroutine,让一段代码和当前代码"同时推进"。但真正写出可靠的并发程序,还要回答几个问题:

  • 主程序怎么知道 goroutine 已经结束?

  • goroutine 之间怎么传递数据?

  • 多个 goroutine 同时改同一个变量会发生什么?

  • 任务很多时,能不能限制同时工作的 goroutine 数量?

这篇文章从新手视角讲清楚这些问题。我们会先理解几个重要名词,再逐步写代码,最后实现一个完整的工作池。

这篇文章适合谁

如果你已经会写最基本的 Go 程序,例如:

复制代码
package main

import "fmt"

func main() {
	fmt.Println("hello, go")
}

并且知道 funcforstructimport 的基本用法,那就可以开始学习并发了。

先分清:并发和并行

很多新手会把"并发"和"并行"混在一起。

并发

并发指的是程序能够同时处理多个任务。

注意,这里的"同时处理"不一定表示同一瞬间真的有多个任务在 CPU 上运行。它更强调任务的组织方式:程序可以在多个任务之间切换、等待、调度。

举个生活例子:

复制代码
你一边烧水,一边切菜,一边等电饭锅煮饭。

你不是同时拥有三双手,而是在多个任务之间安排时间。

并行

并行指的是多个任务在同一时刻真的一起执行。

例如你的电脑有多个 CPU 核心,两个任务分别跑在不同核心上,这就是并行。

Go 里的关系

Go 让你很容易写并发程序。至于它是否并行运行,要看运行环境、CPU 核心数、调度器和 GOMAXPROCS 等因素。

对新手来说,先记住一句话:

复制代码
并发是一种程序结构,并行是一种运行状态。

写 Go 并发程序时,我们最先关心的是:怎么把任务拆开,怎么让任务协作,怎么安全地拿到结果。

重要名词速查

先把几个词放在这里,后面看到代码会轻松很多。

goroutine

goroutine 是 Go 里的轻量级并发执行单元。

你可以把它粗略理解成"由 Go 运行时管理的轻量任务"。启动一个 goroutine 很简单:

复制代码
go doSomething()

这表示:启动一个新的 goroutine 去执行 doSomething(),当前函数继续往下走。

channel

channel 是 goroutine 之间传递数据的管道。

例如:

复制代码
ch := make(chan string)

这行代码创建了一个传递 string 的 channel。

发送数据:

复制代码
ch <- "hello"

接收数据:

复制代码
msg := <-ch

WaitGroup

sync.WaitGroup 用来等待一组 goroutine 结束。

常见流程是:

复制代码
Add:登记有几个任务
Done:某个任务完成
Wait:等待所有任务完成

Mutex

sync.Mutex 是互斥锁。

当多个 goroutine 要同时访问同一份共享数据时,Mutex 可以保证同一时刻只有一个 goroutine 进入关键区域。

race condition

race condition 通常翻译成竞态条件。

如果多个 goroutine 同时读写同一个变量,而且没有使用 channel、Mutex 等同步手段,程序结果就可能变得不确定。这类问题很难靠肉眼发现,所以 Go 提供了 race detector,可以用 -race 检查。

第一个 goroutine

先看一个最小例子:

复制代码
package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("hello from goroutine")
}

func main() {
	go sayHello()

	fmt.Println("hello from main")

	// 暂停一小会儿,给 goroutine 运行的机会。
	// 注意:真实项目里不应该用 Sleep 等待 goroutine 结束。
	time.Sleep(100 * time.Millisecond)
}

运行后,你可能看到:

复制代码
hello from main
hello from goroutine

也可能顺序反过来。

这是第一个要适应的地方:并发程序里的执行顺序不一定固定。

为什么要 Sleep

如果去掉 time.Sleep

复制代码
func main() {
	go sayHello()
	fmt.Println("hello from main")
}

程序可能只打印:

复制代码
hello from main

原因是:main 函数结束时,整个程序就结束了。新启动的 goroutine 还没来得及执行,程序可能已经退出。

但是 Sleep 不是好办法。它只是"猜一个等待时间"。正确方式是用 sync.WaitGroup

用 WaitGroup 等 goroutine 结束

下面把上面的例子改成可靠版本:

复制代码
package main

import (
	"fmt"
	"sync"
)

func sayHello(wg *sync.WaitGroup) {
	// defer 表示函数结束前执行。
	// 无论函数中间怎么返回,Done 都会被调用。
	defer wg.Done()

	fmt.Println("hello from goroutine")
}

func main() {
	var wg sync.WaitGroup

	// Add(1) 表示接下来有 1 个任务需要等待。
	wg.Add(1)

	// 把 WaitGroup 指针传给 goroutine。
	go sayHello(&wg)

	fmt.Println("hello from main")

	// Wait 会阻塞,直到所有登记的任务都调用 Done。
	wg.Wait()
}

这里有三个细节很重要:

  1. wg.Add(1) 要在启动 goroutine 之前调用。

  2. goroutine 里要调用 wg.Done()

  3. WaitGroup 通常传指针,也就是 *sync.WaitGroup

如果你把 WaitGroup 复制给 goroutine,容易出现等待状态不一致的问题。

一次启动多个 goroutine

我们再写一个稍微真实一点的例子:同时处理 5 个任务。

复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

func handleTask(id int, wg *sync.WaitGroup) {
	defer wg.Done()

	fmt.Printf("task %d started\n", id)

	// 模拟耗时操作,比如请求接口、读文件、计算数据。
	time.Sleep(500 * time.Millisecond)

	fmt.Printf("task %d finished\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go handleTask(i, &wg)
	}

	wg.Wait()
	fmt.Println("all tasks done")
}

这段代码里,5 个任务会并发执行。总耗时大约接近 500ms,而不是 5 * 500ms。

新手易错点:循环变量

在并发代码里,循环变量很容易被写错。下面这个写法在旧代码中很常见:

复制代码
for i := 1; i <= 5; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(i)
	}()
}

为了让代码意图更清楚,可以显式把 i 作为参数传进去:

复制代码
for i := 1; i <= 5; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		fmt.Println(id)
	}(i)
}

这个写法的意思是:每次循环都把当前的 i 复制一份,传给 goroutine 里的 id

channel:让 goroutine 传递数据

WaitGroup 可以等待任务结束,但它不负责传递结果。

如果 goroutine 算出了一个值,想交给 main,该怎么办?

这时就可以用 channel。

复制代码
package main

import "fmt"

func sendMessage(ch chan string) {
	// 把字符串发送到 channel。
	ch <- "hello from goroutine"
}

func main() {
	// 创建一个传递 string 的 channel。
	ch := make(chan string)

	go sendMessage(ch)

	// 从 channel 接收数据。
	msg := <-ch

	fmt.Println(msg)
}

这里的执行过程是:

复制代码
main 创建 channel
main 启动 goroutine
goroutine 往 channel 发送字符串
main 从 channel 接收字符串
main 打印结果

无缓冲 channel

上面的 make(chan string) 创建的是无缓冲 channel。

无缓冲 channel 的特点是:

复制代码
发送方和接收方必须同时准备好,数据才能交接。

如果只有发送,没有接收,发送方会阻塞。

如果只有接收,没有发送,接收方会阻塞。

这像两个人当面递东西:一个人伸手递,另一个人也要伸手接。

带缓冲 channel

带缓冲 channel 可以临时存放一些数据。

复制代码
package main

import "fmt"

func main() {
	// 创建一个容量为 2 的 channel。
	ch := make(chan string, 2)

	// 因为 channel 有 2 个缓冲位,所以这两次发送不会阻塞。
	ch <- "first"
	ch <- "second"

	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

它像一个容量有限的队列。

但是不要把缓冲当成"万能加速器"。缓冲只是改变阻塞时机,不会自动解决设计问题。

close 和 range:告诉接收方没有更多数据了

如果发送方会发送多个值,接收方怎么知道什么时候结束?

可以用 close 关闭 channel。

复制代码
package main

import "fmt"

func producer(ch chan int) {
	for i := 1; i <= 3; i++ {
		ch <- i
	}

	// 关闭 channel,表示不会再发送新数据。
	close(ch)
}

func main() {
	ch := make(chan int)

	go producer(ch)

	// range 会一直接收,直到 channel 被关闭。
	for value := range ch {
		fmt.Println(value)
	}

	fmt.Println("done")
}

关于 close 的规则

新手可以先记住三条:

  1. 通常由发送方关闭 channel。

  2. 不要在接收方关闭 channel。

  3. 不要重复关闭同一个 channel,否则会 panic。

为什么通常由发送方关闭?

因为只有发送方最清楚"以后还会不会继续发送数据"。

单向 channel:让函数职责更清楚

Go 支持把 channel 参数声明成"只能发送"或"只能接收"。

复制代码
func producer(ch chan<- int) {
	// chan<- int 表示这个函数只能发送 int。
	ch <- 1
}

func consumer(ch <-chan int) {
	// <-chan int 表示这个函数只能接收 int。
	value := <-ch
	fmt.Println(value)
}

这样做的好处是:函数签名直接表达了职责。

在多人协作或项目变大以后,这种写法能减少误用。

select:同时等待多个 channel

select 有点像 channel 专用的 switch

它可以同时等待多个 channel 操作,哪个先准备好,就执行哪个分支。

复制代码
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		time.Sleep(2 * time.Second)
		ch <- "result"
	}()

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

这段代码会打印:

复制代码
timeout

因为 goroutine 要 2 秒才发送结果,而 time.After(1 * time.Second) 1 秒后就会触发。

select 常见用途

select 常用于:

  • 设置超时

  • 监听取消信号

  • 同时处理多个 channel

  • 避免 goroutine 永远阻塞

共享变量和竞态条件

很多新手第一次写并发计数器,会这样写:

复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	count := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)

		go func() {
			defer wg.Done()

			// 这行不是安全的。
			// count++ 本质上包含读取、加一、写回三个步骤。
			count++
		}()
	}

	wg.Wait()
	fmt.Println(count)
}

你可能期望结果永远是:

复制代码
1000

但实际上不一定。

因为 count++ 不是一个不可拆分的动作。它大致可以理解成:

复制代码
读取 count
计算 count + 1
写回 count

如果两个 goroutine 同时读到 count = 10,它们都算出 11,再都写回 11,就丢了一次加法。

这就是竞态条件。

用 Mutex 保护共享变量

如果多个 goroutine 必须共享一个变量,就要用同步手段保护它。

下面用 sync.Mutex 改写计数器:

复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	count := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)

		go func() {
			defer wg.Done()

			// 加锁:同一时刻只允许一个 goroutine 进入下面的区域。
			mu.Lock()
			count++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println(count)
}

被锁保护的区域通常叫关键区域。

更稳妥的写法是用 defer 解锁:

复制代码
mu.Lock()
defer mu.Unlock()
count++

在很小的代码块里直接 Unlock 也可以,但一旦中间逻辑变复杂,defer 更不容易漏掉。

channel 和 Mutex 怎么选

新手常问:我应该用 channel,还是用 Mutex?

可以先用这个简单判断:

复制代码
如果重点是传递数据和组织任务,用 channel。
如果重点是保护共享状态,用 Mutex。

例如:

  • 多个 worker 从任务队列里拿任务,适合 channel。

  • 多个 goroutine 更新同一个 map,适合 Mutex。

  • 一个 goroutine 生产数据,另一个 goroutine 消费数据,适合 channel。

  • 统计全局计数器,适合 Mutex 或 atomic。

不要为了"看起来更 Go"而强行用 channel。写并发程序的目标是清楚、正确、可维护。

用 race detector 检查数据竞争

Go 提供了 race detector。

如果你的代码在 main.go 里,可以这样运行:

复制代码
go run -race main.go

如果是测试:

复制代码
go test -race ./...

它不能证明程序绝对没有并发问题,但能帮你发现很多真实的数据竞争。

建议你养成习惯:并发代码写完后,跑一遍 -race

工作池:为什么需要 worker pool

现在进入最后一个例子:工作池。

假设你有 10000 个任务要处理,例如:

  • 给 10000 个用户发通知

  • 处理 10000 张图片

  • 请求 10000 个 URL

  • 计算 10000 条数据

一种直接写法是:每个任务启动一个 goroutine。

复制代码
for _, job := range jobs {
	go handle(job)
}

这在任务少时没问题,但任务很多时可能造成:

  • goroutine 数量过多

  • 内存压力变大

  • 数据库、接口、文件系统被打爆

  • 错误处理和退出控制变复杂

工作池的核心思想是:

复制代码
任务可以很多,但同时工作的 worker 数量固定。

比如有 10000 个任务,但只启动 5 个 worker。每个 worker 不断从任务 channel 里取任务,处理完一个再取下一个。

工作池结构图

可以把工作池想象成这样:

复制代码
          jobs channel
main  -----------------> worker 1
      -----------------> worker 2
      -----------------> worker 3

worker 1 --------------\
worker 2 ---------------> results channel ---> main 收集结果
worker 3 --------------/

里面有三个角色:

  1. main:创建任务、启动 worker、收集结果。

  2. jobs:任务队列。

  3. results:结果队列。

完整工作池例子

下面是完整可运行代码:

复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

// Job 表示一个任务。
// 这里为了方便演示,让任务内容变成"计算某个数字的平方"。
type Job struct {
	ID     int
	Number int
}

// Result 表示任务处理结果。
type Result struct {
	JobID  int
	Number int
	Square int
}

// worker 是真正干活的函数。
//
// 参数说明:
// id      :worker 编号,方便观察是哪一个 worker 在处理任务。
// jobs    :只接收的 channel,worker 从这里拿任务。
// results :只发送的 channel,worker 把结果发到这里。
// wg      :通知 main:这个 worker 什么时候结束。
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()

	// range jobs 会不断从 jobs channel 接收任务。
	// 当 jobs 被关闭,并且里面的数据被取完后,循环自动结束。
	for job := range jobs {
		fmt.Printf("worker %d started job %d\n", id, job.ID)

		// 模拟耗时操作。
		time.Sleep(300 * time.Millisecond)

		result := Result{
			JobID:  job.ID,
			Number: job.Number,
			Square: job.Number * job.Number,
		}

		// 把处理结果发送给 results channel。
		results <- result

		fmt.Printf("worker %d finished job %d\n", id, job.ID)
	}
}

func main() {
	const workerCount = 3
	const jobCount = 8

	jobs := make(chan Job)
	results := make(chan Result)

	var wg sync.WaitGroup

	// 1. 启动固定数量的 worker。
	for i := 1; i <= workerCount; i++ {
		wg.Add(1)
		go worker(i, jobs, results, &wg)
	}

	// 2. 单独启动一个 goroutine 发送任务。
	go func() {
		for i := 1; i <= jobCount; i++ {
			jobs <- Job{
				ID:     i,
				Number: i,
			}
		}

		// 所有任务都发送完后,关闭 jobs。
		// worker 收到关闭信号后,会在取完剩余任务后退出。
		close(jobs)
	}()

	// 3. 单独启动一个 goroutine 等待所有 worker 结束。
	go func() {
		wg.Wait()

		// 所有 worker 都结束了,说明不会再产生新结果。
		// 这时可以关闭 results,让 main 的 range 循环结束。
		close(results)
	}()

	// 4. main 收集所有结果。
	for result := range results {
		fmt.Printf(
			"result: job=%d number=%d square=%d\n",
			result.JobID,
			result.Number,
			result.Square,
		)
	}

	fmt.Println("all jobs done")
}

可能输出如下:

复制代码
worker 1 started job 1
worker 2 started job 2
worker 3 started job 3
worker 2 finished job 2
worker 2 started job 4
result: job=2 number=2 square=4
worker 1 finished job 1
worker 1 started job 5
result: job=1 number=1 square=1
...
all jobs done

每次运行的顺序可能不同,这是正常的。并发程序通常不应该依赖日志顺序判断正确性。

工作池代码逐段讲解

为什么 worker 数量固定

复制代码
const workerCount = 3

这表示最多同时有 3 个 worker 处理任务。

即使有 8 个任务,也不会同时启动 8 个 worker。任务会排队,哪个 worker 空闲了,就从 jobs 里取下一个。

为什么 jobs 由发送方关闭

复制代码
close(jobs)

jobs 是 main 负责发送的,所以 main 最清楚任务什么时候发送完。

关闭 jobs 后,worker 里的这段循环会自然结束:

复制代码
for job := range jobs {
	// 处理任务
}

为什么 results 不能马上关闭

很多新手会想:

复制代码
close(jobs)
close(results)

这通常是错的。

因为 jobs 关闭只表示"不再发送新任务",不表示 worker 已经处理完已有任务。worker 可能还在计算,并准备往 results 发送结果。

如果你过早关闭 results,worker 再发送数据就会 panic。

所以正确顺序是:

复制代码
关闭 jobs
等待 worker 全部结束
关闭 results

这就是为什么代码里有:

复制代码
go func() {
	wg.Wait()
	close(results)
}()

为什么 results 用 range 接收

复制代码
for result := range results {
	fmt.Println(result)
}

只要 results 没关闭,range 就会继续等结果。

当所有 worker 结束,results 被关闭后,循环自然结束。

这比手动数"收到了几个结果"更适合很多真实场景。

新手常见问题

goroutine 越多越好吗

不是。

goroutine 很轻量,但不是没有成本。启动过多 goroutine 会带来内存、调度、外部资源压力。

如果任务数量很多,优先考虑工作池、限流、批处理。

channel 需要每次都 close 吗

不是。

只有当接收方需要通过关闭信号判断"不会再有数据"时,才需要 close。

如果只是发送一个值、接收一个值,不一定要 close。

close channel 是不是会清空数据

不是。

关闭 channel 后,里面已经发送但还没接收的数据仍然可以被接收。

range ch 会把剩余数据取完,然后结束。

WaitGroup 能不能重复使用

可以,但要非常小心。

新手阶段建议一个 WaitGroup 对应一批任务,不要在上一批 Wait 还没结束时又开始乱加任务。

Mutex 会不会让并发变慢

锁会让部分代码串行执行,但它换来的是正确性。

错误的并发比慢一点的并发更危险。

如果锁竞争很严重,再考虑调整数据结构、减少共享状态、改用 channel 或分片锁等方案。

学 Go 并发的建议路线

你可以按这个顺序练习:

  1. go 启动一个 goroutine。

  2. WaitGroup 等多个 goroutine 结束。

  3. 用 channel 从 goroutine 拿结果。

  4. closerange 处理多条数据。

  5. select 做超时控制。

  6. Mutex 保护共享变量。

  7. go run -race 检查数据竞争。

  8. 写一个工作池限制并发数量。

如果这 8 步都能写顺,Go 并发的入门门槛基本就跨过去了。

总结

Go 的并发模型很适合写清楚的任务协作代码。

你需要记住几件事:

  • goroutine 用来并发执行任务。

  • WaitGroup 用来等待一组 goroutine 结束。

  • channel 用来在 goroutine 之间传递数据。

  • close 用来告诉接收方不会再有新数据。

  • select 用来同时等待多个 channel 操作。

  • Mutex 用来保护共享变量。

  • -race 可以帮助发现数据竞争。

  • 工作池可以限制并发数量,让大量任务更可控。

最后再强调一句:

复制代码
并发程序首先要正确,其次才是快。

不要为了"看起来高级"而滥用 goroutine。能用简单同步写清楚,就先写清楚;等真的遇到吞吐量、延迟、资源限制问题,再引入更复杂的并发结构。

参考资料