عنوان:

‫بهینه‌سازی مصرف حافظه با ویژگی‌های مدرن دات‌نت


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۲/۱۴ ۰۷:۵۳
آدرس: www.dntips.ir

مقدمه

مدیریت بهینه حافظه یکی از جنبه‌های حیاتی در توسعه نرم‌افزارهای کارآمد و با عملکرد بالا است. در اکوسیستم دات‌نت، با ظهور ویژگی‌های زبانی و کتابخانه‌ای جدید، فرصت‌های بیشتری برای کنترل دقیق‌تر مصرف حافظه در اختیار توسعه‌دهندگان قرار گرفته است. این مقاله به بررسی یکی از جنبه‌های مهم بهینه‌سازی حافظه، یعنی مدیریت ظرفیت (Capacity) مجموعه‌ها (Collections) مانند لیست‌ها (List<T>) و دیکشنری‌ها (Dictionary<TKey, TValue>) می‌پردازد و نشان می‌دهد که چگونه استفاده صحیح از این ویژگی‌ها می‌تواند تأثیر چشمگیری بر عملکرد و میزان حافظه مصرفی برنامه‌های دات‌نت داشته باشد.

ظرفیت مجموعه‌ها

مجموعه‌ها در دات‌نت، مانند List<T> و Dictionary<TKey, TValue>, برای ذخیره داده‌ها، از آرایه‌هایی با اندازه ثابت در پشت صحنه استفاده می‌کنند. هنگامیکه تعداد عناصر، از ظرفیت آرایه فعلی بیشتر می‌شود، یک آرایه جدید با ظرفیت بزرگتر تخصیص داده شده و عناصر موجود، به آرایه جدید کپی می‌شوند. اگرچه کامپایلر و زمان اجرا (Runtime) در بیشتر موارد این فرآیند را به صورت بهینه مدیریت می‌کنند، اما یک سناریوی قابل توجه وجود دارد که در آن مداخله توسعه‌دهنده می‌تواند بهبود چشمگیری ایجاد کند.
این سناریو مربوط به استفاده از مقداردهنده‌های مجموعه (Collection Initializers) است. ممکن است تصور شود که هنگام استفاده از یک مقداردهنده مجموعه، کامپایلر به صورت ایستا ظرفیت اولیه مجموعه را بر اساس تعداد عناصر مشخص شده تعیین می‌کند. با این حال، در عمل این اتفاق نمی‌افتد. برای روشن شدن این موضوع، به نتایج یک بنچمارک توجه کنید:
[Benchmark]
public Dictionary<string, string> DefaultCapacity()
{
    return new Dictionary<string, string>
    {
        ["1"] = "1",
        ["2"] = "2",
        ["3"] = "3",
        ["4"] = "4",
        ["5"] = "5",
        ["6"] = "6",
        ["7"] = "7",
        ["8"] = "8",
    };
}

[Benchmark]
public Dictionary<string, string> ExactCapacity()
{
    return new Dictionary<string, string>(8)
    {
        ["1"] = "1",
        ["2"] = "2",
        ["3"] = "3",
        ["4"] = "4",
        ["5"] = "5",
        ["6"] = "6",
        ["7"] = "7",
        ["8"] = "8",
    };
}
نتایج این بنچمارک نشان می‌دهد که مقدار مصرف CPU و حافظه در حالت DefaultCapacity (ظرفیت پیش‌فرض) به طور قابل توجهی بیشتر از حالت ExactCapacity (ظرفیت دقیق) است:

MethodMeanErrorStdDevGen0Allocated
DefaultCapacity113.00 ns0.376 ns0.333 ns0.1185992 B
ExactCapacity65.57 ns0.623 ns0.521 ns0.0526440 B
دلیل این تفاوت چشمگیر این است که هنگام استفاده از یک مقداردهنده مجموعه بدون تعیین ظرفیت اولیه، سازنده پیش‌فرض (Default Constructor) کلاس Dictionary<string, string> فراخوانی می‌شود که مجموعه را با ظرفیت صفر مقداردهی اولیه می‌کند. سپس، عناصر به صورت تکی به مجموعه اضافه می‌شوند و در صورت نیاز، الگوریتم تغییر اندازه داخلی (Internal Resizing Algorithm) فعال می‌شود. برای درک بهتر این موضوع، به مثال زیر توجه کنید:
var d = new Dictionary<int, int>();
Console.WriteLine($"Capacity: {d.Capacity,2}, Count: {d.Count}");

