一、前言
话接上回《跟着 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 对a
和done
的写入操作可能对main
goroutine 不可见,即使我们在setup
和main
之间添加了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 对a
和done
的写入操作对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 run
、go test
或go 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 并发机制》文章到此写完。温故知新,收获颇丰,随便总结下:
Goroutine:Goroutine 是 Go 语言的并发单元,属于轻量级的线程,由 Go runtime 管理,也可称协程。
Channel:Channel 是 Go 语言中在 goroutines 之间通信的主要方式。
Select:
select
语句可以用于在多个 channel 操作之间进行选择。当多个 case 都可以运行时,select
会随机选择一个。Mutexes 和 sync 包:
sync
包提供了一些同步原语,如 Mutex(互斥锁)、RWMutex(读写锁)、WaitGroup(等待组)和 Cond(条件变量)。这些原语可以用于保护共享数据,或者同步 goroutines 的执行。原子操作:Go 语言的 sync/atomic 包提供了一些原子操作函数,这些函数可以在多个 goroutine 之间安全地操作数据,而不需要使用互斥锁。原子操作包括 AddInt32、AddInt64、CompareAndSwapInt32、CompareAndSwapInt64 等等。这些函数可以保证在任何时刻只有一个 goroutine 能够对数据进行操作,从而避免数据竞争。
Context 包:
context
包提供了一种在 API 边界之间传递请求范围的值、取消信号和超时信息的方式。你可以使用context.WithCancel
、context.WithDeadline
、context.WithTimeout
和context.WithValue
函数来创建新的 Context。内存模型:Go 的内存模型定义了在并发程序中,如何以及何时对变量的读写操作对其他 goroutine 可见。你应该使用 channel 或者
sync
包中的原语来同步数据,以确保 happens-before 关系。Race Detector:Race Detector 是一个用于检测数据竞争的工具。你可以在
go run
、go test
或go build
命令后添加-race
选项来启用 Race Detector。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.