CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Go 语言入门/课时/并发模型
RawBlame
课时目录12 节

Go 语言入门

  1. 01Go 是什么
  2. 02基础语法与类型
  3. 03数组、切片与 map
  4. 04结构体与接口
  5. 05模块与包管理
  6. 06并发模型
  7. 07错误处理
  8. 08泛型入门
  9. 09I/O 与 CLI
  10. 10Web 与数据库
  11. 11测试与基准
  12. 12综合练习

并发模型

goroutines、channels、select、sync、context、worker pools。

40 分钟

并发模型

这一课你将能做到

  1. 用 go 关键字启动 goroutine,并用 sync.WaitGroup 正确等待一组任务结束
  2. 用 channel 在 goroutine 间传递数据,说清无缓冲与有缓冲、close 与 range 的规则
  3. 组合 select、worker pool、Mutex、context 解决四类典型并发问题

为什么要学

并发是 Go 的招牌,也是面试的重灾区。Java 起一个线程默认 1MB 栈、创建销毁都昂贵;Go 的 goroutine 初始栈只有约 2KB,由运行时调度(GMP 模型)在少量系统线程上复用百万级任务,go f() 一行就能启动。

Go 的并发哲学一句话:不要通过共享内存来通信,而要通过通信来共享内存。channel 是通信的管道,Mutex 是共享的锁,context 是取消的开关——这一课把这套工具链全部打通。

先建立图像

           go f() 启动
main ────────────────▶ goroutine A ─┐
        │                            │ channel(先进先出管道)
        │            goroutine B ────┤  ch <- 数据  (发送)
        ▼                            │  v := <-ch   (接收)
   主流程继续跑,不等 goroutine         └──▶ 另一端取到数据

有缓冲 channel:    [ ][ ][ ][ ]   装满后发送方阻塞
无缓冲 channel:    两端必须同时就位,像接力交接

两个心智要点:go 之后主流程立刻往下走,不等待;channel 是「带锁的队列 + 阻塞语义」,收发两端速度不一致时自动等待。

跟着做

步骤 1:第一次启动 goroutine——以及它的陷阱

package main

import (
	"fmt"
	"time"
)

func say(msg string) {
	fmt.Println("goroutine 说:", msg)
}

func main() {
	go say("hello")            // 启动后立刻返回
	go say("world")
	time.Sleep(time.Second)    // 丑陋的等待,仅演示
	fmt.Println("main 结束")
}

问题:main 一退出,所有 goroutine 被直接终止。time.Sleep 猜等待时间不可靠,工程做法是 WaitGroup。

步骤 2:sync.WaitGroup——等待一组任务

package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 3. 结束时 Done(务必 defer,防 panic 漏调)
	fmt.Printf("worker %d 干活\n", id)
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)          // 1. 启动前 Add
		go worker(i, &wg)  // 2. 必须传指针!传值会拷贝计数器
	}
	wg.Wait() // 4. 阻塞直到计数归零
	fmt.Println("全部完成")
}

三步口诀:启动前 Add,结束 defer Done,主流程 Wait。最常见的事故是 go worker(i, wg) 传了值——计数器被拷贝,Done 减的是副本,Wait 永远等不到。

步骤 3:无缓冲 channel——同步接力

package main

import "fmt"

func main() {
	ch := make(chan int) // 无缓冲

	go func() {
		ch <- 42 // 发送:等接收方就位才继续
		fmt.Println("已送出 42")
	}()

	v := <-ch // 接收:等发送方送来
	fmt.Println("收到:", v)

	// 由于发送阻塞到接收完成,这里"已送出"一定在"收到"之后打印
}

无缓冲 channel 的收发是同步点:谁先到谁阻塞,像两人握手交接。这也天然是一种同步手段。

步骤 4:有缓冲 channel、close 与 range

package main

import "fmt"

func main() {
	ch := make(chan int, 3) // 缓冲 3

	ch <- 1 // 缓冲没满,立即返回
	ch <- 2
	ch <- 3
	// ch <- 4 // 取消注释会死锁:缓冲满且无接收者

	close(ch) // 由发送方关闭,广播"没有更多数据了"

	for v := range ch { // range 会自动在 close 且取完后退出
		fmt.Println(v)
	}

	// 关闭后再接收:立即返回零值 + false
	v, ok := <-ch
	fmt.Println(v, ok) // 0 false
}

