goroutine可能减慢代码的速度(译)

不当的使用goroutine,可能会使CPU忙于移动数据,导致减慢代码运行速度的效果。
这里我们假设有一个很大的循环;为了加快计算速度,将循环分割成多份,然后分别让不同的goroutine执行。

1、串行版本

我们的使用一个简单的串行循环(除了总结循环索引之外什么都不做)作为例子来说明问题。

const (
    limit = 10000000000
)
func SerialSum() int {
    sum := 0
    for i := 0; i < limit; i++ {
        sum += i
    }
    return sum
}

上述代码只是将 1~limit 之间所有数字求和。

2、并发版本

下面我们使用goroutine优化

func ConcurrentSum() int {  
    n := runtime.GOMAXPROCS(0)
    sums := make([]int, n)
    wg := sync.WaitGroup{}
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func(i int) {
            start := (limit / n) * i
            end := start + (limit / n)
            for j := start; j < end; j += 1 {
                sums[i] += j
            }   
            wg.Done()
        }(i)
    }
    wg.Wait()
    sum := 0
    for _, s := range sums {
        sum += s
    }
    return sum
}

3、遗憾的结果

遗憾的是 结果是负向的

func BenchmarkSerialSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SerialSum()
    }
}

func BenchmarkConcurrentSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcurrentSum()
    }
}

我们使用上述代码进行测试:

$ go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/appliedgo/concurrencyslower
BenchmarkSerialSum-4           1      6090666568 ns/op
BenchmarkConcurrentSum-4       1      15741988135 ns/op
PASS
ok      github.com/appliedgo/concurrencyslower 21.840s

从结果中我们可以看到,使用goroutine的并发版本是串行版本的约2.5 倍

4、硬件加速的反击

为了解释这种违反直觉的结果,我们必须看一下所有软件--CPU芯片下面的东西。
问题的原因在于缓存内存有助于加速每个CPU核心。
为了清晰和简洁,以下是一个粗略的过度简化。 每个现代CPU都有一个非平凡的缓存层次结构,位于主内存和裸CPU内核之间,但出于我们的目的,我们只会查看属于各个内核的缓存。

5、CPU缓存的目的

一般来说,缓存是一个非常小但超快的内存块。 它位于CPU芯片上,因此每次读取或写入值时,CPU都不必到达主RAM。 相反,该值存储在缓存中,后续读取和写入受益于更快的RAM单元和更短的访问路径。
CPU的每个核心都有自己的本地缓存,不与任何其他核心共享。 对于n个CPU内核,这意味着最多可以有n + 1个相同数据的副本; 一个在主内存中,一个在每个CPU内核的缓存中。
现在,当CPU内核更改其本地缓存中的值时,必须在某个时刻将其同步回主内存。 同样,如果缓存的值在主内存中被更改(由另一个CPU内核),则缓存的值无效,需要从主内存刷新。

6、缓存行

为了以有效的方式同步高速缓存和主存储器,数据以通常64字节的块同步。 这些块称为缓存行。
因此,当缓存值更改时,整个缓存行将同步回主内存。 同样,包含此高速缓存行的所有其他CPU核心的高速缓存现在也必须同步此高速缓存行以避免对过时数据进行操作。

7、邻居

这对我们的代码有何影响? 请记住,并发循环使用全局切片来存储中间结果。 切片的元素存储在连续的空间中。 概率很高,两个相邻的切片元素将共享相同的高速缓存行。
有n个高速缓存的n个CPU内核重复读取和写入全部位于同一高速缓存行中的切片元素。 因此,只要一个CPU内核使用新的总和更新“其”切片元素,所有其他CPU的高速缓存行就会失效。 必须将更改的高速缓存行写回主内存,并且所有其他高速缓存必须使用新数据更新其各自的高速缓存行。 即使每个核心访问切片的不同部分!
这消耗了宝贵的时间 - 超过了串行循环更新其单个和变量所需的时间。
这就是我们的并发循环比串行循环需要更多时间的原因。 对切片的所有并发更新都会导致繁忙的缓存行同步跳跃。

8、传送数据

既然我们知道了速度放缓的原因,那么解决方案就是显而易见的。 我们必须将切片转换为n个单独的变量,这些变量有望彼此远离存储,以便它们不共享相同的高速缓存行。
所以让我们改变我们的并发循环,以便每个goroutine将其中间和存储在goroutine-local变量中。 为了将结果传递回主goroutine,我们还必须添加一个通道。 这反过来允许我们删除等待组,因为通道不仅是通信的手段,而且是优雅的同步机制。

9、使用局部变量并发循环

func ChannelSum() int {
    n := runtime.GOMAXPROCS(0)
    res := make(chan int)

    for i := 0; i < n; i++ {
        go func(i int, r chan<- int) {
            sum := 0
            start := (limit / n) * i
            end := start + (limit / n)
            for j := start; j < end; j += 1 {
                sum += j
            }
            r <- sum    
        }(i, res)
    }

    sum := 0
    for i := 0; i < n; i++ {
        sum += <-res
    }
    return sum
}

在我们的测试文件中添加第三个基准测试功能BenchmarkChannelSum之后,我们现在可以在循环的所有三个变体上运行基准测试。

$ go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/appliedgo/concurrencyslower
BenchmarkSerialSum-4          1       6022493632 ns/op
BenchmarkConcurrentSum-4      1       15828807312 ns/op
BenchmarkChannelSum-4         1       1948465461 ns/op
PASS
ok      github.com/appliedgo/concurrencyslower  23.807s`

将中间和扩展到各个局部变量,而不是将它们放在一个片中,这无疑帮助我们逃避了缓存同步问题。
但是,我们如何确保各个变量永远不会共享同一个缓存行? 好吧,启动一个新的goroutine会在堆栈上分配2KB到8KB的数据,这比64字节的典型缓存行大小要多。 并且由于中间和变量不是从创建它的goroutine之外的任何地方引用的,因此它不会转移到堆(它可能最终接近其他中间和变量之一)。 所以我们可以非??隙挥辛礁鲋屑浜捅淞炕嵩谕桓龌捍嫘兄薪崾?/p>

参考文献

  1. Slow down your code with goroutines
?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,128评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,316评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事?!?“怎么了?”我有些...
    开封第一讲书人阅读 159,737评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,283评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,384评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,458评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,467评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,251评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,688评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,980评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,155评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,818评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,492评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,142评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,382评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,020评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,044评论 2 352

推荐阅读更多精彩内容

  • feisky云计算、虚拟化与Linux技术笔记posts - 1014, comments - 298, trac...
    不排版阅读 3,837评论 0 5
  • 又是一年秋招季,哎呀妈呀我被虐的惨来~这不,前几阵失踪没更新博客,其实是我偷偷把时间用在复习课本了(雾 坚持在社区...
    tengshe789阅读 2,005评论 0 8
  • 今天看到一位朋友写的mysql笔记总结,觉得写的很详细很用心,这里转载一下,供大家参考下,也希望大家能关注他原文地...
    信仰与初衷阅读 4,727评论 0 30
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,094评论 1 32
  • 很静 天还是有些冷 有雾 没风 没车 没人 溜了十几公里 终于碰到了一个乘客 他靠近我的车,弱弱地问一句: 师傅,...
    阿磊_6812阅读 326评论 0 3