hojjatjafary
hojjatjafary
خواندن ۶ دقیقه·۵ سال پیش

حافظه‌ی مدیریت شده در Unity

مدتی بود که چند توییت فنی در مورد جزییات مدیریت حافظه موتور بازی سازی Unity نوشتم که مورد استقبال دوستان قرار گرفت و به پیشنهاد همین دوستان تصمیم گرفتم همان مطالب را با جزییات بیشتر اینجا منتشر کنم، این اولین تجربه من با ویرگول هست، امیدوارم مفید باشه.

حافظه‌ در Unity

موتور Unity برای زیرسیستم‌های مختلف خود حافظه را به روش‌های مختلفی مدیریت می‌کند، الگوریتم‌ها و روش‌های مدیریت حافظه‌ی درونی زیر سیستم‌ها از دسترس و چشم کاربران موتور دور و مخفی هستند برای همین اطلاع زیادی از آنها نداریم، اما بخشی که بیشتر با آن درگیر هستیم و کمی با نحوه‌ی عملکرد آن آشنا هستیم بخش‌ scripting runtime است که می‌تواند Mono یا IL2CPP باشد. بنابراین این مقاله روی این بخش از حافظه‌ی موتور یونیتی متمرکز خواهد بود.

حافظه‌ی مدیریت شده

به طور کلی قسمتی از حافظه که برای تخصیص پویای حافظه استفاده می‌شود Heap می‌گویند. حافظه‌ی Heap مدیریت شده، حافظه‌ای است که برای ذخیره‌ی همه‌ی اشیاء اسکریپت‌ها استفاده می‌شود. به این علت مدیریت شده نامیده شده که زمانی که یک شی هیچ ارجاعی نداشته باشد توسط زباله‌روب (garbage collector) بازیابی می‌شود.
زباله‌روب حافظه‌ی یونیتی که کار مدیریت حافظه را به عهده دارد الگوریتم Boehm را پیداه سازی کرده و دارای ویژگی هایی است که دانستن آنها به نحوه‌ی استفاده‌ی صحیح ما از یونیتی کمک می‌کند.

این الگوریتم از نوع الگوریتم‌های mark-sweep است، که دارای دو مرحله اصلی است، مرحله‌ اول شامل پیمایش حافظه برای پیدا کردن اشیاء قابل دسترسی و علامت زدن آنها (mark)، و مرحله دوم که حافظه‌های غیر قابل درسترسی پاک می‌شوند(sweep).

زباله روب حافظه در یونیتی به نوعی stop-the-world garbage collector است، به این معنا که تمام فرآیندهای موتور باید متوقف شود تا زباله‌روب کار خود را انجام دهد و این موضوع برای بازی‌هایی که می‌خواهند frame rate نرم و روان داشته باشند باعث دردسر خواهد بود.

این زباله روب از نوع Non-generational است، بدین معنا که برای پیدا کردن حافظه‌هایی که زباله محسوب شده و باید جمع آوری شوند باید کل حافظه‌ی Heap را پیمایش کند. در مدل‌های generational حافظه به چند بخش (generation) تقسیم شده و هر بخش با فرکانس متفاوت بررسی می‌شود.

بعد از مرحله‌ی پاکسازی در اکثر garbage collector ها، در حافظه فضاهای خالی‌ای به وجود می‌آید که با compact کردن و shift دادن آن به یک سمت از بین می‌روند. ولی مدیریت حافظه‌ در یونیتی از نوع
non-compacting است بنابراین حافظه‌ی مدیریت شده در یونیتی به شدت مستعد fragment شدن است. علت این امر هزینه‌ی زمان اجرای بالای جابه جایی حافظه است که این کار را برای یک موتور بازی سازی نامناسب می‌کند.

حافظه‌ی متفرق شده
حافظه‌ی متفرق شده


وقتی حافظه‌ی جدیدی درخواست می‌کنید gc ابتدا در بین فضاهای خالی موجود دنبال یک مکان خالی به اندازه‌ی درخواست شده می‌گردد، اگر جای خالی پیدا شود آن را به شئ شما اختصاص می‌دهد در غیر این صورت مجبور به گسترش حافظه است که بسته به سیستم عامل و دستگاه مشخص می‌شود ولی در اکثر سیستم عامل‌ها یونیتی حافظه را دوبرابر می‌کند.
این حافظه هیچگاه به سیستم بازگردانده نمی‌شود به عبارت دیگر Heap مدیریت شده فقط می‌تواند رشد کند.

عواملی که باعث Fragment شدن حافظه‌ می‌شود

به طور کلی استفاده از حافظه‌ی پویا و رها سازی آن و اجرای garbage collector باعث متفرق شدن حافظه می‌شود، بنابراین باید تا جای ممکن از حافظه‌ی پویا استفاده نکرد و حافظه‌های مورد نیاز را ابتدا تخصیص دهیم و سعی کنیم از آنها مجدد استفاده کنیم.

