尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Go 语言入门笔记(六):goroutine 和 channel——并发编程的入门

Go 语言入门笔记(六):goroutine 和 channel——并发编程的入门 终于到 Go 最吸引人的部分了。记得当初决定学 Go很大原因就是听说了它“天生支持并发”“用 goroutine 写并发像开挂一样”。但真正开始写的时候才发现并发编程哪有那么简单goroutine 用起来爽channel 用不好照样死锁、泄漏、数据竞争。这篇就把我入门并发时踩过的坑和搞懂的概念梳理一遍。1. goroutine比线程更轻量的“协程”1.1 启动一个 goroutine在 Go 里启动一个并发执行的函数非常简单只需要在函数调用前加一个go关键字func sayHello() { fmt.Println(hello from goroutine) } func main() { go sayHello() // 启动一个 goroutine fmt.Println(hello from main) time.Sleep(time.Second) // 给 goroutine 一点执行时间 }输出可能是hello from main hello from goroutine也可能是hello from goroutine hello from main顺序不确定因为 goroutine 是并发执行的。注意如果main函数执行完了所有 goroutine 都会被强制结束不管它们有没有执行完。所以上面故意加了个time.Sleep否则可能看不到 goroutine 的输出。实际项目中不会用time.Sleep来等待而是用 channel 或者sync.WaitGroup后面细说。1.2 goroutine 和线程的区别我一开始以为 goroutine 就是 Go 版的线程后来看了些资料才明白完全不是一回事。线程由操作系统调度每个线程有固定的栈空间一般 1MB 以上创建和切换的开销比较大。goroutine由 Go 运行时调度初始栈空间只有 2KB 左右可以动态增长。创建和销毁的成本极低轻松启动成千上万个 goroutine。Go 的调度器会把多个 goroutine 分配到几个操作系统线程上执行这就是所谓的M:N 调度模型。所以写 Go 并发通常不需要关心底层线程直接go func()就完事。1.3 匿名函数 goroutine最常用的写法是直接 go 一个匿名函数go func() { fmt.Println(我在 goroutine 里) }()但要注意闭包捕获变量的问题前面讲 for 循环时提过Go 1.22 之前循环变量捕获有坑1.22 修复了。不过为了兼容老代码了解这个坑很重要。2. channelgoroutine 之间的通信管道光有 goroutine 还不够它们之间需要通信。Go 的哲学是不要通过共享内存来通信而应该通过通信来共享内存。这句话我花了好长时间才真正理解。简单说就是与其用锁保护共享变量不如把数据通过 channel 从一个 goroutine 传给另一个 goroutine这样天然避免了数据竞争。2.1 创建 channel用make创建ch : make(chan int) // 无缓冲 channel只能传 int 类型2.2 发送和接收ch - 42 // 发送数据到 channel value : -ch // 从 channel 接收数据箭头方向很直观ch -表示把数据塞进 channel-ch表示从 channel 取数据。2.3 无缓冲 channel同步阻塞无缓冲 channel 的特点是发送和接收必须同时准备好否则会阻塞。看这个例子func main() { ch : make(chan int) go func() { fmt.Println(goroutine 准备发送) ch - 100 // 发送后阻塞直到有人接收 fmt.Println(发送完成) }() time.Sleep(time.Second) // 先让 goroutine 执行到发送 fmt.Println(main 准备接收) value : -ch // 接收唤醒发送者 fmt.Println(收到:, value) time.Sleep(time.Second) }输出goroutine 准备发送 main 准备接收 收到: 100 发送完成可以看到发送者在接收者出现之前一直阻塞这就是无缓冲 channel 的同步特性。这种特性天然适合用来做信号传递比如“我完成了你继续”。2.4 有缓冲 channel异步队列创建时指定容量ch : make(chan int, 3) // 缓冲区大小 3有缓冲 channel 就像一个队列发送方在缓冲区满之前不会阻塞接收方在缓冲区空之前不会阻塞。ch - 1 // 不会阻塞 ch - 2 ch - 3 // ch - 4 // 缓冲区满了会阻塞 fmt.Println(-ch) // 1 fmt.Println(-ch) // 2有缓冲 channel 适用于异步通信比如生产者消费者模型生产者和消费者速度不匹配时可以暂时缓冲。如何选择我的经验是除非有明确的性能需求否则先用无缓冲 channel因为无缓冲的同步语义更清晰不容易写出隐藏的并发 bug。等需要优化吞吐量时再改成有缓冲。3. select监听多个 channel如果同时要等待多个 channel可以用select。它和switch语法类似但每个 case 都是一个 channel 操作。func main() { ch1 : make(chan string) ch2 : make(chan string) go func() { time.Sleep(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) } }select会阻塞直到某个 case 可以执行。如果多个 case 同时就绪随机选择一个执行。3.1 default 分支非阻塞操作加上default后如果所有 case 都阻塞立刻执行 defaultselect { case msg : -ch: fmt.Println(收到:, msg) default: fmt.Println(没有消息不阻塞) }这个在写非阻塞 I/O和超时控制时很有用。3.2 超时控制select { case msg : -ch: fmt.Println(msg) case -time.After(3 * time.Second): fmt.Println(超时了) }time.After返回一个 channel3 秒后会发送一个值配合 select 实现超时等待。4. channel 的关闭与遍历4.1 关闭 channel用close函数关闭ch : make(chan int) go func() { for i : 0; i 5; i { ch - i } close(ch) // 发送完关闭 }() for v : range ch { // range 会自动在 channel 关闭后退出 fmt.Println(v) }关闭规则只有发送方应该关闭 channel接收方不要关闭。不能关闭已经关闭的 channel会 panic。向已关闭的 channel 发送数据会 panic。从已关闭的 channel 接收数据如果缓冲区还有数据会先取完取完后会得到零值并且第二个返回值ok为 false。v, ok : -ch if !ok { fmt.Println(channel 已关闭且没有数据) }我一开始总忘记检查ok结果 channel 关闭后还一直收到零值逻辑出错。这个要形成肌肉记忆。4.2 判断 channel 是否关闭没有直接的“isClosed”函数只能用接收时的ok来判断。这是 Go 设计上的一个特点避免竞态。5. 几个必踩的坑5.1 死锁无缓冲 channel 在同一个 goroutine 里发送和接收会死锁ch : make(chan int) ch - 1 // 死锁因为没有人接收当前 goroutine 阻塞程序崩溃 fmt.Println(-ch)报错fatal error: all goroutines are asleep - deadlock!所有 goroutine 都阻塞时Go 运行时会检测到死锁并 panic。修复方法就是让发送和接收在不同的 goroutine 里或者用有缓冲 channel。5.2 向 nil channel 发送或接收var ch chan int // nil channel ch - 1 // 永久阻塞 -ch // 永久阻塞nil channel 是永久阻塞的不会 panic但也干不了活。这在 select 里其实有用比如可以用一个 nil channel 暂时禁用某个 case。5.3 循环变量捕获Go 1.22 之前之前提过这里再强调一下因为并发场景下特别容易踩for i : 0; i 5; i { go func() { fmt.Println(i) // 可能都打印 5 }() }修复方法在 goroutine 启动前复制变量for i : 0; i 5; i { i : i // 复制 go func() { fmt.Println(i) }() }或者通过参数传递for i : 0; i 5; i { go func(n int) { fmt.Println(n) }(i) }好在 Go 1.22 已经修复了但如果面试被问到能讲出这个历史问题和解决思路很加分。5.4 goroutine 泄漏如果 goroutine 因为某些原因永远阻塞它就不会被回收导致内存泄漏。比如func worker(ch chan int) { // 等待 channel 数据但 channel 永远没人发goroutine 永远阻塞 -ch }实际项目中goroutine 泄漏通常发生在没有设置超时或者忘记关闭 channel的情况。排查方法用runtime.NumGoroutine()查看数量。6. sync.WaitGroup等待所有 goroutine 完成虽然这篇主要讲 channel但sync.WaitGroup也是并发必备工具简单说两句。var wg sync.WaitGroup for i : 0; i 5; i { wg.Add(1) // 计数器 1 go func(n int) { defer wg.Done() // 完成后计数器 -1 fmt.Println(n) }(i) } wg.Wait() // 阻塞直到计数器为 0 fmt.Println(所有 goroutine 执行完毕)Add、Done、Wait三件套。常用于等待一批 goroutine 完成。坑Add要在 goroutine 启动前调用不要在 goroutine 内部调用否则可能Wait提前返回。7. 小结这篇把 goroutine 和 channel 的基础过了一遍核心要点go关键字启动 goroutine轻量、便宜但需要管理生命周期。channel 用于 goroutine 间通信无缓冲同步有缓冲异步。select多路复用配合time.After做超时。关闭 channel 要小心只能发送方关闭接收方用ok判断。死锁、nil channel、循环变量捕获、goroutine 泄漏都是新手常见坑。sync.WaitGroup用于等待所有 goroutine 完成。并发编程是 Go 的核心竞争力也是面试必考。下一篇我准备写 Go 标准库里的几个常用包比如sync的互斥锁、context的超时控制以及实际项目中怎么组织并发代码。慢慢来先把基础打牢。如果这篇文章对你有帮助欢迎点赞收藏评论区一起交流。
返回列表