Go: هل يجب استخدام المؤشر بدلاً من نسخة من البنية الخاصة بي؟

صورة
تم إنشاء رسم توضيحي لرحلة مع الذهاب من غوفر أصلي تم إنشاؤه بواسطة Rene French.

من حيث الأداء ، يبدو أن الاستخدام المنتظم للمؤشرات بدلاً من نسخ البنية نفسها لمشاركة الهياكل مع العديد من مطوري Go هو الخيار الأفضل. من أجل فهم تأثير استخدام المؤشر بدلاً من نسخة من البنية ، سننظر في حالتين من حالات الاستخدام.

توزيع مكثف للبيانات


دعنا نلقي نظرة على مثال بسيط عندما تريد مشاركة بنية للوصول إلى قيمها:

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

فيما يلي الهيكل الأساسي ، الذي يمكن مشاركة الوصول إليه عن طريق النسخ أو المؤشر:

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,
  }
}

بناءً على هاتين الطريقتين ، يمكننا كتابة معيارين. الأول هو حيث يتم تمرير الهيكل مع نسخة:

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)
}

الثاني - يشبه إلى حد كبير الأول - حيث يتم تمرير الهيكل بواسطة المؤشر:

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)
}

دعنا ندير المعايير:

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

نحصل على الإحصائيات التالية:

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

كان استخدام نسخة من البنية أسرع 8 مرات من استخدام مؤشر لها!

لفهم السبب ، دعنا نلقي نظرة على الرسوم البيانية التي تم إنشاؤها بواسطة التتبع:

صورة
الرسم البياني للهيكل الذي تم تمريره بواسطة نسخة

صورة
الرسم البياني للهيكل الذي تم تمريره بواسطة المؤشر

الرسم البياني الأول بسيط للغاية. نظرًا لعدم استخدام الكومة ، لا يوجد جامع قمامة وجوروتين زائد.

في الحالة الثانية ، يؤدي استخدام المؤشرات إلى قيام المترجم Go بنقل المتغير إلى الكومة والعمل كمجمع القمامة. إذا قمنا بزيادة مقياس الرسم البياني ، فسوف نرى أن جامع القمامة يشغل جزءًا مهمًا من العملية:

صورة

يوضح هذا الرسم البياني أن جامع القمامة يبدأ كل 4 مللي ثانية.

إذا قمنا بالتكبير مرة أخرى ، فيمكننا الحصول على معلومات تفصيلية حول ما يحدث بالضبط:

صورة

الخطوط الزرقاء والوردية والحمراء هي مراحل جامع القمامة ، وترتبط الخطوط البنية بالتخصيص في الكومة (تم وضع علامة "runtime.bgsweep" على الرسم البياني):

الكنس هو الإصدار من كومة من الأقسام المتعلقة بالبيانات في الذاكرة والتي لم يتم وضع علامة على أنها مستخدمة. يحدث هذا الإجراء عندما تحاول goroutines عزل القيم الجديدة في ذاكرة كومة الذاكرة المؤقتة. يضاف تأخير الكنس إلى تكلفة تنفيذ التخصيص في ذاكرة الكومة ولا ينطبق على أي تأخير مرتبط بجمع البيانات المهملة.

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

حتى إذا كان هذا المثال متطرفًا بعض الشيء ، فإننا نرى كيف يمكن أن يكون تخصيص تكلفة متغير على كومة الذاكرة المؤقتة بدلاً من المكدس. في مثالنا ، يتم تخصيص البنية بشكل أسرع على المكدس ويتم نسخها من التي تم إنشاؤها على الكومة ويتم مشاركة عنوانها.

إذا لم تكن على دراية بالكومة / الكومة ، وإذا كنت تريد معرفة المزيد عن تفاصيلها الداخلية ، فيمكنك العثور على الكثير من المعلومات على الإنترنت ، على سبيل المثال ، هذه المقالة التي كتبها Paul Gribble.

يمكن أن تكون الأمور أسوأ إذا قصرنا المعالج على 1 باستخدام GOMAXPROCS = 1:

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

إذا لم يتغير المعيار القياسي للوضع على المكدس ، فقد انخفض المؤشر على الكومة من 75ns / op إلى 114ns / op.

مكالمات دالة مكثفة


سنضيف طريقتين فارغتين إلى هيكلنا ونعدل معاييرنا قليلاً:

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

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

سيؤدي المعيار مع الموضع على المكدس إلى إنشاء الهيكل وتمريره نسخة:

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)
     }
  }
}

ويمرر مؤشر كومة الذاكرة المؤقتة الهيكل بواسطة المؤشر:

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)
     }
  }
}

كما هو متوقع ، فإن النتائج مختلفة تمامًا الآن:

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

استنتاج


استخدام المؤشر بدلاً من نسخة من البنية في الذهاب ليس جيدًا دائمًا. لاختيار دلالات جيدة لبياناتك ، أوصي بشدة بقراءة منشور عن دلالات القيمة / المؤشر كتبها بيل كينيدي . سيعطيك هذا فكرة أفضل عن الاستراتيجيات التي يمكنك استخدامها مع الهياكل والأنواع المدمجة. بالإضافة إلى ذلك ، سيساعدك تحديد ملف تعريف الذاكرة بالتأكيد على فهم ما يحدث مع تخصيصاتك وكومة.

All Articles