规则:只有发送方需要 close;接收方关闭会引发发送 panic;重复 close 同样 panic。绝大多数场景甚至不必 close——channel 会被 GC 回收,close 只在「用 range 收尾」或「广播结束信号」时需要。

步骤 5:select——多路复用与超时

package main

import (
	"fmt"
	"time"
)

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

	go func() { time.Sleep(100 * time.Millisecond); ch1 <- "来自 ch1" }()
	go func() { time.Sleep(200 * time.Millisecond); ch2 <- "来自 ch2" }()

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

	// 经典超时模式
	select {
	case msg := <-ch1:
		fmt.Println("及时:", msg)
	case <-time.After(1 * time.Second):
		fmt.Println("超时了")
	}
}

select 等多个 channel 操作,谁就绪执行谁;多个同时就绪则随机选一个(防饿死)。time.After 是最常用的超时触发器。

步骤 6:worker pool——固定并发的任务池

package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs { // jobs 关闭后循环自动结束
		fmt.Printf("worker %d 处理 %d\n", id, j)
		results <- j * 10
	}
}

func main() {
	const numWorkers = 3
	const numJobs = 9

	jobs := make(chan int)          // 任务队列
	results := make(chan int, numJobs) // 结果收集(容量=任务数,防阻塞)
	var wg sync.WaitGroup

	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, jobs, results, &wg)
	}

	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}
	close(jobs) // 关闭任务队列 → worker 的 range 退出

	wg.Wait()
	close(results)

	for r := range results {
		fmt.Println("结果:", r)
	}
}

worker pool 是生产环境控制并发数的标配:goroutine 数固定为 numWorkers,任务多到多少都不会压垮系统。

步骤 7:共享内存就用 Mutex

不是所有场景都该用 channel。多个 goroutine 改同一变量时,加锁最直接:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		counter int
		mu      sync.Mutex
		wg      sync.WaitGroup
	)

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++ // 不加锁:结果随机且大概率小于 1000(数据竞争)
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println(counter) // 稳定输出 1000
}

先用 go run -race main.go 复现数据竞争警告,再上锁修复,-race 是排查并发 bug 的第一工具。

步骤 8:context——取消与超时的标准姿势

package main

import (
	"context"
	"fmt"
	"time"
)

