看完本文你将速通以下内容:

这几把啥:进程线程协程,GMP模型->抢占式调度,工作窃取,内存模型

快速上手:Goroutine,Channel->Buffered/Unbuffered, Nil/Closed 读写panic行为,Select->Timeout (超时控制),For Select (循环监听)

由于篇幅问题下面内容将放在下一篇:

同步与安全:WaitGroup,互斥锁,读写锁,Sync Map,Sync Once,Sync.Pool (GC优化),Sync.Cond

上下文:Context Base,Cancellation (取消),Timeout/Deadline (截止时间),Values (传值),Context 的父子传递与内存泄漏风险

模式与工具:原子操作(CAS),工作池,扇入扇出,协程泄漏,ErrGroup,优雅退出 (Graceful Shutdown),流水线 (Pipeline),限流 (Rate Limit)

调试与诊断工具:Pprof(CPU/Mem/Block/Mutex profile),Trace(可视化调度流程),死锁检查,Race Detector (go build -race),逃逸分析

先跑起来

看下面这些东西前,先看这个案例感受到Go并发的强大

同样的任务,创建1千个子任务,每个都是干同一件事就是生成两个1~99的随机数相加然后直接return

如果使用传统win32 api,使用CreateThread,在Go里就是这样写的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package main

import (
"math/rand"
"syscall"
"time"
)

var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreateThread = kernel32.NewProc("CreateThread")
procWaitForSingle = kernel32.NewProc("WaitForSingleObject")
procCloseHandle = kernel32.NewProc("CloseHandle")
)

const (
INFINITE = 0xFFFFFFFF
)

// 线程实际执行的函数
func threadProc(param uintptr) uintptr {
// 为了公平,使用独立的随机源,避免全局锁竞争
// 使用 param 作为种子的一部分确保随机性
r := rand.New(rand.NewSource(time.Now().UnixNano() + int64(param)))

a := r.Intn(99) + 1
b := r.Intn(99) + 1
res := a + b

// 防止编译器优化掉计算过程
if res > 200 {
return 0
}

return uintptr(res)
}

func main() {
var handles []uintptr
threadCount := 1000

// 将 Go 函数转换为 C 回调函数指针
// 注意:这在 Go 中是非常规操作,仅用于演示 Win32 API 调用
cb := syscall.NewCallback(threadProc)

for i := 0; i < threadCount; i++ {
// CreateThread(NULL, 0, &threadProc, param, 0, NULL)
handle, _, _ := procCreateThread.Call(
0, // lpThreadAttributes
0, // dwStackSize
cb, // lpStartAddress (回调函数地址)
uintptr(i), // lpParameter (传个参当随机种子)
0, // dwCreationFlags
0, // lpThreadId
)
handles = append(handles, handle)
}

// 等待所有线程结束 (模拟 Join)
// Windows WaitForMultipleObjects 最多支持 64 个,所以这里简单循环 Wait
for _, h := range handles {
procWaitForSingle.Call(h, uintptr(INFINITE))
procCloseHandle.Call(h)
}
}

看看它的执行时间,使用了108毫秒。后续的运行可能会比第一次快,因为Windows有个SuperFetch机制。可以多运行几次看看耗时。我这里差不多是80~120毫秒

1
2
3
4
5
6
7
8
9
10
11
12
13
PS C:\go-start> go build .\hello.go
PS C:\go-start> Measure-Command {.\hello.exe}
Days : 0
Hours : 0
Minutes : 0
Seconds : 0
Milliseconds : 108
Ticks : 1082801
TotalDays : 1.25324189814815E-06
TotalHours : 3.00778055555556E-05
TotalMinutes : 0.00180466833333333
TotalSeconds : 0.1082801
TotalMilliseconds : 108.2801

而如果切换成Go的并发写法,也就是使用Go的协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"math/rand"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup
threadCount := 1000

wg.Add(threadCount)

for i := 0; i < threadCount; i++ {
// 传入 i 作为参数以初始化随机种子,保持与 Win32 版本逻辑一致
go func(seed int) {
defer wg.Done()

// 创建局部随机源,避免全局锁竞争
r := rand.New(rand.NewSource(time.Now().UnixNano() + int64(seed)))

a := r.Intn(99) + 1
b := r.Intn(99) + 1
res := a + b

// 保持逻辑一致,防止优化
if res > 200 {
return
}
// 在 Goroutine 中 return 相当于线程结束
return
}(i)
}

// 等待所有 Goroutine 结束
wg.Wait()
}

来看看有多快,只用了14毫秒:

