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

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

·

5 min read

一、前言

话接上回《跟着 GPT-4 从0到1学习 Golang 并发机制(二)》,我想试试看 GPT-4 能不能当好一个编程老师,循循善诱,由浅入深,深入浅出…… 给我讲解下 Golang 里的并发机制。

今天继续当白纸,GPT 为笔,代码为颜料,挥笔泼墨,接着作画。

二、开聊

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

2.1 关于 goroutine 泄露问题

  • 我问 GPT:

可以介绍下 goroutine 泄露问题吗

  • GPT 说:

Goroutine 泄露是 Go 程序中一个常见的问题,它发生在你启动了一个 goroutine,但是没有提供一个结束它的方式。这个 goroutine 就会一直运行,占用内存和 CPU,直到整个程序结束。如果你的程序启动了大量的这样的 goroutine,它可能会消耗大量的资源,甚至导致程序崩溃。

以下是一个 goroutine 泄露的示例:

package main

import (
    "fmt"
    "time"
)

func leakyFunction() {
    go func() {
        for {
            fmt.Println("I'm leaking!")
            time.Sleep(1 * time.Second)
        }
    }()
}

func main() {
    leakyFunction()
    time.Sleep(5 * time.Second)
}

在这个示例中,leakyFunction 启动了一个 goroutine,这个 goroutine 会无限循环打印一条消息。然而,leakyFunction 并没有提供一个结束这个 goroutine 的方式,所以这个 goroutine 会一直运行,直到整个程序结束。

为了避免 goroutine 泄露,你应该总是提供一个结束 goroutine 的方式。这通常可以通过 context 取消、关闭 channel 或者使用 sync.WaitGroup 来实现。

以下是一个修改后的示例,避免了 goroutine 泄露:

package main

import (
    "context"
    "fmt"
    "time"
)

func notLeakyFunction(ctx context.Context) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                return
            default:
                fmt.Println("I'm not leaking!")
                time.Sleep(1 * time.Second)
            }
        }
    }()
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    notLeakyFunction(ctx)
    time.Sleep(10 * time.Second)
}

在这个示例中,我们使用了一个可取消的 context。当 context 被取消时,goroutine 会检测到这个信号,并结束运行。这样,我们就避免了 goroutine 泄露。

寥寥几句,似乎已经讲完了这个话题。好吧,那继续看内存模型去。

2.2 内存模型

  • 我又问 GPT:

继续介绍下 Go 的内存模型吧

  • GPT 说:

Go 的内存模型定义了在并发程序中,如何以及何时对变量的读写操作对其他 goroutine 可见。它是理解并发编程中数据同步的关键。

在 Go 中,如果一个 goroutine 在没有使用 channel 或者其他同步原语(如 sync 包中的锁)的情况下,对一个变量的写入操作对其他 goroutine 是不可见的。这是因为 Go 运行时并不保证在没有同步的情况下,一个 goroutine 的写入操作何时对其他 goroutine 可见。

以下是一个示例:

package main

import (
    "fmt"
    "time"
)

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    time.Sleep(1 * time.Second)
    if done {
        fmt.Println(a)
    }
}

在这个示例中,setup goroutine 对 adone 的写入操作可能对 main goroutine 不可见,即使我们在 setupmain 之间添加了 time.Sleep。这是因为 Go 的内存模型并不保证在没有同步的情况下,一个 goroutine 的写入操作何时对其他 goroutine 可见。

为了在 goroutine 之间正确同步数据,你应该使用 channel 或者 sync 包中的原语。例如,你可以使用 sync.Mutex 来保护对共享变量的访问,或者使用 sync.WaitGroup 来等待一个 goroutine 完成。

以下是一个修改后的示例,使用了 sync 包来同步数据:

package main

import (
    "fmt"
    "sync"
)

var a string
var done bool
var mu sync.Mutex

func setup() {
    a = "hello, world"
    mu.Lock()
    done = true
    mu.Unlock()
}

func main() {
    go setup()
    time.Sleep(1 * time.Second)
    mu.Lock()
    if done {
        fmt.Println(a)
    }
    mu.Unlock()
}

在这个示例中,我们使用了 sync.Mutex 来保护对 done 的访问。这样,我们就可以确保 setup goroutine 对 adone 的写入操作对 main goroutine 可见。

这个结果看起来并不好,我不满意。

  • 我接着问 GPT:

