Skip to content

تحليل الأداء

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

تحليل الهروب (Escape Analysis)

في Go، يتم تحديد تخصيص ذاكرة المتغيرات بواسطة المترجم، وعادة ما يتم تخصيصها في مكانين: المكدس والكومة. إذا تم تخصيص متغير كان من المفترض أن يُخصص على المكدس إلى الكومة، فإن هذه الحالة تُسمى الهروب، وتحليل الهروب هو تحليل حالة تخصيص الذاكرة في البرنامج. وبما أنه يتم في مرحلة التجميع، فهو نوع من التحليل الثابت.

TIP

انتقل إلى مقالة تخصيص الذاكرة لفهم كيفية تخصيص Go للذاكرة بالتفصيل.

إرجاع مؤشر محلي

go
package main

func main() {
  GetPerson()
}

type Person struct {
  Name string
  Mom  *Person
}

func GetPerson() Person {
  mom := Person{Name: "lili"}
  son := Person{Name: "jack", Mom: &mom}
  return son
}

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

bash
$ go build -gcflags="-m -m -l"

gcflags هي معلمات المترجم gc:

  • -m، طباعة اقتراحات تحسين الكود، وجود اثنين معًا يوفر تفاصيل أكثر
  • -l، تعطيل تحسين الإدراج (inline)

الإخراج كالتالي:

bash
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:13:2: mom escapes to heap:
./main.go:13:2:   flow: son = &mom:
./main.go:13:2:     from &mom (address-of) at ./main.go:14:35
./main.go:13:2:     from Person{...} (struct literal element) at ./main.go:14:15
./main.go:13:2:     from son := Person{...} (assign) at ./main.go:14:6
./main.go:13:2:   flow: ~r0 = son:
./main.go:13:2:     from return son (return) at ./main.go:15:2
./main.go:13:2: moved to heap: mom

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

::: tips

إذا كنت مهتمًا بتفاصيل تحليل الهروب، يمكنك معرفة المزيد في المكتبة القياسية cmd/compile/internal/escape/escape.go.

:::

إغلاق مرجعي

إذا أشار الإغلاق إلى متغير خارج الدالة، فإن هذا المتغير سيهرب أيضًا إلى الكومة، وهذا سهل الفهم.

go
package main

func main() {
  a := make([]string, 0)
  do(func() []string {
    return a
  })
}

func do(f func() []string) []string {
  return f()
}

الإخراج:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:10:9: f does not escape
./main.go:4:2: main capturing by value: a (addr=false assign=false width=24)
./main.go:4:11: make([]string, 0) escapes to heap:
./main.go:4:11:   flow: a = &{storage for make([]string, 0)}:
./main.go:4:11:     from make([]string, 0) (spill) at ./main.go:4:11
./main.go:4:11:     from a := make([]string, 0) (assign) at ./main.go:4:4
./main.go:4:11:   flow: ~r0 = a:
./main.go:4:11:     from return a (return) at ./main.go:6:3
./main.go:4:11: make([]string, 0) escapes to heap
./main.go:5:5: func literal does not escape

مساحة غير كافية

عندما تكون مساحة المكدس غير كافية، تحدث أيضًا ظاهرة الهروب، الشريحة التالية تم إنشاؤها بطلب سعة 1<<15:

go
package main

func main() {
  _ = make([]int, 0, 1<<15)
}

الإخراج:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:4:10: make([]int, 0, 32768) escapes to heap:
./main.go:4:10:   flow: {heap} = &{storage for make([]int, 0, 32768)}:
./main.go:4:10:     from make([]int, 0, 32768) (too large for stack) at ./main.go:4:10
./main.go:4:10: make([]int, 0, 32768) escapes to heap

طول غير معروف

عندما يكون طول الشريحة متغيرًا، وبما أن طوله غير معروف، تحدث ظاهرة الهروب (الخرائط لا تحدث لها):

go
package main

func main() {
  n := 100
  _ = make([]int, n)
}

الإخراج:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:5:10: make([]int, n) escapes to heap:
./main.go:5:10:   flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:10:     from make([]int, n) (non-constant size) at ./main.go:5:10
./main.go:5:10: make([]int, n) escapes to heap

هناك حالة خاصة وهي عندما يكون معامل الدالة من نوع ...any قد يحدث أيضًا هروب:

go
package main

import "fmt"

func main() {
  n := 100
  fmt.Println(n)
}

الإخراج:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:7:14: n escapes to heap:
./main.go:7:14:   flow: {storage for ... argument} = &{storage for n}:
./main.go:7:14:     from n (spill) at ./main.go:7:14
./main.go:7:14:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14:   flow: {heap} = {storage for ... argument}:
./main.go:7:14:     from ... argument (spill) at ./main.go:7:13
./main.go:7:14:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: n escapes to heap

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