1
2
3
4
5
6
7
8
9
10
11
12
13
PS C:\go-start> go build .\hello.go
PS C:\go-start> Measure-Command {.\hello.exe}
Days : 0
Hours : 0
Minutes : 0
Seconds : 0
Milliseconds : 14
Ticks : 146139
TotalDays : 1.69142361111111E-07
TotalHours : 4.05941666666667E-06
TotalMinutes : 0.000243565
TotalSeconds : 0.0146139
TotalMilliseconds : 14.6139

这几把啥

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而应通过通信来共享内存。

进程?线程?协程?

需要极高的隔离性和稳定性(一个挂了不影响另一个),用进程。
需要利用多核 CPU 进行计算密集型任务,用线程。
面临高并发 IO(如处理 10万个 Socket 连接),且逻辑复杂,用协程(避免了 10万个线程带来的巨大内存消耗和调度开销)。

概念与区别

特性 进程 (Process) 线程 (Thread) 协程  / 纤程
定义 资源分配的基本单位 CPU调度的基本单位 用户态的轻量级线程
内存空间 独立的虚拟地址空间 共享进程的地址空间 共享进程的地址空间
调度者 操作系统内核 (OS Kernel) 操作系统内核 (OS Kernel) 用户程序 (Runtime/程序员)
切换开销 极大 (切页表、切缓存) 较大 (切寄存器、切内核栈) 极小 (仅切少量寄存器)
通信机制 IPC (管道、套接字、共享内存) 直接读写共享变量 + 锁 直接读写共享变量 (通常无需锁)
并行性 多进程可并行 (多核) 多线程可并行 (多核) 并发不并行 (同一时间通常只在一个线程上跑)

底层原理

以Windows操作系统为例,Windows 是基于抢占式多线程的操作系统。

看不懂就不要强求了如果你没有Windows相关开发经验的话

1. 进程 (Process)

内核对象:在内核中由 EPROCESS 结构体表示。
进程本质上是一个容器。它不执行代码,它拥有一个私有的虚拟地址空间(Virtual Address Space)、一个句柄表(Handle Table,用于管理文件、窗口等资源)和安全上下文(Token)。
每个进程至少包含一个主线程。
Windows 通过页表(Page Tables)映射,保证进程 A 无法直接访问进程 B 的内存。

2. 线程 (Thread)

内核对象:在内核中由 ETHREAD 结构体表示。
线程是 Windows 调度器真正调度的对象。每个线程拥有自己的栈(包括内核栈和用户栈)、程序计数器(PC/RIP)和寄存器上下文。
在用户态,每个线程有一个 TEB (Thread Environment Block),用于存储线程局部存储(TLS)和异常处理链表。
Windows 使用基于优先级的轮转调度。当线程时间片用完或被更高优先级抢占时,内核会触发上下文切换(Context Switch),保存当前线程寄存器状态到内核栈,加载新线程状态。需要从用户态(Ring 3)陷入内核态(Ring 0)。

3. 协程 (Coroutine) & 纤程 (Fiber)

协程:是编程语言层面实现的。本质上是“用户态线程”。操作系统不知道协程的存在,它只看到承载协程的线程。
纤程 (Fiber):Windows 操作系统层面提供的“协程”机制。
Windows 内核将纤程视为轻量级对象,但调度由用户手动控制(非抢占式)。内核只调度线程,线程内部的代码逻辑决定运行哪个纤程。
纤程有自己的栈和一部分寄存器上下文,保存在用户空间。

使用与开销和性能分析

1. 进程 (Process)

相关API: 创建:CreateProcess,结束:ExitProcess, TerminateProcess,获取信息:OpenProcess, GetProcessId
其中CreateProcess是最复杂的 API 之一。它会创建内核对象、分配 4GB(32位)或更巨大的(64位)虚拟内存空间、加载 EXE 和 DLL、创建主线程。
内存开销: 极高,因为需要独立的页目录、页表,虽然利用写时复制(COW),但初始启动仍需数 MB 到数 GB 不等。
进程切换/调度最慢(> 几微秒),切换页表目录基址(CR3 寄存器)。这导致 CPU 的 TLB(转换后备缓冲器)失效,TLB 失效意味着内存访问速度大幅下降(直到缓存热身完毕),并且切换的操作包含线程切换的所有步骤。

2. 线程 (Thread)

