前置:转Go学习笔记1语法入门
目录
Golang进阶
groutine协程并发
概念梳理
Go 语言最具代表性的核心特性之一,就是其轻量级用户态协程——Goroutine。在深入理解 goroutine 之前,我们先回顾一下协程的基本概念以及它们解决的并发痛点。
1.单线程与多线程的限制
(1)单线程模型:在早期单核系统中,计算机只能顺序执行单一任务。当遇到 I/O 阻塞时,整个线程只能等待,导致 CPU 空转,浪费了大量计算资源。
(2)多线程/多进程:为提升 CPU 利用率,引入了多线程/多进程并发模型。通过操作系统的时间片轮转机制,多个线程/进程在逻辑上“同时”执行,实则在 CPU 核心间快速切换:
- 优点:当一个线程阻塞时,CPU 可以调度其他线程执行,提升总体利用率。
- 缺点:频繁的上下文切换带来额外开销(保存/恢复寄存器状态、内核态切换、线程栈空间大(通常几 MB)等),尤其在高并发场景下切换成本呈指数增长,影响整体性能。
2.协程模型演进
为降低多线程模型下的切换和调度开销,业界引入了用户态协程(coroutine)模型,其核心思想是将调度逻辑上移到用户态,避开内核态的频繁切换。常见调度模型对比:
1:1 模型:每个用户线程绑定一个内核线程,调度仍完全依赖操作系统调度器,无法解决内核态切换开销。
N:1 模型:多个用户协程复用一个内核线程,切换由用户态调度器管理,内核无感,极大减少上下文切换开销。但当某个协程执行系统调用或纯阻塞操作时,会阻塞其所在的内核线程,导致所有复用该线程的协程均被阻塞。
M:N 模型:M 个内核线程复用 N 个用户协程,调度逻辑由语言运行时管理,能充分利用多核 CPU 并缓解阻塞问题。Go 语言采用的是 M:N 模型,并通过一套自研的调度器设计高效规避了 N:1 模型的典型阻塞痛点:
-
- P 与 M 解耦:当 goroutine 阻塞时,调度器会将逻辑处理器 P 从阻塞的内核线程 M 中分离,并迁移到其他空闲或新建线程继续调度其他 goroutine,避免阻塞扩散。
-
- 非阻塞 I/O 封装:Go 运行时内部将大部分系统调用(如网络、文件 I/O)封装为非阻塞模型,结合内置的网络轮询器(netpoller)机制,在用户态实现高效的 I/O 多路复用。
Go 在实现 goroutine 时,不仅更名为 “Goroutine”,更在核心设计上做了优化:
- 内存占用更小:每个 goroutine 栈空间通常只占用几 KB,且支持按需动态扩展,相比传统线程动辄几 MB 的栈空间大幅降低内存压力,进程甚至可能达到几GB。
- 调度开销更低:轻量化特性让调度器可以频繁快速地切换执行 goroutine,整体并发性能得到大幅提升。
Go语言早期的调度器设计存在较大问题。如下图所示:其中 G 表示 Goroutine,M 表示操作系统线程。早期调度器的做法是:假设有一个 4 核 CPU,它维护一个全局队列,所有新创建的 Goroutine 都被加入到这个全局队列中。每个线程(M)在执行时,会先获取全局队列的锁,拿到一个 Goroutine 执行。执行后,其余未执行的 Goroutine 会被移动到队列头,等待下一个线程调度;执行完成的 Goroutine 会被放回队列尾部。整个流程简单粗暴,但存在以下明显缺陷:
激烈的锁竞争:创建、销毁和调度 Goroutine 时,所有线程都需获取全局队列的锁,导致频繁的同步与阻塞,性能大幅下降。
任务迁移延迟:如果某个 M 在运行 G 时新创建了 G’,理论上希望 G’ 也在当前 M 上执行以保持数据局部性。但由于使用全局队列,G’ 可能被其他 M 取走,增加了切换成本,降低了缓存命中率。
系统调用频繁:线程在切换、阻塞与唤醒过程中,频繁进行系统调用,进一步增加了系统开销。
3.Go 的 GMP 模型
Go 采纳 M:N 模型,并引入了 G(goroutine)、M(machine,内核线程)、P(processor,逻辑处理器) 三元结构──GMP 模型:
G:轻量级协程,初始栈大小仅几 KB,按需动态增长,内存占用极小。
M:操作系统线程,真正执行 goroutine 的载体。
P:逻辑处理器,调度器核心单元,,持有本地任务队列(本地 runnable G 列表),决定哪个 G 由哪个 M 执行。P 的数量由
GOMAXPROCS
环境变量决定,最多并行(注意是并行而非并发)运行 P 个协程。
此外,还维护一个全局队列用于存放溢出的 goroutine,保证负载均衡。新创建的 goroutine 优先放入其所属 P 的本地队列,若本地队列已满,才会转移到全局队列,确保整体调度平衡。全队列还有一个锁的保护,所以从全队列取东西效率会比较慢一些。
4.Go 调度器的关键策略
Go调度器的设计包含四大核心策略:线程复用、并行利用、抢占机制、全局G队列。下面分别说明:
(1)线程复用(Work Stealing与Hand Off机制)
Go通过复用线程提升调度效率,主要依靠Work Stealing与Hand Off两种机制:
Work Stealing(工作窃取)
每个P(Processor)有自己的本地G队列。当某个M(Machine)空闲时,它会从其他 P 的本地队列尾部"窃取"任务,充分提升资源利用率与并行度,避免任务堆积或线程空闲。
Hand Off(让渡机制)
当运行中的G发生阻塞(如IO或锁等待),绑定其所在P的M会尝试将P迁移给其他可用的M(新建或唤醒线程),继续执行本地队列中的其他G任务。阻塞的M进入休眠,待阻塞解除后再参与调度。该机制确保阻塞不会影响其他G的执行,最大化CPU利用率。
(2)并行利用
通过设置 GOMAXPROCS 控制 P 数量,合理分配 CPU 资源。
比如在 8 核 CPU 下,若将 GOMAXPROCS 设为 4,Go 运行时仅会使用 4 核心资源,剩余可供其他系统任务使用,提供良好的资源隔离能力。
(3)抢占机制
传统协程调度依赖协程主动让出CPU,容易导致长时间占用。Go 从 1.14 版本起引入强制抢占机制:每个G最多运行约10ms,无需其主动让出,调度器可强制将CPU分配给其他等待的G。此设计保证了调度公平性和系统响应性,避免某些G长期独占CPU。
(4)全局G队列
在每个P的本地队列之外,Go还维护一个全局G队列作为任务缓冲。新创建的G优先进入本地队列,若本地已满才进入全局队列。空闲的M在本地与其他P的队列均无任务时,最后尝试从全局队列取任务。全局队列的访问需要加锁,相比本地队列性能略低,但作为兜底机制,保障了任务分配的完整性与平衡性。
总结一下Go 调度器的关键策略:
- 1.线程复用(Work Stealing & Hand Off)
-
- 工作窃取:当某个 P 的本地队列空闲时,会从其它 P 窃取可执行的 G,避免某些线程闲置。
-
- P 与 M 分离(Hand Off):当执行中的 G 阻塞(如网络 I/O),调度器会将对应的 P 从当前 M 分离,挂载到其他空闲或新建的 M 上,保持剩余 G 在本地队列不中断执行。
2.并行 通过 GOMAXPROCS 设置 P 的数量,决定最大并行协程数,灵活利用多核 CPU。
3.抢占 Go 从 1.14 起支持协程抢占,当某个 G 占用 CPU 超过一定时间(约 10 ms)或出现函数调用边界时,可强制调度,避免单个 G 长期占用,保证所有 G 的公平执行。
4.本地与全局队列 大部分 G 都存放在 P 的本地队列,只有在本地队列满时才会入全局队列。空闲时优先窃取本地队列,只有在无其他可用 G 时才访问全局队列,降低全局锁竞争。
小结 —— 为什么 Goroutine 如此高效?
低内存开销:初始栈极小,且支持动态伸缩,百万级并发成为可能;
高效调度:用户态调度极大减少内核切换次数,整体并发性能远优于传统线程;
抢占式公平性:保证调度不会被单个 goroutine 长时间垄断;
本地+全局队列:高效的本地队列配合全局队列兜底,确保任务平衡与快速分发;
I/O 封装优化:大部分阻塞 I/O 在用户态实现了非阻塞封装,极大缓解系统调用瓶颈。
创建goroutine语法
如下代码所示,通过go
关键字创造goroutine
package main
import (
"fmt"
"time"
)
// 一个用于演示的子goroutine任务函数,不断地每秒打印当前计数值。
func newTask() {
i := 0
for {
i++
fmt.Printf("new Goroutine : i = %d\n", i) // 其中 %d 表示格式化为十进制整数
time.Sleep(1 * time.Second) // // 通过 time.Sleep 让当前 goroutine 休眠 1 秒钟
}
}
// main 函数是 Go 程序的入口函数,同时它本身就是一个 goroutine(称为主 goroutine)
func main() {
// 通过 go 关键字创建一个新的 goroutine,去异步执行 newTask() 函数
go newTask()
// 此处主 goroutine 继续往下执行,不会等待 newTask 执行结束
fmt.Println("main goroutine exit")
i := 0
for {
i++
// 主 goroutine 也每秒打印一次当前计数值
fmt.Printf("main goroutine: i = %d\n", i)
time.Sleep(1 * time.Second)
}
// 1. 在 Go 语言中,使用 go 关键字可以在运行时动态创建新的 goroutine(轻量级线程)。
// Go 运行时会负责调度多个 goroutine,通常在一个或多个操作系统线程上并发执行。
//
// 2. 主 goroutine 退出时,整个进程随之结束,所有其他子 goroutine 无论是否完成都会被强制终止。
// 因此,如果将上面的 for 循环注释掉,仅执行 fmt.Println 后主函数直接退出,
// 那么子 goroutine newTask 也无法执行或只执行极短时间后被终止。
//
// 3. 在实际项目中,如果希望主 goroutine 等待其他 goroutine 执行结束,可以使用 sync.WaitGroup、
// channel 或 context 等机制来实现 goroutine 之间的同步与协调。
}
实际上在承载一个go程的时候不一定要把go程写为一个定义好的函数,我们直接写一个匿名函数去加载也可以,这里演示一下:
package main
import (
"fmt"
"runtime"
"time"
)
// 本示例主要演示了在 Go 语言中:
// 1. 使用匿名函数(函数字面量)直接创建 goroutine;
// 2. 使用 runtime.Goexit() 退出当前 goroutine;
// 3. 说明 goroutine 函数中无法直接返回值给调用者。
func main() {
// 使用 go 关键字创建 goroutine,并在其中定义并调用匿名函数(没有参数和返回值)
go func() {
defer fmt.Println("A.defer") // 延迟执行,在当前匿名函数退出时执行
// 内层匿名函数
func() {
defer fmt.Println("B.defer") // 延迟执行,在当前匿名函数退出时执行
// 如何在go程中退出当前goroutine? 用runtime.Goexit()
// runtime.Goexit() 用于立即终止当前 goroutine 的执行。
// 注意:它只终止当前 goroutine,不会影响其他 goroutine,包括主 goroutine。
// 此外,它在退出时仍会调用所有已注册的 defer 函数(类似于正常退出时的清理逻辑)。
// 因此 "B.defer" 会被打印,而 "B" 不会被打印。
// 注意如果这里是用return的话 只是退出了当前函数调用栈帧 "A"仍会被打印
runtime.Goexit()
// 由于上面调用了 Goexit(),所以下面这句不会被执行:
fmt.Println("B")
}() // 如果只是写这个函数,就只是定义了但没被调用,加个()等于我定义了这么一个函数,同时调用起来
// 调用时我们没有传递任何参数,因为这里的函数定义就没有任何参数
// 由于外层 goroutine 也被 Goexit() 终止了,因此这句也不会被执行:
fmt.Println("A")
// runtime.Goexit() 并不是像 return 那样只退出当前函数调用栈帧,
// 它直接终止整个当前 goroutine,跳出所有调用栈,当然 defer 仍然会执行。
}()
// 使用匿名函数创建并立即调用带参数的 goroutine
go func(a int, b int) bool {
fmt.Println("a = ", a, ", b = ", b)
return true
}(10, 20) // 这里匿名函数定义后立刻通过()调用,并传入参数 10 和 20
// 即使匿名函数有返回值 (bool),但由于 goroutine 是并发执行的,无法通过 return 直接获取结果
/* 补充说明:
- Go 语言中不支持像 flag := go func() bool {...}() 这样的语法,
因为 go 关键字启动的 goroutine 是异步执行的,其返回值不会传递回主 goroutine。
- goroutine 之间默认无法返回值或传递数据,若要实现结果返回或通信,
需要借助 channel、sync 包或 context 机制来实现同步与通信。
*/
// 死循环用于防止 main goroutine 提前退出,确保前面创建的 goroutine 有机会执行完毕
for {
time.Sleep(1 * time.Second)
}
}
在 Go 语言中,main 函数的退出意味着整个程序的结束。所以如果 main 函数提前退出,所有未执行完的子 goroutine 会立即被强制终止。在实际应用中,通常不建议用死循环阻塞主 goroutine,可以使用 sync.WaitGroup
更优雅地等待子 goroutine 结束。这里写一份goroutine + WaitGroup 基础通用模板:
package main
import (
"fmt"
"sync"
"time"
)
// 子任务函数:可以传参,支持 defer、panic 恢复等
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 每启动一个 goroutine,结束时必须调用 Done()
// panic 保护(可选,但建议加上,避免单个 goroutine 崩溃导致全局异常)
defer func() {
if err := recover(); err != nil {
fmt.Printf("Worker %d recovered from panic: %v\n", id, err)
}
}()
fmt.Printf("Worker %d start\n", id)
// 模拟任务执行时间
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
numWorkers := 5 // 启动 5 个并发任务
for i := 1; i <= numWorkers; i++ {
wg.Add(1) // 每个任务启动前,先增加计数
go worker(i, &wg)
}
// 阻塞等待所有子 goroutine 完成
wg.Wait()
fmt.Println("所有任务执行完毕,主程序退出")
}
wg.Add(1)
: 每个 goroutine 启动前,先登记 1 个待完成任务
defer wg.Done()
: 每个 goroutine 执行完后自动减一,防止漏掉
recover()
:捕获 panic,避免整个程序因某个 goroutine 崩溃
wg.Wait()
: 阻塞主 goroutine,直到所有登记的任务完成
time.Sleep()
: 模拟任务处理时间,实际可替换成任何逻辑
channel实现goroutine之间通信
channel是Go语言中的一个核心数据类型,可以把它看成管道,,主要用来解决go程的同步问题以及go程之间数据共享(数据传递)的问题。并发核心单元通过它就可以发送或者接收数据进行通讯,这在一定程度上又进一步降低了编程的难度。
goroutine运行在相同的地址空间,因此访问共享内存必须做好同步。goroutine 奉行通过通信来共享内存,而不是共享内存来通信。
下面我们学习一下channel的基本用法:
package main
import "fmt"
func main() {
// 定义一个 channel,用于传递 int 类型的数据。
// 这里使用的是无缓冲(unbuffered)channel:只能同时存放一个数据。
// 当向无缓冲 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 从 channel 中接收数据。
c := make(chan int)
// 启动一个新的 goroutine(协程,相当于一个轻量级线程)。
// channel 通常用于多个 goroutine 之间的通信,这里就是 main goroutine 和新开启的 goroutine 之间的通信。
go func() {
// 在函数退出时输出一句话,表明这个 goroutine 结束了
defer fmt.Println("goroutine结束")
fmt.Println("goroutine 正在运行...")
// 向 channel 中发送数据:666
// 发送操作:c <- 666
// 因为 channel 是无缓冲的,如果 main goroutine 没有准备好接收数据,发送操作会阻塞在这里
c <- 666 //将666发送给c 这个是发送的语法
}()
// 从 channel 中接收数据:<-c
// 这个接收操作会阻塞,直到有数据被发送到 channel 中
// 接收到的数据赋值给变量 num
num := <-c //从c中接受数据,并赋值给num 这个是接收的语法
// - <-c 是接收操作,把 channel 中的数据取出
// - <-c 也可以单独写成:<-c 只取出数据而不保存(丢弃)
// 例如: <-c // 取出数据但不保存任何变量中,数据被丢弃
fmt.Println("num = ", num) // num = 666
fmt.Println("main goroutine 结束...")
}
这里因为使用的是无缓冲channel,当向无缓冲 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 从 channel 中接收数据,接收操作会阻塞,直到有数据被发送到 channel 中。
在 Go 语言中,channel 分为无缓冲(unbuffered)和有缓冲(buffered)两种。
无缓冲 channel:
发送和接收必须同步进行。
发送操作会阻塞,直到有接收者从 channel 中取走数据;接收操作也会阻塞,直到有发送者发送数据。
适用于需要确保发送方与接收方同步的场景,常用于协程之间的同步控制。
有缓冲 channel:
在内部有一个有限的缓冲区,可以容纳一定数量的元素。
发送操作在缓冲未满时不会阻塞;只有当缓冲区满时才会阻塞发送方。
接收操作在缓冲非空时不会阻塞;只有当缓冲区为空时才会阻塞接收方。
适用于发送和接收速度不完全匹配的场景,可以提升一定的并发性能和吞吐能力。
简单来说:无缓冲更偏向同步,有缓冲更偏向异步。
下面我们测试一下有缓冲channel的效果:
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个带缓冲区的 channel,类型为 int,缓冲区大小为 3。
// 这意味着最多可以缓存 3 个尚未被接收的元素。
c := make(chan int, 3)
// 打印当前 channel 的长度和容量:
// len(c): 当前缓冲区中已有的数据个数(初始为 0)
// cap(c): 缓冲区总容量(此处为 3)
fmt.Println("len(c) = ", len(c), ", cap(c)", cap(c)) // 输出: len(c) = 0 , cap(c) = 3
// 启动一个新的 goroutine 来向 channel 中发送数据
go func() {
defer fmt.Println("子go程结束") // 在函数结束时自动打印,标记子 goroutine 结束
// 循环向 channel 中发送 4 个整数(注意:发送次数 > 缓冲区容量)
for i := 0; i < 4; i++ {
c <- i // 发送数据到 channel
fmt.Println("子go程正在运行, 发送的元素=", i, " len(c)=", len(c), ", cap(c)=", cap(c))
}
}()
// 主 goroutine 休眠 2 秒,确保子 goroutine 有时间执行发送操作
// 这只是为了演示方便,实际中应使用同步机制(如 wait group)
time.Sleep(2 * time.Second)
// 从 channel 中依次取出 4 个元素(注意:实际发送了 4 个元素)
for i := 0; i < 4; i++ {
num := <-c //从c中接收数据,并赋值给num
fmt.Println("num = ", num)
}
fmt.Println("main 结束")
}
运行结果为:
len© = 0 , cap© 3
子go程正在运行, 发送的元素= 0 len©= 1 , cap©= 3
子go程正在运行, 发送的元素= 1 len©= 2 , cap©= 3
子go程正在运行, 发送的元素= 2 len©= 3 , cap©= 3
num = 0
num = 1
num = 2
num = 3
main 结束
一开始 len© 是 0,因为还没有任何数据发送到 channel。
子 goroutine 发送前 3 个元素时:因为缓冲区容量为 3,每次发送成功后,缓冲区长度 len© 依次变为 1、2、3。此时发送都是非阻塞的(因为缓冲区未满)。
当尝试发送第 4 个元素(i=3)时:缓冲区已满,发送操作阻塞,直到主 goroutine 从 channel 中读取数据,腾出空间。由于主 goroutine 在 time.Sleep 中睡眠,子 goroutine 此时会卡在 c <- i 第 4 次发送这里,等待空间腾出。
睡眠结束后,主 goroutine 依次从 channel 中读取 4 个数据:前 3 个立即取出缓冲区中的数据(0、1、2)。取出第 3 个数据时,缓冲区变为不满,子 goroutine 解除阻塞,成功发送最后一个元素 3。主 goroutine 继续取出最后一个数据 3。
所有数据接收完成后,程序结束。
介绍了有缓冲和无缓冲channel的基本定义与使用后,我们再来看看channel的关闭特点:
package main
import "fmt"
// 在Go语言中,channel不像文件那样需要频繁关闭;通常只有以下两种情况需要关闭:
// 1. 确定不再向channel发送任何数据了(即:发送方完成了全部发送任务)。
// 2. 想要通过关闭channel通知接收方,配合range、for-select等结构优雅退出。
// 注意:关闭channel只是禁止继续发送数据(引发panic错误后导致接收立即返回零值);
// 而接收数据仍然是允许的,直到channel被完全读空。
// 另外:nil channel(值为nil的channel)在收发操作时都会永久阻塞。
func main() {
c := make(chan int) // 创建一个无缓冲的整型channel,类型为chan int
go func() { // 启动一个匿名goroutine作为发送者
for i := 0; i < 5; i++ { // 向channel中发送5个整数:0到4
c <- i // 向channel发送数据,若没有接收方则会阻塞
//close(c) // 注意:如果在这里关闭channel,将在第一次发送后关闭,再发送时panic!
}
//close可以关闭一个channel
close(c) // 循环发送完所有数据后,关闭channel,通知接收方:不会再有新的数据发送进来了
}()
for { // 启动主goroutine作为接收者
// 这里使用了逗号ok的惯用写法:data接收从channel读取的数据
// ok为布尔值,若channel未关闭或还有数据,ok为true;当channel关闭且数据读完后,ok返回false
if data, ok := <-c; ok { // channel仍然有数据可以读取
fmt.Println(data)
} else { // channel已关闭且数据读完,退出循环
break
}
}
fmt.Println("Main Finished..")
// 如果不在子程里调用close(c) 或不在子goroutine里发送数据
// 如果不在子goroutine里发送数据,而直接在主goroutine中执行接收
// 由于主goroutine会阻塞在 <-c ,而没有其他goroutine发送数据,最终会导致:
// fatal error: all goroutines are asleep - deadlock
// 这是因为Go运行时检测到了所有goroutine都阻塞,程序无法继续执行,因此直接panic报死锁。
}
这里
if data, ok := <-c; ok {
里面有个分号,这是 Go 语言里 “if 语句支持短变量声明” 的语法,在 Go 里,if 语句可以有两部分:if 简短变量声明; 条件判断 { // ... }
也就是说:分号 ; 把变量声明和条件判断隔开。if 语句执行时,先执行分号前面的短变量声明(这里是 `data, ok := <-c`),然后判断分号后面的条件(这里是 `ok`)。这句代码拆开理解就是:data, ok := <-c // 从channel接收数据,同时判断channel是否已关闭 if ok { fmt.Println(data) }
但是因为 Go 允许你把声明写在 if 里,就可以缩写成一行
channel与range、select
下面我们再看一下channel跟两个比较特殊的关键字的配合使用
channel与range
package main
import "fmt"
func main() {
c := make(chan int) // 创建一个无缓冲的整型channel,类型为chan int
go func() { // 启动一个匿名goroutine作为发送者
for i := 0; i < 5; i++ { // 向channel中连续发送5个整数:0到4
c <- i // 发送数据到channel,若无接收方会阻塞等待
}
// 发送完所有数据后,关闭channel,关闭channel的作用是通知接收方:不会再有新的数据了
close(c)
}()
// =================== 之前写法(手动 for + ok 检查) ===================
/* for { // 启动主goroutine作为接收者
// 这里使用了逗号ok的惯用写法:data接收从channel读取的数据
// ok为布尔值,若channel未关闭或还有数据,ok为true;当channel关闭且数据读完后,ok返回false
if data, ok := <-c; ok { // channel仍然有数据可以读取
fmt.Println(data)
} else { // channel已关闭且数据读完,退出循环
break
}
}*/
// =================== 更简洁的写法:使用range迭代channel ===================
// 使用range可以自动从channel中不断接收数据,直到channel被关闭且数据读空后自动退出
// 注意:只有关闭了channel,range才能正常结束,否则会一直阻塞等待新数据
for data := range c {
fmt.Println(data)
}
// 本质上两种代码逻辑一样,但写法不同。
fmt.Println("Main Finished..")
// 总结:
// 1. for + ok 写法:更通用,能灵活处理接收结果、区分接收失败(例如关闭时返回零值和ok=false)
// 2. range 写法:语法更简洁,适用于简单读取全部channel数据直到关闭
// 3. 不管哪种写法,关闭channel后都无法再向其中发送数据,否则panic
// 4. 未关闭channel时,range会一直阻塞等待,容易导致程序卡死(死锁)
}
channel与select
单流程下一个go只能监控一个channel的状态,select可以完成监控多个channel的状态:
package main
import "fmt"
// 定义一个生成斐波那契数列的函数,使用channel与select控制流程
func fibonacii(c, quit chan int) {
x, y := 1, 1 // 斐波那契数列的前两个数
for {
select {
// select语句可以同时监听多个channel的通信状态
// 当某个case对应的channel准备好后(发送/接收不再阻塞),select就会执行对应的case
case c <- x:
// 当c可写时(即:有人在接收c的数据时),就会进入这个case
// 把当前的x发送到channel c中
// 然后计算下一个斐波那契数
x = y
y = x + y
case <-quit:
// 当从quit channel中接收到数据时(不关心数据内容,所以直接用<-quit)
// 表示收到停止信号,打印"quit",退出函数
fmt.Println("quit")
return // return,当前goroutine结束
}
}
}
func main() {
// 创建两个无缓冲channel:
// c 用于传递斐波那契数列数据
// quit 用于通知fibonacci函数何时退出
c := make(chan int)
quit := make(chan int)
// 启动一个子goroutine负责消费fibonacci生成的数列数据
go func() {
for i := 0; i < 10; i++ {
// 每次从c中接收一个数据并打印
fmt.Println(<-c)
}
// 接收完10个数据后,通知fibonacci函数可以停止了
quit <- 0
}()
// 主goroutine调用fibonacci函数,开始生成数据
// 注意:该函数内是一个无限循环,直到收到quit信号才会退出
fibonacii(c, quit)
}
用你更熟悉的
Java switch
来对比着帮你彻底讲清楚:
一句话总结:Go 的select
每次执行时,先扫描所有 case 中的 channel,如果有一个或多个可以立即执行的,就随机选择其中一个执行(注意:真的随机
,不是顺序!);一旦选定执行一个 case,本轮 select 立即结束,不会执行其他 case。如果没有任何 case 满足条件:如果有default
,则直接执行 default;如果没有 default,则整个 select 阻塞等待,直到至少有一个 case 满足条件。注意:只在所有case都无法执行时才会进入default。
每次 select 执行一轮:
+-----------------------------+
| 检查每个 case 是否 ready |
+-----------------------------+
↓
有多个ready? ——→ 是 ——→ 随机选1个执行
↓
否
↓
是否有default? ——→ 有 ——→ 执行default
↓
没有
↓
阻塞等待
补充一点底层:
Go select 底层其实和调度器有关:Go runtime 会维护一个 goroutine 等待队列;
每当执行 select,实际上在 runtime 层面做了一次channel 状态 polling(检测收发是否能立即完成);
只要有任意一个 channel ready,就从 ready set 里随机取一个执行;
所以它既像“非阻塞的多路复用器”,也像是轻量的“并发调度器”——这也是为什么 Go select 很适合用来做高性能并发通信控制的原因。
GoModules
Go Modules与GOPATH
1.什么是Go Modules?
Go modules 是 Go 语言官方推荐的依赖管理工具,自 Go 1.11 引入,Go 1.13 后功能基本完善,在 Go 1.16 开始默认启用,完全取代了早期的 GOPATH 模式。
在 Go 1.11 之前,Go 一直依赖 GOPATH 进行代码组织和依赖管理,但存在诸多痛点:
- 缺乏版本控制机制;
- 不便于多个项目管理不同版本依赖;
- 无法轻松复现项目依赖环境;
- 不支持私有模块、镜像代理、校验等高级功能。
Go modules 彻底解决了这些问题,成为 Go 语言现代化开发的标配。
2.GOPATH的工作模式
Go Modoules的目的之一就是淘汰GOPATH, 那么GOPATH是个什么?为什么不再推荐 GOPATH 的模式了呢?
(1) What is GOPATH?
$ go env
GOPATH="/home/itheima/go"
...
我们输入go env
命令行后可以查看到 GOPATH 变量的结果,我们进入到该目录下进行查看,如下:
go
├── bin # 可执行文件
├── pkg # 预编译缓存
└── src # 所有源码(项目 & 第三方库)
├── github.com
├── golang.org
├── google.golang.org
├── gopkg.in
....
GOPATH目录下一共包含了三个子目录,分别是:
- bin:存储所编译生成的二进制文件。
- pkg:存储预编译的目标文件,以加快程序的后续编译速度。
- src:存储所有.go文件或源代码。在编写 Go 应用程序,程序包和库时,一般会以
$GOPATH/src/github.com/foo/bar
的路径进行存放。
因此在使用 GOPATH 模式下,我们需要将应用代码存放在固定的$GOPATH/src
目录下,并且如果执行go get来拉取外部依赖会自动下载并安装到$GOPATH
目录下。
(2) GOPATH模式的弊端
在 GOPATH 的 $GOPATH/src
下进行 .go 文件或源代码的存储,我们可以称其为 GOPATH 的模式,这个模式拥有一些弊端:
没有版本控制:go get 无法指定具体版本,只能拉取最新。
依赖不可复现:团队成员很难保持依赖版本一致。
无法支持模块多版本共存:如 v1/v2 无法同时存在,容易出现包冲突。
Go Modules模式
我们接下来用Go Modules的方式创建一个项目, 建议为了与GOPATH分开,不要将项目创建在$GOPATH/src
下.
(1) 常用go mod命令
命令 | 作用 |
---|---|
go mod init | 初始化 Go 项目并创建 go.mod 文件 通常在你开始一个新的 Go 项目时使用 |
go get | go get 用于获取并安装 Go 依赖的包,通常用于下载依赖、更新依赖版本,或者安装可执行包 这个命令通常用于添加新的依赖,或更新已安装的依赖。 |
go mod download | 下载 go.mod 中声明的依赖 你可以在从代码仓库 pull 最新代码后使用该命令来确保本地已经下载了项目中声明的所有依赖。 |
go mod tidy | 整理依赖、清理未使用的依赖 这条命令非常常用,可以帮助你保持 go.mod 和 go.sum 文件的干净整洁。你可以在:新增依赖时使用;从代码仓库 pull 后,执行这条命令来清理任何不再使用的依赖;在你修改了代码后,删除了一些不再需要的包时运行;在 push 代码之前使用,确保没有冗余依赖。 |
go mod graph | 查看项目的依赖图,了解哪些模块依赖于哪些其他模块 例如,你遇到了一些版本冲突或依赖错误时,这个命令可以帮助你查看依赖关系,找到冲突的根源。 |
go mod edit | 手动编辑 go.mod 文件,可以修改模块名称、添加模块或调整模块版本等 这个命令较少直接使用 ,如果需要手动指定版本号,或处理一些模块相关的高级需求时可以使用。 |
go mod vendor | 导出项目所有的依赖到vendor目录(依赖本地化) 在需要保证依赖的稳定性时使用,尤其是当你需要在没有网络连接的环境中工作,或者将代码部署到不稳定网络的环境中时。通常,大型公司或团队项目会使用该命令来确保依赖的完整性。 |
go mod verify | 验证 go.mod 中列出的依赖是否完整,检查模块是否被篡改 用于验证依赖的完整性,确保 go.mod 和 go.sum 文件中的依赖没有被篡改,常在 CI/CD 流程中使用,确保代码的安全性。 |
go mod why | 查看某个依赖为何被引用,帮助你了解该依赖的使用情况 当你发现某个模块在 go.mod 中被列出,但你不清楚为什么需要这个依赖时,可以用 go mod why 查看其引用来源。它能够帮助你跟踪依赖链,尤其是复杂项目中的依赖分析。 |
可以通go mod help
查看学习这些指令,强烈建议多用 go mod tidy
,随时清理无效依赖,保持 go.mod
& go.sum
干净整洁。
go get
和go mod download
的区别:
go get
:用于获取并安装依赖,可能会更新 go.mod 中的依赖版本。
go mod download
:只会下载 go.mod 中列出的依赖,不会更新或修改任何文件,只保证依赖的存在。
简单来说,go get
主要用于获取和安装新的依赖,并可能改变项目依赖版本,而go mod download
只是用来确保下载go.mod
文件中列出的所有依赖。
(2) go mod环境变量
可以通过 go env
命令来进行查看:
$ go env
GO111MODULE="auto"
GOPROXY="https://proxy.golang.org,direct"
GONOPROXY=""
GOSUMDB="sum.golang.org"
GONOSUMDB=""
GOPRIVATE=""
...
GO111MODULE
Go语言提供了 GO111MODULE
这个环境变量来作为 Go modules 的开关,(Go 1.16 及以后默认已废弃该变量,默认就是on),其允许设置以下参数:
- auto:在含有 go.mod 时启用,目前在 Go1.11 至 Go1.14 中仍然是默认值。
- on:始终启用 Go modules(推荐),未来版本中的默认值。
- off:全禁用 Go modules(不推荐)。
可以通过下面的命令来设置:
$ go env -w GO111MODULE=on
GOPROXY
这个环境变量主要是用于设置 Go 模块代理(Go module proxy),其作用是用于使 Go 在后续拉取模块版本时直接通过镜像站点来快速拉取。
GOPROXY 的默认值是:https://proxy.golang.org,direct
proxy.golang.org
国内访问不了,需要设置国内的代理
阿里云:https://mirrors.aliyun.com/goproxy/
七牛云: https://goproxy.cn,direct
如:
$ go env -w GOPROXY=https://goproxy.cn,direct
GOPROXY 的值是一个以英文逗号 “,” 分割的 Go 模块代理列表,允许设置多个模块代理,假设你不想使用,也可以将其设置为 “off” ,这将会禁止 Go 在后续操作中使用任何 Go 模块代理。
设置多个模块代理:
$ go env -w GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy/,direct
而在刚刚设置的值中,我们可以发现值列表中有 “direct” 标识,它又有什么作用呢?
实际上 “direct” 是一个特殊指示符,用于指示 Go 回源到模块版本的源地址去抓取(比如 GitHub 等),场景如下:当值列表中上一个 Go 模块代理返回 404 或 410 错误时,Go 自动尝试列表中的下一个,遇见 “direct” 时回源,也就是回到源地址去抓取,而遇见 EOF 时终止并抛出类似 “invalid version: unknown revision…” 的错误。
GOSUMDB
它的值是一个 Go checksum database
,用于在拉取模块版本时(无论是从源站拉取还是通过 Go module proxy 拉取)保证拉取到的模块版本数据未经过篡改,若发现不一致,也就是可能存在篡改,将会立即中止。
GOSUMDB 的默认值为:sum.golang.org
,在国内也是无法访问的,但是 GOSUMDB 可以被 Go 模块代理所代理,即GOPROXY默认充当这个网站。
因此我们可以通过设置 GOPROXY 来解决,而先前我们所设置的模块代理 goproxy.cn
就能支持代理 sum.golang.org
,所以这一个问题在设置 GOPROXY 后,你可以不需要过度关心。
另外若对 GOSUMDB 的值有自定义需求,其支持如下格式:
- 格式 1:
<SUMDB_NAME>+<PUBLIC_KEY>
- 格式 2:
<SUMDB_NAME>+<PUBLIC_KEY> <SUMDB_URL>
也可以将其设置为“off”,也就是禁止 Go 在后续操作中校验模块版本,不推荐。
GONOPROXY/GONOSUMDB/GOPRIVATE
这三个环境变量都是用在当前项目依赖了私有模块,例如像是你公司的私有 git 仓库,又或是 github 中的私有库,都是属于私有模块,都是要进行设置的,否则会拉取失败。
更细致来讲,就是依赖了由 GOPROXY 指定的 Go 模块代理或由 GOSUMDB 指定 Go checksum database 都无法访问到的模块时的场景。
而一般建议直接设置 GOPRIVATE,它的值将作为 GONOPROXY 和 GONOSUMDB 的默认值,所以建议的最佳姿势是直接使用 GOPRIVATE。
并且它们的值都是一个以英文逗号 “,” 分割的模块路径前缀,也就是可以设置多个,例如:
$ go env -w GOPRIVATE="git.example.com,github.com/eddycjy/mquote"
如果不想每次都重新设置,还支持通配符:
$ go env -w GOPRIVATE="*.example.com"
设置后,后缀为 .example.com 的模块都会被认为是私有模块,都不会经过GOPROXY并经过GOSUMDB检验。需要注意的是不包括 example.com 本身
用Go Modules初始化项目
(1) 开启Go Modules
$ go env -w GO111MODULE=on
又或是可以通过直接设置系统环境变量(写入对应的~/.bash_profile 文件亦可)来实现这个目的:
$ export GO111MODULE=on
(2) 初始化项目
创建项目目录
$ mkdir -p $HOME/aceld/modules_test
$ cd $HOME/aceld/modules_test
我们后面会在modules_test下写代码,首先要执行Go modules 初始化的工作,如下所示,会在本地创建一个go.mod文件。go mod init
后面要跟一个当前模块的名称,这个名称是自定义写的,这个名称他决定于今后导包的时候,即其他人import的时候怎么写
$ go mod init github.com/aceld/modules_test
go: creating new go.mod: module github.com/aceld/modules_test
生成的 go.mod:
module github.com/aceld/modules_test
go 1.14
在执行 go mod init
命令时,我们指定了模块导入路径为 github.com/aceld/modules_test
。接下来我们在该项目根目录下创建 main.go
文件,如下:
package main
import (
"fmt"
"github.com/aceld/zinx/znet"
"github.com/aceld/zinx/ziface"
)
//ping test 自定义路由
type PingRouter struct {
znet.BaseRouter
}
//Ping Handle
func (this *PingRouter) Handle(request ziface.IRequest) {
//先读取客户端的数据
fmt.Println("recv from client : msgId=", request.GetMsgID(),
", data=", string(request.GetData()))
//再回写ping...ping...ping
err := request.GetConnection().SendBuffMsg(0, []byte("ping...ping...ping"))
if err != nil {
fmt.Println(err)
}
}
func main() {
//1 创建一个server句柄
s := znet.NewServer()
//2 配置路由
s.AddRouter(0, &PingRouter{})
//3 开启服务
s.Serve()
}
OK, 我们先不要关注代码本身,我们看当前的main.go也就是我们的aceld/modules_test
项目,是依赖一个叫github.com/aceld/zinx
库的. znet
和ziface
只是zinx的两个模块.
明显我们的项目没有下载刚才代码中导入的那互联网上的两个包,我们只是import导入进来了,如果是之前GOPATH模式的话,应该去GOPATH下的src/git/github.com/aceld
去go get
下来,或者直接手动下载放在指定目录。
但是我们现在是Go Modules,接下来我们在$HOME/aceld/modules_test
,本项目的根目录执行下面的命令,假设我们用到了znet包:
$ go get github.com/aceld/zinx/znet
go: downloading github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100
go: found github.com/aceld/zinx/znet in github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100
还有go get github.com/aceld/zinx/ziface
,当然你也可以直接把整个模块下载下来:go get github.com/aceld/zinx
这样就会帮我们把代码下载下来了,我们会看到 我们的go.mod
被修改,同时多了一个go.sum
文件。同时go run main.go
也能运行了。
(3) 查看go.mod文件
$HOME/aceld/modules_test/go.mod:
module github.com/aceld/modules_test
go 1.14
require github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 // indirect
发现多了一段require,表示项目需要一个库github.com/aceld/zinx
,版本是v0.0.0-20200221135252-8a8954e75100
我们来简单看一下这里面的关键字
module
: 用于定义当前项目的模块路径/模块名称,建议填写仓库实际地址go
:标识当前Go版本.即初始化版本require
: 列出所有直接和间接依赖模块版本// indirect
: 示该模块为间接依赖,也就是在当前应用程序中的 import 语句中,并没有发现这个模块的明确引用,有可能是你先手动 go get 拉取下来的,也有可能是你所依赖的模块所依赖的.我们的代码很明显是依赖的"github.com/aceld/zinx/znet
"和"github.com/aceld/zinx/ziface
",所以就间接的依赖了github.com/aceld/zinx
(4) 查看go.sum文件
在第一次拉取模块依赖后,会发现多出了一个 go.sum 文件,其详细罗列了当前项目直接或间接依赖的所有模块版本,并写明了那些模块版本的 SHA-256 哈希值以备 Go 在今后的操作中保证项目所依赖的那些模块版本不会被篡改。
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 h1:Ez5iM6cKGMtqvIJ8nvR9h74Ln8FvFDgfb7bJIbrKv54=
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100/go.mod h1:bMiERrPdR8FzpBOo86nhWWmeHJ1cCaqVvWKCGcDVJ5M=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
我们可以看到一个模块路径可能有如下两种:
h1:hash
情况
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 h1:Ez5iM6cKGMtqvIJ8nvR9h74Ln8FvFDgfb7bJIbrKv54=
go.mod hash
情况:
github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100/go.mod h1:bMiERrPdR8FzpBOo86nhWWmeHJ1cCaqVvWKCGcDVJ5M=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
h1 hash
是 Go modules 将目标模块版本的 zip 文件开包后,针对所有包内文件依次进行 hash,然后再把它们的 hash 结果按照固定格式和算法组成总的 hash 值。
而go.mod hash
顾名思义就是对mod文件做一次hash。而 h1 hash 和 go.mod hash 两者,要不就是同时存在,要不就是只存在 go.mod hash。那什么情况下会不存在 h1 hash 呢,就是当 Go 认为肯定用不到某个模块版本的时候就会省略它的 h1 hash,就会出现不存在 h1 hash,只存在 go.mod hash 的情况。
那我们刚刚go get
的文件下载到哪了呢?其实是给我们下载到了$GOPATH/pkg/mod/github.com/aceld
下面,这样我
修改模块的版本依赖关系
为了作尝试,假定我们现在对zinx版本作了升级, 由zinx v0.0.0-20200221135252-8a8954e75100
升级到 zinx v0.0.0-20200306023939-bc416543ae24
(注意zinx是一个没有打版本tag打第三方库,如果有的版本号是有tag的,那么可以直接对应v后面的版本号即可)
那么,我们是怎么知道zinx做了升级呢, 我们又是如何知道的最新的zinx版本号是多少呢?
先回到$HOME/aceld/modules_test
,本项目的根目录执行:
$ go get github.com/aceld/zinx/znet
go: downloading github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24
go: found github.com/aceld/zinx/znet in github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24
go: github.com/aceld/zinx upgrade => v0.0.0-20200306023939-bc416543ae24
这样我们,下载了最新的zinx, 版本是v0.0.0-20200306023939-bc416543ae24
, 然后,我们看一下go.mod
module github.com/aceld/modules_test
go 1.14
require github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24 // indirect
我们会看到,当我们执行go get
的时候, 会自动的将本地将当前项目的require
更新了.变成了最新的依赖.
好了, 现在我们就要做另外一件事,就是,我们想用一个旧版本的zinx. 来修改当前zinx模块的依赖版本号.
目前我们在$GOPATH/pkg/mod/github.com/aceld
(可以理解为本地仓库)下,已经有了两个版本的zinx库:
/go/pkg/mod/github.com/aceld$ ls
zinx@v0.0.0-20200221135252-8a8954e75100
zinx@v0.0.0-20200306023939-bc416543ae24
目前,我们/aceld/modules_test
依赖的是zinx@v0.0.0-20200306023939-bc416543ae24
这个是最新版, 我们要改成之前的版本zinx@v0.0.0-20200306023939-bc416543ae24
.
回到/aceld/modules_test
项目目录下,执行:
$ go mod edit -replace=zinx@v0.0.0-20200306023939-bc416543ae24=zinx@v0.0.0-20200221135252-8a8954e75100
然后我们打开go.mod查看一下:
module github.com/aceld/modules_test
go 1.14
require github.com/aceld/zinx v0.0.0-20200306023939-bc416543ae24 // indirect
replace zinx v0.0.0-20200306023939-bc416543ae24 => zinx v0.0.0-20200221135252-8a8954e75100
这里出现了replace关键字.用于将一个模块版本替换为另外一个模块版本。
replace和直接修改require的区别: 直接改require版本是可行的,前提是该版本能被正常下载;而replace不仅可以指定版本,也可以把模块替换到本地路径或 fork 地址,功能更强,适合调试/开发/本地模块。
Go Modules 版本号规范
Go Modules 遵循 语义化版本(Semantic Versioning,SemVer) 标准。
1.基本的语义化版本规则
SemVer 格式为:vMAJOR.MINOR.PATCH,如:v1.2.3
MAJOR(主版本号):发生不兼容 API 修改时递增;
MINOR(次版本号):向后兼容的新功能递增;
PATCH(修订号):向后兼容的问题修正递增。
例如:
版本号 | 说明 |
---|---|
v1.0.0 | 稳定版本发布 |
v1.2.0 | 增加了新功能,兼容老版本 |
v1.2.3 | 修复了某个 bug,兼容老版本 |
v2.0.0 | 存在破坏性改动,不兼容老版本 |
2.Go Modules 对 MAJOR 版本的特殊处理
Go Modules 在处理 主版本号 v2 及以上 时,有额外要求:主版本号 v2 及以上,必须在模块路径中加入版本后缀。
例如,假设你有一个库:仓库地址: github.com/foo/bar
;当前版本: v1.5.0
当你要发布 v2.0.0 时,模块路径需修改为:module github.com/foo/bar/v2
否则,在使用时会导致依赖拉取异常或不兼容的问题。
# 例子
# v1 版本 module 路径
module github.com/foo/bar
# v2 版本及以上 module 路径
module github.com/foo/bar/v2
这种设计的好处:保持对旧版本的兼容性;明确标识重大版本分支;避免不同版本冲突。
实践建议:升级到 v2+ 时,务必修改 go.mod 中的 module 路径;发布新版本时,在 Git 中打上对应 tag,例如:v2.0.0;消费方导入时需使用完整路径:
import "github.com/foo/bar/v2/mypkg"
切勿随意跳过版本号规范,否则会导致下游依赖管理困难,尤其在企业内部的库管理中尤为重要。
vendor 模式实践
1.什么是 vendor 模式?
Go Modules 默认采用 proxy 模式 拉取依赖。但在某些场景下,vendor 模式更适合:企业内网,无法访问公网;离线部署,无法实时拉取依赖;安全审计,依赖需提前锁定;持续集成(CI/CD),确保构建稳定性。
vendor 模式即将所有依赖源码复制到本地的 vendor/
目录中,构建时直接从本地依赖目录读取,无需访问外部网络。
2.如何启用 vendor 模式
生成 vendor 目录:go mod vendor
执行后,会将 go.mod
和 go.sum
中声明的依赖下载并复制到项目下的 vendor/
目录。
强制使用 vendor 编译:go build -mod=vendor
或者:GOFLAGS=-mod=vendor go build
测试时也可指定使用 vendor:go test -mod=vendor ./...
日常开发中,启用全局 vendor,可在项目根目录设置环境变量:export GOFLAGS=-mod=vendor
,这样执行所有 go
命令时,默认启用 vendor 模式。
3.vendor 模式的优缺点
优点 | 缺点 |
---|---|
离线构建、部署更可靠 | 占用磁盘空间 |
防止依赖失效、仓库被删 | 需手动维护同步 |
方便代码安全审计 | 依赖更新需重新执行 go mod vendor |
加速 CI/CD 构建 | – |
4.实践建议
建议在企业内网、私有部署等稳定环境下使用 vendor;
建议将 vendor/ 目录纳入版本控制(如 Git);
每次更新依赖后,务必重新执行 go mod vendor,确保同步;
日常开发中,仍可在本地使用默认的 module 模式,避免频繁维护 vendor。