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

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

·

6 min read

一、前言

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

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

二、开聊

下文通过 DevChat 和 GPT-4 交互,如果你对 DevChat 感兴趣,可以跳转下文浏览:

2.1 Golang 里的并发机制介绍

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

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

  • 我问 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:

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 的示例:

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 的主要区别在于,无论是否有数据从 ch1ch2 接收,"其他工作" 都会每秒执行一次。而使用 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 泄露和内存模型等等话题没聊完。不过,篇幅有限(其实主要是犯困了),剩下的话题咱改日再聊。(预催更,关注公众号“思码逸智能编程”!)

相关资源

  • GitHub:github.com/devchat-ai

  • 注册网站:devchat.ai

  • 微信公众号:微信号为“devchat-ai”,名称暂为“思码逸智能编程”

  • 微信群:“思码逸智能编程”公众号内菜单栏点击“微信群”即可收到群二维码

  • Discord:discord.gg/9t3yrbBUXD