likes
comments
collection
share

Go并发编程 | goroutine

作者站长头像
站长
· 阅读数 52

引言

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
评论
请登录