地鼠工厂的秘密:解锁Go语言中goroutine的并发魔法
想象一个高效运转的地鼠工厂,每只地鼠都在自己的任务轨道上飞速前进,却又彼此协作,井然有序。这正是Go语言并发编程的魅力所在!通过goroutine和通道,Go让开发者轻松实现高效并发任务,无需繁琐的线程管理或复杂的锁机制。本文将带你走进goroutine的世界,探索它如何化繁为简,成为现代编程中的并发利器。
本文详细介绍了Go语言的核心并发机制——goroutine及其配套工具通道(channel)。从goroutine的轻量级创建到多任务并发的实现,再到通道在任务间安全传值的应用,文章通过代码示例和小测试逐步揭示其工作原理。此外,还探讨了select语句、nil通道、阻塞与死锁等关键概念,并展示了如何通过流水线模式构建高效的并发程序。无论是初学者还是进阶开发者,都能从中窥见Go并发编程的独特优势。
goroutine和并发(concurrent)
一个地鼠(gopher)工厂
goroutine
-
在Go中,独立的任务叫做goroutine
- 虽然goroutine与其它语言中的协程、进程、线程都有相似之处,但goroutine和它们并不完全相
- Goroutine创建效率非常高
- Go能直截了当的协同多个并发(concurrent)操作
-
在某些语言中,将顺序式代码转化为并发式代码需要做大量修改
-
在Go里,无需修改现有顺序式的代码,就可以通过goroutine以并发的方式运行任意数量的任务。
启动goroutine
- 只需在调用前面加一个go关键字。
package main
import (
"fmt"
"time"
)
func main() {
go sleepyGopher()
time.Sleep(4 * time.Second)
}
func sleepyGopher() {
time.Sleep(3 * time.Second)
fmt.Println("...snore...")
}
小测试
- 怎样才能在Go语言里同时做不止一件事情?
-
在 Go 语言中,同时做多件事情的核心机制是通过 goroutine 和 通道(channel) 来实现并发和同步。Goroutine 是 Go 的轻量级线程,非常适合用来同时执行多个任务,而通道则用于在这些任务之间安全地传递数据和同步执行。
-
在 Go 中,同时做多件事情的核心是利用 goroutine 实现并发,然后通过以下工具来管理和同步任务:
- sync.WaitGroup:等待一组 goroutine 完成。
- 通道(Channel):在 goroutine 之间传递数据和同步执行。
- context:控制任务的生命周期,传递超时或取消信号。
- 使用什么关键字可以启动一个新的、独立运行的任务?
- go 关键字:用于启动一个 goroutine。
- 独立运行:每个 goroutine 都是独立的,它们的执行顺序是不确定的,由 Go 运行时的调度器决定。
- 轻量级:创建 goroutine 的成本非常低,可以轻松创建大量 goroutine。
不止一个goroutine
-
每次使用go关键字都会产生一个新的goroutine。
-
表面上看,goroutine似乎在同时运行,但由于计算机处理单元有限,其实技术上来说,这些goroutine不是真的在同时运行。
- 计算机处理器会使用“分时”技术,在多个goroutine上轮流花费一些时间。
- 在使用goroutine时,各个goroutine的执行顺序无法确定。
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
go sleepyGopher()
}
time.Sleep(4 * time.Second)
}
func sleepyGopher() {
time.Sleep(3 * time.Second)
fmt.Println("...snore...")
}
goroutine的参数
- 向goroutine传递参数就跟向函数传递参数一样,参数都是按值传递的(传入的是副本)
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
go sleepyGopher(i)
}
time.Sleep(4 * time.Second)
}
func sleepyGopher(id int) {
time.Sleep(3 * time.Second)
fmt.Println("...snore...", id)
}
小测试
- 多个不同的goroutine将以何种顺序执行?
- Go 的 goroutine 是并发执行的,其执行顺序是不确定的。如果需要控制执行顺序,可以通过通道或同步原语来实现。
通道channel
-
通道(channel)可以在多个goroutine之间安全的传值。
-
通道可以用作变量、函数参数、结构体字段…
-
创建通道用make函数,并指定其传输数据的类型
- c := make(chan int)
通道channel发送、接收
-
使用左箭头操作符 <- 向通道发送值 或 从通道接收值
- 向通道发送值:c <- 99
- 从通道接收值:r := <- c
-
发送操作会等待直到另一个goroutine尝试对该通道进行接收操作为止。
- 执行发送操作的goroutine在等待期间将无法执行其它操作
- 未在等待通道操作的goroutine可以继续自由的运行
-
执行接收操作的goroutine将等待直到另一个goroutine尝试向该通道进行发送操作为止。
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
for i := 0; i < 5; i++ {
go sleepyGopher(i, c)
}
for i := 0; i < 5; i++ {
gopherID := <- c
fmt.Println("gopher ", gopherID, " has finished sleeping")
}
}
func sleepyGopher(id int, c chan int) {
time.Sleep(3 * time.Second)
fmt.Println("...snore...", id)
c <- id
}
通道 例子
小测试
- 应该使用什么语句,才能将字符串“hello world”发送至名为c的通道?
- 如何才能从通道中接收值并将其赋给变量?
答:从通道中接收值并赋值给变量的常见方法包括:
- 使用 <-channel 直接接收值。
- 使用 select 和默认分支实现非阻塞接收。
- 使用 for value := range channel 接收通道中的所有值。
- 使用 select 和 time.After 实现带超时的接收。
使用select处理多个通道
-
等待不同类型的值。
-
time.After函数,返回一个通道,该通道在指定时间后会接收到一个值(发送该值的goroutine是Go运行时的一部分)。
-
select和switch有点像。
- 该语句包含的每个case都持有一个通道,用来发送或接收数据。
- select会等待直到某个case分支的操作就绪,然后就会执行该case分支。
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
for i := 0; i < 5; i++ {
go sleepyGopher(i, c)
}
timeout := time.After(2 * time.Second)
for i := 0; i < 5; i++ {
select {
case gopherID := <- c:
fmt.Println("gopher ", gopherID, " has finished sleeping")
case <- timeout:
fmt.Println("my patience ran out")
return
}
}
}
func sleepyGopher(id int, c chan int) {
time.Sleep(time.Duration(rand.Intn(4000)) * time.Millisecond)
c <- id
}
- 注意:即使已经停止等待goroutine,但只要main函数还没返回,仍在运行的goroutine将会继续占用内存。
select语句
- select语句在不包含任何case的情况下将永远等下去。
nil通道
-
如果不使用make初始化通道,那么通道变量的值就是nil(零值)
-
对nil通道进行发送或接收不会引起panic,但会导致永久阻塞。
-
对nil通道执行close函数,那么会引起panic
-
nil通道的用处:
- 对于包含select语句的循环,如果不希望每次循环都等待select所涉及的所有通道,那么可以先将某些通道设为nil,等到发送值准备就绪之后,再将通道变成一个非nil值并执行发送操作。
小测试
- time.After返回什么类型的值?
- 在 Go 中,time.After 函数返回一个 <-chan Time 类型的值。具体来说,它返回一个只读的 chan time.Time 类型通道,该通道会在指定的时间段过后发送一个 time.Time 值。
- time.After 返回值类型:<-chan time.Time(只读 time.Time 通道)。
- 通道发送的值:time.Time 结构体,表示超时那一刻的时间点。
- 对值为nil的通道执行发送或接收操作会产生什么后果?
- 发送(ch <- value):对 nil 通道发送会导致 goroutine 永久阻塞。
- 接收(<-ch 或 value, ok := <-ch):对 nil 通道接收会导致 goroutine 永久阻塞。
- select 中的行为:nil 通道的分支永远不会执行,除非有其他非 nil 通道或 default 分支。
- 结果:除非在 select 中配合 default,否则对 nil 通道的操作通常会导致程序无法继续运行,甚至引发死锁。
- select语句的每个case可以包含什么?
答:select 语句的每个 case 可以包含以下内容:
- 从通道接收值:value := <-channel 或 <-channel。
- 向通道发送值:channel <- value。
- 超时操作:case <-time.After(timeout)。
- 默认分支:default,用于实现非阻塞行为。
阻塞和死锁
-
当goroutine在等待通道的发送或接收时,我们就说它被阻塞了。
-
除了goroutine本身占用少量的内存外,被阻塞的goroutine并不消耗任何其它资源。
- goroutine静静的停在那里,等待导致其阻塞的事情来解除阻塞。
-
当一个或多个goroutine因为某些永远无法发生的事情被阻塞时,我们称这种情况为死锁。而出现死锁的程序通常会崩溃或挂起。
-
(引发死锁的例子)
package main
func main() {
c := make(chan int)
// go func() {c <- 2} ()
<- c // deadlock 死锁
}
小测试
-
被阻塞的goroutine会做什么?
-
在 Go 中,当一个 goroutine 被阻塞(blocked)时,它会暂停执行并等待某个条件满足,直到可以继续运行为止。被阻塞的 goroutine 不会消耗 CPU 资源,而是由 Go 运行时(runtime)管理,放入等待队列中,直到阻塞条件解除。
-
总结
- 被阻塞的 goroutine 会暂停执行,等待条件满足。
- 它不消耗 CPU,由 Go 调度器高效管理。
- 一旦阻塞条件解除(例如通道收到数据或锁被释放),它会被唤醒并继续运行。
- 这种机制是 Go 高并发能力的核心,允许开发者轻松编写高效的并发程序。
地鼠装配线
package main
func sourceGopher(downstream chan string) {
for _, v := range []string{"hello world", "a bad apple", "goodbye all"}
{
downstream <- v
}
downstream <- ""
}
func filterGopher(upstream, downstream chan string) {
for {
item := <-upstream
if item == "" {
downstream <- ""
return
}
if !strings.Contains(item, "bad") {
downstream <- item
}
}
}
func printGopher(upstream chan string) {
for {
v := <-upstream
if v == "" {
return
}
fmt.Println(v)
}
}
func main() {
c0 := make(chan string)
c1 := make(chan string)
go sourceGopher(c0)
go filterGopher(c0, c1)
printGopher(c1)
}
-
Go允许在没有值可供发送的情况下通过close函数关闭通道
- 例如close(c)
-
通道被关闭后无法写入任何值,如果尝试写入将引发panic。
-
尝试读取被关闭的通道会获得与通道类型对应的零值。
-
注意:如果循环里读取一个已关闭的通道,并没检查通道是否关闭,那么该循环可能会一直运转下去,耗费大量CPU时间
-
执行以下代码可得知通道是否被关闭:
- v, ok := <- c
package main
import (
"fmt"
"strings"
)
func sourceGopher(downstream chan string) {
for _, v := range []string{"hello world", "a bad apple", "goodbye all"}
{
downstream <- v
}
close(downstream)
}
func filterGopher(upstream, downstream chan string) {
for {
item, ok := <-upstream
if !ok {
close(downstream)
return
}
if !strings.Contains(item, "bad") {
downstream <- item
}
}
}
func printGopher(upstream chan string) {
for {
v := <-upstream
if v == "" {
return
}
fmt.Println(v)
}
}
func main() {
c0 := make(chan string)
c1 := make(chan string)
go sourceGopher(c0)
go filterGopher(c0, c1)
printGopher(c1)
}
常用模式
-
从通道里面读取值,直到它关闭为止。
- 可以使用range关键字达到该目的
package main
import (
"fmt"
"strings"
)
func sourceGopher(downstream chan string) {
for _, v := range []string{"hello world", "a bad apple", "goodbye all"}
{
downstream <- v
}
close(downstream)
}
//func filterGopher(upstream, downstream chan string) {
// for {
// item, ok := <-upstream
// if !ok {
// close(downstream)
// return
// }
// if !strings.Contains(item, "bad") {
// downstream <- item
// }
// }
//}
func filterGopher(upstream, downstream chan string) {
for item := range upstream {
if !strings.Contains(item, "bad") {
downstream <- item
}
}
close(downstream)
}
//func printGopher(upstream chan string) {
// for {
// v := <-upstream
// if v == "" {
// return
// }
// fmt.Println(v)
// }
//}
func printGopher(upstream chan string) {
for v := range upstream {
fmt.Println(v)
}
}
func main() {
c0 := make(chan string)
c1 := make(chan string)
go sourceGopher(c0)
go filterGopher(c0, c1)
printGopher(c1)
}
小测试
- 尝试读取一个已经关闭的通道会得到什么值?
- 从已关闭的通道读取时:
- 如果通道中有数据,先返回数据。
- 如果通道已空,则返回零值,且通过 ok 可以判断通道已关闭。
- 不会发生 panic 或错误,只是返回零值并继续执行。
- 如何才能检测通道是否已经关闭?
- 使用 value, ok := <-ch 是检测通道是否关闭的标准方法。
- ok == false 表示通道已关闭。
- 结合 for 或 range 可以优雅地处理通道关闭的情况。
- 这种机制简单且安全,避免了显式锁或状态标志的需要。
作业题
- 编写一个流水线部件(一个goroutine),他需要记住前面出现的所有值,并且只有在值之前从未出现过的情况下才会将值传递至流水线的下一个阶段。假定第一个值永远不是空字符串。
- 编写一个流水线部件,它接收字符串并将它们拆分成单词,然后向流水线的下一阶段一个接一个的发送这些单词。可以使用strings.Fields函数
总结
goroutine和通道是Go语言在并发编程领域的杀手锏,它们让复杂的并发任务变得简单而优雅。通过轻量级的goroutine,开发者可以轻松启动成千上万的任务;而通道则提供了安全的数据传递和同步手段,确保程序高效运行。无论是处理多任务并行,还是避免死锁陷阱,Go的并发模型都展现了无与伦比的灵活性。掌握这些工具,你将能在地鼠工厂中指挥千军,打造属于自己的并发杰作!