GoLang 学习(二)
Go 语言进阶(并发编程)
Goroutine(协程)
核心逻辑对比:
-
线程(OS Thread):由操作系统内核管理,切换成本高(毫秒级),初始栈空间大(通常1-2MB)
-
Goroutine:由 go 运行是调度,切换成本低(纳秒级),初始栈空间仅2KB,且能根据需要动态伸缩
-
M:N 调度:go 内部实现了一个调度器,它将 M 个 Goroutine 映射到 N 个内核线程上。这意味着可以在一台普通pc上轻松开启10w 个Goroutine
tip:进程、线程、协程的区别 -
进程;像一个独立的应用(自己有独立的内存、互相隔离、开销大)
-
线程:像进程里面的一条执行线(共享同一个进程的内存、比进程低、还是由操作系统调度)
-
协程:像线程里面更小、更轻的任务(非常轻、可以开很多很多、有go自己调度、通常比进程更省资源)
开启 Goroutine
在 go 里开启并发只需要一个关键字:go
主协程启动后,子协程会并发运行;但如果主协程先结束,整个程序就退出,所以要花一点时间等子协程执行完(主协程和子协程谁先完并不知道)

tip:非阻塞:调用 go sayHelo( )后,程序不会等待函数执行完,而是立即执行下一行
并发同步:WaitGroup
这本质上就是一个原子计数器,用来知道什么时候所有子任务都跑完了

Channel (通道)
“不要通过共享内存来通信,而要通过通信来共享内存。” Channel 就像是一根带有类型约束的管道,确保数据在协程之间安全、有序地流动。
无缓冲通道
Channel 的默认形式,容量为0
核心逻辑:握手机制
无缓冲通道的发送和接受是同步的
- 发送方阻塞:直到有另一个协程来接受
- 接收方阻塞:直到有另一个协程来发送
tip:这种特性常用于协程间的强同步,确保一个任务完成后另一个才开始
![img]()
有缓冲通道
在创建时指定容量,容量大于0的通道就像一个先进先出的队列
核心逻辑:解耦
- 发送方:只要缓冲区没满,发送操作就是非阻塞的
- 接收方:只要缓冲区不空,接收操作就是非阻塞的
tip:这种特性非常适合处理 生产者-消费者模型,或者应对瞬时流量洪峰
![img]()
单向通道
如果希望限制某个函数对通道的操作权限(一个下载器只能"写"通道,一个解析器只能"读"通道)
核心逻辑:类型安全
- chan<- T:只写通道(只能发送数据到通道)
- <-chan T:只读通道(只能从通道接受数据)
tip:这种限制通常出现在函数参数中,利用编译器检查来防止误操作
![img]()
通道的“开关”管理
核心规则:
- 谁先谁关:通常由发送方关闭通道,接收方感知
- 写已关必崩:向已关闭的通道发送数据会触发 panic
- 读已关拿零:从已关闭的通道读取会立刻返回该类型的“零值”
![img]()
- 读一个已关闭但还有缓冲数据的通道:还能把缓冲里的数据读出来
- 读一个已关闭且空了的通道:返回零值 + false
- 继续读也一样,不会 panic
Select
select 会阻塞当前协程,直到其中的某一个 case(通道操作)准备就绪
- 随机公平性:如果同时有多个 case 准备好了,go 运行时会随机选择一个执行,这不同于 switch 的顺序匹配,目的是为了防止某个通道被“饿死”
- 阻塞性:如果没有任何 case 准备好,且没有 default 分支,select 会一直阻塞。
- 单次执行:一个 select 语句执行一次就结束。如果要持续监听,必须把它放进 for 循环里
基础用法:多路复用
监听多个通道,谁先有数据就处理谁

超时控制
在开发中,如果 API 响应极其不稳定,可以利用 select 配合 time.After 实现秒级熔断

非阻塞通信
默认情况下,往通信发数据或收数据都会阻塞。如果想尝试,如果不行就立刻干别的,就需要 default

退出信号
一个主协程可能开启了多个子协程。当主任务结束时,需要通知所有子协程优雅退出

tip:在 select 中,如果一个 case 的通道值是 nil,这个 case 永远不会被选中。这可以用来在运行期动态地屏蔽掉某个分支。
tip:空 select 的死锁:如果你写一个 select {},它不包含任何 case,该协程会永久阻塞
sync 包的介绍
虽然 Go 强烈推荐 Channel,但在某些极致追求性能的底层场景,或者简单的状态同步时,传统的锁机制依然是不可或缺的
Mutex(互斥锁)
最基础的锁,同一时刻只允许一个协程进入临界区
核心逻辑:保护共享资源:
如果多个协程同时写一个变量,会导致数据竞态。Mutex 确保了操作的原子性

