مهندس نرمافزار ساده
آهای گولنگ، مموری اضافهی من کجاست؟
اگر تا حالا با زبان برنامهنویسی گولنگ کد زده باشید حتما از struct ها استفاده کردید. زمانی که یک شئ (Object) از یک struct میسازید با توجه به شرایط و نحوهی ساخت و استفاده، یک مقدار فضای مشخصی از stack یا heap اشغال میکنه.
ولی تا حالا بررسی کردید چقدر فضا اشغال میکنه؟ میشه یه کاری کرد کمتر جا بگیره؟ بریم بررسی کنیم ببینیم چی میشه!
اندازهگیری میزان اشغال حافظه
خب اولین راهی که به ذهن آدم میرسه برای اینکه میزان اشغال حافظه یا همون مموری، توسط یه آبجکت رو اندازه بگیره، اینه که سایز هر کدوم از فیلد هاش رو باهم جمع بکنه. تو لیست زیر اندازه primitive type ها تو گولنگ رو میتونید ببینید.
- uint8, int8: 1 Byte
- uint16, int16: 2 Bytes
- uint32, int32, float32: 4 Bytes
- uint64, int64, float64, complex64: 8 Bytes
- complex128: 16 Bytes
خب با این حساب میتونید اندازه یک آبجکت از struct زیر رو اندازهگیری کنید؟
type Foo struct {
A bool
B int64
C bool
D float64
}
یه جمع و تفریق ساده است دیگه. طبق لیست بالا، ۱ بایت برای هر کدوم از فیلدهای A و C و همچنین ۸ بایت برای هر فیلد B و D.
جمعا میشه ۱۸ بایت.
ولی من یه کوچولو شک دارم! بذارید یه کد بزنیم و ببینیم اندازه واقعی یک آبجکت از این نوع Foo چقدر حافظه اشغال میکنه!
اندازهگیری اندازهی آبجکتها در گولنگ
توی گولنگ با استفاد از پکیج unsafe میشه اندازهی واقعی آبجکتها رو با استفاده از تابع Sizeof فهمید.
قطعه کد زیر یک آبجکت از نوع Foo میسازه و سایزش رو چاپ میکنه!
package main
import (
"fmt"
"unsafe"
)
type Foo struct {
A bool
B int64
C bool
D float64
}
func main() {
f := Foo{}
fmt.Println(unsafe.Sizeof(f)) // prints 32!
}
در کمال تعجب این کد عدد ۳۲ رو چاپ میکنه! ۳۲ کجا ۱۸ کجا! این عدد ۷۷ درصد بزرگتر از چیزیه که فکر میکردیم!
به نظر میاد گولنگ داره مموری میدزده!
دلیلش چی میتونه باشه؟
گولنگ و Padding/Alignment
یکم که درباره نحوهی کار کامپایلر گولنگ بخونیم، میفهمیم مفهومی وجود داره به اسم Padding یا Alignment
کامپایلر گولنگ یه سری ویژگیهایی در این مورد داره. هدف اصلی این ویژگی ها هم استفاده بهتر CPU و سریع تر بودن اجرای برنامه است که دونه دونه بررسیشون میکنیم.
۱. اندازهی هر struct حداقل ۱ بایت خواهد بود.
خب این که تقریبا واضحه. کامپایلر کمتر از ۱ بایت نمیتونه در اختیار بگیره.
۲. اندازهی هر struct مضرب بزرگترین فیلد آن است.
توی مثال خودمون، بزرگترین فیلد B یا D هستن که هر کدوم ۸ بایت حافظه اشغال میکنن. یعنی اندازهی Foo باید مضرب ۸ باشه. پس نتیجه میگیریم اندازهی Foo باید بزرگتر مساوی ۲۴ بایت باشه گرچه ما فقط ۱۸ بایت نیاز داریم!
شاید بگید باشه همون ۲۴ بایت رو بده مشتری شیم! ولی دلایل دیگهای هست که باعث میشه Foo اندازهاش ۳۲ بایت باشه!
۳. آدرس شروع هر فیلد در حافظه، باید مضرب اندازهی خودش باشه!
این یعنی آدرس شروع یک فیلد از نوع int32 که ۴ بایت فضا اشغال میکنه باید مضرب ۴ باشه یا یک فیلد float64 باید توی آدرسی با مضرب ۸ قرار داده بشه.
اینها همه دلایلی دارن که تو این پست بررسی نمیکنیم.
این مورد چطوری باعث افزایش سایز میشه؟ در ادامه متوجه میشیم.
۴. ترتیب قرارگیری فیلدها توی حافظه، دقیقا همون ترتیبی هست که موقع تعریف struct نوشتیم.
الان احتمالا بتونید حدس بزنید چرا سایز Foo شده بود ۳۲ بایت.
در ادامه رفتار کامپایلر رو شرح میدیم.
نحوهی رفتار کامپایلر گولنگ برای ساخت آبجکت
اگر فرض کنیم توی آدرس 0 بخوایم این آبجکت رو بسازیم اینطوری شروع میکنیم:
۱. طبق ویژگی شماره ۴، اول باید فیلد A که از نوع bool هست و ۱ بایت جا میگیره رو رزرو کنیم. از اونجایی که 0 مضرب ۱ هست (ویژگی شمارهی ۳) خونهی 0 رو برای این فیلد در نظر میگیریم.
۲. فیلد بعدی B هست که ۸ بایت جا میگیره. با توجه به ویژگی شمارهی ۳، باید آدرس شروع این فیلد مضرب ۸ باشه. پس محل این فیلد تو حافظه میشه خونهی ۸ تا ۱۵. پس خونهی ۱ تا ۷ چی؟ مجبوریم خالی بذاریمش! کامپایلر این محلهای را اصطلاحا به عنوان padding خالی میذاره. حالا میفهمیم کجا مموری دزدیده میشه!
۳. فیلد C هم که ۱ بایت جا میگیره با توجه به ویژگی ها میذاریمش تو خونهی ۱۶.
۴. برای D هم دقیقا همون اتفاقی که برای B افتاد میفته و مجبوریم بذاریمش خونهی ۲۴ تا ۳۱
توی تصویر زیر شکل آبجکت داخل حافظه رو میتونید ببینید:
حالا چیکار کنیم؟
شاید این سوال به ذهنتون برسه که «چه کاری میشه کرد که برای مصرف کمتر حافظه؟»
از ۴ تا ویژگی کامپایلر گولنگ که بالاتر اشاره کردیم، فقط یک موردش رو میتونیم کنترل کنیم. اونم ترتیب فیلد هاست!
از لحاظ ریاضیاتی ثابت میشه که اگر فیلدها را به ترتیب از بزرگترین به کوچک ترین موقع تعریف struct بنویسیم، کمترین padding ممکن توسط کامپایلر قرار داده میشه.
با این حال با اجرای کد زیر که نکتهی بالا رو رعایت کردیم عدد ۲۴ چاپ میشه.
package main
import (
"fmt"
"unsafe"
)
type Foo struct {
//Changed the order of fields
B int64
D float64
A bool
C bool
}
func main() {
f := Foo{}
fmt.Println(unsafe.Sizeof(f)) // prints 24
}
تصویر زیر هم نحوهی قرارگیری آبجکت جدید در حافظه رو نشون میده:
ابزارهای کمکی
لینترهایی وجود دارن که struct های قابل تغییر رو تشخیص میدن و به شما اطلاع میدن که بهبودشون بدید. لینک دو تا لینتر رو برای این مورد قرار میدم.
- https://gitlab.com/opennota/check
- https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment
نظر شخصی
شخصا معتقدم که همیشه نیاز نیست این کار رو انجام بدیم و حواسمون به ترتیب فیلدهای struct هامون باشه. از نظر من خوانایی کد در وهله اول خیلی مهم تره.
در شرایط مختلف ممکنه ترتیب خاصی از فیلدها خوانایی بیشتری داشته باشه.
تنهای زمانی میارزه فیلدها را بر اساس اندازه مرتب کنیم که مموری خیلی خیلی برامون مهم باشه.
منابع
- https://golang.org/ref/spec#Size_and_alignment_guarantees
- https://go101.org/article/memory-layout.html
- https://dave.cheney.net/2015/10/09/padding-is-hard
مطلبی دیگر از این انتشارات
ساخت پروژه با معماری مایکروسرویس، زبان گولنگ، اندپوینت رست، کوبرنتیز و... (قسمت اول)
مطلبی دیگر از این انتشارات
کتاب فارسی Go Succinctly
مطلبی دیگر از این انتشارات
ساخت پروژه با معماری مایکروسرویس، زبان گولنگ، اندپوینت رست، کوبرنتیز و... (قسمت دوم)