相关API:创建:CreateThread (Win32 API) / _beginthreadex (C Runtime),挂起/恢复:SuspendThread, ResumeThread,同步:WaitForSingleObject (等待线程结束), InitializeCriticalSection (锁),睡眠:Sleep (主动放弃 CPU 时间片)
CreateThread会在当前进程空间内分配栈内存,创建内核对象,加入调度队列。
内存开销:中等,虽然ETHREAD 结构体约 1-2KB,但是默认情况下,Windows 为每个线程预留1MB的栈空间
线程切换/调度慢 (约 1-5 微秒)。

  1. 模式切换:用户态 -> 内核态 -> 用户态(Ring 3 <-> Ring 0)。这涉及 CPU 特权级检查和中断处理。
  2. 保存上下文:将通用寄存器、浮点寄存器保存到内核栈。
  3. 调度算法:内核需要扫描就绪队列,计算优先级。
  4. 缓存污染:新线程的代码和数据可能不在 L1/L2 Cache 中。

3. 协程 (Coroutine) & 纤程 (Fiber)

语言级协程没有统一的 Win32 API,Windows的纤程有但是这里不展开了
内存开销:极低,Go 协程初始 2KB,动态扩容,C++20 无栈协程甚至只占用堆上的一个数十字节的状态帧结构体
协程切换/调度:极快 (约 10-200 纳秒),因为不涉及内核态切换,没有系统调用,只需保存几个被调用者保存寄存器(Callee-saved registers)和栈指针(SP)。切换目标通常由代码直接指定(如 YieldSwitchToFiber),复杂度为 O(1)。因为还在同一个线程内,L1/L2 Cache 大概率还有效。

GMP模型

GMP 是 Go 运行时(Runtime)实现的一套用户态调度系统。它在操作系统内核线程之上,构建了一层更轻量的调度逻辑,目的是让极少的内核线程(M)支撑起极多的 Go 协程(G)。这是M:N 调度模型(M个协程映射到 N个内核线程)。

G M P

G (Goroutine) —— 砖头

含义:Go 协程。
本质:它是 Go 语言层面的线程,非常轻量。
资源:初始栈只有 2KB(OS 线程通常 1-2MB),可动态伸缩。
包含:函数代码、指令指针(PC)、栈指针(SP)、状态(等待、运行等)。
角色:相当于“要搬的砖”或者“要执行的任务”。

M (Machine) —— 工人

含义:工作线程(OS Kernel Thread)。
本质:操作系统内核线程,对应底层的 Windows/Linux 线程。
资源:开销大,创建和切换由操作系统管理(涉及内核态切换)。
角色:相当于“搬砖的工人”。只有 M 才能真正利用 CPU 执行指令。

一个 OS 线程(M)必须绑定一个逻辑处理器(P),才能执行 P 本地队列里的协程(G)。

P (Processor) —— 小推车

含义:逻辑处理器(Context)。
本质:运行 G 所需的资源上下文。
数量:由 GOMAXPROCS 决定,通常等于 CPU 核心数。
包含:本地队列(Local Run Queue)。这是 P 的核心,存储着等待被 M 执行的 G。
角色:相当于“手推车”或“工位”。工人(M)必须拿到手推车(P),才能去装砖头(G)干活。

在同一时刻,一个 P 只能被一个 M 绑定。

调度策略

本地队列 —— 减少锁竞争

早期的 Go 只有 G 和 M,所有 G 都放在一个全局队列,M 获取 G 时需要猛烈地抢全局锁,性能极差。

现在每个 P 都有自己的本地队列。M 绑定 P 后,优先从 P 的本地队列取 G 执行。这几乎不需要加锁,因为一个 P 此时只属于一个 M,极大地提高了效率。

工作窃取 —— 负载均衡

M1 绑定的 P1 里的 G 执行完了,没事干了。M1 不会闲着,它会尝试从其他 P(比如 P2)的本地队列里偷一半 G 过来自己执行。所有 CPU 核心都能一直保持忙碌,不会出现“一核有难,八核围观”的情况。

调度循环

当一个 M 想要执行任务时,它会按照以下顺序寻找 G:

  1. 检查自己绑定的 P 的本地队列。如果有,直接取出来干。(无锁,最快)。
  2. 为了防止全局队列里的 G 饿死,M 会以一定的概率(每 61 次调度检查 1 次)去检查全局队列。(需要加锁)。
  3. 检查网络轮询器,看是否有网络 IO 就绪的 G。(非阻塞)。
  4. 开偷:如果上面都没找到,M 就开始“偷”了。它会随机选择另一个 P(Victim),试图从它的本地队列里偷走一半的 G。
  5. 如果连偷都偷不到,M 就会解绑 P,进入休眠状态,放入空闲 M 列表。