for (int i = 0; i < 8; ++i)
{
    d.Add(i, i);
    Console.WriteLine($"Capacity: {d.Capacity,2}, Count: {d.Count}");
}
خروجی این برنامه نشان می‌دهد که چگونه ظرفیت دیکشنری به صورت پویا و با تخصیص آرایه‌هایی با اندازه‌های 3 و 7 تغییر می‌کند و در نهایت به 17 می‌رسد، در حالی که تنها 8 عنصر در مجموعه وجود دارد. این تخصیص‌های میانی و اندازه نهایی بزرگتر از حد نیاز، منجر به کار اضافی و مصرف حافظه بیشتر می‌شود. با تعیین ظرفیت اولیه مناسب، می‌توان از این تخصیص‌های غیرضروری جلوگیری کرد.

لیست‌ها و عبارات مجموعه

برای لیست‌ها (List<T>), علاوه بر تعیین ظرفیت اولیه از طریق سازنده، یک روش کارآمدتر برای مقداردهی اولیه وجود دارد: عبارات مجموعه (Collection Expressions). عبارات مجموعه نه تنها یک ویژگی ظاهری برای ساده‌تر کردن نحوه مقداردهی اولیه مجموعه‌ها هستند، بلکه از نظر عملکردی نیز بهینه‌تر عمل می‌کنند. برخلاف مقداردهنده‌های مجموعه، عبارات مجموعه ظرفیت دقیق مورد نیاز را تعیین می‌کنند و به همین دلیل سرعت اجرای بالاتری دارند:
[Benchmark]
public List<string> InitializerDefaultCapacity()
{
    return new List<string> { "1", "2", "3", "4", "5", "6", "7", "8" };
}

[Benchmark]
public List<string> InitializerExactCapacity()
{
    return new List<string>(8) { "1", "2", "3", "4", "5", "6", "7", "8" };
}

[Benchmark]
public List<string> CollectionExpression()
{
    return ["1", "2", "3", "4", "5", "6", "7", "8"];
}
نتایج بنچمارک زیر این برتری را نشان می‌دهد:

MethodMeanErrorStdDevGen0Allocated
InitializerDefaultCapacity57.46 ns0.244 ns0.228 ns0.0440368 B
InitializerExactCapacity26.59 ns0.109 ns0.102 ns0.0162136 B
CollectionExpression10.69 ns0.012 ns0.012 ns0.0163136 B
همانطور که مشاهده می‌شود، استفاده از عبارت مجموعه (["1", "2", "3", "4", "5", "6", "7", "8"]) در مقایسه با هر دو روش مقداردهی اولیه با استفاده از سازنده و مقداردهنده مجموعه، هم از نظر سرعت اجرا و هم از نظر میزان حافظه تخصیص یافته، عملکرد بهتری دارد. این به این دلیل است که عبارات مجموعه به طور مستقیم ظرفیت نهایی لیست را تعیین می‌کنند و از تخصیص‌های میانی غیرضروری جلوگیری می‌کنند.

نتیجه‌گیری

بهینه‌سازی مصرف حافظه در برنامه‌های دات‌نت یک فرآیند مستمر است که نیازمند توجه به جزئیات مختلف است. مدیریت صحیح ظرفیت مجموعه‌ها، به ویژه در بخش‌های پرکاربرد (Hot Code Path) برنامه، می‌تواند تأثیر قابل توجهی بر عملکرد و کارایی کلی نرم‌افزار داشته باشد. در حالی که برای دیکشنری‌ها تعیین ظرفیت اولیه به صورت دستی بهترین راه حل است، برای لیست‌ها، استفاده از عبارات مجموعه (Collection Expressions) نه تنها کد را خواناتر می‌کند بلکه عملکرد بهتری نیز ارائه می‌دهد. با این حال، لازم به ذکر است که اعمال این بهینه‌سازی‌ها باید با در نظر گرفتن context و نیازهای خاص برنامه انجام شود و نباید به صورت کورکورانه در کل پایگاه کد اعمال گردد. اگر بخش قابل توجهی از تخصیص حافظه برنامه شما ناشی از تغییر اندازه مجموعه‌ها است، استفاده از این تکنیک‌ها می‌تواند یک راه حل آسان و مؤثر برای بهبود عملکرد باشد.

جدول مقایسه کارایی روش‌های مقداردهی اولیه لیست


ویژگیمقداردهنده مجموعه (ظرفیت پیش‌فرض)مقداردهنده مجموعه (ظرفیت دقیق)عبارت مجموعه (Collection Expression)
تعیین ظرفیت اولیهخیربله (دستی)بله (خودکار)
تعداد تخصیص حافظهچندگانهیک باریک بار
سرعت اجراکندترسریع‌ترسریع‌ترین
میزان حافظه تخصیص‌یافتهبیشترکمترکمتر
سهولت استفادهآسانمتوسطآسان



مشاهده مطلب اصلی