这个例子其实并不能太好地解释“内存模型”,因为不管执行多少次,main 函数中都会读到 done 为 true。我希望你更深入地介绍 happens before 以及 Golang 哲学中的“Do not communicate by sharing memory; instead, share memory by communicating”

  • GPT 说:

你的观察是正确的,我之前的例子可能并不能很好地展示 Go 的内存模型。让我们更深入地讨论一下。

Happens Before

在 Go 的内存模型中,"happens before" 是一个非常重要的概念。如果我们说事件 A happens before 事件 B,那么意味着在所有的 goroutine 中,事件 A 的效果对事件 B 是可见的。也就是说,如果一个 goroutine 对一个变量的写入操作 happens before 另一个 goroutine 的读取操作,那么读取操作将会看到写入操作的效果。

在 Go 中,以下几种情况会建立 happens before 关系:

  • 对同一个 channel 的发送操作 happens before 相应的接收操作。

  • 对一个 Mutex 或者 RWMutex 的解锁操作 happens before 后续的锁定操作。

  • 对一个 WaitGroup 的 Add 操作 happens before 相应的 Wait 操作。

  • 对一个 Cond 的 Signal 或者 Broadcast 操作 happens before 相应的 Wait 操作返回。

"Do not communicate by sharing memory; instead, share memory by communicating"

这是 Go 并发哲学的核心原则。它鼓励我们使用 channel 来在 goroutine 之间传递数据,而不是通过共享内存。这样,我们可以通过数据的所有权转移来避免数据竞争。

以下是一个使用 channel 来在 goroutine 之间传递数据的示例:

package main

import (
    "fmt"
    "time"
)

func worker(done chan bool) {
    fmt.Print("working...")
    time.Sleep(time.Second)
    fmt.Println("done")

    done <- true
}

func main() {
    done := make(chan bool, 1)
    go worker(done)

    <-done
}

在这个示例中,我们创建了一个 worker goroutine,并给它传递了一个 channel。当 worker 完成工作后,它会向这个 channel 发送一个信号。主 goroutine 会等待这个信号,然后结束程序。这样,我们就通过 channel 在 goroutine 之间传递了数据,而不是通过共享内存。

  • 我接着问 GPT:

看了这个新的例子,我大概理解“happens before”以及“share memory by communicating”了。在前面一个例子中,你通过共享内存来实现 goroutine 之间的通信,其实是不可靠的,因为读取操作可以发生在写入操作之前。如果你没有加上1秒钟的等待,那么完全可能发生读取到的 done 为 false;而通过 channel 通信来实现共享内存就避免了这个问题,因为 done 的读取会等待写入完成。总之这里的关键就是尽量用 channel 实现 goroutine 之间的通信,避免使用共享内存;同时在并发编程时尽量使用 sync 包提供的各种同步锁和条件变量等等机制,避免使用类似“time.Sleep()”这种不可靠的方式去实现 goroutine 同步。我的理解对吗?

  • GPT 说:

你的理解是完全正确的。在 Go 中,我们应该优先使用 channel 来在 goroutine 之间传递数据,而不是通过共享内存。这样可以避免数据竞争,使得代码更容易理解和维护。

同时,你也正确地指出了在并发编程中,我们应该使用 sync 包提供的同步原语,如 Mutex、WaitGroup 和 Cond,来同步 goroutine 的执行。这些同步原语可以帮助我们建立明确的 happens-before 关系,确保我们的程序按照预期的顺序执行。

最后,你也正确地指出了,我们应该避免使用 time.Sleep() 这样的方式来同步 goroutine。因为 time.Sleep() 并不能保证 happens-before 关系,所以它不能确保 goroutine 的执行顺序。在实际的并发程序中,我们应该使用 channel、Mutex、WaitGroup 或者其他同步原语来同步 goroutine,而不是依赖于 time.Sleep()

2.3 Race Detector 检测数据竞争

  • 我问 GPT:

最后,我还想知道 Race Detector 是什么

  • GPT 说:

Go 语言提供了一个名为 Race Detector 的工具,用于检测代码中的数据竞争条件。数据竞争发生在当两个或更多的 goroutine 并发访问同一块内存区域,且至少有一个访问是写入操作时。

Race Detector 可以在运行时检测到这些数据竞争,帮助你找到并修复它们。你可以通过在 go rungo testgo build 命令后添加 -race 选项来启用 Race Detector。

以下是一个有数据竞争的程序:

package main

import (
    "fmt"
)

var counter int

func increment() {
    counter++
}