细节

为什么偷一半?偷 1 个的话,频率太高,频繁加锁/CAS 操作开销大。全偷走的话,原来的 P 变空了,马上又得去偷别人的,造成震荡。偷一半是权衡后的最佳策略,既分担了负载,又减少了竞争。

在进入休眠前,M 会进行一段时间的“自旋”(空转循环),持续尝试窃取。这是因为创建/唤醒线程的开销很大,如果只是短暂没活干,不如空转一会儿等待新任务,这比让线程睡眠再唤醒要快得多。

系统调用 —— 避免阻塞

这是解决“一个线程阻塞导致整个 CPU 闲置”的关键。
M1 正在执行 G1,G1 调用了阻塞的系统调用(比如读取文件、网络请求),导致 M1 必须在内核态等待,无法继续利用 CPU。

  1. 分离:P1 发现 M1 被阻塞了,马上把 M1 踢开(解绑)。
  2. 接管:P1 寻找(或新建)一个新的线程 M2 来绑定自己,继续执行队列里剩下的 G。
  3. 回归:当 M1 的系统调用结束,它会尝试获取一个空闲的 P。如果拿不到,就把 G1 扔到全局队列,M1 自己进入休眠(或被回收)。

抢占式调度

在 Go 1.14 之前,Go 的调度其实是“协作式”的(尽管有一些补丁),但在 Go 1.14 引入了基于信号的异步抢占后,它变成了真正的抢占式。如果是协作式调度,如果一个 G 写了个死循环 for {},它会一直霸占 M,导致该 P 上的其他 G 饿死。如果 GOMAXPROCS 较小,甚至会导致整个程序停止响应(GC 也无法运行)

Go 1.14 及以后,利用的是操作系统的信号机制。实现真正抢占式

  1. 监控:sysmon 是一个特殊的独立线程(不绑定 P),它每隔一段时间(10ms - 10ms)醒来检查所有的 P。
  2. 发现:如果发现某个 G 运行时间超过了 10ms,sysmon 判定它需要被抢占。
  3. 发信号:sysmon 向运行该 G 的线程(M)发送一个操作系统信号(Linux 下通常是 SIGURG)。
  4. 中断:M 收到信号,操作系统中断 M 当前的执行流,挂起用户代码,转而去执行信号处理函数。
  5. 插桩:Go 注册的信号处理函数会修改 M 的程序计数器(PC)和栈指针(SP),将其指向一个特定的 Go 运行时函数 asyncPreempt
  6. 切换:当信号处理返回,M 不再回到原来的代码位置,而是去执行 asyncPreempt。这个函数会保存当前 G 的上下文(Context),将其放入全局队列(Global Queue),然后 M 会去调度下一个 G。

内存模型

内存分配机制

Go 的内存分配器是基于 Google 的TCMalloc算法改进的。它和 GMP 模型高度结合,解决了多线程内存分配需要频繁加锁的问题。

  • mcache (P 的本地缓存 - 无锁)

    • 每个 P 都有一个 mcache
    • 因为一个 P 在同一时间只能被一个 M 拥有,所以 M 从 P 的 mcache 分配内存是不需要加锁的,这就是 Go 内存分配极快的原因。
    • 它维护了不同大小规格(Span Class)的空闲链表,专门用于分配小对象(<= 32KB)。
  • mcentral (中心缓存 - 需要锁)

    • 当 P 的 mcache 里的内存用完了,它会向 mcentral 申请一批新的内存块。
    • mcentral 是所有 P 共享的,所以需要加锁。
  • mheap (全局堆 - 需要锁)

    • mcentral 也没内存了,就向操作系统申请(虚拟内存),由 mheap 管理。
  1. 微对象/小对象:从 P 的 mcache 拿。
  2. mcache 不够:去 mcentral 批发一组。
  3. 大对象 (>32KB):从 mheap 分配。

内存一致性

在一个协程里对变量 A 的修改,什么时候能被另一个协程看到?

Go保证了以下核心的先行发生规则,只要遵循这些就能保证内存安全:

  1. 初始化:main 函数的启动一定发生在所有 init 函数执行完成之后。
  2. Goroutine 创建:go func() 语句的执行,一定发生在协程内部代码执行之前。
  3. Channel:
    • Send -> Receive:对一个 Channel 的发送操作,一定发生在接收操作完成之前。
    • Close -> Receive:Channel 的关闭操作,一定发生在接收方收到“零值”之前。
    • (注意:无缓冲 Channel 的接收,发生在发送完成之前。这是一种同步点。)
  4. Lock:
    • Unlock() 一定发生在该锁被下一次 Lock() 之前。这意味着你必须先释放锁,别人才能拿到,以此保证临界区数据的可见性。

