Golang:为什么使用goroutines并行调用最终会变慢?

时间:2017-01-01 19:13:18

标签: multithreading performance go concurrency parallel-processing

我有两个版本的合并排序实现。第一个是“正常”版本,第二个使用goroutines,它在递归的每个步骤中并行化对切片的每个子集所做的工作。

可以假设能够并行化这项工作会使并发实现更快:如果我需要处理切片A和切片B,那么同时处理它们应该比同步执行它们更快。

现在我假设我的理解实现有问题,因为我的并发版本最终比同步版本慢13-14倍。

任何人都可以指出我正确的方向,我错过了什么?

“正常”(同步实施):

// MergeSort sorts the slice s using Merge Sort Algorithm
func MergeSort(s []int) []int {
    if len(s) <= 1 {
        return s
    }

    n := len(s) / 2

    var l []int
    var r []int

    l = MergeSort(s[:n])
    r = MergeSort(s[n:])

    return merge(l, r)
}

“并发”版本:

// MergeSortMulti sorts the slice s using Merge Sort Algorithm
func MergeSortMulti(s []int) []int {
    if len(s) <= 1 {
        return s
    }

    n := len(s) / 2

    wg := sync.WaitGroup{}
    wg.Add(2)

    var l []int
    var r []int

    go func() {
        l = MergeSortMulti(s[:n])
        wg.Done()
    }()

    go func() {
        r = MergeSortMulti(s[n:])
        wg.Done()
    }()

    wg.Wait()
    return merge(l, r)
}

两者都使用相同的merge函数:

func merge(l, r []int) []int {
    ret := make([]int, 0, len(l)+len(r))
    for len(l) > 0 || len(r) > 0 {
        if len(l) == 0 {
            return append(ret, r...)
        }
        if len(r) == 0 {
            return append(ret, l...)
        }
        if l[0] <= r[0] {
            ret = append(ret, l[0])
            l = l[1:]
        } else {
            ret = append(ret, r[0])
            r = r[1:]
        }
    }
    return ret
}

这是我的基准代码:

package msort

import "testing"

var a []int

func init() {
    for i := 0; i < 1000000; i++ {
        a = append(a, i)
    }
}
func BenchmarkMergeSortMulti(b *testing.B) {
    for n := 0; n < b.N; n++ {
        MergeSortMulti(a)
    }
}

func BenchmarkMergeSort(b *testing.B) {
    for n := 0; n < b.N; n++ {
        MergeSort(a)
    }
}

它揭示了并发版本比普通同步版本慢很多:

BenchmarkMergeSortMulti-8              1    1711428093 ns/op
BenchmarkMergeSort-8                  10     131232885 ns/op

2 个答案:

答案 0 :(得分:3)

这是因为你在调用wg.Wait()时会产生大量的goroutine。调度程序不知道选择哪一个,它可以选择随机阻止的那个,直到它遇到一个最终可以返回并解除另一个。当我限制MergeSortMulti的并发调用次数时,它变得比同步版本快大约3倍。

这段代码并不美观,但这是一个证明。

// MergeSortMulti sorts the slice s using Merge Sort Algorithm
func MergeSortMulti(s []int) []int {
    if len(s) <= 1 {
        return s
    }

    n := len(s) / 2

    wg := sync.WaitGroup{}
    wg.Add(2)

    var l []int
    var r []int

    const N = len(s)
    const FACTOR = 8  // ugly but easy way to limit number of goroutines

    go func() {
        if n < N/FACTOR {
            l = MergeSort(s[:n])
        } else {
            l = MergeSortMulti(s[:n])
        }
        wg.Done()
    }()

    go func() {
        if n < N/FACTOR {
            r = MergeSort(s[n:])
        } else {
            r = MergeSortMulti(s[n:])
        }
        wg.Done()
    }()

    wg.Wait()
    return merge(l, r)
}

您的计算机上的结果会有所不同,但是:

因素= 4:

BenchmarkMergeSortMulti-8             50          33268370 ns/op
BenchmarkMergeSort-8                  20          91479573 ns/op

因素= 10000

BenchmarkMergeSortMulti-8             20          84822824 ns/op
BenchmarkMergeSort-8                  20         103068609 ns/op

因子= N / 4

BenchmarkMergeSortMulti-8              3         352870855 ns/op
BenchmarkMergeSort-8                  10         129107177 ns/op

加分:你也可以使用信号量来限制我机器上慢一点的goroutine数量(选择用于避免死锁):

var sem = make(chan struct{}, 100)

// MergeSortMulti sorts the slice s using Merge Sort Algorithm
func MergeSortMulti(s []int) []int {
    if len(s) <= 1 {
        return s
    }

    n := len(s) / 2

    wg := sync.WaitGroup{}
    wg.Add(2)

    var l []int
    var r []int

    select {
    case sem <- struct{}{}:
        go func() {
            l = MergeSortMulti(s[:n])
            <-sem
            wg.Done()
        }()
    default:
        l = MergeSort(s[:n])
        wg.Done()
    }

    select {
    case sem <- struct{}{}:
        go func() {
            r = MergeSortMulti(s[n:])
            <-sem
            wg.Done()
        }()
    default:
        r = MergeSort(s[n:])
        wg.Done()
    }

    wg.Wait()
    return merge(l, r)
}

它产生:

BenchmarkMergeSortMulti-8             30          36741152 ns/op
BenchmarkMergeSort-8                  20          90843812 ns/op

答案 1 :(得分:1)

您的假设不正确:

  

人们会认为能够并行化这项工作   并发实现更快:如果我需要处理切片A和   切片B,然后同时处理它们应该比做更快   如此同步。

所有并行软件属于Amdahl定律(on Wikipedia),我可以将其解释为“顺序设置不是免费的”。

当仅使用单个 CPU内核时,这当然尤其如此。但是,即使有多个核心,它仍然很重要,如果要实现高性能,则需要通过来考虑核心工作单元的编排和分布。幸运的是,kopiczko的答案在具体案例中提供了一些很好的建议。

几十年来,这一直是一个研究课题:例如, Tidmus和Chalmers在“实际并行处理:并行解决问题的介绍”中的一个旧的(但仍然相关)的交易技巧摘要。