هچنین یکی از مهمترین عواملی که باعث تخصیص حافظه‌ی پویا می‌‌شود اشیا و حافظه‌های موقتی هستند که به صورت خودکار ساخته می‌شوند. یکی از بد نام ترین این حافظه‌ها حافظه‌ی مربوط به String در زبان #C است. رشته‌ها در #C اصطلاحا immutable هستند و با هر بار اتصال دو رشته (concat) یک رشته دیگر ساخته می‌شود، متاسفانه اکثر توابع کار با رشته همین مشکل را دارند.

راه‌های استفاده‌ی بهینه‌ی حافظه‌ی مدیریت شده

یکی از راههای جلوگیری از تخصیص پویای حافظه استفاده از انواع Pool مثل Object Pool است، بدین صورت که ابتدا میزان مشخصی از شئ ای که قرار است استفاده شود می‌سازیم و درون یک ظرف(container) نگهداری می‌کنیم، به جای اختصاص حافظه‌ی پویا، شئ را از Pool درخواست می‌کنیم و بعد از استفاده دوباره آن را به Pool باز می‌گردانیم.

با توجه به نحوه‌ی کار کردن حافظه‌ی مدیریت شده در یونیتی باید به نکات زیر توجه داشت:

  • همیشه به میزان تخصیص حافظه (GC Alloc) را در Profiler دقت کنید، از Profiler های پیشرفته حافظه نیز استفاده کنید مثل Heap Explorer و Memory Profiler
  • اگر List یا Dictionary با پارامتر جنریک از نوع Enum استفاده می‌کنید، باید دقت داشته باشید که این دو نوع ظرف داده از عملگر تساوی استفاده می‌کنند، این مقایسه باعث Box شدن مقادیر Enum شده که باعث تولید حافظه‌ی موقتی زیادی می‌شود. همچنین Dictionary برای پیدا کردن عناصر از تابع Object.getHashCode استفاده می‌کند که برای نوع داده‌ای ارجاع است و باعث Box شدن Enum می‌شود. برای همین باید برای Enum هایی که داریم کلاس مقایسه کننده‌ی خودمان را بسازیم:
public class MyEnumComparer : IEqualityComparer<MyEnum> { public bool Equals(MyEnum x, MyEnum y) { return x == y; } public int GetHashCode(MyEnum x) { return (int)x; } }
  • تابع ()List.Remove ابتدا تابع IndexOf فراخوانی می‌کند یعنی باید روی تمام عناصر لیست حرکت کند و آنها را یکی یکی مقایسه کند و index عنصر مورد نظر را پیدا کند و بعد با استفاده از index آن را پاک کند. اگر لیست بزرگی در حافظه دارید مراقب این تابع باشید. موضوع زمانی بدتر می‌شود که لیست از نوع Enum باشد و عمل مقایسه باعث ایجاد حاظفه‌ی موقتی زیاد شود.
  • همیشه زمان ساخت List هایتان میزان Capacity تخمینی در نظر بگیرید، چون هر بار که یک عنصر درون لیست Add می‌کنید تابع GrowIfNeeded فراخوانی می‌شود و اگر لازم باشد ظرفیت حافظه دو برابر خواهد شد و همه‌ی عناصر به حافظه‌ی جدید کپی شده که کار پر هزینه‌ای است.(شبیه Vector در ++C)
  • فراخوانی توابع با طول پارامترهای متغیر یا پر کردن آرایه‌ای از object ها به علت Boxing باعث تولید زباله‌ی زیادی می‌شود که پیدا کردن آن با چشم و ظاهر کد بسیار سخت است.
public class MyClass { public static void UseParams(params int[] list) { for (int i = 0; i < list.Length; i++) { Console.Write(list[i] + " "); } Console.WriteLine(); } static void Main() { // You can send a comma-separated list of arguments of the // specified type. UseParams(1, 2, 3, 4); UseParams(1, 'a', "test"); } }
  • یونیتی توابعی دارد که به شما اجازه می‌دهد از آرایه‌های موجود دوباره استفاده کنید (reuse) تا مجبور نباشید مجدد حافظه بگیرید.بادقت ازاین توابع استفاده کنید.
ParticleSystem.SetParticles public void SetParticles(out Particle[] particles, int size); public void SetParticles(out Particle[] particles, int size, int offset);
  • تا جای ممکن از foreach برای حلقه‌های خود استفاده نکنید، برخی تصور می‌‌کنند که مشکل آن حل شده ولی همچنان مشکل Boxing برای foreach وجود دارد.
  • از توابعی که خروجی آنها یک آرایه است به دقت استفاده کنید، به طور مثال تابع GetComponentsInChildren یا Input.touches در حلقه‌ی زیر هر بار یک آرایه جدید باز می‌گرداند
for (int i = 0; i < Input.touches.Length; i++ ) { Touch touch = Input.touches[i]; // … }


منابع:

https://blogs.unity3d.com/2016/12/05/unity-webgl-memory-the-unity-heap/
https://docs.unity3d.com/Manual/UnderstandingAutomaticMemoryManagement.html
https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity2.html
https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html




برنامه نویسییونیتیبازی سازیبهینه سازیمدیریت حافظه
کسی که می خواهد برنامه نویس بماند، برنامه نویس شرکت فن افزار، بازی ساز، گرشاسپ راز اژدها، شمشیر تاریکی...
شاید از این پست‌ها خوشتان بیاید