func slowWork(ctx context.Context) {
	select {
	case <-time.After(3 * time.Second): // 模拟耗时 3 秒
		fmt.Println("工作完成")
	case <-ctx.Done(): // 取消/超时信号
		fmt.Println("被取消:", ctx.Err()) // context deadline exceeded
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel() // 惯例:拿到 cancel 必须 defer,防泄漏

	slowWork(ctx)
}

Go 的数据库、HTTP 客户端全部支持 ctx 贯穿,超时取消一路传递,这是写服务必备素养。

讲清楚要点

  1. goroutine 轻在栈和调度:2KB 起步栈可伸缩,GMP 模型把大量 goroutine 复用到少量 OS 线程上;创建成本约是线程的千分之一。
  2. channel 三规则:发送方 close;向 nil channel 收发永久阻塞;向已 close 的 channel 发送 panic。
  3. WaitGroup 必须传指针;Add 必须在 go 语句之前,避免 Wait 抢跑。
  4. select 是随机公平的;空 select(无 case)永久阻塞,可作 sleep 到永远。
  5. channel 传递是拷贝:传大结构体不如传指针或通知信号;方向可约束 chan<-(只发)、<-chan(只收),编译期防呆。
  6. 能用锁就别硬上 channel:保护共享状态(计数器、缓存)用 Mutex;传递数据流、任务分发用 channel。

你可能会懵的地方

  • goroutine 和线程到底差在哪? 内核线程由 OS 调度、栈 MB 级、切换进内核;goroutine 是运行时用户态调度、栈 KB 级、切换只是函数调用级别开销。所以单机百万 goroutine 可行,百万线程直接压垮内存。

  • 什么时候用 channel,什么时候用 Mutex? 「拥有数据的所有权转移」用 channel(任务分发、结果汇聚、流水线);「多个执行流频繁读写同一份状态」用 Mutex。Go 官方建议:能想清楚谁拥有数据就选 channel,想不清就先用锁保平安。

  • goroutine 泄漏是什么?怎么发现? goroutine 阻塞在永远无人收发的 channel 上,永不退出,随请求累积耗尽内存。典型源头:只发不收、忘记 close、忘记 ctx 取消。用 runtime.NumGoroutine 监控或 pprof 排查。

  • GMP 是什么?(概念级) G=goroutine,M=OS 线程,P=调度上下文(处理器)。P 持有本地运行队列,M 绑定 P 执行 G;G 阻塞在系统调用时 P 会被移交其他 M,避免整体卡死。

  • Go 1.22 对循环变量做了什么改动? 旧版本 for 循环变量全轮次共享,goroutine 闭包捕获会踩同一个变量;1.22 起每轮迭代都是新变量,老坑已消除。但显式传参的写法仍然推荐,兼容且清晰。

错法对照

错法问题正解
go worker(i, wg) 传值 WaitGroup计数器被拷贝,Wait 永远阻塞&wg 传指针
在接收方 close(ch)发送方再发就 panic只由发送方关闭
用 time.Sleep 等 goroutine时长不可控,竞态WaitGroup / channel 同步
多 goroutine 直接读写 mapfatal error: concurrent map writes,无法 recoverMutex 包住或 sync.Map
忘记读取 channel 结果发送方永久阻塞 → goroutine 泄漏缓冲、select + 超时,或明确消费方
recover 想接住 map 并发错误fatal error 不是 panic,接不住预防为主:加锁

卡点急救

  1. fatal error: all goroutines are asleep - deadlock!:所有 goroutine 都在等 channel。检查是否漏了发送方/接收方、是否忘 close 导致 range 不退出。
  2. 数据竞争结果不对:立刻 go run -race .,它会精确打印冲突的读写位置,按提示加锁或改传 channel。
  3. goroutine 数量持续上涨:泄漏。排查阻塞的 channel 收发和未取消的 context。
  4. Wait 不返回:Add 与 Done 次数不匹配;确认 Done 用 defer、且传的是指针。

面试常问

  • channel 的底层实现?有缓冲与无缓冲的区别? 底层是带互斥锁的环形队列(hchan 结构:缓冲数组、send/recv 等待队列)。无缓冲:收发必须同时就位,是同步点;有缓冲:缓冲未满发送不阻塞,语义从「同步」变成「异步投递」。

  • 如何控制 goroutine 并发数? worker pool(固定数量 goroutine 从 jobs channel 取任务);或带缓冲 channel 作信号量,sem <- struct{}{} 占位、<-sem 释放。

  • sync.Map 和 Mutex+map 怎么选? sync.Map 适合读多写少、key 集合稳定的场景;写多时它内部优化的开销反而不划算,常规业务用 Mutex + map 更简单可控。

  • 如何优雅退出所有 goroutine? 统一传 context,goroutine select 监听 ctx.Done();或关闭一个广播用 channel。配合 http.Server.Shutdown 完成排空(第 12 课实战)。

自测三问

  1. 无缓冲 channel 上先发送后接收,发送方会怎样?换成有缓冲 cap=1 呢?
  2. 写出 WaitGroup 的三步口诀,并说明为什么必须传指针。
  3. 一个 goroutine 向无缓冲 channel 发数据但没有任何接收者,程序会发生什么?如何用 select+超时规避?

小结一句话

goroutine 负责「并发地跑」,channel 负责「安全地传」,Mutex 负责「锁住共享」,context 负责「叫得停」——四件套各司其职,就是 Go 并发的全部骨架。

延伸

  • Go 官方博客:Share Memory By Communicating
  • 数据竞争检测器
  • 下一课:错误处理——Go 用「错误是值」的哲学替代 try/catch,这是每段 Go 代码的日常。
加载本节练习…
← 上一课模块与包管理下一课 →错误处理