快速上手

你已经精通了基本概念,现在速速开始写代码

Goroutine

Goroutine,也就是这里称作协程的用户态线程。这是 Go 的原子级并发单位。

注意:main 函数本身也是一个协程。如果 main 结束了,所有的 Goroutine 都会被强制杀掉(不管有没有执行完)。通常需要 sync.WaitGroup 来等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

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

func main() {
var wg sync.WaitGroup

wg.Add(1)

go func(name string) {
defer wg.Done()
fmt.Printf("来自 %s 的问好!\n", name)
time.Sleep(5000 * time.Millisecond) // 模拟耗时
}("Goroutine-1")

fmt.Println("主函数已经启动,等待 Goroutine 完成...")
wg.Wait()
fmt.Println("全部完成,包括Goroutine")
}

运行结果如下:

1
2
3
4
PS C:\go-start> go run .\hello.go 
来自 Goroutine-1 的问好!
主函数已经启动,等待 Goroutine 完成...
全部完成,包括Goroutine

("Goroutine-1")何意味

传值给go func(name string)这个匿名函数的。上一篇迁移的文章可能没有提到,匿名函数的语法是go func(参数声明) { 函数体 }(实际传入的值)

func(name string) 定义了匿名函数接收一个字符串参数。末尾的 ("Goroutine-1") 就像普通函数调用一样,把字符串传给了 name
前面的 go 关键字告诉 Go 运行时:请在一个新的协程里异步执行这个函数调用。

wg.Add(1)何意味

如果你熟悉别的语言多线程开发的肯定这里会有疑惑,看得懂wg.Wait()是等协程全干完活的意思,但是wg.Add(1)何意味?不用指定是哪个协程?

如果是Windows创建线程要用WaitForSingleObject然后指定线程句柄等待结束,go里直接wg.Add(1)怎么知道我要等哪个协程?万一只有一个要等别的不等无所谓呢?

这就是 Go 协程和 Windows/C++ 线程模型最大的区别之一:sync.WaitGroup 本质上只是一个“计数器”,它完全不关心是谁在执行。
Windows WaitForSingleObject 逻辑:主线程盯着那个具体的句柄(Handle),看有没有完成。这是一种“强绑定”的关系。
wg.Add(1):计数器 +1(“有 1 件事还没办完”)。
wg.Done():计数器 -1(“有 1 件事办完了”)。
wg.Wait():阻塞,直到计数器归零。
它不知道、也不需要知道是哪个协程。wg 只看加了多少,减了多少。它不记得哪个协程加的,哪个协程减的。 只要有一个协程执行了 Done(),计数器就会减 1。

Go的哲学是“高并发”。假设启动了10000个协程去爬网页,肯定不想维护10,000个句柄。只想说:“我有10,000个任务,全部减到0了告诉我一声就行”。

如果 Add 的次数比 Done 多,Wait 会死锁;如果 Done 多了,程序会 Panic。

Channel

上面还有一个没解决的问题就是,万一只有一个协程要等别的不等无所谓呢?

可以使用多个WaitGroup,定义 wg1wg2wg1.Wait() 只会等那些调用了 wg1.Done() 的协程。

Channel 是协程之间通信的管道。如果习惯了Windows那种“拿到一个句柄/标志”来判断,Go 里的Channel(通道) 才是对应的工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
"time"
)

func main() {
done := make(chan bool) // 创建一个信号通道

go func() {
fmt.Println("正在干活...")
time.Sleep(2 * time.Second)
done <- true // 干完活了,发个信号
}()

<-done // 只有收到信号才会继续走,不关心别的协程
fmt.Println("指定的协程干完活了")
}

运行结果如下:

1
2
3
PS C:\go-start> go run .\hello.go
正在干活...
指定的协程干完活了

在继续前,先了解<-操作符

<-操作符

在Go中,<- 符号是 通道操作符。它的含义非常直观:数据流向哪里,箭头就指向哪里。

在上面的示例代码里有两个作用,分别是发送数据和接收数据。

done <- true:箭头在通道变量 done 的右边,把右边的值(true)“塞进”左边的通道(done)里。就是通道 <- 数据 ,即数据流入通道。

<-done : 箭头在通道变量 done 的左边。从通道 done 中“取出来”一个值。就是<- 通道 ,即数据从通道流出。