func main() {
    go increment()
    go increment()
    fmt.Println(counter)
}

在这个程序中,两个 goroutine 都在尝试增加 counter 的值。这会导致数据竞争,因为 counter++ 不是一个原子操作。

如果你使用 Race Detector 来运行这个程序(go run -race main.go),它会报告数据竞争,并给出详细的报告,包括数据竞争发生的位置和涉及的 goroutine。

注意,虽然 Race Detector 是一个非常有用的工具,但它可能会使你的程序运行得更慢,并使用更多的内存。因此,你应该只在测试和调试时启用它,而不是在生产环境中使用。

看起来挺有道理的,我试下这段代码:

$ go run -race main.go
0
==================
WARNING: DATA RACE
Write at 0x00010269a8a0 by goroutine 7:
  main.increment()
      /Users/danielhu/go/src/demo/main.go:10 +0x3c

Previous read at 0x00010269a8a0 by main goroutine:
  main.main()
      /Users/danielhu/go/src/demo/main.go:16 +0x44

Goroutine 7 (running) created at:
  main.main()
      /Users/danielhu/go/src/demo/main.go:15 +0x34
==================
Found 1 data race(s)
exit status 66

到这里,我还想追问一句:

  • 我问 GPT:

所以 Race Detector 其实并不是为了检测并发访问一段数据,而是检测哪些并发访问数据时没有正确地使用合适的同步原语对吗?

  • GPT 说:

是的,你的理解是正确的。Race Detector 的主要目的是检测数据竞争,也就是多个 goroutine 同时访问同一块数据,且至少有一个 goroutine 在进行写操作,而这些操作之间没有进行适当的同步。

如果你的代码中存在数据竞争,那么你的代码的行为就是未定义的,可能会导致各种难以预料和难以调试的问题。通过使用 Race Detector,你可以在开发和测试阶段就发现这些问题,然后通过添加适当的同步原语(如互斥锁、channel 等)来修复它们。

所以,Race Detector 并不是用来检测并发访问数据本身,而是用来检测并发访问数据时是否存在数据竞争,即是否存在没有进行适当同步的并发读写操作。

三、总结

三篇《跟着 GPT-4 从0到1学习 Golang 并发机制》文章到此写完。温故知新,收获颇丰,随便总结下:

  1. Goroutine:Goroutine 是 Go 语言的并发单元,属于轻量级的线程,由 Go runtime 管理,也可称协程。

  2. Channel:Channel 是 Go 语言中在 goroutines 之间通信的主要方式。

  3. Selectselect 语句可以用于在多个 channel 操作之间进行选择。当多个 case 都可以运行时,select 会随机选择一个。

  4. Mutexes 和 sync 包sync 包提供了一些同步原语,如 Mutex(互斥锁)、RWMutex(读写锁)、WaitGroup(等待组)和 Cond(条件变量)。这些原语可以用于保护共享数据,或者同步 goroutines 的执行。

  5. 原子操作:Go 语言的 sync/atomic 包提供了一些原子操作函数,这些函数可以在多个 goroutine 之间安全地操作数据,而不需要使用互斥锁。原子操作包括 AddInt32、AddInt64、CompareAndSwapInt32、CompareAndSwapInt64 等等。这些函数可以保证在任何时刻只有一个 goroutine 能够对数据进行操作,从而避免数据竞争。

  6. Context 包context 包提供了一种在 API 边界之间传递请求范围的值、取消信号和超时信息的方式。你可以使用 context.WithCancelcontext.WithDeadlinecontext.WithTimeoutcontext.WithValue 函数来创建新的 Context。

  7. 内存模型:Go 的内存模型定义了在并发程序中,如何以及何时对变量的读写操作对其他 goroutine 可见。你应该使用 channel 或者 sync 包中的原语来同步数据,以确保 happens-before 关系。

  8. Race Detector:Race Detector 是一个用于检测数据竞争的工具。你可以在 go rungo testgo build 命令后添加 -race 选项来启用 Race Detector。

  9. Go 并发哲学:"Do not communicate by sharing memory; instead, share memory by communicating." 这是 Go 并发哲学的核心原则。它鼓励我们使用 channel 来在 goroutine 之间传递数据,而不是通过共享内存。这样可以避免数据竞争,使得代码更容易理解和维护。

Do not communicate by sharing memory; instead, share memory by communicating.

Do not communicate by sharing memory; instead, share memory by communicating.

Do not communicate by sharing memory; instead, share memory by communicating.