今天玩点不一样的,我想试试看 GPT-4 能不能当好一个编程老师,循循善诱,由浅入深,深入浅出…… 给我讲解下 Golang 里的并发机制。
今天倒掉腹中半杯水,当回白纸,GPT 为笔,代码为颜料,挥笔泼墨,且看能绘出怎样的世界。
二、开聊2.1 Golang 里的并发机制介绍在 DevChat 里和 GPT-4 交互的体验:下文通过 DevChat 和 GPT-4 交互,如果你对 DevChat 感兴趣,可以跳转下文浏览:
(资料图片仅供参考)
《DevChat:将 GPT-4 无缝融入 VS Code,极致提升你的编程体验》
下文主要贴文字,避免过多使用截图。
我问 GPT:GPT 说:能不能介绍下 Golang 里的并发机制
在 Golang 中,主要的并发机制是通过 Goroutines 和 Channels 实现的。
Goroutines:Goroutines 是 Go 语言中的轻量级线程实现,由 Go 运行时环境管理。Goroutines 相比于线程的优势在于其创建和销毁的开销小,可以轻松创建上百万个而不会导致系统资源耗尽。创建一个 Goroutine 只需要在函数调用前加上关键字
go
即可。Channels:Channels 是 Go 中提供的一种强大的并发同步机制。它可以在多个 Goroutine 之间传递数据并同步执行。你可以把它想象成一个 Goroutines 可以发送和接收值的管道。通过
make
函数创建 Channels,通过<-
操作符发送和接收数据。以下是一个简单的示例,展示了如何在 Go 中使用 Goroutines 和 Channels:
package mainimport ("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:GPT 说:Goroutines 和线程的关系是什么?Goroutines 调度和线程调度分别是什么原理?
2.3 Goroutine 与线程的调度开销我问 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 大。线程可以利用多核处理器的并行计算能力,但是过多的线程会导致调度开销增大,影响程序的性能。
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:GPT 说:除了 Goroutines 和 Channel,还有哪些和 Golang 并发编程相关的知识点?
除了 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:GPT 说:请继续介绍一下 Select 语句相关知识点,结合相关代码示例
Select 语句在 Go 语言中用于处理多个 channel 的发送和接收操作。Select 会阻塞,直到满足以下条件之一:
某个 case 语句可以执行,这通常意味着某个 channel 可以进行发送或接收操作。如果存在 default 语句,那么在没有其他 case 语句可以执行时,执行 default 语句。如果有多个 case 语句同时满足条件,那么 select 会随机选择一个执行。
以下是一个使用 select 的示例:
package mainimport ("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:GPT 说:select 语句的 default 在什么场景下有用?请结合代码示例解释。
default
语句在select
中的作用是当其他case
语句都不满足条件(即所有的 channel 都不能发送或接收数据)时,执行default
语句。这样,select
语句就不会阻塞,而是继续执行。这在你希望
select
语句在无法立即处理 I/O 操作时不阻塞,而是执行其他操作的场景下非常有用。以下是一个使用
default
语句的示例:package mainimport ("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:GPT 说:能不能构造一个真实的业务场景来进一步解释 default 的作用?
当然可以。假设我们正在开发一个网络服务器,它需要从多个客户端接收数据。我们可以为每个客户端创建一个 goroutine 和一个 channel,然后使用 select 语句来处理这些 channel 的数据。如果所有的 channel 都没有数据,我们不希望服务器阻塞,而是希望它能继续处理其他任务,如清理内存、更新统计信息等。这时,我们就可以使用 default 语句。
以下是一个简化的示例:
package mainimport ("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:GPT 说:这段代码中的 main 如果改成这样,相比于使用 default,哪一种写法更好?
这段代码中,你创建了一个新的 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 说:GPT 说:我明白了。请重新构造一个 select, default 的例子,在 case 中满足条件时结束循环,程序结束;在等待的过程中,default 里实现每秒打印一次日志的逻辑。
以下是一个满足你要求的示例:
package mainimport ("fmt""time")func worker(done chan bool) {fmt.Println("Working...")time.Sleep(5 * time.Second) // simulate workfmt.Println("Done working.")done <- true}func main() {done := make(chan bool)go worker(done)for {select {case <-done:fmt.Println("Received done signal. Exiting.")returndefault: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 泄露和内存模型等等话题没聊完。不过,篇幅有限(其实主要是犯困了),剩下的话题咱改日再聊。(预催更,关注公众号“胡说云原生”!)
相关文章
《跟着 GPT-4 从0到1学习 Golang 并发机制(二)》关键词:
X 关闭
X 关闭