شاید در نگاه اول انتشار نسخههای جدید داتنت بیشتر شبیه به یک سری عدد و نام باشد؛ اما در پشت صحنهی هر نسخه، تغییراتی انجام میشوند که مستقیماً روی سرعت اجرای کدهای تاثیر میذارند بدون اینکه حتی یک خط از کد را تغییر بدهید. یکی از جالبترین این تغییرات در دات نت 10، بازنگری در نحوهی مدیریت حافظه و فراخوانی توابع (calling convention) است. در نسخههای قبلی داتنت، وقتی تابعی مقداری بزرگ (مثل یک struct شامل چند مقدار) برمیگرداند، باید جایی در حافظه برای این مقدار تخصیص داده میشد. این محل گاهی در heap بود، گاهی روی stack، و همین باعث میشد GC وارد ماجرا شود تا مراقب ارجاعها باشد.
از Write Barrier تا Return Buffer
اگر مدتهاست با داتنت کار میکنید، احتمالاً وقتی مایکروسافت نسخهی جدیدی از Runtime منتشر میکند، دنبال تغییرات قابللمس میگردید: قابلیتهای زبانی جدید، بهبود LINQ، یا تغییرات در APIها. اما بخش بزرگی از پیشرفتهای واقعی در جایی اتفاق میافتد که معمولاً هیچکس نگاه نمیکند در اعماق Garbage Collector (GC) و JIT Compiler. در دات نت 10 چند تغییر زیرپوستی در همین بخشها باعث شده عملکرد بسیاری از برنامهها بهتر شود، حتی بدون اینکه شما یک خط کد را تغییر دهید.
Write Barrier چیست؟
فرض کنید در برنامهتان یک شی جدید میسازید و درون یک فیلد ذخیرهاش میکنید:
person.Address = new Address("123 Main St");در ظاهر، این فقط یک عمل انتساب ساده است. اما در پشت صحنه، دات نت باید مطمئن شود Garbage Collector از این تغییر خبر دارد. چون GC باید بداند کدام شی به کدام شی اشاره دارد تا هنگام جمعآوری حافظه، چیزی را اشتباهی پاک نکند. به همین دلیل، هر بار که یک ارجاع (reference) در حافظه تغییر میکند، GC باید “علامتگذاری” شود. این علامتگذاری همان Write Barrier است در واقع یک سد کنترلی که جلوی بینظمی در حافظه را میگیرد.
میتوانیم با یک مثال روزمره توضیح دهیم:
تصور کنید یک مدیر بایگانی دارید که باید بداند هر پرونده در کدام قفسه قرار دارد. اگر کارمندی پوشهای را جابجا کند و به مدیر اطلاع ندهد، روزی که بخواهند آن پوشه را پیدا کنند، هرجومرج میشود. Write Barrier همان تماس تلفنی است که هر کارمند بعد از جابجایی پوشه باید به مدیر بزند. در اغلب موارد، GC میداند که کدام بخش از حافظه «کوتاهعمر» است (نسل صفر یا Gen0) و کدام بخش «بلندعمر» (Gen1 و Gen2). فرض اصلی این است که اشیا جدید معمولاً موقتی هستند. بنابراین GC برای سرعت بیشتر، فقط بخش Gen0 را در جمعآوری اولیه بررسی میکند. اما گاهی اتفاق میافتد که شی ای از نسل قدیمیتر (مثلاً Gen2) به شی ای از نسل جدیدتر اشاره کند. در این حالت GC باید بداند که چنین ارجاعی وجود دارد، وگرنه ممکن است شی جدید را زودتر از موعد پاک کند. و این همانجاست که Write Barrier وارد عمل میشود. Write Barrierها بهصورت خودکار توسط JIT اضافه میشوند یعنی برای هر انتسابی که احتمال دارد نسلها را قطع کند، یک فراخوانی اضافه به GC انجام میشود. اگر کد شما شامل میلیونها انتساب در طول اجرا باشد (که در برنامههای واقعی معمولاً همینطور است)، این تماسها میتوانند هزینهبر باشند. در دات نت 9، حتی در مواردی که GC نیازی به اطلاع نداشت، باز هم Write Barrier اجرا میشد. چون JIT در همهی شرایط نمیتوانست مطمئن شود که ارجاع موردنظر روی Heap است یا نه. و از آنجا که ایمنی از سرعت مهمتر است، همیشه جانب احتیاط را میگرفت.
در نظر بگیر struct زیر را داریم:
public record struct Person(string FirstName, string LastName, string Address, string City);
و تابعی که چنین مقداری را برمیگرداند:
public Person GetPerson()
=> new(_firstName, _lastName, _address, _city);در دات نت 9 وقتی این تابع اجرا میشد، برای نوشتن هر یک از فیلدهای Person در حافظه، یک Write Barrier ایجاد میکرد. یعنی چهار تماس با GC فقط برای چهار فیلد ساده.
دات نت 10 چه تغییری ایجاد کرده است؟
در دات نت 10، تیم Runtime یک تصمیم مهم گرفت:
“تمام Return Bufferها باید روی Stack قرار بگیرند، نه روی Heap.”
اما Return Buffer چیست؟
وقتی تابعی مقداری از نوع Value Type (مثل Struct) را برمیگرداند، این مقدار باید در جایی ذخیره شود تا بعد به فراخواننده تحویل داده شود. در واقع، جایی در حافظه برای “نتیجهی خروجی” در نظر گرفته میشود به این ناحیه، Return Buffer میگویند. در نسخههای قبلی، ممکن بود این بافر روی Heap باشد (یعنی جایی که GC در آن دخالت دارد). در دات نت 10، این رفتار استاندارد شد: Return Buffer همیشه روی Stack است و چون Stack در محدودهی GC نیست، دیگر نیازی نیست برای نوشتن در آن، هر بار به GC اطلاع دهیم. در نتیجهی این تصمیم، وقتی تابعی مثل GetPerson اجرا میشود، دیگر نیازی به اجرای Write Barrier نیست. JIT مستقیماً مقادیر را در بافر خروجی مینویسد، بدون تماس با GC.
در دنیای واقعی، این یعنی:
- فراخوانیهای کوتاه و پرتکرار سریعتر اجرا میشوند
- کدهایی که دادههای حجیم برمیگردانند (مثل DTOها در APIها) تا چند ده درصد سریعتر میشوند
- مصرف CPU در مسیرهای پرترافیک کاهش مییابد
به زبان ساده: داتنت یاد گرفته است چه زمانی لازم نیست به GC زنگ بزند.
فرض کن در ادارهای کار میکنی که باید هر بار فایل جدیدی بسازید، و گزارش آن را به مدیر بدهید. در نسخهی قدیمی، حتی اگر مدیر بداند تو فقط روی میز خودت کار میکنی، باز هم باید هر بار خبر بدهی. اما حالا مدیر باهوشتر شده و میداند وقتی فایل روی میز خودت است، لازم نیست درگیر شود. نتیجه؟ کارها سریعتر پیش میروند، بدون هیچ خطر اضافی.
تغییرات در ARM64
در معماری ARM64 هم تغییر مشابهی رخ داده است. پیش از دات نت 10، تمام حالتهای Write Barrier از یک مدل کلی استفاده میکردند. اما اکنون، سیستم جدیدی به نام WriteBarrierManager وظیفه دارد نوع مناسب Barrier را بر اساس وضعیت GC انتخاب کند. نتیجهی این تغییر دو مزیت دارد:
- دقت بالاتر در تشخیص مناطقی از حافظه که واقعاً نیاز به بررسی دارند.
- کاهش فشار کاری GC چون دیگر لازم نیست کل حافظه را اسکن کند.
در عمل، این یعنی برنامههای شما روی دستگاههای ARM عملکرد بهتری خواهند داشت، مخصوصاً در بارهای سنگین.
جمعبندی
در ظاهر، تغییر در نحوهی کار Write Barrier یا Return Buffer ممکن است جزئی به نظر برسد. اما وقتی این تغییر در میلیونها عملیات حافظه در هر ثانیه تکرار شود، نتیجه شگفتانگیز است.
بهینهسازیهای دات نت 10 در این زمینه باعث شده:
- مسیرهای پرتکرار تا ۳۰٪ سریعتر اجرا شوند،
- GC کمتر درگیر شود،
- و در نتیجه، CPU زمان بیشتری برای اجرای واقعی کد شما داشته باشد.
آنچه در دات نت 10 اتفاق افتاده است، نمونهی درخشانی از طراحی هوشمند Runtime است: تغییراتی که شاید در سطح کد دیده نشوند، اما در عملکرد کل سیستم احساس میشوند.
در واقع، این تغییرات یادآور این نکتهاند که در دنیای نرمافزار، همیشه “سریعتر شدن” از جایی آغاز میشود که کمتر دیده میشود از دل همان جزئیاتی که تنها تیمهای Runtime به آن فکر میکنند.