Go: Soll ich einen Zeiger anstelle einer Kopie meiner Struktur verwenden?

Bild
Illustration erstellt für eine Reise mit Go von einem ursprünglichen Gopher erstellt von Rene French.

In Bezug auf die Leistung scheint die systematische Verwendung von Zeigern anstelle des Kopierens der Struktur selbst, um Strukturen für viele Go-Entwickler freizugeben, die beste Option zu sein. Um den Effekt der Verwendung eines Zeigers anstelle einer Kopie der Struktur zu verstehen, werden zwei Anwendungsfälle betrachtet.

Intensive Datenverteilung


Schauen wir uns ein einfaches Beispiel an, wenn Sie eine Struktur freigeben möchten, um auf ihre Werte zuzugreifen:

type S struct {
  a, b, c int64
  d, e, f string
  g, h, i float64
}

Hier ist die Grundstruktur, auf die der Zugriff per Kopie oder Zeiger freigegeben werden kann:

func byCopy() S {
  return S{
     a: 1, b: 1, c: 1,
     e: "foo", f: "foo",
     g: 1.0, h: 1.0, i: 1.0,
  }
}

func byPointer() *S {
  return &S{
     a: 1, b: 1, c: 1,
     e: "foo", f: "foo",
     g: 1.0, h: 1.0, i: 1.0,
  }
}

Basierend auf diesen beiden Methoden können wir 2 Benchmarks schreiben. Die erste ist, wo die Struktur mit einer Kopie übergeben wird:

func BenchmarkMemoryStack(b *testing.B) {
  var s S

  f, err := os.Create("stack.out")
  if err != nil {
     panic(err)
  }
  defer f.Close()

  err = trace.Start(f)
  if err != nil {
     panic(err)
  }

  for i := 0; i < b.N; i++ {
     s = byCopy()
  }

  trace.Stop()

  b.StopTimer()

  _ = fmt.Sprintf("%v", s.a)
}

Die zweite - sehr ähnlich der ersten - bei der die Struktur per Zeiger übergeben wird:

func BenchmarkMemoryHeap(b *testing.B) {
  var s *S

  f, err := os.Create("heap.out")
  if err != nil {
     panic(err)
  }
  defer f.Close()

  err = trace.Start(f)
  if err != nil {
     panic(err)
  }

  for i := 0; i < b.N; i++ {
     s = byPointer()
  }

  trace.Stop()

  b.StopTimer()

  _ = fmt.Sprintf("%v", s.a)
}

Lassen Sie uns die Benchmarks ausführen:

go test ./... -bench=BenchmarkMemoryHeap -benchmem -run=^$ -count=10 > head.txt && benchstat head.txt
go test ./... -bench=BenchmarkMemoryStack -benchmem -run=^$ -count=10 > stack.txt && benchstat stack.txt

Wir erhalten folgende Statistiken:

name          time/op
MemoryHeap-4  75.0ns ± 5%

name          alloc/op
MemoryHeap-4   96.0B ± 0%

name          allocs/op
MemoryHeap-4    1.00 ± 0%

------------------

name           time/op
MemoryStack-4  8.93ns ± 4%

name           alloc/op
MemoryStack-4   0.00B

name           allocs/op
MemoryStack-4    0.00

Die Verwendung einer Kopie der Struktur war achtmal schneller als die Verwendung eines Zeigers darauf!

Um zu verstehen, warum, schauen wir uns die durch die Ablaufverfolgung erzeugten Diagramme an: das

Bild
Diagramm für die von der Kopie übergebene Struktur Kopieren Sie das

Bild
Diagramm für die vom Zeiger übergebene Struktur

Das erste Diagramm ist ziemlich einfach. Da der Haufen nicht verwendet wird, gibt es keinen Müllsammler und überschüssiges Gorutin.

Im zweiten Fall bewirkt die Verwendung von Zeigern, dass der Go-Compiler die Variable auf den Heap verschiebt und als Garbage Collector fungiert. Wenn wir den Maßstab des Diagramms vergrößern, werden wir sehen, dass der Garbage Collector einen wichtigen Teil des Prozesses einnimmt:

Bild

Dieses Diagramm zeigt, dass der Garbage Collector alle 4 ms startet.