为什么用它来等指定协程?

阻塞是Go协程同步的核心机制。默认情况下,通道的发送和接收都是阻塞的。

当主线程运行到 <-done 时,它会像看门大爷一样在那里等着。如果 done 里面没有东西,主线程就停在这一行,不再往下执行

此时,子协程正在 time.Sleep。等子协程睡醒了,执行 done <- true,把一个值放进了通道。

主线程发现通道里有东西了,立刻取出这个值,然后解除阻塞,继续执行后面的 fmt.Println

接收并赋值

数据从左边流出,所以其实也可以把管道里的数据取出来并且赋值

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
done := make(chan int) // 创建一个信号通道

go func() {
fmt.Println("正在干活...")
time.Sleep(2 * time.Second)
done <- 114514 // 干完活了,发个信号
}()

hachimi := <-done // 只有收到信号才会继续走,不关心别的协程
fmt.Println("指定的协程干完活了,返回值是", hachimi)
}

运行结果就是

1
2
正在干活...
指定的协程干完活了,返回值是 114514

通道缓冲

  • 无缓冲 (Unbuffered): make(chan int)
    • 同步通信。发送方(Sender)会阻塞,直到有一个接收方(Receiver)准备好拿数据。就像“快递员必须亲手把货交给你,否则他不走”。
  • 有缓冲 (Buffered): make(chan int, 3)
    • 异步通信。只要缓冲区没满,发送方发完就走,不阻塞。如果满了,发送方阻塞。就像“快递员把货放快递柜”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
// 1. 无缓冲通道 (同步)
ch1 := make(chan int)
go func() {
fmt.Println("发送方: 开始发送")
ch1 <- 100 // 阻塞,直到 main 中接收
fmt.Println("发送方: 发送完毕")
}()
val := <-ch1 // 接收
fmt.Printf("接收方: 收到 %d\n", val)

// 2. 有缓冲通道 (异步)
ch2 := make(chan string, 2) // 容量为 2
ch2 <- "A" // 不阻塞
ch2 <- "B" // 不阻塞
//ch2 <- "C" // 第三条会阻塞,因为满了

fmt.Println(<-ch2) // 取出 A
fmt.Println(<-ch2) // 取出 B
}

运行的结果是:

1
2
3
4
5
发送方: 开始发送
发送方: 发送完毕
接收方: 收到 100
A
B

上面的无缓冲就不多说了,刚刚就是使用无缓冲通道的机制实现等待指定协程结束

打个比方

缓冲和栈的顺序不同,栈是后进先出,而缓冲是先进先出。所以可以把缓冲理解成队列

既然如此,可以把ch2想象成一个传送带

  1. make(chan string, 2):买了两个格子的传送带。
  2. ch2 <- "A":把 A 放到了第一个格子里。因为还有空位,不需要等,放完就走(非阻塞)。
  3. ch2 <- "B":把 B 放到了第二个格子里。此时格子满了,但放完了,还是可以继续走(非阻塞)。
  4. ch2 <- "C":如果再放 C,因为传送带满了,必须在那等着,直到有人从另一头拿走一个。

让我看看

可以使用两个内置函数查看缓冲的状态:

  • cap(ch2):查看缓冲通道总容量。
  • len(ch2):查看现在缓冲通道已经使用的量。

没人等我啊

如果尝试把上面那个ch2 <- "C"取消注释后运行就会发现,程序没有卡死而是直接坠机了

1
2
3
4
5
6
7
8
9
发送方: 开始发送
发送方: 发送完毕
接收方: 收到 100
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
C:/go-start/hello.go:20 +0x128
exit status 2

因为Go有一个内置的“死锁检测机制

为什么不等

ch2 <- "C" 时,主线程其实尝试在那里等。

但是,Go 运行时在后台巡视了一圈,发现:主线程在等别人从 ch2 取走数据。其他的协程:一个都没有!全都在睡觉或者退出了

主线程在等一个永远不会发生的信号,因为没人能从通道取数据了,如果不报错,这个程序就会永远卡死在内存里浪费电。所以干脆报错退出

fatal error: all goroutines are asleep - deadlock!

致命错误:所有的协程都睡着了 ——死锁了!

给我等

