تحليل الأداء
عند الانتهاء من كتابة برنامج، فإن متطلباتنا لا تقتصر فقط على القدرة على التشغيل، بل نأمل أن يكون تطبيقًا مستقرًا وفعالًا. من خلال مجموعة متنوعة من الاختبارات، يمكننا ضمان معظم استقرار البرنامج، أما ما إذا كان البرنامج فعالاً فيتطلب منا إجراء تحليل الأداء عليه. في المحتوى السابق، كان الوسيلة الوحيدة لتحليل الأداء هي استخدام Benchmark لاختبار متوسط وقت التنفيذ لوحدة وظيفية معينة وحالة تخصيص الذاكرة وغيرها. ومع ذلك، في الواقع تتطلب احتياجات تحليل أداء البرنامج أكثر من ذلك بكثير، فأحيانًا نحتاج إلى تحليل استخدام CPU الإجمالي للبرنامج، واستخدام الذاكرة، وحالة تخصيص الذاكرة الكومة، وحالة الكوروتينات، ومسارات الكود الساخنة وغيرها، وهي أمور لا يستطيع Benchmark تلبيتها. لحسن الحظ، توفر سلسلة أدوات go العديد من أدوات تحليل الأداء للمطورين لاستخدامها، وفيما يلي شرح تفصيلي لكل منها.
تحليل الهروب (Escape Analysis)
في Go، يتم تحديد تخصيص ذاكرة المتغيرات بواسطة المترجم، وعادة ما يتم تخصيصها في مكانين: المكدس والكومة. إذا تم تخصيص متغير كان من المفترض أن يُخصص على المكدس إلى الكومة، فإن هذه الحالة تُسمى الهروب، وتحليل الهروب هو تحليل حالة تخصيص الذاكرة في البرنامج. وبما أنه يتم في مرحلة التجميع، فهو نوع من التحليل الثابت.
TIP
انتقل إلى مقالة تخصيص الذاكرة لفهم كيفية تخصيص 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 كقيمة إرجاع للدالة، لذا قام المترجم بتخصيصه على الكومة. هذا مثال بسيط جدًا، لذا لا يتطلب فهمه مجهودًا كبيرًا، ولكن إذا كان مشروعًا كبيرًا بعشرات الآلاف من أسطر الكود، فإن التحليل اليدوي لن يكون بهذه السهولة، ولهذا نحتاج إلى استخدام أدوات لإجراء تحليل الهروب. ذكرنا سابقًا أن تخصيص الذاكرة يتم تحت سيطرة المترجم، لذا يتم أيضًا إجراء تحليل الهروب بواسطة المترجم، واستخدامه بسيط جدًا، فقط قم بتنفيذ الأمر التالي:
$ go build -gcflags="-m -m -l"gcflags هي معلمات المترجم gc:
-m، طباعة اقتراحات تحسين الكود، وجود اثنين معًا يوفر تفاصيل أكثر-l، تعطيل تحسين الإدراج (inline)
الإخراج كالتالي:
$ 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.
:::
إغلاق مرجعي
إذا أشار الإغلاق إلى متغير خارج الدالة، فإن هذا المتغير سيهرب أيضًا إلى الكومة، وهذا سهل الفهم.
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:
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طول غير معروف
عندما يكون طول الشريحة متغيرًا، وبما أن طوله غير معروف، تحدث ظاهرة الهروب (الخرائط لا تحدث لها):
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 قد يحدث أيضًا هروب:
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 أنه يمكن استعادتها.
type Writer struct {
buf []byte
}
func (w Writer) Close() error {
w.buff = nil
return nil
}pprof
pprof (program profiling)، هو أداة قوية لتحليل أداء البرنامج، حيث يقوم بأخذ عينات من بيانات وقت تشغيل البرنامج، covering CPU، الذاكرة، الكوروتينات، الأقفال، معلومات المكدس والعديد من الجوانب الأخرى، ثم استخدام الأدوات لتحليل العينات وعرض النتائج.
لذا فإن خطوات استخدام pprof هي خطوتان فقط:
- جمع البيانات
- تحليل النتائج
الجمع
هناك طريقتان لجمع البيانات: تلقائية ويدوية، لكل منهما مزايا وعيوب. قبل ذلك، دعنا نكتب دالة بسيطة لمحاكاة استهلاك الذاكرة و CPU:
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:
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 كما هو موضح في الكود التالي:
profiles.m = map[string]*Profile{
"goroutine": goroutineProfile,
"threadcreate": threadcreateProfile,
"heap": heapProfile,
"allocs": allocsProfile,
"block": blockProfile,
"mutex": mutexProfile,
}هذه الدالة ستكتب البيانات المجمعة إلى الملف المحدد، والرقم المُمرر عند الكتابة له المعاني التالية:
0، كتابة بيانات Protobuf المضغوطة، غير قابلة للقراءة1، كتابة بيانات بتنسيق نصي، يمكن قراءتها، وهذا ما يُرجعه واجهة http2، متاح فقط لـgoroutine، يعني طباعة معلومات المكدس بأسلوبpanic
جمع بيانات CPU يتطلب استخدام دالة pprof.StartCPUProfile بشكل منفصل، فهو يحتاج وقتًا معينًا لأخذ العينات، وبياناته الأولية غير قابلة للقراءة، كما هو موضح أدناه:
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) يتم بنفس الطريقة:
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 وتسجلها في المسار الافتراضي، كما هو موضح أدناه:
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 بضغطة زر واحدة:
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. يمكنك أيضًا دمج هذه الواجهات في المسارات الخاصة بك بدلاً من استخدام المسارات الافتراضية، كما هو موضح أدناه:
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)
سطر الأوامر
استخدم ملف البيانات المجمع سابقًا كمعامل:
$ go tool pprof heap.pbإذا كانت البيانات مجمعة من الويب، استبدل اسم الملف بعنوان الويب:
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heapثم ستظهر واجهة سطر أوامر تفاعلية:
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/totalcum%، 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 لعرض الصور والكود المصدري في المتصفح.
الويب
قبل ذلك، لجعل البيانات أكثر تنوعًا، دعنا نعدل دوال المحاكاة:
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)
}تحليل الويب يمكنه عرض النتائج بصريًا، مما يوفر علينا التشغيل اليدوي لسطر الأوامر. عند استخدام تحليل الويب، فقط قم بتنفيذ الأمر التالي:
$ go tool pprof -http :8080 heap.pbإذا كانت البيانات مجمعة من الويب، استبدل اسم الملف بعنوان الويب:
$ 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/goroutineTIP
لمعرفة المزيد حول كيفية تحليل البيانات، انتقل إلى pprof: How to read the graph

