# 跟着 GPT-4 从0到1学习 Golang 并发机制(一)


## 1. 前言

今天玩点不一样的，我想试试看 GPT-4 能不能当好一个编程老师，循循善诱，由浅入深，深入浅出…… 给我讲解下 Golang 里的并发机制。

今天倒掉腹中半杯水，当回白纸，GPT 为笔，代码为颜料，挥笔泼墨，且看能绘出怎样的世界。

## 2. 开聊

> 下文通过 DevChat 和 GPT-4 交互，如果你对 DevChat 感兴趣，可以跳转下文浏览：
> - [《DevChat：将 GPT-4 无缝融入 VS Code，极致提升你的编程体验》](https://zh.devchat.blog/devchat-gpt4-vscode)

### 2.1 Golang 里的并发机制介绍

- 在 DevChat 里和 GPT-4 交互的体验：

![](1.png)

**下文主要贴文字，避免过多使用截图。**

- **我问 GPT：**

> 能不能介绍下 Golang 里的并发机制

- **GPT 说：**

> 在 Golang 中，主要的并发机制是通过 Goroutines 和 Channels 实现的。
> 
> 1. **Goroutines**：Goroutines 是 Go 语言中的轻量级线程实现，由 Go 运行时环境管理。Goroutines 相比于线程的优势在于其创建和销毁的开销小，可以轻松创建上百万个而不会导致系统资源耗尽。创建一个 Goroutine 只需要在函数调用前加上关键字 `go` 即可。
> 
> 2. **Channels**：Channels 是 Go 中提供的一种强大的并发同步机制。它可以在多个 Goroutine 之间传递数据并同步执行。你可以把它想象成一个 Goroutines 可以发送和接收值的管道。通过 `make` 函数创建 Channels，通过 `<-` 操作符发送和接收数据。
> 
> 以下是一个简单的示例，展示了如何在 Go 中使用 Goroutines 和 Channels：
> 
> ```go
> 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`，它每秒向 Channel `c` 发送一次 "Hello"，然后在 main 函数中，我们从 Channel `c` 中接收并打印这些消息。当 Channel `c` 被关闭并且没有更多的值可以接收时，`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 说：**

> 线程调度的开销主要来自以下几个方面：
> 
> 1. **上下文切换**：当操作系统决定停止运行一个线程并运行另一个线程时，它需要保存当前线程的状态（包括程序计数器，寄存器，内存映射等），然后加载另一个线程的状态。这个过程叫做上下文切换，它需要在用户态和内核态之间切换，这是一个相对耗时的操作。
> 
> 2. **内核态和用户态的切换**：线程是由操作系统管理的，所以线程的创建、销毁和调度都需要在内核态完成。每次从用户态切换到内核态，都需要保存和恢复大量的状态信息，这也会增加额外的开销。
> 
> 3. **资源消耗**：每个线程都有自己的堆栈，通常大小为几百KB到几MB。当线程数量增多时，这些内存的消耗会变得非常显著。此外，操作系统需要维护所有线程的状态信息，这也会消耗一定的系统资源。
> 
> 相比之下，Goroutines 的调度是在用户态完成的，不需要频繁地在用户态和内核态之间切换，因此开销更小。同时，Goroutines 的堆栈大小可以从很小（如几KB）开始，根据需要动态地增长和缩小，因此内存利用率更高。

这里提到了三方面的原因，其中“内核态和用户态的切换”稍稍不好理解一些，我想继续追问。

### 2.4 用户态和内核态

- **我问 GPT：**

> 可以解释下用户态和内核态以及其切换的原因和原理吗？

- **GPT 说：**

> 在操作系统中，为了保护系统的安全和稳定，通常会将运行环境分为用户态（User Mode）和内核态（Kernel Mode）两种模式。
> 
> 1. **用户态**：在用户态下运行的程序不能直接访问操作系统内部的资源。这些程序只能通过系统调用来请求操作系统的服务，系统调用的过程实际上是一个用户态到内核态的切换。
> 
> 2. **内核态**：在内核态下，代码有权访问系统的任何资源，没有任何限制。操作系统的核心部分，如调度器、内存管理等，都在内核态下运行。
> 
> 用户态和内核态的切换通常发生在以下情况：
> 
> - **系统调用**：当用户态的程序需要操作系统提供服务时，如读写文件、创建线程等，它需要通过系统调用来请求。这时，程序会从用户态切换到内核态，执行相应的系统调用，然后再切换回用户态。
> 
> - **中断处理**：当外部设备（如键盘、鼠标、网络卡等）发出中断请求时，CPU 会暂停当前的任务，切换到内核态处理中断。
> 
> - **异常处理**：当程序运行出错，如除以零、访问非法内存等，CPU 会切换到内核态，进行异常处理。
> 
> 用户态和内核态的切换涉及到大量的状态保存和恢复，是一种相对耗时的操作。因此，频繁的切换会影响系统的性能。这也是为什么在设计并发模型时，如 Go 的 Goroutines，会尽量减少用户态和内核态的切换，以提高性能。

OK，到此基本知道了 Goroutine 和“线程”的区别。下一步我也不知道该学点啥，让 GPT 自己说吧。

### 2.5 Golang 并发编程相关的知识点

- **我问 GPT：**

> 除了 Goroutines 和 Channel，还有哪些和 Golang 并发编程相关的知识点？

- **GPT 说：**

> 除了 Goroutines 和 Channels，以下是一些与 Go 语言并发编程相关的其他重要知识点：
> 
> 1. **Select**：Select 语句用于在多个发送/接收 channel 操作中进行选择。Select 会阻塞，直到条件满足，然后执行相应的操作。如果有多个条件满足，Select 会随机选择一个执行。
> 
> 2. **Sync 包**：Go 语言的标准库中有一个 sync 包，提供了一些并发控制的功能，如互斥锁（Mutex）、读写锁（RWMutex）、条件变量（Cond）、等待组（WaitGroup）等。
> 
> 3. **Atomic 包**：Atomic 包提供了一些原子操作的函数，可以在多个 goroutine 之间安全地操作数据。
> 
> 4. **Context 包**：Context 包用于在 goroutine 之间传递上下文信息，包括取消信号、超时时间、键值对等。
> 
> 5. **Race Detector**：Go 提供了一个 Race Detector，用于检测并发程序中的数据竞争问题。
> 
> 6. **协程泄露**：协程泄露是 Go 程序中常见的问题，如果一个 goroutine 启动后永远不会结束，那么它就会一直占用内存，这就是协程泄露。我们需要确保每个启动的 goroutine 在不需要时都能正确地结束。
> 
> 7. **内存模型**：Go 语言的内存模型定义了在并发程序中，如何以及何时对共享数据的读写操作对其他 goroutine 可见。理解这个模型对于编写正确的并发程序是非常重要的。

我去，博大精深啊…… 给人一种从入门到放弃的冲动。

### 2.6 Select 语句

- **我问 GPT：**

> 请继续介绍一下 Select 语句相关知识点，结合相关代码示例

- **GPT 说：**

> Select 语句在 Go 语言中用于处理多个 channel 的发送和接收操作。Select 会阻塞，直到满足以下条件之一：
> 
> - 某个 case 语句可以执行，这通常意味着某个 channel 可以进行发送或接收操作。
> - 如果存在 default 语句，那么在没有其他 case 语句可以执行时，执行 default 语句。
> 
> 如果有多个 case 语句同时满足条件，那么 select 会随机选择一个执行。
> 
> 以下是一个使用 select 的示例：
> 
> ```go
> 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` 语句的示例：
> 
> ```go
> 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 语句。
> 
> 以下是一个简化的示例：
> 
> ```go
> 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 去执行又有什么区别呢？我脑海中闪过这个想法，于是我写了这一段：

```go
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 提问时，可以通过这种方式：

![](2.png)

到此，我知道两种写法的主要区别了。在 select 里的 default 只有当 case 不被执行的时候才会触发，换言之这里是可以加 break 逻辑结束循环的。而我这种改法就不好直接达到这种效果了。由此我想到了一个更合适的场景来解释 select-default 语法，我让 GPT 来帮我表述：

- **我对 GPT 说：**

> 我明白了。请重新构造一个 select, default 的例子，在 case 中满足条件时结束循环，程序结束；在等待的过程中，default 里实现每秒打印一次日志的逻辑。

- **GPT 说：**

> 以下是一个满足你要求的示例：
> 
> ```go
> 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 可以做点其他事。就好比吃饭排队时每分钟抬头看看有没有轮到自己，没有轮到就默认低头继续玩一分钟手机一样。

## 3. 总结

别急别急，我知道你想说还有 Sync、Atomic、Context、Race Detector、Goroutine 泄露和内存模型等等话题没聊完。不过，篇幅有限（其实主要是犯困了），剩下的话题咱改日再聊。（欲催更，关注公众号“胡说云原生”！）

**相关文章**

- [《跟着 GPT-4 从0到1学习 Golang 并发机制(二)》](https://www.danielhu.cn/from_0_to_1_go_concurrency_with_gpt_2/)
- [《跟着 GPT-4 从0到1学习 Golang 并发机制(三)》](https://www.danielhu.cn/from_0_to_1_go_concurrency_with_gpt_3/)