留一个活着的协程晚点再去从通道里取数据,这样主线程就会等这个协程了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func main() {
ch2 := make(chan string, 2)

var wg sync.WaitGroup
wg.Add(1)

// 往通道里放两个数据

ch2 <- "A"
ch2 <- "B"

// 开启一个子协程,3秒后取走一个数据
go func() {
defer wg.Done()
fmt.Println("子协程:我先睡3秒,然后再去拿 A...")
time.Sleep(3 * time.Second)
val := <-ch2
fmt.Printf("子协程:拿走了 [%s]\n", val)
}()

fmt.Println("主线程:准备放 C...")
ch2 <- "C" // 这次不会报错了,主线程会在这里真的阻塞(等)3秒,因为有个活着的协程
fmt.Println("主线程:刚刚把 C 放进去了!")

fmt.Println(<-ch2) // 取出 B
fmt.Println(<-ch2) // 取出 C

wg.Wait()
}

运行结果是

1
2
3
4
5
6
主线程:准备放 C...
子协程:我先睡3秒,然后再去拿 A...
子协程:拿走了 [A]
主线程:刚刚把 C 放进去了!
B
C

我给那个匿名函数加了个sync.WaitGroup来等,不加的话可能子协程还没来得及输出子协程:拿走了 [A]这句话,主线程就取出B和C然后结束了

  • 阻塞(Wait)是功能:当通道满了或空了,协程会进入等待状态。
  • 死锁(Deadlock)是错误:当 Go 发现所有存活的协程都在互相等待,或者都在等待一个不可能到达的信号时,它会主动Panic,防止程序变成僵尸进程。

遍历Channel

可以直接使用for v := range ch2,由于通道的特性,Channel里每新增一个数据,for循环里的代码就会立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
ch := make(chan int) // 无缓冲通道
// ch := make(chan int, 3) // 或者有缓冲通道效果也一样

go func() {
for i := 1; i <= 3; i++ {
time.Sleep(1 * time.Second) // 模拟生产耗时
fmt.Printf("【发送方】准备发送: %d\n", i)
ch <- i
}
close(ch) // 发完关门
}()

fmt.Println("【接收方】开始巡逻(进入 range)...")
for v := range ch {
// 只要 ch 没数据,这里就卡住(阻塞)
// 只要 ch 一进数据,这里立刻就会跳出来执行
fmt.Printf("【接收方】立即捕捉到了: %d\n", v)
}
fmt.Println("【接收方】通道关了,我下班了。")
}

运行的结果是

1
2
3
4
5
6
7
8
【接收方】开始巡逻(进入 range)...
【发送方】准备发送: 1
【接收方】立即捕捉到了: 1
【发送方】准备发送: 2
【接收方】立即捕捉到了: 2
【发送方】准备发送: 3
【接收方】立即捕捉到了: 3
【接收方】通道关了,我下班了。

关闭Channel

如果业务逻辑不需要通过“关闭信号”来通知接收者停止工作,只是简单地把 Channel 当作同步工具或数据队列,且不涉及 range 循环,那么完全可以不用手动关闭Channel。当程序运行结束或不再使用时,GC会处理它。

但是如果要用for range 遍历Channel或者其他需要明确告诉对方“我的任务彻底结束了”时。就需要手动调用 close(ch)关闭Channel

Channel 什么时候会关闭?

Channel不会自动关闭,必须手动调用 close(ch)

通常在以下两种场景下需要关闭:

1.循环读取 Channel(比如使用 for range)时,如果不关闭,接收方会一直阻塞在那里等。只有关闭了,range 循环才会正常结束。

2.广播下班信号:关闭 Channel 会瞬间让所有阻塞在“接收”状态的协程收到信号。

广播下班信号

这个特性是 Go 语言实现“广播信号”的技巧。因为从一个已经关闭的 Channel 读取数据,不会阻塞,而是会立刻返回该类型的“零值”以及一个“失败”标志。

如果往通道发一个数据( ch <- 1),只有一个正在等待的协程能抢到这个数据,抢完后通道又空了,其他协程继续等。

如果关闭通道(close(ch)),所有正在等待的协程都会瞬间“被唤醒”,并收到通道已关闭的消息。

举个例子,百米赛跑三个运动员在等下班

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func runner(name string, startChan chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()

fmt.Printf(" %s 已就位,等待起跑口令...\n", name)

// 重点:所有协程都阻塞在这里,试图从 startChan 读东西
_, ok := <-startChan
if !ok {
fmt.Printf(" %s 收到关闭信号,下班! \n", name)
return
}
}

