مدیریت حافظه در Rust
برنامهها در زمان اجرا به حافظه (memory) نیاز دارند. مدیریت حافظه (memory managemen) یعنی اینکه
برنامه حافظهی مورد نیازش را از سیستمعامل درخواست کند و حافظهای را که نیاز ندارد، آزاد کند و به سیستمعامل
برگرداند.
البته در واقع، مدیریت حافظه فقط به آزادسازی حافظه مربوط میشود و دریافت حافظه اتفاقی است که به صورت خودکار
رخ میدهد.
زبانهای برنامهنویسی به یکی از دو شیوهی زیر، کار مدیریت حافظه را انجام میدهند.
-
مدیریت دستی حافظه یا Manual Memory Management در زبانهای سیستمی و سطح پایینی مانند C++
رخ میدهد. در این روش، آزادسازی حافظهی بلااستفاده بر عهدهی خود برنامهنویس است.
-
مدیریت خودکار حافظه یا Automatic Memory Management در زبانهای سطح بالاتری مانند پایتون و جاوااسکریپت و
حتی
زبانهایی مانند جاوا و C# (که نسبت به پایتون و جاوااسکریپت، سطح پایینتری دارند) رخ میدهد.
در این روش، آزادسازی حافظهی بلااستفاده بدون نیاز به دخالت برنامهنویس و با تکیه بر ابزاری به نام
Garbage Collector یا GC انجام میشود.
هر کدام از این دو روش مدیریت حافظه، مزایا و معایبی دارند و هر زبان با توجه به فلسفهی وجودی خود، یکی
از این دو روش را برگزیده است. برای مثال، زبانی مثل C++ که به واسطهی ماهیت سیستمی و سطح پایین
خود،
بیش از هر چیز روی کارایی (performance) متمرکز است، روش مدیریت دستی را انتخاب کرده تا سربار ناشی از اجرای
مداوم
GC را به برنامه تحمیل نکند. اما در عوض، برنامهنویس باید خودش مراقب وضعیت حافظه باشد و هرجا که حافظهای
بلااستفاده شد، صراحتاً آن را آزاد کند.
این کار اولاً برنامهنویس را که علیالقاعده باید روی منطق برنامه متمرکز باشد، درگیر این جزئیات سطح پایین
میکند و ثانیاً
امکان بروز انواع مختلفی از خطاها را دارد. اگر بخشی از حافظه دیرتر از زمانی که باید، آزاد شود، تلف شده است.
اگر زودتر آزاد شود، در برنامه متغیر نامعتبر خواهیم داشت و اگر سعی کنیم اشتباهاً حافظهای را که قبلاً آزاد
شده، دوباره آزاد کنیم، با
خطایی موسوم به double free مواجه میشویم که حتی میتواند تبعات امنیتی داشته باشد.
از طرف دیگر، زبانهای سطح بالاتری که به ارگونومی و راحتیِ استفاده اولویت میدهند، روش
اتوماتیک را برمیگزینند تا به برنامهنویس امکان بدهند به منطق برنامهاش بپردازد. اما در عوض، یک
ابزاری به نام GC به صورت دورهای اجرا میشود و وضعیت حافظه را بررسی میکند و هر وقت لازم باشد، اقدام
به آزادسازی بخشی از حافظه میکند. اجرای GC و بررسیهای زمان اجرایی که انجام میدهد، روی کارایی
و سرعت اجرای برنامهها تأثیز منفی دارد.
پس، سازندگان زبانها اینجا ناچاراً باید تن به یک Trade-off یا مصالحه بدهند؛ یعنی امتیازی بدهند و در عوض،
امتیازی بگیرند. اما Rust یک روش سومی برای مدیریت حافظه ارائه داده که نیاز به این Trade-off کلاسیک را از بین
میبرد.
روشی که مدیریت حافظه را به صورت خودکار اما بدون نیاز به ابزاری مثل GC انجام میدهد.
این یک حرکت انقلابی و یک پیشرفت بزرگ محسوب میشود نه یک بهبود ساده؛ چون اینجا یک Trade-off حذف شده است.
مکانیزمی که Rust برای مدیریت حافظه به کار میگیرد، سیستم مالکیت (ownership system) است که شامل
چند قاعدهی ساده است که کامپایلر آنها را در زمان کامپایل چک میکند و اگر نقض شده باشند، اجازهی
کامپایل برنامه را نمیدهد.
نقش Stack و Heap
stack و heap بخشهایی از حافظه هستند که در دسترس کدها قرار دارند اما از نظر ساختاری با
هم فرق دارند و هر کدام، دیتای مشخصی را نگه میدارند. stack دارای یک ساختار LIFO یا last-in-first-out
است؛ یعنی در این ساختار، آخرین ورودی، اولین خروجی است. چند بشقاب که روی هم قرار دارند، یک
چنین ساختاری دارند. وقتی قرار باشد بشقابی اضافه شود، همیشه روی بشقابها قرار میگیرد و وقتی بخواهیم بشقابی
را از
این مجموعه حذف کنیم، بالاترین بشقاب برداشته میشود. دادههایی که سایز مشخص و ثابتی دارند، در stack ذخیره
میشوند.
اما دادههایی که سایزشان در زمان کامپایل مشخص نیست یا امکان تغییرش وجود دارد، در heap ذخیره میشوند.
در heap اوضاع به خوبی stack نیست و در واقع، heap ساختار پیجیدهتری دارد.
برای ذخیرهی دیتا در heap ابتدا باید حافظهی مورد نیاز از سیستمعامل درخواست شود. سپس، کامپوننتی از
سیستمعامل به نام Memory allocator بخشی از heap را که فضای کافی داشته باشد، پیدا کرده، آن را علامت میزند
و
یک
اشارهگر (pointer) برمیگرداند که به بخش علامت گذاریشده اشاره میکند؛ یعنی حاوی آدرس این بخش از حافظه
است.
این کار Allocating نامیده میشود.
خودِ اشارهگر، چون دارای سایز ثابت و مشخصی است، در stack ذخیره میشود اما دیتای واقعی در heap است و
برای دسترسی به آن باید اشارهگر را دنبال کرد.
-
ذخیرهی داده در stack سریعتر انجام میشود. چون مکانی که داده باید در آنجا قرار گیرد، همیشه مشخص است.
اما در مورد heap اول باید جستجویی برای پیدا کردن فضا انجام شود و سپس، کارهایی هم در راستای آمادهسازی
برای
تخصیصهای بعدی صورت گیرد.
-
دسترسی به داده هم در stack سریعتر است. چون دادههای stack کنار هم هستند و پردازندههای امروزی طوری
طراحی
شدهاند که
دادههایی را که نزدیک هم هستند و به تعداد پرش (jump) کمتری نیاز دارند، سریعتر پردازش میکنند.
سیستم مالکیت، نیاز به ردیابی اینکه چه بخشهایی از کدها از چه بخشهایی از heap استفاده میکنند
را از بین میبرد، دادههای بلااستفاده را به صورت خودکار حذف میکند و داده های تکراری در heap
را به حداقل میرساند.
وقتی تابعی را فراخوانی کنیم، پارامترها و متغیرهای لوکال تابع در stack ذخیره میشوند.
با به پایان رسیدن تابع، این مقادیر هم از stack حذف میشوند.
سیستم مالکیت
گفتیم که سیستم مالکیت یا Ownership system در Rust از چند قانون تشکیل میشود که
کامپایلر، بررسیهایی را بر اساس آنها انجام میدهد تا امنیت حافظه را تضمین کند. این قوانین از این قرارند:
-
هر مقدار (value) در Rust دارای یک مالک (owner) است.
-
هر مقدار در هر لحظه فقط یک مالک دارد.
-
وقتی مالک یک مقدار از scope یا محدودهاش خارج شد، آن مقدار drop میشود؛ یعنی حافظهاش آزاد میشود.
میبینید که قوانین مالکیت لااقل صورت سادهای دارند. اما حقیقت ماجرا این است که با توجه به جدید
بودن این مفهوم، درک کامل مقهوم مالکیت و عادت کردن به قوانین آن به کمی زمان نیاز دارد.
فعلاً یک نگاه دیگر به این قوانین بیندازید و در مطالبی که در ادامه، بیان میشود، آنها را مد نظر داشته باشید.
قبل از اینکه به جزئیات مربوط به سیستم و قوانین مالکیت بپردازیم، باید با یک نوع جدید
با نام String آشنا شویم. مقادیر نوعهایی که در درس
نوعهای داده در Rust معرفی کردیم، همگی
در stack ذخیره میشوند؛ چون سایز مشخص و ثابتی دارند. اما مالکیت، و به طور کلی، مدیریت حافظه
به heap مربوط است. پس، برای اینکه بتوانیم سیستم مالکیت را در عمل ببینیم، به یک نوع
نیاز داریم که مقادیرش در heap ذخیره شوند و نوع String را به همین دلیل معرفی میکنیم.
نوع String در Rust
در Rust، رشتههای لیترال، یعنی رشتههای متنی که به صورت مستقیم در برنامه وارد میشوند، دارای نوعی به
نام &str هستند. اگر قبلاً پلاگین rust-analyzer را نصب نکردهاید، الان این کار
را
انجام دهید و سپس، پروژهای با نام ownership ایجاد کنید و کد زیر را در قایل
main.rs وارد کنید.
src/main.rs
fn main() {
let s = "Hi";
println!("{}", std::any::type_name_of_val(s));
}
پلاگین rust-analyzer گزارهی اول را به صورت let s: &str = "Hi"
نمایش میدهد که نشان میدهد نوع استنتاجشده برای متغیر s نوع &str است.
در گزارهی دوم هم نوع متغیر s را با استفاده از تابعی به نام type_name_of_val() که در ماژولی به نام any در
کتابخانهی استاندارد std قرار دارد، در خروجی چاپ کردهایم.
اگر برنامه را اجرا کنید، خواهید دید که عبارت str در ترمینال نمایش داده میشود.
علت اینکه پلاگین rust-analyzer نوع متغیر s در کد بالا را &str گزارش
میکند اما تابع
type_name_of_val() این نوع را str مینامد، این است که کاراکتر & جزئی از نام نوع نیست.
در واقع، عبارت &str به معنای یک Reference به نوع str است.
برای آشنایی با مفهوم Reference و درک کامل این موضوع،
باید تا پایان این فصل صبر کنید.
لیترالهای رشتهای، تغییرناپذیرند و سایز مشخص و ثابتی دارند و بنابراین، در stack ذخیره میشوند.
این امر از طرفی باعث میشود که سرعت کار با این مقادیر بالا باشد اما از طرف دیگر، آنها را در مواردی که مقدار
رشته در زمان نوشتن کدها معلوم نباشد، غیر قابل استفاده میکند. برای مثال، اگر بخواهیم ورودی را از کاربر
دریافت کنیم و آن را ذخیره کنیم، رشتههای لیترال به کار نمیآیند.
Rust برای سناریوهایی که به متن تغییرپذیر و دینامیک نیاز باشد، یک نوع دیگر با نام String
را ارائه داده است.
مقادیر String تغییرپذیرند و در heap ذخیره میشوند و بنابراین، برای مواردی که به کار با
متن به صورت
دینامیک نیاز داریم، باید از این نوع استفاده کنیم. یک String را میتوانیم مانند زیر، از
روی
یک لیترال رشتهای ایجاد کنیم.
src/main.rs
fn main() {
let s = String::from("Hi");
}
نوع String تغییرپذیر است و بنابراین، امکان ویرایش درجای آن (بدون نیاز به تخصیص مجدد به
یک متغیر)
وجود دارد. در کد زیر، از متدی به نام push_str() برای ویرایش درجای یک مقدار String استفاده شده است.
src/main.rs
fn main() {
let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}");
}
تفاوت بین نوعهای str و String یعنی تغییرناپذیری str و تغییرپذیری String از
رفتار آنها با حافظه، ناشی میشود.
تخصیص و آزادسازی حافظه
حالا که با نوع String آشنا شدیم، میتوانیم فرایند تخصیص و آزادسازی حافظه را بررسی کنیم.
در مورد لیترالهای رشتهای، محتوای متنی از قبل مشخص است و بنابراین متن به صورت مستقیم و
اصطلاحاً به شکل hardcoded در فایل اجرایی نهایی گنجانده میشود. مقدار حافظهی مورد نیاز هم از قبل مشخص است و
برای متن، رزرو میشود.
در واقع، لیترالهای رشتهای شرایط لازم برای ذخیره در stack را دارند و از این روست که کار با آنها سریعتر
است.
اما مقادیر String، به خاطر ماهیت تغییرپذیر و دینامیکشان، در heap
ذخیره میشوند. بنابراین:
-
حافظهی مورد نیاز، باید در زمان اجرا از Memory allocator درخواست شود.
-
وقتی دیگر نیازی به یک مقدار String نداشته باشیم، باید حافظهای که اشغال کرده، آزاد شود
و به سیستمعامل برگردانده شود.
بخش اول، یعنی در خواست حافظه، به صورت خودکار انجام میشود. در واقع، پیادهسازی داخلی
تابع String::from() به گونهای است که با درخواست حافظه از allocator همراه است. این
روالی است که در همهی زبانهای برنامهنویسی، یکسان است.
اما بخش دوم، یعنی آزادسازی حافظه، در برخی زبانها به صورت خودکار و با تکیه بر ابزاری به نام GC انجام میشود
و در برخی
زبانها هم باید توسط خود برنامهنویس انجام شود. اما Rust با تکیه بر مفهوم مالکیت،
مکانیزمی را تدارک دیده که آزادسازی حافظه به صورت خودکار اما بدون نیاز به GC انجام شود.
این یعنی ما میتوانیم کارایی زبانهای سطح پایین و ارگونومی زبانهای سطح بالا را با هم داشته باشیم و این یک
اتفاق واقعاً ویژه است.
البته همچنان بهایی هست که باید پرداخته شود و آن درک ساز و کار سیستم مالکیت است که مفهوم جدیدی است و
درک کامل آن به کمی زمان نیاز دارد.
چکیدهی قوانین مالکیت این است که هر مقدار در هر لحظه دارای یک و فقط یک مالک است و وقتی
scope این مالک، یعنی محدودهای که در آن معتبر است، به پایان برسد، حافظهای که آن مقدار (value) اشغال کرده،
آزاد میشود. ظاهراً که همهچیز خیلی واضح و ساده است اما باید بدانید که سیستم مالکیت، تأثیر زیادی روی نحوهی
کدنویسی در Rust دارد. برای اینکه درک خوبی از این موضوع پیدا کنید، در ادامه، نقش و تأثیر مالکیت
را در دو مورد پرکاربرد، بررسی میکنیم. یکی در مورد تخصیص مقادیر یکسان به متغیرها و دیگری
در مورد پارامترهای توابع.
تخصیص مقادیر یکسان به متغیرها
وقتی یک متغیر را به متغیری دیگر تخصیص دهیم، چه اتفاقی میافتد؟
در مورد دادههای stack یک کپی انجام میشود؛ یعنی مقدار متغیر سمت راست در متغیر سمت چپ
کپی میشود. کد زیر را ببینید.
src/main.rs
fn main() {
let x = 5;
let y = x;
}
در اینجا مقدار متغیر x یعنی 5 در متغیر y کپی میشود و بنابراین، هر دو متغیر
دارای مقدار یکسان 5 حواهند بود. کپی در stack یک فرایند سرراست و ساده است و هیچ جای ابهامی ندارد.
الان دو متغیر x و y دارای مقدار یکسانی هستند اما از هم مستقل هستند. یعنی اگر مثلاً هر دو متغیر
را با استفاده از کلمه کلیدی mut تعریف کنیم و بعد از تخصیص، مقدار یکی را تغییر
دهیم،
این تغییر هیچ ربطی به متغیر دیگر نخواهد داشت.
اما در مورد دادههای heap داستان کمی پیچیدهتر است. به کد زیر نگاه کنید.
src/main.rs
fn main() {
let s1 = String::from("Hello");
let s2 = s1;
}
مقادیر String یک بخش اشارهگر یا pointer دارند که در stack ذخیره میشود و یک بخش شامل
دیتای واقعی
که در heap ذخیره میشود. وقتی متغیری که مقداری از نوع String را نگه داشته به یک متغیر
دیگر تخصیص داده میشود،
چند اتفاق میتواند رخ دهد:
-
کپی ضعیف یا shallow copy: این اتفاقی است که در اکثر زبانهای دیگر رخ میدهد. به این ترتیب که
فقط بخش اشارهگر کپی میشود و نتیجتاً ما دو متغیر خواهیم داشت که به مکان یکسانی از حافظه
اشاره میکنند. در این وضع، اگر مقدار یکی از متغیرها تغییر کند، دیگری هم تغییر خواهد کرد.
این اتفاق در Rust رخ نمیدهد؛ چون ناقض قانون دوم مالکیت است که میگوید یک مقدار نمیتواند
در هر لحظه دو مالک داشته باشد.
-
کپی کامل (deep copy): به این معناست که هم اشارهگر و هم دیتا کپی شوند.
کپی مقادیر در heap با هزینههای بالایی در ارتباط با کارایی (performance) همراه است و به
کندی صورت میگیرد. بنابراین، در Rust هم مثل اکثر زبانهای دیگر، یک کپی کامل
به درخواست صریح نیاز دارد و به طور پیشفرض رخ نمیدهد. در مثال بالا، اگر گزارهی دوم را به صورت
let s2 = s1.clone() بنویسیم، یک کپی کامل رخ میدهد. یعنی ما دو متغیر خواهیم داشت
که اگرچه مقدار یکسانی دارند، اما مستقل از هم هستند و تغییر یکی باعث تغییر دیگری نمیشود.
-
انتقال (move): این اتفاقی است که در حالت پیش فرض، در Rust رخ میدهد.
متغیر s1 مالک یک مقدار String است اما با تخصیص s1 به s2، مالکیت
این مقدار به s2 منتقل میشود.
از این نقطه به بعد، متغیر s1 در برنامه معتبر نیست و اگر سعی کنیم از آن استفاده کنیم،
با خطای کامپایلر مواجه میشویم.
یک گزاره به کد بالا اضافه کنید که از متغیر s1 استفاده میکند.
src/main.rs
fn main() {
let s1 = String::from("Hello");
let s2 = s1;
let s3 = s1;
}
با این کار، خطایی گزارش میشود که به خاطر استفاده از متغیر نامعتبر s1 رخ داده است.
در پیغام خطا، کامپایلر پیشنهاد داده که در صورتی که هزینهی افت کارایی برایمان قابل تحمل است، میتوانیم
از متد clone() برای انجام یک کپی کامل استفاده کنیم.
src/main.rs
fn main() {
let s1 = String::from("Hello");
let s2 = s1.clone();
let s3 = s1;
}
مالکیت و پارامترهای توابع
پاس کردن یک مقدار به یک تابع، مکانیزمی مشابه تخصیص یک مقدار به یک متغیر دارد.
یعنی پاس کردن مقدار به تابع، میتواند یک عمل move یا یک عمل copy باشد.
به مثال زیر دفت کنید.
src/main.rs
fn main() {
let s = String::from("hello");
takes_ownership(s);
let x = 5;
makes_copy(x);
}
fn takes_ownership(some_string: String) {
println!("{some_string}");
}
fn makes_copy(some_integer: i32) {
println!("{some_integer}");
}
در اینجا دو تابع تعریف شده که کار یکسانی را انجام می دهند؛ یعنی آرگومان خود را در
خروجی چاپ میکنند اما نوع آرگومانها با هم فرق دارد. تابع takes_ownership() یک پارامتر از نوع
String دارد. مقداری که به این تابع پاس میشود، به تابع move میشود. بنابراین، در کد بالا، اگر بعد از
فراخوانی این تابع، سعی کنیم از متغیر s استفاده کنیم، با خطا مواجه خواهیم شد.
چون مالکیت مقدار رشتهای hello به تابع منتقل میشود.
اما پارامتر تابع makes_copy() از نوع i32 است که در stack ذخیره میشود.
بنابراین، پاس کردن مقدار به این تابع یک عمل کپی است و اگر بعد از فراخوانی این تابع در کد بالا،
بخواهیم از متغیر x استفاده کنیم، مشکلی به وجود نخواهد آمد.
در کد بالا، اگر بخواهیم متغیر s بعد از پاس شدن به تابع takes_ownership() همچنان در دسترس
برنامه باشد، میتوانیم این متغیر را به فرم s.clone() به تابع پاس کنیم. اما همانطور که گفتیم،
این کار با تحمیل هزینههای کارایی همراه است. باید راهی برای تخصیص یا پاس کردن مقادیر، بدون
انتقال مالکیت آنها و تحمل هزینهی کارایی، وجود داشته باشد. رفرنسها این امکان را برای ما فراهم میکنند. در درس بعد به
رفرنسها و کاربرد آنها میپردازیم.