یک مهندس نرمافزار در دیوار.
پوینتر ها میتونن کد Go شما رو کند کنن!
اوایل که وارد دنیای Go شده بودم برام دنیای گیج کننده ایی بود. توی PHP خیلی با پوینتر سر و کله نمیزدم. چیزی که معمولا میشنیدم از دوستام اینه که خروجی توابعت رو با پوینتر برگردونی بنظر بهتر میاد. با خودمم فکر میکردم که منطقیه دیگه. نمیاد همهی مقادیر یک متغیر رو کپی کنه. فقط پوینتر رو برمیگردونه. اما اشتباه میکردم.
آیا خروجی دادن یک مقدار پوینتری سریع تره؟
خداروشکر توی Go ابزار Benchmark گیری داریم! بدون اینکه بریم ببینیم دلیل چیه و اینا یک بنچمارک بگیریم ببینیم اصلا حرفی که میزنیم درست هست یا نه. من یک تایپ به اسم SimpleReturn تعریف کردم که یک struct ساده هست که قراره خروجی تابعمون باشه.
type SimpleReturn struct {
Value int
}
حالا وقتی این متغیر رو ساختیم کلش رو برگردونیم یا فقط پوینترش رو؟
func NewSimpleReturn(n int) *SimpleReturn {
return &SimpleReturn{Value: n}
}
func NewSimpleReturnV(n int) SimpleReturn {
return SimpleReturn{Value: n}
}
- همونطوری که میبینید توی تابع اول ما مقدار پوینترش رو برگردوندیم و در تابع دوم یک کپی از مقادیر متغیر رو.
- NewSimpleReturn: به صورت پوینتر برمیگردونه
- NewSimpleReturnV: به صورت مقدار برمیگردونه
برای تست این موضوع که کدوم یکی از این دو روش سریع تر هستن یک بنچ مارک هم نوشتم:
var vSimpleReturnV SimpleReturn
var vSimpleReturn *SimpleReturn
func BenchmarkNewSimpleReturn(b *testing.B) {
for i := 0; i < b.N; i++ {
vSimpleReturn = NewSimpleReturn(10)
}
}
func BenchmarkNewSimpleReturnV(b *testing.B) {
for i := 0; i < b.N; i++ {
vSimpleReturnV = NewSimpleReturnV(10)
}
}
همونطوری که میبینید بنچمارک اول برای تابع ایی هست که پوینتر برمیگردونه و بنچمارک دومی برای تابعی که مقدار برمیگردونه. اجرا میکنم و با هم ببینیم جواب چطوره:
goos: linux
goarch: amd64
pkg: hello
BenchmarkNewSimpleReturn-8 84650518 14.6 ns/op
BenchmarkNewSimpleReturnV-8 1000000000 0.405 ns/op
PASS
اجرای هر خروجی مقداری نیم نانوثانیه طول کشید درحالی که وقتی پوینتر برمیگردوندیم ۱۵ میلیثانیه! خب این برعکس چیزیه که قبلا فکر میکردیم. یکم بیشتر بررسیش کنیم.
آیا فقط روش خروجی دادن مهمه؟ مقداری که خروجی میدیم چطور؟
میخوایم تست رو تغییر بدیم. جای اینکه یک تایپ یکسان رو به ۲ شکل مختلف خروجی بدیم. دو تا تایپ رو به شکل مقداری خروجی میدیم. با این تفاوت که یکی از این تایپها توی خودش پوینتر داره. ببینیم خروجی چطور میشه:
type SimpleReturn struct {
Value int // using value
}
type SimpleReturnWithPointer struct {
Value *int // using pointer
}
func NewSimpleReturn(n int) SimpleReturnWithPointer {
return SimpleReturnWithPointer{Value: &n}
}
func NewSimpleReturnV(n int) SimpleReturn {
return SimpleReturn{Value: n}
}
سمت کد بنچمارک هم چنین چیزی داریم:
var vSimpleReturnV SimpleReturn // all value
var vSimpleReturn SimpleReturnWithPointer // has pointer inside
func BenchmarkNewSimpleReturn(b *testing.B) {
for i := 0; i < b.N; i++ {
vSimpleReturn = NewSimpleReturn(10)
}
}
func BenchmarkNewSimpleReturnV(b *testing.B) {
for i := 0; i < b.N; i++ {
vSimpleReturnV = NewSimpleReturnV(10)
}
}
اجرا کنیم ببینیم خروجی چطور میشه؟
goos: linux
goarch: amd64
pkg: hello
BenchmarkNewSimpleReturn-8 83301891 15.1 ns/op
BenchmarkNewSimpleReturnV-8 1000000000 0.449 ns/op
PASS
باز هم نتیجه مثل قبل شد. انگار پوینتر موقع کپی شدن داره اذیت میکنه. اما علت چیه؟
چرا کپی کردن پوینتر کند تر از کپی کردن مقدار هست؟
همونطوری که توی ویکی کد ریویو گولنگ میتونیم بخونیم تاکید داره برای ارسال مقادیر کوچیکی که به مقدارشون فقط نیاز داریم از پوینتر استفاده نکنیم.
- توی متغیرهای کوچیک (کمتر از ۳۲کیلوبایت) کپی کردن یک پوینتر تقریبا به اندازه کپی کردن مقدار اون متغیر هزینه داره (توی حافظه کش). پس از این جهت سودی نمیبریم.
- کامپایلر چک هایی رو تولید میکنه که موقع رانتایم زمان dereferencing پوینتر اجرا میشن.
نکتهی دیگه ایی هم که هست پوینتر ها اکثرا توی Heap ذخیره میشن. برای مثال کدی که نوشتیم رو escape analysis کنیم. برای این کار از ابزار های Go استفاده میکنیم ( go build -gcflags="-m" main.go )
type SimpleReturn struct {
Value int
}
func NewSimpleReturn(n int) *SimpleReturn {
return &SimpleReturn{Value: n}
}
func NewSimpleReturnV(n int) SimpleReturn {
return SimpleReturn{Value: n}
}
go build -gcflags="-m" main.go
...
./main.go:44:9: &SimpleReturn literal escapes to heap
...
همونطوری که آنالیز به ما میگه return &SimpleReturn{Value: n} مقدارش در heap ذخیره میشه. اما اگر به صورت مقداری برگردونیم در stack ذخیره میشه. همونطوری که میدونیم ذخیره در stack بسیار بهینه تر هست. Garbage collector میاد heap رو چک میکنه و همونطوری که میدونیم هربار GC درحال بررسی هست به مدت چند میلیثانیه کل سرویس ما فریز میشه. و میتونه مشکل هایی مثل Memory Leak و .. بوجود بیاد. ( در مورد این موضوع خیلی بیشتر میشه توضیح داد. کلا چرا باید سعی کنیم سمت stack کدمون اجرا شه، حقیقتا موضوع جذابیه و در قالب یک پست که چطور مموری لیک رو تشخیص دادیم و رفع کردیم در آینده حتما صحبت میکنیم).
پوینتر نه تنها سود نداشت بلکه کلی ضرر داشت! پس کی پوینتر استفاده کنیم؟
پوینتر رو وقتی استفاده میکنیم که واقعا بهش نیاز داریم. فرض کنید یک فایل ۱۰۰ مگابایتی رو توی یک متغیر لود کردید. منطقیه که برای استفاده کمتر از حافظه بهتره اون رو به صورت پوینتر پاس بدیم تا ازش کپی نسازیم. (طبق نیاز البته). یا برای مثال نیازه توی یک تابع روی متغیر تغییراتی ایجاد کنیم که خارج از تابع هم به اون تغییرات نیازه (کلا اساس پوینتر). اما وقتی پوینتر نیازی از ما رفع نمیکنه و صرفا فقط برای سریع تر شدن یا صرفه جویی حداقلی (چند کیلوبایتی) در حافظه بخوایم از پوینتر استفاده کنیم لزوما کار درستی نیست.
مطلبی دیگر از این انتشارات
جنریک ها در گو و بررسی پرفورمنس آنها با گرفتن بنچ مارک
مطلبی دیگر از این انتشارات
Go Developer Roadmap part 3
مطلبی دیگر از این انتشارات
Go Developer Roadmap part 2