为什么这个程序在分配较少的线程时 运行 更快?

Why does this program run faster when it's allocated fewer threads?

我有一个相当简单的 Go 程序,旨在计算随机斐波那契数以测试我在我编写的工作池中观察到的一些奇怪行为。 当我分配一个线程时,程序在 1.78 秒内完成。当我分配4时,它在9.88秒内完成。

代码如下:

var workerWG sync.WaitGroup

func worker(fibNum chan int) {
    for {
        var tgt = <-fibNum
        workerWG.Add(1)
        var a, b float64 = 0, 1
        for i := 0; i < tgt; i++ {
            a, b = a+b, a
        }
        workerWG.Done()
    }
}

func main() {
    rand.Seed(time.Now().UnixNano())
    runtime.GOMAXPROCS(1) // LINE IN QUESTION

    var fibNum = make(chan int)

    for i := 0; i < 4; i++ {
        go worker(fibNum)
    }
    for i := 0; i < 500000; i++ {
        fibNum <- rand.Intn(1000)
    }
    workerWG.Wait()
}

如果我将 runtime.GOMAXPROCS(1) 替换为 4,则程序需要的时间是 运行 的四倍。

这是怎么回事?为什么 添加 更多可用线程到工作池会减慢整个池的速度?

我个人的理论是,这与 worker 的处理时间小于线程管理的开销有关,但我不确定。我的预订是由于以下测试引起的:

当我用下面的代码替换worker函数时:

for {
    <-fibNum
    time.Sleep(500 * time.Millisecond)
}

1 个可用线程和 4 个可用线程花费的时间相同。

您在 worker 中的主要计算例程不允许调度程序 运行。 像

这样手动调用调度器
    for i := 0; i < tgt; i++ {
        a, b = a+b, a
        if i%300 == 0 {
            runtime.Gosched()
        }
    }

从一个线程切换到两个线程时,挂钟减少 30%。

这样的人工微基准测试真的很难做到正确。

我修改了你的程序,如下所示:

package main

import (
    "math/rand"
    "runtime"
    "sync"
    "time"
)

var workerWG sync.WaitGroup

func worker(fibNum chan int) {
    for tgt := range fibNum {
        var a, b float64 = 0, 1
        for i := 0; i < tgt; i++ {
            a, b = a+b, a
        }
    }
    workerWG.Done()
}

func main() {
    rand.Seed(time.Now().UnixNano())
    runtime.GOMAXPROCS(1) // LINE IN QUESTION

    var fibNum = make(chan int)

    for i := 0; i < 4; i++ {
        go worker(fibNum)
        workerWG.Add(1)
    }
    for i := 0; i < 500000; i++ {
        fibNum <- rand.Intn(100000)
    }
    close(fibNum)
    workerWG.Wait()
}
  • 我清理了等待组的使用情况。
  • 我把rand.Intn(1000)改成了rand.Intn(100000)

在我的机器上产生:

$ time go run threading.go (GOMAXPROCS=1)

real    0m20.934s
user    0m20.932s
sys 0m0.012s

$ time go run threading.go (GOMAXPROCS=8)

real    0m10.634s
user    0m44.184s
sys 0m1.928s

这意味着在您的原始代码中,执行的工作与同步(通道 read/write)相比可以忽略不计。速度变慢的原因是必须跨线程同步而不是一个线程,并且在两者之间只执行非常少量的工作。

从本质上讲,与计算高达 1000 的斐波那契数相比,同步是昂贵的。这就是人们倾向于不鼓励微基准的原因。增加这个数字可以提供更好的视角。但更好的想法是对正在完成的实际工作进行基准测试,即包括 IO、系统调用、处理、处理、写入输出、格式化等。

编辑:作为实验,我将 GOMAXPROCS 设置为 8 的工人数量增加到 8,结果是:

$ time go run threading.go 

real    0m4.971s
user    0m35.692s
sys 0m0.044s

@thwd 写的代码是正确且地道的Go。

由于 sync.WaitGroup 的原子性质,您的代码正在被序列化。 workerWG.Add(1)workerWG.Done() 都会阻塞,直到它们能够自动更新内部计数器。

  • 由于工作负载在 0 到 1000 次递归调用之间,单核的瓶颈足以将等待组计数器上的数据竞争降至最低。
  • 在多核上,处理器花费大量时间自旋来修复等待组调用的冲突。再加上等待组计数器保留在一个核心上这一事实,您现在已经添加了核心之间的通信(占用更多周期)。

一些简化代码的提示:

  • 对于小的、固定数量的 goroutines,一个完整的通道(chan struct{} 以避免分配)使用起来更便宜。
  • 使用关闭发送通道作为 goroutines 的终止信号,并让它们发出已经退出的信号(等待组或通道)。然后,关闭 complete channel 为 GC 释放它们。
  • 如果您需要等待组,请尽量减少对其的调用次数。这些调用必须在内部序列化,因此额外的调用会强制添加同步。