Go并发编程 | goroutine
引言
goroutine 是 Go 并发编程中基础的组件,与现有的进程、线程、协程的含义都不同,goroutine 只是一个与同一地址空间中其他 goroutine 并发执行的函数。goroutine 被多路复用到多个操作系统线程上,通过与 Go 运行时深度集成使得不需要手动暂停和恢复执行。goroutine使得并发编程更简单、更安全,并充分利用多核处理器的能力。
goroutine的使用
go 语句是用户程序启动 goroutine 的唯一途径。goroutine 是一个并发的函数,使用go
关键字来创建。可以在函数前增加go
关键字,也可以在匿名函数或者变量前使用。
func printHello() {
fmt.Println("Hello")
}
func main() {
go printHello()
go func() {
fmt.Println("Hello")
}()
printHello2 := func() {
fmt.Println("Hello")
}
go printHello2
}
Go 语言遵循一个被称为 fork-join 的并发模型。fork 指在程序中的任意一点,可以将执行的子分支和父节点同时运行。join指在将来的某时这些分支将会合并在一起。
在上面的例子中,三个打印操作分别在三个 goroutine上运行,但是没有设置 join 点,三个 goroutine 将在未来某个不确定的点退出。但是因为 main goroutine 中没有其他的操作,大概率这个程序会在三个打印 goroutine 被调用前结束而看不到任何输出。
可以在主函数的结尾执行一个 sleep 操作,但这并没有真正意义上创建一个 join 点,只是增加了一个竞争条件(执行顺序的改变导致程序的结果改变)。join 点是保证程序正确性和消除竞争条件的关键,创建 join 点来同步多个 goroutine 有多种方式,这里可以简单使用一个Waitgroup
实现。
func printHello(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Hello")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go printHello(&wg)
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("Hello")
}()
printHello2 := func() {
defer wg.Done()
fmt.Println("Hello")
}
wg.Add(1)
go printHello2()
wg.Wait()
}
其中,defer
用于确保函数调用在当前函数返回之前执行,与go
相同,可以用于函数或闭包之前。使用defer
也有一些有趣的问题,这里先不做过多介绍。
闭包(closure)是一个允许在函数内部定义一个函数的特性,这个内部函数可以访问外部函数的局部变量。在使用go
关键字时,使用闭包是一种常见的做法。在 Go 中,有一个常见的闭包陷阱。
func main() {
var wg sync.WaitGroup
for _, name := range []string{"A", "B", "C"} {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(name)
}()
}
wg.Wait()
}
按照常理来说,这个程序应该以不确定的顺序打印 A、B、C,但是实际上会是连续的“CCC”。因为执行输出的 goroutine 可能在未来的任意时间点开始运行,所以这时实际捕获的是一个引用而不是变量的当前值拷贝。所以当 goroutine 开始运行时,这个变量已经迭代到了最后一个元素。
func main() {
var wg sync.WaitGroup
for _, name := range []string{"A", "B", "C"} {
wg.Add(1)
go func(name string) {
defer wg.Done()
fmt.Println(name)
}(name)
}
wg.Wait()
}
应该像这样创建一个副本传递给闭包来得到正确的输出。
goroutine的成本
当分析一门语言的效率和成本的时候,可以从CPU和内存两个角度分析。 goroutine 以轻量而闻名,那么 goroutine 与线程相比,如何利用CPU资源和内存资源? 可以写一些简单的程序验证 goroutine 究竟有多轻量。
内存资源
func main() {
memConsumed := func() uint64 {
runtime.GC()
var s runtime.MemStats
runtime.ReadMemStats(&s)
return s.Sys
}
var c <-chan interface{}
var wg sync.WaitGroup
noop := func() {
wg.Done()
<-c
}
const numGoroutines = 1e4
wg.Add(numGoroutines)
before := memConsumed()
for i := numGoroutines; i > 0; i-- {
go noop()
}
wg.Wait()
after := memConsumed()
fmt.Printf("%.3fkb", float64(after-before)/numGoroutines/1000)
}
这段程序通过测量开启10000个空操作前后内存使用量的差值。计算出一个 goroutine 的内存用量。在我的设备中计算出的值是:3.729KB
与众所周知的 Linux 中线程默认分配的 8M 相比,大多数场景中不必考虑创建和消除一个 goroutine 的成本,一个 8G 内存的个人PC中就可以创建上百万个 goroutine。
CPU资源
上下文切换是影响 CPU 使用效率的关键因素。与进程切换需要切换地址空间相比,共用相同地址空间的线程切换已足够轻量。但因为需要切换到内核态,与在用户态完成切换的 goroutine 相比还是过于耗时,可以在只要一个核心的前提下,进行线程间通信来估算上下文切换的耗时。
taskset -c 0 perf bench sched pipe -T
# Running 'sched/pipe' benchmark:
# Executed 1000000 pipe operations between two threads
Total time: 1.941 [sec]
1.941470 usecs/op
515073 ops/sec
使用perf
估算出一次线程切换的耗时大约为0.97μs。
func BenchmarkSwitch(b *testing.B) {
var wg sync.WaitGroup
begin := make(chan struct{})
c := make(chan struct{})
var token struct{}
sender := func() {
defer wg.Done()
<-begin
for i := 0; i < b.N; i++ {
c <- token
}
}
receiver := func() {
defer wg.Done()
<-begin
for i := 0; i < b.N; i++ {
<-c
}
}
wg.Add(2)
go sender()
go receiver()
b.StartTimer()
close(begin)
wg.Wait()
}
go test -bench=. -cpu=1 switch_test.go
goos: linux
goarch: amd64
cpu: 13th Gen Intel(R) Core(TM) i7-13700KF
BenchmarkSwitch 10476094 109.0 ns/op
PASS
ok command-line-arguments 1.259s
使用go benchmark
测试出的 goroutine 的上下文切换仅需109ns,数量级上的差距意味大多数情况下 goroutine 的切换不会成为程序的效率瓶颈。
引用
晁岳攀.深入理解Go并发编程.2023 Kather Cox-Buday.Go语言并发之道.2018
转载自:https://juejin.cn/post/7370979948117229608