در دنیای برنامه نویسی، به ویژه در Go (همچنین به عنوان Golang شناخته می شود)، دو روش اصلی برای تخصیص (allocation) یک اسلایس جدید وجود دارد: استفاده از تابع make
برای پیش تخصیص slice و تنظیم هر مقدار، یا ایجاد یک slice خالی و اضافه کردن عناصر جدید به آن. در حالی که هر دو رویکرد نتیجه یکسانی را ایجاد می کنند، عملکرد آنها به دلیل عملیات زیربنایی در این توابع به طور قابل توجهی متفاوت است. در این مقاله، عملکرد تخصیص پویا و استاتیک در Go را تست میکنیم و بررسی میکنیم که کدام روش و چرا کارآمدتر است.
قبل از اینکه وارد کد شویم، اجازه دهید این دو اصطلاح را تعریف کنیم.
static allocation در Go چیست؟
تخصیص حافظه استاتیک (static allocation) زمانی انجام می شود که کامپایلر برنامه شما را کامپایل کرده و یک فایل آبجکت تولید کند. پیوند دهنده همه این فایل های آبجکت را ادغام می کند تا یک فایل اجرایی واحد ایجاد کند. لودر این فایل اجرایی واحد را در حافظه اصلی بارگیری و اجرا می کند. تخصیص حافظه استاتیک نیاز به دانستن اندازه داده های مورد نیاز قبل از شروع اجرا دارد.
مثالی از تخصیص حافظه static
اعلان آرایه: نمونه ای از تخصیص حافظه استاتیک است. اندازه آرایه باید از قبل مشخص باشد. پس از تخصیص حافظه، اندازه آرایه ها قابل تغییر نیستند.
سیستم های real-time: سیستم های real-time اغلب به رفتار قطعی و استفاده از حافظه قابل پیش بینی نیاز دارند. تخصیص حافظه استاتیک امکان تخصیص حافظه اولیه را فراهم می کند و خطر تاخیر یا وقفه غیرمنتظره در طول زمان اجرا را کاهش می دهد.
برنامه های کاربردی حیاتی: تخصیص حافظه استاتیک می تواند بهتر از تخصیص دینامیک عمل کند. با تخصیص ایستا، آدرسهای حافظه در زمان کامپایل شناخته میشوند که دسترسی کارآمد به حافظه را ممکن میسازد و سربار زمان اجرا را کاهش میدهد.
درایورهای دستگاه: درایورهای دستگاه با دستگاه های سخت افزاری تعامل دارند و اغلب از تخصیص حافظه ثابت استفاده می کنند. این به آنها اجازه می دهد تا بافرهای حافظه را برای تبادل داده با دستگاه ها به طور موثر مدیریت کنند و دسترسی قابل اعتماد و قابل پیش بینی به حافظه را تضمین می کند.
استفاده از تخصیص حافظه استاتیک
آرایه-آرایه مجموعه ای از داده های مشابه است که در مکان های پیوسته ذخیره می شود. این ساده ترین ساختار داده امکان دسترسی مستقیم به هر مورد داده را تنها با استفاده از شماره فهرست فراهم می کند.
Dynamic Allocation در Go چیست؟
تخصیص حافظه پویا (Dynamic Allocation) در طول اجرای برنامه انجام می شود. زمانی که برای اولین بار در طول اجرای برنامه از آن استفاده می شود، حافظه به نمونه ای از یک برنامه اختصاص می یابد. از آنجایی که اندازه واقعی داده های مورد نیاز در زمان اجرا مشخص است، برنامه مقدار صحیح حافظه را تخصیص می دهد و باعث کاهش اتلاف حافظه می شود.
تخصیص حافظه دینامیک انعطاف پذیری را برای اجرای برنامه فراهم می کند. زیرا می تواند تصمیم بگیرد که چه مقدار فضای حافظه مورد نیاز برنامه باشد. اگر برنامه به اندازه کافی بزرگ باشد، یک تخصیص حافظه دینامیک در قسمت های مختلف برنامه انجام می شود که در حال حاضر مورد استفاده قرار می گیرد. این امر باعث کاهش اتلاف حافظه و بهبود عملکرد سیستم می شود.
مثالی از تخصیص حافظه دینامیک
برای تخصیص حافظه در زمان اجرا به صورت پویا:
()malloc
- یک بلوک از حافظه را در بایت در زمان اجرا اختصاص می دهد.()calloc
- تخصیص بلوک های بدون توقف حافظه در زمان اجرا.
کاربرد تخصیص حافظه دینامیک
این تخصیص حافظه پویا معمولاً برای :
لیست های لینک شده: این ساختار داده مجموعه ای از گره های متصل خطی است. هر گره حداقل دو مقدار دارد. داده های گره (محتوا) و گره بعدی/مرجع (اشاره به گره در حال "لینک").
هیپ: هیپ نوع خاصی از درخت است که ویژگی های هیپ را برآورده می کند. رابطه بین گره والد P و گره فرزند C در سراسر درخت ثابت است.
صف: یک صف به عنوان یک ساختار داده خطی با انتهای باز تعریف می شود و عملیات به ترتیب اول در اول (FIFO) انجام می شود.
استک: استک یک ساختار داده محدود شده است. عناصر را فقط می توان از بالای استک اضافه و حذف کرد. Push یک عنصر را به بالای استک اضافه می کند. Pop یک عنصر را از بالا حذف می کند. یک قیاس مفید استک از کتاب است. همچنین میتوانید با حذف کتاب برتر، کتابهای جدیدی اضافه کنید.
اکنون می توانیم به کد نگاه کنیم:
// Number of elements to create
n := 1000
// Case 1: append approach (dynamic)
var arr1 []int
for i := 0; i < n; i++ {
arr1 = append(arr1, i)
}
// Case 2: make and set approach (static)
arr2 := make([]int, n)
for i := 0; i < n; i++ {
arr2[i] = i
}
به نظر شما کدام یک از این دو رویکرد سریعتر است؟
اگر آنچه قبلاً گفته شد را در نظر بگیرید، باید یکی از تفاوت های اصلی آنها را قبلاً پیدا کرده باشید. در هر صورت، نگران نباشید، خواهیم دید که چگونه آن را تست کنیم تا پاسخ عملی تری دریافت کنیم.
تست های Benchmark برای Static و Dynamic Allocation
هیچ راه بهتری برای درک اینکه کدام کد سریعتر از تست معیار است وجود ندارد و Go همه چیزهایی را که برای اجرای این نوع تست نیاز دارید از پیش ساخته است.
ابتدا باید یک ماژول Go خالی جدید برای اجرای تست ها ایجاد کنیم:
mkdir example
go mod init example
touch main_test.go
داخل main_test.go
کد زیر را بنویسید:
package main_test
import "testing"
func BenchmarkAppend(b *testing.B) {
for n := 0; n < b.N; n++ {
var arr []int
for i := 0; i < 1000; i++ {
arr = append(arr, i)
}
}
}
func BenchmarkMakeAndSet(b *testing.B) {
for n := 0; n < b.N; n++ {
arr := make([]int, 1000)
for i := 0; i < 1000; i++ {
arr[i] = i
}
}
}
برای اجرای تست های قبلی کافی است این دستور را اجرا کنید:
go test -bench=. -benchtime=100000x
پارامتر benchtime
به ما این امکان را می دهد که یک مقدار سفارشی برای b.N
تعیین کنیم تا مشخص کنیم چند بار می خواهیم کد را تست کنیم.
برای دیدن نتایج واقعی، باید عدد بالایی را انتخاب کنیم، در غیر این صورت ممکن است تفاوت بین این دو تست برای دیدن خیلی کم باشد.
نتایج نهایی
هنگامی که تست های قبلی را اجرا می کنیم، باید چیزی مشابه به دست آورید:
goos: linux
goarch: amd64
pkg: example
cpu: Intel(R) Core(TM) i7-1065G7 CPU @ 1.30GHz
BenchmarkAppend-8 100000 3637 ns/op
BenchmarkMakeAndSet-8 100000 298.5 ns/op
PASS
ok example 0.396s
در این نتایج، میتوانیم ببینیم که هر رویکرد برای هر عملیات چند ثانیه نیاز دارد. در مورد ما، این دو قطعه نتایج یکسانی را به دو روش متفاوت تولید می کنند، بنابراین مقدار بالاتر به معنای کد کندتر است.
در سیستم مدنظر، رویکرد استاتیک ~ 18 برابر سریعتر از رویکرد پویا است!
این تفاوت بر اساس سخت افزار می تواند تغییرات زیادی داشته باشد، اما با این حال، رویکرد استاتیک باید سریعتر از روش پویا باشد. در بخش بعدی، بهتر متوجه خواهیم شد که چرا این اتفاق می افتد.
تجزیه و تحلیل نتایج
با رویکرد پویا، هر بار که append
را فراخوانی میکنید، Go بررسی میکند که آیا ناحیه حافظه فعلی میتواند عنصر جدید را نگه دارد یا خیر، و اگر نه، slice ها را مجدداً برای ذخیره حداقل عناصر فعلی به اضافه عنصر جدید تخصیص میدهد.
یک فرآیند جابجایی شامل چندین دسترسی به حافظه است و ممکن است زمان زیادی طول بکشد: باید ناحیه حافظه جدید را پیدا کنید، مقادیر قدیمی را کپی کنید و سپس هر مرجعی را به آنها به روز کنید. این فرآیند را می توان چندین بار تکرار کرد که منجر به سربار زیادی می شود.
از طرف دیگر، اگر یک ناحیه حافظه را به صورت ایستا با make
اختصاص دهیم، نیازی به جابجایی بیشتر نیست و با رعایت طول می توانیم بدون مشکل به هر نقطه از slice دسترسی داشته باشیم.
به همین دلیل، رویکرد استاتیک از نقطه نظر عملکرد بهتر است، زیرا به تعداد عملیات کمتری برای انجام همان عمل و استرس کمتری روی حافظه نیاز دارد.
از توضیحات رسمی تابع append
:
// The append built-in function appends elements to the end of a slice. If
// it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
// Append returns the updated slice. It is therefore necessary to store the
// result of append, often in the variable holding the slice itself:
//
// slice = append(slice, elem1, elem2)
// slice = append(slice, anotherSlice...)
//
// As a special case, it is legal to append a string to a byte slice, like this:
//
// slice = append([]byte("hello "), "world"...)
func append(slice []Type, elems ...Type) []Type
بنابراین چه زمانی باید از رویکرد پویا یا استاتیک استفاده کنم؟
به طور کلی، زمانی که تعداد عناصر را از قبل نمیدانید، یا زمانی که ممکن است اغلب تغییر کند، از یک رویکرد پویا استفاده کنید.
به یاد داشته باشید که شما همچنین می توانید به صورت ایستا یک slice جدید را با یک بعد معین اختصاص دهید و سپس در صورت نیاز با استفاده از هر دو متد، عناصر جدیدی را اضافه کنید.
همچنین، اگر میخواهید چندین عنصر را بهصورت انبوه در یک slice اختصاصیافته ذخیره کنید، یک اسلایس جدید و بزرگتر ایجاد کنید که به اندازه کافی بزرگ باشد تا عناصر جدید و قدیمی را ذخیره کند و با استفاده از قسمت داخلی، قطعه قدیمی را به جدید کپی کنید.
تابع copy
:
// The copy built-in function copies elements from a source slice into a
// destination slice. (As a special case, it also will copy bytes from a
// string to a slice of bytes.) The source and destination may overlap. Copy
// returns the number of elements copied, which will be the minimum of
// len(src) and len(dst).
func copy(dst, src []Type) int
ایده این است که وقتی میدانید چند آیتم باید اضافه کنید، از چندین بار فراخوانی append
اجتناب کنید و به طور مستقیم یک ناحیه بزرگ جدید از حافظه را اختصاص دهید که منجر به عملکرد بهتر میشود.
small := make([]int, 1000)
// ...
bigger := make([]int, len(small)+5000)
copy(bigger, small)
البته، مسائل مربوط به عملکرد زمانی به وجود می آیند که باید با تعداد زیادی از عناصر سر و کار داشته باشید. برای 100 یا 1000 عنصر، ممکن است تفاوت آنقدر کم باشد که شما باید هر چیزی را که کد را پاکتر و استفاده آسانتر میکند ترجیح دهید.
نکات کلیدی در مورد تخصیص حافظه پویا و تخصیص حافظه استاتیک
تخصیص حافظه ایستا کارآمد است زیرا حافظه را قبل از شروع اجرا به فرآیند اختصاص می دهد. بنابراین، برنامه سریعتر اجرا می شود زیرا در حین اجرای برنامه، عملیات تخصیص حافظه سربار وجود ندارد. از سوی دیگر، طرح تخصیص حافظه پویا حافظه را در حین اجرا به فرآیند تخصیص می دهد.
با تخصیص حافظه استاتیک، اندازه حافظه مورد نیاز باید قبل از اجرای برنامه مشخص باشد. با تخصیص حافظه پویا، شما نمی دانید برنامه شما به چه مقدار نیاز دارد، بنابراین مقدار حافظه مورد انتظار به فرآیند شما اختصاص می یابد. این منجر به تلف شدن حافظه می شود.
تخصیص حافظه پویا در زمان اجرای برنامه انجام می شود. بنابراین از هدر رفتن حافظه خودداری کنید و مقدار مناسب حافظه را به برنامه خود اختصاص دهید.
تخصیص حافظه پویا دارای سربار عملیات تخصیص حافظه در طول اجرای برنامه است که اجرای برنامه را کند می کند.
تخصیص حافظه پویا انعطاف پذیری را فراهم می کند زیرا اگر برنامه به اندازه کافی بزرگ باشد، عملیات تخصیص حافظه در قسمت های مختلف برنامه انجام می شود و باعث کاهش اتلاف حافظه می شود.
نتیجه
مدیریت حافظه کامپیوتر می تواند مشکل باشد زیرا عوامل زیادی باید هماهنگ عمل کنند. مدیریت حافظه برای اجرای برنامهها، بهینهسازی عملکرد سیستم، و محدود کردن دسترسی به وبسایتهای پورنوگرافی، از جمله موارد دیگر، ضروری است. خوشبختانه، اکثر کاربران کامپیوتر دانش کمی در این زمینه دارند، زیرا آنها نحوه کار کامپیوترهای خود را بر اساس سال ها توسعه توسط متخصصان در زمینه محاسبات می پذیرند.