一、前言
今天玩点不一样的,我想试试看 GPT-4 能不能当好一个编程老师,循循善诱,由浅入深,深入浅出…… 给我讲解下 Golang 里的并发机制。
今天倒掉腹中半杯水,当回白纸,GPT 为笔,代码为颜料,挥笔泼墨,且看能绘出怎样的世界。
二、开聊
下文通过 DevChat 和 GPT-4 交互,如果你对 DevChat 感兴趣,可以跳转下文浏览:
2.1 Golang 里的并发机制介绍
- 在 DevChat 里和 GPT-4 交互的体验:
下文主要贴文字,避免过多使用截图。
- 我问 GPT:
能不能介绍下 Golang 里的并发机制
- GPT 说:
在 Golang 中,主要的并发机制是通过 Goroutines 和 Channels 实现的。
Goroutines:Goroutines 是 Go 语言中的轻量级线程实现,由 Go 运行时环境管理。Goroutines 相比于线程的优势在于其创建和销毁的开销小,可以轻松创建上百万个而不会导致系统资源耗尽。创建一个 Goroutine 只需要在函数调用前加上关键字
go
即可。Channels:Channels 是 Go 中提供的一种强大的并发同步机制。它可以在多个 Goroutine 之间传递数据并同步执行。你可以把它想象成一个 Goroutines 可以发送和接收值的管道。通过
make
函数创建 Channels,通过<-
操作符发送和接收数据。以下是一个简单的示例,展示了如何在 Go 中使用 Goroutines 和 Channels:
package main import ( "fmt" "time" ) func say(s string, c chan string) { for i := 0; i < 5; i++ { time.Sleep(1 * time.Second) c <- s } close(c) } func main() { c := make(chan string) go say("Hello", c) for msg := range c { fmt.Println(msg) } }
在这个示例中,我们创建了一个 Goroutine
say
,它每秒向 Channelc
发送一次 "Hello",然后在 main 函数中,我们从 Channelc
中接收并打印这些消息。当 Channelc
被关闭并且没有更多的值可以接收时,range c
循环将结束。
GPT 给了一段示例代码,并且在最后给出了这段代码的解释,整体看来还是挺通俗易懂。接下来我想让 GPT 解释下 Goroutine 和“线程”的关系。我大概记得 Goroutine 是用户级线程,Go 的 runtime 自己管理和调度的,或者简单地叫做“协程”;而线程在这里指的是内核级线程,OS 完成的管理和调度。且看下 GPT 怎么说吧。
2.2 Goroutine 与线程
- 我问 GPT:
Goroutines 和线程的关系是什么?Goroutines 调度和线程调度分别是什么原理?
- GPT 说:
Goroutines 和线程的关系:
Goroutines 是 Go 语言中的并发体,它是一种轻量级的线程,由 Go 运行时(runtime)管理和调度,而不是由操作系统直接管理。
Goroutines 相比于操作系统的线程,其创建和销毁的开销更小,内存占用更少,可以轻松创建和管理上百万个 Goroutines。
一个 Go 程序在运行时会有多个系统线程,每个系统线程在运行多个 Goroutine。Go 运行时会负责在这些系统线程上调度 Goroutines 的执行。
Goroutines 的调度:
Go 语言运行时包含了自己的调度器,这个调度器使用了 M:N 的调度模型,即 M 个 Goroutines 映射到 N 个系统线程上。
Go 调度器的工作方式和操作系统的调度器类似,但是在用户态完成调度,不需要切换到内核态,因此调度开销小。
Go 调度器使用了一种叫做分段式的抢却式调度策略,当一个 Goroutine 在执行时,会定期检查是否需要进行调度(例如,是否有更高优先级的 Goroutine 等待运行),这样可以保证 Goroutines 的公平调度。
线程的调度:
线程的调度是由操作系统完成的,通常使用一种叫做抢占式的调度策略,当一个线程在执行时,操作系统会定期中断它,保存它的状态,然后选择另一个线程运行。
线程的创建、销毁和切换都需要在内核态完成,因此开销比 Goroutines 大。
线程可以利用多核处理器的并行计算能力,但是过多的线程会导致调度开销增大,影响程序的性能。
2.3 Goroutine 与线程的调度开销
- 我问 GPT:
为什么线程调度的开销更大?
- GPT 说:
线程调度的开销主要来自以下几个方面:
上下文切换:当操作系统决定停止运行一个线程并运行另一个线程时,它需要保存当前线程的状态(包括程序计数器,寄存器,内存映射等),然后加载另一个线程的状态。这个过程叫做上下文切换,它需要在用户态和内核态之间切换,这是一个相对耗时的操作。
内核态和用户态的切换:线程是由操作系统管理的,所以线程的创建、销毁和调度都需要在内核态完成。每次从用户态切换到内核态,都需要保存和恢复大量的状态信息,这也会增加额外的开销。
资源消耗:每个线程都有自己的堆栈,通常大小为几百KB到几MB。当线程数量增多时,这些内存的消耗会变得非常显著。此外,操作系统需要维护所有线程的状态信息,这也会消耗一定的系统资源。
相比之下,Goroutines 的调度是在用户态完成的,不需要频繁地在用户态和内核态之间切换,因此开销更小。同时,Goroutines 的堆栈大小可以从很小(如几KB)开始,根据需要动态地增长和缩小,因此内存利用率更高。
这里提到了三方面的原因,其中“内核态和用户态的切换”稍稍不好理解一些,我想继续追问。
2.4 用户态和内核态
- 我问 GPT:
可以解释下用户态和内核态以及其切换的原因和原理吗?
- GPT 说:
在操作系统中,为了保护系统的安全和稳定,通常会将运行环境分为用户态(User Mode)和内核态(Kernel Mode)两种模式。
用户态:在用户态下运行的程序不能直接访问操作系统内部的资源。这些程序只能通过系统调用来请求操作系统的服务,系统调用的过程实际上是一个用户态到内核态的切换。
内核态:在内核态下,代码有权访问系统的任何资源,没有任何限制。操作系统的核心部分,如调度器、内存管理等,都在内核态下运行。
用户态和内核态的切换通常发生在以下情况:
系统调用:当用户态的程序需要操作系统提供服务时,如读写文件、创建线程等,它需要通过系统调用来请求。这时,程序会从用户态切换到内核态,执行相应的系统调用,然后再切换回用户态。
中断处理:当外部设备(如键盘、鼠标、网络卡等)发出中断请求时,CPU 会暂停当前的任务,切换到内核态处理中断。
异常处理:当程序运行出错,如除以零、访问非法内存等,CPU 会切换到内核态,进行异常处理。
用户态和内核态的切换涉及到大量的状态保存和恢复,是一种相对耗时的操作。因此,频繁的切换会影响系统的性能。这也是为什么在设计并发模型时,如 Go 的 Goroutines,会尽量减少用户态和内核态的切换,以提高性能。
OK,到此基本知道了 Goroutine 和“线程”的区别。下一步我也不知道该学点啥,让 GPT 自己说吧。
2.5 Golang 并发编程相关的知识点
- 我问 GPT:
除了 Goroutines 和 Channel,还有哪些和 Golang 并发编程相关的知识点?
- GPT 说:
除了 Goroutines 和 Channels,以下是一些与 Go 语言并发编程相关的其他重要知识点:
Select:Select 语句用于在多个发送/接收 channel 操作中进行选择。Select 会阻塞,直到条件满足,然后执行相应的操作。如果有多个条件满足,Select 会随机选择一个执行。
Sync 包:Go 语言的标准库中有一个 sync 包,提供了一些并发控制的功能,如互斥锁(Mutex)、读写锁(RWMutex)、条件变量(Cond)、等待组(WaitGroup)等。
Atomic 包:Atomic 包提供了一些原子操作的函数,可以在多个 goroutine 之间安全地操作数据。
Context 包:Context 包用于在 goroutine 之间传递上下文信息,包括取消信号、超时时间、键值对等。
Race Detector:Go 提供了一个 Race Detector,用于检测并发程序中的数据竞争问题。
协程泄露:协程泄露是 Go 程序中常见的问题,如果一个 goroutine 启动后永远不会结束,那么它就会一直占用内存,这就是协程泄露。我们需要确保每个启动的 goroutine 在不需要时都能正确地结束。
内存模型:Go 语言的内存模型定义了在并发程序中,如何以及何时对共享数据的读写操作对其他 goroutine 可见。理解这个模型对于编写正确的并发程序是非常重要的。
我去,博大精深啊…… 给人一种从入门到放弃的冲动。
2.6 Select 语句
- 我问 GPT:
请继续介绍一下 Select 语句相关知识点,结合相关代码示例
- GPT 说:
Select 语句在 Go 语言中用于处理多个 channel 的发送和接收操作。Select 会阻塞,直到满足以下条件之一:
某个 case 语句可以执行,这通常意味着某个 channel 可以进行发送或接收操作。
如果存在 default 语句,那么在没有其他 case 语句可以执行时,执行 default 语句。
如果有多个 case 语句同时满足条件,那么 select 会随机选择一个执行。
以下是一个使用 select 的示例:
package main import ( "fmt" "time" ) func server1(ch chan string) { time.Sleep(2 * time.Second) ch <- "from server1" } func server2(ch chan string) { time.Sleep(1 * time.Second) ch <- "from server2" } func main() { output1 := make(chan string) output2 := make(chan string) go server1(output1) go server2(output2) select { case s1 := <-output1: fmt.Println(s1) case s2 := <-output2: fmt.Println(s2) } }
在这个示例中,我们有两个服务器 server1 和 server2,它们分别在不同的时间向各自的 channel 发送一个消息。在 main 函数中,我们使用 select 语句等待这两个服务器的响应,并打印第一个收到的响应。
注意,如果没有 case 语句可以执行,并且没有提供 default 语句,那么 select 语句将永远阻塞。这在某些情况下可能是有用的,例如,我们可以创建一个永远阻塞的 goroutine,只有当接收到特定的信号时才退出。
这个例子中没有用到 default,尽管我可以理解 default 的语法含义,但是一时间也没有特别直观地想到哪个真实的场景下需要用到 default 来“抢夺 case 的阻塞态”。那么 default 在什么场景下使用呢?
2.7 Select 语句的 default 使用场景
- 我问 GPT:
select 语句的 default 在什么场景下有用?请结合代码示例解释。
- GPT 说:
default
语句在select
中的作用是当其他case
语句都不满足条件(即所有的 channel 都不能发送或接收数据)时,执行default
语句。这样,select
语句就不会阻塞,而是继续执行。这在你希望
select
语句在无法立即处理 I/O 操作时不阻塞,而是执行其他操作的场景下非常有用。以下是一个使用
default
语句的示例:
package main import ( "fmt" "time" ) func main() { ch := make(chan string) go func() { time.Sleep(2 * time.Second) ch <- "message" }() select { case msg := <-ch: fmt.Println(msg) default: fmt.Println("no message received") } // Do some other work // ... }
在这个示例中,我们创建了一个 goroutine,它在 2 秒后向 channel 发送一个消息。然后我们在
select
语句中尝试从 channel 接收消息。如果消息已经发送,我们就打印这个消息;如果消息还没有发送(即 channel 还不能接收数据),我们就执行default
语句,打印 "no message received",然后继续执行其他的工作。注意,如果我们去掉
default
语句,select
语句就会阻塞,直到 channel 可以接收数据。
我希望再直观一点。
- 我又问 GPT:
能不能构造一个真实的业务场景来进一步解释 default 的作用?
- GPT 说:
当然可以。假设我们正在开发一个网络服务器,它需要从多个客户端接收数据。我们可以为每个客户端创建一个 goroutine 和一个 channel,然后使用 select 语句来处理这些 channel 的数据。如果所有的 channel 都没有数据,我们不希望服务器阻塞,而是希望它能继续处理其他任务,如清理内存、更新统计信息等。这时,我们就可以使用 default 语句。
以下是一个简化的示例:
package main import ( "fmt" "time" ) func client(ch chan string, id int) { for { time.Sleep(time.Duration(id) * time.Second) ch <- fmt.Sprintf("client %d data", id) } } func main() { ch1 := make(chan string) ch2 := make(chan string) go client(ch1, 1) go client(ch2, 2) for { select { case data := <-ch1: fmt.Println(data) case data := <-ch2: fmt.Println(data) default: fmt.Println("No data received. Doing other work.") time.Sleep(1 * time.Second) // simulate work } } }
在这个示例中,我们有两个客户端,它们分别在不同的时间向各自的 channel 发送数据。在 main 函数中,我们使用 select 语句等待这两个客户端的数据。如果有数据,我们就打印数据;如果没有数据,我们就执行 default 语句,打印 "No data received. Doing other work.",然后模拟执行其他的工作。
所以 default 可以让 select 不阻塞,在没有一个 case 可以立即执行的情况下先处理其他任务。那么相比于 default 的工作单独开一个 goroutine 去执行又有什么区别呢?我脑海中闪过这个想法,于是我写了这一段:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go client(ch1, 1)
go client(ch2, 2)
go func(){
for {
fmt.Println("No data received. Doing other work.")
time.Sleep(1 * time.Second) // simulate work
}
}()
for {
select {
case data := <-ch1:
fmt.Println(data)
case data := <-ch2:
fmt.Println(data)
}
}
我尝试让 GPT 解释这样写的优缺点。
- 我又问 GPT:
这段代码中的 main 如果改成这样,相比于使用 default,哪一种写法更好?
- GPT 说:
这段代码中,你创建了一个新的 goroutine 来执行 "其他工作"。这个 goroutine 会无限循环,每秒打印一次 "No data received. Doing other work.",然后休眠一秒。
这种写法和使用
default
的主要区别在于,无论是否有数据从ch1
或ch2
接收,"其他工作" 都会每秒执行一次。而使用default
的版本中,"其他工作" 只有在没有数据接收时才会执行。哪种写法更好取决于你的具体需求。如果 "其他工作" 是可以并行执行,并且不依赖于数据接收的,那么你的写法> 是可以的。但是,如果 "其他工作" 需要在没有数据接收时才执行,或者它需要访问一些共享资源,那么使用
default
可能更合适,因为它可以确保在执行 "其他工作" 时,不会有新的数据被接收。此外,你的写法中,"其他工作" 的 goroutine 是无法结束的,这可能会导致 goroutine 泄露。如果你需要在某个条件下结束这个 goroutine,你需要添加相应的逻辑,例如使用一个额外的 channel 来发送结束信号。
这里用了一个小技巧,当需要就某段代码向 DevChat 提问时,可以通过这种方式:
到此,我知道两种写法的主要区别了。在 select 里的 default 只有当 case 不被执行的时候才会触发,换言之这里是可以加 break 逻辑结束循环的。而我这种改法就不好直接达到这种效果了。由此我想到了一个更合适的场景来解释 select-default 语法,我让 GPT 来帮我表述:
- 我对 GPT 说:
我明白了。请重新构造一个 select, default 的例子,在 case 中满足条件时结束循环,程序结束;在等待的过程中,default 里实现每秒打印一次日志的逻辑。
- GPT 说:
以下是一个满足你要求的示例:
package main import ( "fmt" "time" ) func worker(done chan bool) { fmt.Println("Working...") time.Sleep(5 * time.Second) // simulate work fmt.Println("Done working.") done <- true } func main() { done := make(chan bool) go worker(done) for { select { case <-done: fmt.Println("Received done signal. Exiting.") return default: fmt.Println("No done signal received. Waiting...") time.Sleep(1 * time.Second) } } }
在这个示例中,我们创建了一个
worker
goroutine,它在完成工作后向done
channel 发送一个信号。在 main 函数中,我们使用 select 语句等待done
信号。如果收到done
信号,我们打印 "Received done signal. Exiting.",然后结束程序;如果没有收到done
信号,我们就执行 default 语句,打印 "No done signal received. Waiting...",然后休眠一秒。这个过程会一直重复,直到收到done
信号。
这会好理解了,当 case 等待的工作没有完成的时候,default 可以做点其他事。就好比吃饭排队时每分钟抬头看看有没有轮到自己,没有轮到就默认低头继续玩一分钟手机一样。
三、总结
别急别急,我知道你想说还有 Sync、Atomic、Context、Race Detector、Goroutine 泄露和内存模型等等话题没聊完。不过,篇幅有限(其实主要是犯困了),剩下的话题咱改日再聊。(预催更,关注公众号“思码逸智能编程”!)
相关资源
微信公众号:微信号为“devchat-ai”,名称暂为“思码逸智能编程”
微信群:“思码逸智能编程”公众号内菜单栏点击“微信群”即可收到群二维码
Discord:discord.gg/9t3yrbBUXD