تفاصيل صغيرة

بالنسبة لبعض الأنواع المرجعية، عند التأكد من أننا لن نستخدمها مرة أخرى، يمكننا ضبطها على nil لإخبار GC أنه يمكن استعادتها.

go
type Writer struct {
  buf []byte
}

func (w Writer) Close() error {
  w.buff = nil
  return nil
}

pprof

pprof (program profiling)، هو أداة قوية لتحليل أداء البرنامج، حيث يقوم بأخذ عينات من بيانات وقت تشغيل البرنامج، covering CPU، الذاكرة، الكوروتينات، الأقفال، معلومات المكدس والعديد من الجوانب الأخرى، ثم استخدام الأدوات لتحليل العينات وعرض النتائج.

لذا فإن خطوات استخدام pprof هي خطوتان فقط:

  1. جمع البيانات
  2. تحليل النتائج

الجمع

هناك طريقتان لجمع البيانات: تلقائية ويدوية، لكل منهما مزايا وعيوب. قبل ذلك، دعنا نكتب دالة بسيطة لمحاكاة استهلاك الذاكرة و CPU:

go
func Do() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 24 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

يدوي

الجمع اليدوي يعني التحكم عبر الكود، ومزاياه هي التحكم والمرونة والتخصيص. لاستخدام pprof مباشرة في الكود تحتاج إلى استيراد حزمة runtime/pprof:

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
)

func main() {
    Do()
  w, _ := os.Create("heap.pb")
  heapProfile := pprof.Lookup("heap")
  err := heapProfile.WriteTo(w, 0)
  if err != nil {
    log.Fatal(err)
  }
}

المعلمات التي يدعمها pprof.Lookup كما هو موضح في الكود التالي:

go
profiles.m = map[string]*Profile{
    "goroutine":    goroutineProfile,
    "threadcreate": threadcreateProfile,
    "heap":         heapProfile,
    "allocs":       allocsProfile,
    "block":        blockProfile,
    "mutex":        mutexProfile,
}

هذه الدالة ستكتب البيانات المجمعة إلى الملف المحدد، والرقم المُمرر عند الكتابة له المعاني التالية:

  • 0، كتابة بيانات Protobuf المضغوطة، غير قابلة للقراءة
  • 1، كتابة بيانات بتنسيق نصي، يمكن قراءتها، وهذا ما يُرجعه واجهة http
  • 2، متاح فقط لـ goroutine، يعني طباعة معلومات المكدس بأسلوب panic

جمع بيانات CPU يتطلب استخدام دالة pprof.StartCPUProfile بشكل منفصل، فهو يحتاج وقتًا معينًا لأخذ العينات، وبياناته الأولية غير قابلة للقراءة، كما هو موضح أدناه:

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("cpu.out")
  err := pprof.StartCPUProfile(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  pprof.StopCPUProfile()
}

جمع بيانات التتبع (trace) يتم بنفس الطريقة:

go
package main