Wenn wir erneut hineinzoomen, erhalten wir detaillierte Informationen darüber, was genau passiert: Die

Bild

blauen, rosa und roten Streifen sind die Phasen des Garbage Collectors, und die braunen Streifen sind mit der Zuordnung im Heap verbunden (in der Grafik mit „runtime.bgsweep“ gekennzeichnet):

Sweeping ist die Freigabe von datenbezogenen Speicherabschnitten aus dem Haufen, die nicht als verwendet markiert sind. Diese Aktion tritt auf, wenn Goroutinen versuchen, neue Werte im Heapspeicher zu isolieren. Die Sweeping-Verzögerung wird zu den Kosten für die Durchführung der Zuordnung im Heap-Speicher addiert und gilt nicht für Verzögerungen im Zusammenhang mit der Speicherbereinigung.

www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html

Auch wenn dieses Beispiel etwas extrem ist, sehen wir, wie teuer es sein kann, eine Variable auf dem Heap und nicht auf dem Stack zuzuweisen. In unserem Beispiel wird die Struktur auf dem Stapel viel schneller zugewiesen und kopiert als auf dem Heap erstellt, und ihre Adresse wird gemeinsam genutzt.

Wenn Sie mit dem Stapel / Heap nicht vertraut sind und mehr über die internen Details erfahren möchten, finden Sie im Internet viele Informationen, z. B. diesen Artikel von Paul Gribble.

Es kann noch schlimmer sein, wenn wir den Prozessor mit GOMAXPROCS = 1 auf 1 beschränken:

name        time/op
MemoryHeap  114ns ± 4%

name        alloc/op
MemoryHeap  96.0B ± 0%

name        allocs/op
MemoryHeap   1.00 ± 0%

------------------

name         time/op
MemoryStack  8.77ns ± 5%

name         alloc/op
MemoryStack   0.00B

name         allocs/op
MemoryStack    0.00

Wenn sich der Benchmark für das Platzieren auf dem Stapel nicht geändert hat, hat sich der Indikator auf dem Heap von 75 ns / op auf 114 ns / op verringert.

Intensive Funktionsaufrufe


Wir werden unserer Struktur zwei leere Methoden hinzufügen und unsere Benchmarks ein wenig anpassen:

func (s S) stack(s1 S) {}

func (s *S) heap(s1 *S) {}

Der Benchmark mit der Platzierung auf dem Stapel erstellt die Struktur und übergibt ihr eine Kopie:

func BenchmarkMemoryStack(b *testing.B) {
  var s S
  var s1 S

  s = byCopy()
  s1 = byCopy()
  for i := 0; i < b.N; i++ {
     for i := 0; i < 1000000; i++  {
        s.stack(s1)
     }
  }
}

Und der Benchmark für den Heap wird die Struktur per Zeiger übergeben:

func BenchmarkMemoryHeap(b *testing.B) {
  var s *S
  var s1 *S

  s = byPointer()
  s1 = byPointer()
  for i := 0; i < b.N; i++ {
     for i := 0; i < 1000000; i++ {
        s.heap(s1)
     }
  }
}

Wie erwartet sind die Ergebnisse jetzt völlig anders:

name          time/op
MemoryHeap-4  301µs ± 4%

name          alloc/op
MemoryHeap-4  0.00B

name          allocs/op
MemoryHeap-4   0.00

------------------

name           time/op
MemoryStack-4  595µs ± 2%

name           alloc/op
MemoryStack-4  0.00B

name           allocs/op
MemoryStack-4   0.00

Fazit


Die Verwendung eines Zeigers anstelle einer Kopie der Struktur in go ist nicht immer gut. Um eine gute Semantik für Ihre Daten auszuwählen, empfehle ich dringend, einen Beitrag über die Wert- / Zeigersemantik von Bill Kennedy zu lesen . Auf diese Weise erhalten Sie eine bessere Vorstellung von den Strategien, die Sie für Ihre Strukturen und integrierten Typen verwenden können. Darüber hinaus hilft Ihnen die Profilerstellung der Speichernutzung auf jeden Fall dabei, zu verstehen, was mit Ihren Zuordnungen und Ihrem Heap geschieht.

All Articles