func main() {
var wg sync.WaitGroup
// 创建一个“信号通道”
startChan := make(chan struct{})

// 启动 3 个运动员
runners := []string{"运动员-A", "运动员-B", "运动员-C"}
for _, name := range runners {
wg.Add(1)
go runner(name, startChan, &wg)
}

// 模拟裁判准备阶段
time.Sleep(2 * time.Second)
fmt.Println("裁判:预备... 别跑了!下班时间到!")

// 关闭通道,瞬间唤醒所有阻塞在 <-startChan 的协程
close(startChan)

wg.Wait()
fmt.Println("比赛结束!")
}

运行结果就是

1
2
3
4
5
6
7
8
 运动员-B 已就位,等待起跑口令...
运动员-C 已就位,等待起跑口令...
运动员-A 已就位,等待起跑口令...
裁判:预备... 别跑了!下班时间到!
运动员-B 收到关闭信号,下班!
运动员-C 收到关闭信号,下班!
运动员-A 收到关闭信号,下班!
比赛结束!

通过关闭通道实现了广播下班信号

未初始化的 Channel

简而言之就是nil Channel。

1
var ch chan int // 只是声明了,没有 make,此时 ch 是 nil

它的行为非常怪异且危险:
往里发数据:永久阻塞(Deadlock)。
从里取数据:永久阻塞(Deadlock)。
关闭它:直接 Panic。

但不是一无是处,在高级 select 切换中很有用。比如暂时想禁用某个 Case,可以把那个 Channel 设为 nil。后文会说

Channel的行为表

操作 nil channel (未初始化) closed channel (已关闭) open channel (正常)
Close Panic Panic 成功关闭
Send (写) 永久阻塞 Panic 阻塞直到满或被读
Receive (读) 永久阻塞 没有数据的时候返回零值 阻塞直到有数据

向已关闭的 channel 发送数据会 Panic。读已关闭的 channel 不会阻塞,它会一直返回该类型的零值(0, “”, nil, false)。

Select

select 类似于 Switch,但它用于 Channel。它会随机(防止某个通道总是被忽略) 选择一个可以执行的 case 执行。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
ch1 := make(chan string)
ch2 := make(chan string)

go func() {
time.Sleep(1 * time.Second)
ch1 <- "消息来自 ch1"
}()

go func() {
time.Sleep(2 * time.Second)
ch2 <- "消息来自 ch2"
}()

// 监听两个通道,谁先来处理谁
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
//default:
// 如果上面两个都没准备好,会瞬间走这里
// fmt.Println("没有任何消息准备好")
}
}

这样的运行结果是

1
消息来自 ch1

因为ch1先往通道里输入数据,如果把下面default注释去掉,运行结果一定是:没有任何消息准备好。因为 select 的特性是:它只会“看一眼”所有的 case。如果有准备好的就执行;如果没有,且写了 default,它就会立刻执行 default 然后头也不回地往下走。

Timeout(超时控制)

这是后端开发最常用的模式,防止某个请求卡死整个服务。利用 selecttime.After

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
// 模拟一个很慢的操作
resultCh := make(chan string)
go func() {
time.Sleep(3 * time.Second) // 模拟耗时 3 秒
resultCh <- "数据库查询结果"
}()

// 超时控制
select {
case res := <-resultCh:
fmt.Println("成功:", res)
case <-time.After(2 * time.Second): // 2秒后这个 channel 会吐出一个时间
fmt.Println("错误: 操作超时了!")
}
}

运行结果肯定就是错误: 操作超时了!

For Select (循环监听 / 守护进程)

这是 Go 中实现Worker或Daemon的例子。需要一直监听任务,同时还需要能优雅退出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func main() {
// 任务通道
jobChan := make(chan int)
// 退出信号通道
doneChan := make(chan struct{}) // struct{} 也就是空结构体,不占内存,专门发信号用

// 启动 Worker
go func() {
for {
select {
case job := <-jobChan:
fmt.Printf("处理任务: %d\n", job)

case <-doneChan: // 监听退出信号
fmt.Println("收到退出信号,清理资源,停止工作...")
return // 退出 for 循环,结束协程
}
}
}()

// 模拟发送任务
jobChan <- 1
jobChan <- 2
time.Sleep(500 * time.Millisecond)

// 发送退出信号
close(doneChan) // 关闭通道可以广播给 select,比发送数据更好用

time.Sleep(1 * time.Second) // 等待 worker 打印退出日志
fmt.Println("主程序退出")
}

运行结果就是

1
2
3
4
处理任务: 1
处理任务: 2
收到退出信号,清理资源,停止工作...
主程序退出

最后先挖个坑吧,因为使用Gin/GORM开发后端的时候是不用手写go func()的,甚至大部分时间不用写sql语句。所以高级并发操作就等以后有空再深入研究