import (
  "log"
  "os"
  "runtime/trace"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("trace.out")
  err := trace.Start(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  trace.Stop()
}

تلقائي

حزمة net/http/pprof تغلف دوال التحليل المذكورة أعلاه في واجهات http وتسجلها في المسار الافتراضي، كما هو موضح أدناه:

go
package pprof

import ...

func init() {
    http.HandleFunc("/debug/pprof/", Index)
    http.HandleFunc("/debug/pprof/cmdline", Cmdline)
    http.HandleFunc("/debug/pprof/profile", Profile)
    http.HandleFunc("/debug/pprof/symbol", Symbol)
    http.HandleFunc("/debug/pprof/trace", Trace)
}

هذا يجعلنا نستطيع تشغيل جمع بيانات pprof بضغطة زر واحدة:

go
package main

import (
  "net/http"
    // تذكر استيراد هذه الحزمة
  _ "net/http/pprof"
)

func main() {
    go func(){
        http.ListenAndServe(":8080", nil)
    }
    for {
        Do()
    }
}

عند فتح المتصفح والذهاب إلى http://127.0.0.1:8080/debug/pprof، ستظهر هذه الصفحة:

توجد في الصفحة عدة خيارات متاحة، وهي تمثل:

  • allocs: عينات تخصيص الذاكرة
  • block: تتبع حجب بدائل التزامن
  • cmdline: استدعاء سطر الأوامر للبرنامج الحالي
  • goroutine: تتبع جميع الكوروتينات
  • heap: عينات تخصيص الذاكرة للكائنات الحية
  • mutex: تتبع معلومات أقفال التعاقب
  • profile: تحليل CPU، سيحلل لفترة زمنية ويحمّل ملفًا
  • threadcreate: تحليل أسباب إنشاء خيوط OS جديدة
  • trace: تتبع حالة تنفيذ البرنامج الحالي، سيحمّل ملفًا أيضًا

معظم البيانات هنا ليست عالية القراءة، وتهدف أساسًا للاستخدام بواسطة الأدوات للتحليل، كما في الصورة التالية:

العمل التحليلي المحدد سيُترك لاحقًا. باستثناء الخيارين profile و trace، إذا أردت تنزيل ملفات البيانات في صفحة الويب، يمكنك إزالة معامل query المسمى debug=1. يمكنك أيضًا دمج هذه الواجهات في المسارات الخاصة بك بدلاً من استخدام المسارات الافتراضية، كما هو موضح أدناه:

go
package main

import (
  "net/http"
  "net/http/pprof"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/trace", pprof.Trace)
  servre := &http.Server{
    Addr:    ":8080",
    Handler: mux,
  }
  servre.ListenAndServe()
}

وبهذه الطريقة، يمكن دمجها في أطر عمل الويب الأخرى مثل gin و iris وغيرها.

التحليل

بعد الحصول على ملفات البيانات المجمعة، هناك طريقتان للتحليل: سطر الأوامر أو الويب، وكلاهما يتطلب استخدام أداة سطر الأوامر pprof، و Go تدمج هذه الأداة افتراضيًا، لذا لا تحتاج لتنزيلها بشكل إضافي.

عنوان pprof مفتوح المصدر: google/pprof: pprof is a tool for visualization and analysis of profiling data (github.com)

سطر الأوامر

استخدم ملف البيانات المجمع سابقًا كمعامل:

bash
$ go tool pprof heap.pb

إذا كانت البيانات مجمعة من الويب، استبدل اسم الملف بعنوان الويب:

bash
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heap

ثم ستظهر واجهة سطر أوامر تفاعلية:

bash
15:27:38.3266862 +0800 CST
Type: inuse_space
Time: Apr 15, 2024 at 3:27pm (CST)
No samples were found with the default sample value type.
Try "sample_index" command to analyze different sample values.
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

أدخل help لعرض الأوامر الأخرى:

  Commands:
    callgrind        Outputs a graph in callgrind format
    comments         Output all profile comments
    disasm           Output assembly listings annotated with samples
    dot              Outputs a graph in DOT format
    eog              Visualize graph through eog
    evince           Visualize graph through evince
  ...

في سطر الأوامر، عادة ما نستخدم أمر top لعرض البيانات، ويمكن أيضًا استخدام أمر traces لكن إخراجه طويل جدًا، وأمر top يعطي نظرة عامة بسيطة فقط.

(pprof) top 5
Showing nodes accounting for 117.49MB, 100% of 117.49MB total
      flat  flat%   sum%        cum   cum%
  117.49MB   100%   100%   117.49MB   100%  main.makeSlice (inline)
         0     0%   100%   117.49MB   100%  main.Do
         0     0%   100%   117.49MB   100%  main.main
         0     0%   100%   117.49MB   100%  runtime.main

شرح بسيط لبعض المؤشرات (وينطبق نفس الشيء على CPU):

  • flat، يمثل الموارد التي تستهلكها الدالة الحالية
  • cum، مجموع الموارد التي تستهلكها الدالة الحالية وسلسلة الاستدعاءات اللاحقة
  • flat%، flat/total
  • cum%، cum/total

يمكننا أن نرى بوضوح أن استخدام الذاكرة لمكدس الاستدعاءات بالكامل هو 117.49 ميجابايت، وبما أن دالة Do نفسها لا تفعل شيئًا سوى استدعاء دوال أخرى، فإن مؤشر flat الخاص بها هو 0. أما إنشاء الشريحة فهو مسؤولية دالة makeSlice، لذا فإن مؤشر flat الخاص بها هو 100%.

يمكننا التحويل إلى تنسيق مرئي، ويدعم pprof العديد من التنسيقات مثل pdf و svg و png و gif وغيرها (يتطلب تثبيت Graphviz).

(pprof) png
Generating report in profile001.png

من خلال الصورة يمكننا أن نرى بوضوح أكبر حالة الذاكرة لمكدس الاستدعاءات بالكامل.

من خلال أمر list يمكن العرض بصيغة الكود المصدري:

(pprof) list Do
Total: 117.49MB
ROUTINE ======================== main.Do in D:\WorkSpace\Code\GoLeran\golearn\example\main.go
         0   117.49MB (flat, cum)   100% of Total
         .          .     21:func Do() {
         .          .     22:   for i := 0; i < 10; i++ {
         .   117.49MB     23:           slice := makeSlice()
         .          .     24:           sortSlice(slice)
         .          .     25:   }
         .          .     26:}
         .          .     27:
         .          .     28:func makeSlice() []int {

بالنسبة للصور والكود المصدري، يمكن أيضًا استخدام أمرين web و weblist لعرض الصور والكود المصدري في المتصفح.

الويب

قبل ذلك، لجعل البيانات أكثر تنوعًا، دعنا نعدل دوال المحاكاة:

go
func Do1() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func Do2() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 12 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

تحليل الويب يمكنه عرض النتائج بصريًا، مما يوفر علينا التشغيل اليدوي لسطر الأوامر. عند استخدام تحليل الويب، فقط قم بتنفيذ الأمر التالي:

bash
$ go tool pprof -http :8080 heap.pb

إذا كانت البيانات مجمعة من الويب، استبدل اسم الملف بعنوان الويب:

bash
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/heap
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/profile
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/goroutine

TIP

لمعرفة المزيد حول كيفية تحليل البيانات، انتقل إلى pprof: How to read the graph

توجد في صفحة الويب 6 عناصر يمكن عرضها:

  • Top، نفس أمر top
  • Graph، رسم بياني خطي
  • Flame Graph، مخطط اللهب
  • Peek
  • Source، عرض الكود المصدري
  • Disassemble، عرض التفكيك

بالنسبة للذاكرة، هناك أربعة أبعاد يمكن تحليلها:

  • alloc_objects: عدد جميع الكائنات المخصصة حاليًا، بما في ذلك那些 التي تم تحريرها
  • alloc_spcae: جميع مساحة الذاكرة المخصصة حتى الآن، بما في ذلك那些 التي تم تحريرها
  • inuse_objects: عدد الكائنات قيد الاستخدام
  • inuse_space: مساحة الذاكرة قيد الاستخدام

مخطط تحليل الذاكرة

العقد الورقية البيضاء في أسفل الصورة أعلاه تمثل كائنات مختلفة الأحجام مشغولة.

مخطط تحليل CPU

بالنسبة للمخطط الخطي، هناك عدة نقاط يجب ملاحظتها:

  • كلما كان لون الكتلة أغمق، كان الاستخدام أعلى، وكلما كان الخط أسمك، كان الاستخدام أعلى
  • الخط المتصل يمثل استدعاء مباشر، والخط المتقطع يمثل تخطي بعض سلاسل الاستدعاء

مخطط لهب الذاكرة

مخطط لهب CPU

بالنسبة لمخطط اللهب، النظر من أعلى إلى أسفل هو سلسلة الاستدعاء، والنظر من اليسار إلى اليمين هو النسبة المئوية لاستخدام cum.

trace

pprof مسؤول بشكل أساسي عن تحليل استخدام موارد البرنامج، بينما trace أكثر ملاءمة لتتبع تفاصيل تشغيل البرنامج، وهو غير متوافق مع ملفات البيانات الخاصة بالأول، ويتم إكمال عمل التحليل ذي الصلة بواسطة أمر go tool trace.

إذا كانت البيانات مجمعة يدويًا، يمكنك استخدام اسم الملف كمعامل:

$ go tool trace trace.out

إذا كانت مجموعة تلقائيًا، نفس الأمر:

bash
$ curl http://127.0.0.1:8080/debug/pprof/trace > trace.out && go tool trace trace.out

بعد التنفيذ سيتم فتح خادم ويب:

2024/04/15 17:15:40 Preparing trace for viewer...
2024/04/15 17:15:40 Splitting trace for viewer...
2024/04/15 17:15:40 Opening browser. Trace viewer is listening on http://127.0.0.1:51805

بعد الفتح، الصفحة تقريبًا كما يلي:

تحتوي هذه الصفحة بشكل أساسي على الأجزاء التالية، وهذه البيانات ليست سهلة الفهم:

  • Event timelines for running goroutines

    • trace by proc: عرض الجدول الزمني للكوروتينات التي تعمل على كل معالج في كل لحظة

    • trace by thread: عرض الجدول الزمني للكوروتينات التي تعمل على خيوط OS في كل لحظة

    • Goroutine analysis: عرض معلومات إحصائية عن الكوروتينات لكل مجموعة دوال رئيسية

  • Profiles

    • Network blocking profile: معلومات الكوروتينات المحجوبة بسبب IO الشبكة
    • Synchronization blocking profile: معلومات الكوروتينات المحجوبة بسبب بدائل التزامن
    • Syscall profile: معلومات الكوروتينات المحجوبة بسبب استدعاءات النظام
  • User-defined tasks and regions

    • User-defined tasks: معلومات الكوروتينات للمهام المحددة من قبل المستخدم
    • User-defined regions: معلومات الكوروتينات لمناطق الكود المحددة من قبل المستخدم
  • Garbage collection metrics

    • Minimum mutator utilization: عرض أطول وقت لأحدث GC

Golang تم تحريره بواسطة www.golangdev.cn