توجد في صفحة الويب 6 عناصر يمكن عرضها:
- Top، نفس أمر top
- Graph، رسم بياني خطي
- Flame Graph، مخطط اللهب
- Peek
- Source، عرض الكود المصدري
- Disassemble، عرض التفكيك
بالنسبة للذاكرة، هناك أربعة أبعاد يمكن تحليلها:
alloc_objects: عدد جميع الكائنات المخصصة حاليًا، بما في ذلك那些 التي تم تحريرهاalloc_spcae: جميع مساحة الذاكرة المخصصة حتى الآن، بما في ذلك那些 التي تم تحريرهاinuse_objects: عدد الكائنات قيد الاستخدامinuse_space: مساحة الذاكرة قيد الاستخدام

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

بالنسبة للمخطط الخطي، هناك عدة نقاط يجب ملاحظتها:
- كلما كان لون الكتلة أغمق، كان الاستخدام أعلى، وكلما كان الخط أسمك، كان الاستخدام أعلى
- الخط المتصل يمثل استدعاء مباشر، والخط المتقطع يمثل تخطي بعض سلاسل الاستدعاء


بالنسبة لمخطط اللهب، النظر من أعلى إلى أسفل هو سلسلة الاستدعاء، والنظر من اليسار إلى اليمين هو النسبة المئوية لاستخدام cum.
trace
pprof مسؤول بشكل أساسي عن تحليل استخدام موارد البرنامج، بينما trace أكثر ملاءمة لتتبع تفاصيل تشغيل البرنامج، وهو غير متوافق مع ملفات البيانات الخاصة بالأول، ويتم إكمال عمل التحليل ذي الصلة بواسطة أمر go tool trace.
إذا كانت البيانات مجمعة يدويًا، يمكنك استخدام اسم الملف كمعامل:
$ go tool trace trace.outإذا كانت مجموعة تلقائيًا، نفس الأمر:
$ 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

