【问题标题】:Differences between slice and container/list切片和容器/列表的区别
【发布时间】:2018-11-05 14:56:11
【问题描述】:

我刚刚开始使用 Go,我遇到了一种情况,我需要创建一组实体,其大小/长度仅在运行时才知道。我最初认为使用列表会很合适,但很快意识到切片是 Go 中惯用的数据结构。 好奇,我写了以下基准

package main

import (
    "container/list"
    "testing"
)

var N = 10000000

func BenchmarkSlices(B *testing.B) {
    s := make([]int, 1)
    for i := 0; i < N; i += 1 {
        s = append(s, i)
    }
}

func BenchmarkLists(B *testing.B) {
    l := list.New()
    for i := 0; i < N; i += 1 {
        l.PushBack(i)
    }
}

这给了我

BenchmarkSlices-4       2000000000               0.03 ns/op
BenchmarkLists-4               1        1665489308 ns/op

鉴于append 会创建一个新数组,并在旧数组已满时将所有数据从旧数组复制到新数组,因此在上面的示例中,我预计列表的性能优于切片。但是,我的期望显然是错误的,我正在尝试了解原因。

为了更好地理解append 在需要时如何创建新数组,我编写了以下内容:

package main

import "fmt"

func describe(s []int) {
    fmt.Printf("len = %d, cap = %d\n", len(s), cap(s))
}

func main() {
    s := make([]int, 2)
    for i := 0; i < 15; i += 1 {
        fmt.Println(i)
        describe(s)
        s = append(s, i)
    }
}

这给了我

0
len = 2, cap = 2
1
len = 3, cap = 4
2
len = 4, cap = 4
3
len = 5, cap = 8
4
len = 6, cap = 8
5
len = 7, cap = 8
6
len = 8, cap = 8
7
len = 9, cap = 16
8
len = 10, cap = 16
9
len = 11, cap = 16
10
len = 12, cap = 16
11
len = 13, cap = 16
12
len = 14, cap = 16
13
len = 15, cap = 16
14
len = 16, cap = 16

目前我唯一的猜测是,为什么切片比列表性能更好的是,为大小为两倍的新数组分配内存并复制所有数据比每次插入时为单个元素分配内存要快。 我的猜测正确吗?我错过了什么吗?

【问题讨论】:

  • 您谈论append 创建新数组--append 仅适用于切片,不适用于数组。我不太确定你在这里问什么。
  • container/list 提供双向链表。如果您需要双向链表,请使用它。如果您不需要双向链表,请不要使用它。不应考虑性能。
  • @Flimzy 谢谢你的留言。据我所知,引擎盖下没有数组就不可能切片。因此,如果我理解正确,append 将创建一个数组和一个切片并返回后者。它是否正确?我没有深入细节,因为我认为这与我实际提出的问题无关。感谢您指出这个细节,但我的措辞确实可能会误导某些人。
  • @Flimzy 这也是我一开始的立场。然而,通过阅读一些代码和文章,我感觉到人们倾向于使用切片,如果他们可以用切片解决问题,即使列表更适合。这让我有点困惑,这就是为什么我开始怀疑性能是否是人们倾向于尽可能多地使用切片的原因之一。如果列表在数据结构方面有意义并省略切片,您认为我应该使用列表吗,尽管它们(显然?)是惯用的?
  • 我使用切片而不是双向链表,因为我几乎不需要双向链表。尽管在我大学期间讨论链表和双向链表的时间过多,但在我的编程生涯中,我只需要一次双向链表。

标签: list go slice


【解决方案1】:

您运行的基准测试有误。您应该首先设置初始数据结构,然后按照testing.B 实例指示的次数运行被基准测试的操作。

我将您的代码替换为:

var N = 1

func BenchmarkSlices(B *testing.B) {
    s := make([]int, 1)
    for n := 0; n < B.N; n++ {
        for i := 0; i < N; i++ {
            s = append(s, i)
        }
    }
}

func BenchmarkLists(B *testing.B) {
    l := list.New()
    for n := 0; n < B.N; n++ {
        for i := 0; i < N; i++ {
            l.PushBack(i)
        }
    }
}

得到了这个结果:

BenchmarkSlices-4       100000000               14.3 ns/op
BenchmarkLists-4         5000000               275 ns/op

至少这次差异似乎是合理的,而不是一万亿倍。

请注意,我还将N 的值替换为1,因此ns/op 实际上表示nanoseconds per operation 而不是nanoseconds per N operations。但是,这也可能会影响结果。

现在谈谈你的问题:在 Go 中实现的链表与简单地向预先分配的切片添加另一个 int 相比会产生额外的成本:list 方法需要创建一个新的Element,将你的值包含在interface{} 并重新分配一些指针。

同时,追加到一个尚未达到最大容量的切片将导致 CPU 级别的几条指令:将一个 int 移动到内存位置,增加切片的长度,然后就完成了。

还有一个事实是,底层分配器可能会重新分配切片,因此根本不需要复制现有的底层数组元素。

【讨论】:

  • 好的答案和赞成,但我认为在你的基准测试中你不应该将N 设置为 1。如果N 更大,它将强制在切片情况下进行一些重新分配,这应该使比较更接近。这是相关的,因为问题特别提到了这一点。
  • 哦,我看错了你的代码,基准案例相互混淆。我认为这根本不合理——为什么不每次都重新初始化列表/切片,并在每次运行基准测试时附加 N(N 大)次?
  • 您必须在基准测试中保持一致才能使 ns/op 有意义。如果你对“allocation + N appends”的时间进行基准测试,那么你绝对可以做到。但是你的数字将意味着“每次分配的纳秒 + N 追加”。
  • 如果你想知道一个人跑得有多快,你不需要测量他们在 10 秒内跑了多远,然后看看他们跑 100 倍需要多长时间,并从中计算出他们的平均速度。他们跑得越远,他们跑得越慢,所以如果他们在前 10 秒跑得快,那么他们跑的距离就会更长,平均速度也会更慢。这正是您的基准测试(和测试标准库)在这里所做的。
  • @justinas 非常感谢您的回复和修复基准代码 :) 创建Element 的需要是我在“为每个元素分配内存”中所指的部分内容插入发生的时间”。只是为了确认一下:您是说创建Element 如此繁重以至于它比创建新切片并在切片最大时复制所有数据要慢吗?或者也许它不是确定性的,人们不应该担心它?
猜你喜欢
  • 1970-01-01
  • 2017-05-29
  • 2012-04-26
  • 1970-01-01
  • 2010-12-14
  • 2014-08-07
  • 2014-06-19
  • 2015-07-24
相关资源
最近更新 更多