tip:样例中的锁的作用是:保证多个 goroutine 同时修改 count 时,不会把数据改乱。 可以尝试把锁去掉,然后你会发现答案基本每次都不同,因为并发下是不稳定的
RWMutex(读写锁)
在很多场景中,我们是“读多写少”,如果用普通的 Mutex,多个读取者也会互相阻塞,这在逻辑上是没必要的性能损耗。
核心逻辑:多读单写
- 读锁:允许多个协程同时持有,只要没有写锁
- 写锁:排他的。一旦有人在写,其他人都不能读也不能写
![img]()
WaitGroup(计数信号量)
在协程那一章提过,它是等待一组协程结束的利器。
核心逻辑:引用计数
- Add( ):计数器加 n
- Done( ):计数器减 1
- Wait( ):阻塞直到计数器为 0
tip:不要在协程内部执行 Add,因为协程启动有延迟,可能主协程直接跑到了 Wait 而子协程还没来得及 Add。
Once(单次执行)
解决无论多少个协程并发调用,这个初始化逻辑只执行一次的问题
核心逻辑:原子性初始化
sync.Once 内部有一个状态位和一把锁,保证了 Do(func) 里的函数在全球范围内只跑一遍

Cond(条件变量)
这是 sync 包里相对冷门但逻辑最复杂的组件。它通常用于:一个协程在等待某个条件达成,而另一个协程在达成条件后发出通知。
核心逻辑:Wait 和 Signal
Cond 必须配合 Locker(通常是 Mutex)使用
- Wait():释放锁并挂起协程,等待通知。被唤醒时会自动重新拿锁
- Signal():唤醒一个正在等待的协程
- Broadcast():唤醒所有正在等待的协程
![img]()
tip:锁的范围越小性能越好,但逻辑复杂度会指数级上升
tip:Go 运行时有简单的死锁检测。如果你所有的协程都在 Lock 处互相等待,程序会崩溃报错
tip:
锁是面向内存地址的:适合简单的状态同步(计数、开关)
Channel 是面向逻辑流的:适合数据的加工、传递、解耦
Context(上下文控制)
如果开启了一个复杂的线程树,想要在中途瞬间关闭整个子树的所有线程,context包提供了一套标准的“自杀”和“超时”机制
Context 的核心逻辑:树状传播
Context 是一个树状结构。当创建一个子 Context 时,它会继承父级的状态。
- 向下传播:如果父级 Context 被取消,所有由它派生出来的子级、孙级协程都会收到取消信号。
- 向上隔离:子级的取消不会影响父级
常用的操作全家桶
- 根节点:Background 与 TODO
- context.Background():通常作为整个请求的最顶层,代表一个空的上下文。
- context.TODO():当你还不确定要用哪个 Context 时占位用。
- WithCancel:手动开关
- 返回一个副本和 cancel 函数。调用 cancel() 后,所有监听该 Context 的协程都会收到信号。
(ctx) 是调用匿名函数时传入的参数,前面的 ctx context.Context 是函数定义的参数列表。(定义的同时,马上把 ctx 传进去执行)
tips:
ctx:一个“可取消”的上下文
cancel:一个专门用来取消这个ctx的函数
- WithTimeout / WithDeadline:自动熔断
- WithValue:跨协程传值
tip:不要用它传业务大数据!它不安全,且查找效率是 $O(n)$
- Context 的四大使用军规
准则 逻辑理由 函数首参数 约定俗成。第一个参数永远写 ctx context.Context。 不要存储在结构体中 应该显式传递,保证生命周期随函数调用结束。 必须手动调用 cancel 否则会导致子 Context 挂载在父级下,产生内存泄漏。 传递的是接口 Context 是接口,底层是线程安全的。
tip:<-ctx.Done() 的底层其实返回的是一个 只读 Channel (<-chan struct{})。当 cancel() 被调用时,Go 运行时会关闭这个 Channel。
并发安全
在多线程环境下,最怕的就是 Data Race(数据竞态)。Go 提供了三种逻辑维度的保护方案
原子操作
如果并发安全逻辑只是为了保护一个简单的计数器(如 int64),用 Mutex 就太重了。sync/atomic 直接利用 CPU 指令集保证操作的不可分割性。
核心逻辑:
- 效率极高:它不需要上下文切换,直接在硬件层面锁定缓存行。
- 适用场景:全局状态位、简单的自增 ID。
![img]()
传统锁机制
当需要保护的是一整块复杂的逻辑(比如修改一个结构体、往 Map 里存数据)时,就必须划定临界区。
核心逻辑:
- 锁定范围:锁定的代码块越小,并发性能越好。
- 逻辑补丁:Go 的 map 是并发不安全的!多个协程同时写 map 会直接抛出致命错误 fatal error: concurrent map writes
![img]()
通信机制
如果数据只在一个协程里处理,就不存在安全问题。可以通过 Channel 将数据的所有权“移交”给下一个协程。
核心逻辑:所有权转移
把复杂的逻辑串行化。比如 10 个协程负责抓取网页,1 个协程负责把结果存入数据库。通过 Channel 传递,数据库协程成了唯一的写者,逻辑自然安全。
竞态检测器
Go 在语言工具链里内置了检测器。当运行测试或程序时,加上 -race 参数

tip:如果代码中存在哪怕万分之一概率的并发读写冲突,Go 会直接输出:
1. 冲突的变量地址
2. 发生读操作的协程栈
3. 发生写操作的协程栈







(ctx) 是调用匿名函数时传入的参数,前面的 ctx context.Context 是函数定义的参数列表。(定义的同时,马上把 ctx 传进去执行)
tip:不要用它传业务大数据!它不安全,且查找效率是 $O(n)$

浙公网安备 33010602011771号