عنوان:

‫بهینه‌سازی تخصیص حافظه با متد جدید StringBuilder.MoveChunks در NET 11.


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۰۳ ۰۹:۵۰
آدرس: www.dntips.ir
یکی از اصول اولیه‌ و کلیدی در بهینه‌سازی کارایی (Performance) برنامه‌های مبتنی بر دات‌نت، استفاده از کلاس StringBuilder برای مدیریت و اتصال رشته‌هاست. این کلاس با جلوگیری از ایجاد رشته‌های میانی (Intermediate Strings)، فشار روی مکانیسم مدیریت حافظه و به ویژه کامپایلر زباله (Garbage Collector) را به شکل چشمگیری کاهش می‌دهد. با این حال، حتی در هنگام استفاده از StringBuilder نیز فراخوانی متد ToString() در پایان کار، منجر به تخصیص حافظه (Allocation) برای یک رشته‌ی نهایی بزرگ می‌شود.
در نسخه پیش‌نمایش NET 11 (Preview 5).، مایکروسافت متد استاتیک جدیدی تحت عنوان StringBuilder.MoveChunks() معرفی کرده‌است که هدف آن، حل این چالش تخصیص حافظه در سناریوهای خاص، به ویژه در ابزارهایی مانند کامپایلر Roslyn و سورس ژنراتورها (Source Generators) است. در این مطلب، به بررسی ساختار داخلی StringBuilder، نحوه عملکرد متد MoveChunks، پیاده‌سازی آن و در نهایت فلسفه وجودی این API جدید می‌پردازیم.

ساختار داخلی StringBuilder و مفهوم Chunks
برای درک بهتر عملکرد MoveChunks، ابتدا باید نگاهی به معماری داخلی StringBuilder داشته باشیم. برخلاف تصور رایج، StringBuilder یک آرایه‌ی یکپارچه و بزرگ از کاراکترها نیست که به طور مداوم تغییر سایز دهد؛ بلکه از یک ساختار لیست پیوندی ناپیوسته از آرایه‌های کاراکتری (char[]) تشکیل شده‌است که در اصطلاح فنی به آن‌ها تکه‌ها (Chunks) گفته می‌شود.
هر زمان که متد Append فراخوانی می‌شود، داده‌ها در چانک فعلی نوشته می‌شوند. اگر ظرفیت چانک جاری تکمیل شود، یک چانک جدید تخصیص یافته و چانک قبلی به عنوان m_ChunkPrevious به آن متصل می‌شود. فرآیند تبدیل این ساختار به یک رشته منسجم، تنها زمانی رخ می‌دهد که متد ToString() فراخوانی شود؛ فرآیندی که مستلزم کپی کردن تمام این تکه‌ها در یک بلوک حافظه پیوسته و جدید است.

معرفی و نحوه عملکرد متد StringBuilder.MoveChunks
متد StringBuilder.MoveChunks یک متد استاتیک است که نمونه‌ی موجود از StringBuilder را دریافت کرده، محتوای داخلی آن را به یک نمونه جدید منتقل می‌کند و نمونه اولیه را به وضعیت اولیه (Reset) برمی‌گرداند.
مفهوم کلی این متد را می‌توان با کد زیر شبیه‌سازی کرد، هرچند که پیاده‌سازی واقعی بسیار بهینه‌تر است:
// شبیه‌سازی مفهومی عملکرد MoveChunks
var snapshot = new StringBuilder(source.ToString());
source.Clear();

تفاوت بنیادین با ()Clear
شاید این سوال مطرح شود که چه تفاوتی میان این متد و متد متداول Clear() وجود دارد؟
هنگامی که شما متد Clear() را روی یک StringBuilder فراخوانی می‌کنید، آرایه‌های کاراکتری داخلی (Chunks) آزاد نمی‌شوند؛ بلکه فقط شاخص‌ها و طول رشته (Length) صفر می‌شوند تا آرایه‌های موجود برای استفاده مجدد (Reuse) باقی بمانند. اما در MoveChunks، تمام چانک‌های داخلی به نمونه‌ی جدید منتقل شده و شیء اولیه کاملاً خالی از هرگونه چانک حافظه می‌شود.
به نمونه کد زیر توجه کنید:
var source = new StringBuilder();
source.AppendLine("Hello World");

// انتقال چانک‌ها به نمونه جدید
StringBuilder snapshot = StringBuilder.MoveChunks(source);

Console.WriteLine(snapshot.ToString()); // خروجی: "Hello World"
Console.WriteLine(source.ToString());   // خروجی: "" (رشته خالی)
Console.WriteLine(source.Length);       // خروجی: 0

// شیء اولیه مجدداً قابل استفاده است
source.Append("Ready for reuse");

بررسی پیاده‌سازی داخلی در دات‌نت ۱۱
کد منبع متد MoveChunks در دات‌نت ۱۱ بسیار ساده و در عین حال هوشمندانه پیاده‌سازی شده‌است. این متد به جای کپی کردن المان‌های آرایه، صرفاً مراجع (References) و فیلدهای مقداری (Value Types) را جابه‌جا می‌کند:
namespace System.Text;

public sealed partial class StringBuilder
{
    public static StringBuilder MoveChunks(StringBuilder source)
    {
        ArgumentNullException.ThrowIfNull(source);

        // ساخت یک نمونه جدید با استفاده از سازنده اختصاصی
        StringBuilder destination = new StringBuilder(source);

        // ریست کردن تمام فیلدهای داخلی شیء مبدا به وضعیت اولیه
        source.m_ChunkChars = [];
        source.m_ChunkPrevious = null;
        source.m_ChunkLength = 0;
        source.m_ChunkOffset = 0;

        return destination;
    }

    // Move Constructor (سازنده انتقالی)
    private StringBuilder(StringBuilder from)
    {
        // کپی کردن مراجع آرایه‌ها و مقادیر بدون هیچ‌گونه تخصیص حافظه اضافی
        m_ChunkLength = from.m_ChunkLength;
        m_ChunkOffset = from.m_ChunkOffset;
        m_ChunkChars = from.m_ChunkChars;
        m_ChunkPrevious = from.m_ChunkPrevious;
        m_MaxCapacity = from.m_MaxCapacity;

        // بررسی صحت ساختار در حالت Debug
        AssertInvariants();
    }
}
همانطور که مشاهده می‌کنید، این فرآیند از مرتبه اجرایی $O(1)$ است؛ چرا که هیچ حلقه‌ای برای کپی کاراکترها وجود ندارد و صرفاً اشاره‌گرهای آرایه‌ها منتقل می‌شوند.

چرا به این API نیاز داریم؟
در طول نشست‌های بررسی معماری NET API.، چالش‌ها و مخالفت‌های زیادی با این متد صورت گرفت؛ چرا که توسعه‌دهندگان می‌توانستند رفتاری مشابه را با جایگزینی شیء (مانند source = new()) پیاده‌سازی کنند. اما انگیزه اصلی معرفی این متد، رفع یک چالش جدی در Source Generators و کامپایلر Roslyn است.
سورس ژنراتورها کدهای سی‌شارپ را در زمان کامپایل تولید می‌کنند و برای این کار به شدت به StringBuilder وابسته هستند. در پلتفرم Roslyn، برای ساخت ساختار متنی کد از کلاس انتزاعی SourceText استفاده می‌شود که متدهای کارخانه‌ای (Factory Methods) آن به شرح زیر است:
namespace Microsoft.CodeAnalysis.Text;

public abstract class SourceText
{
    public static SourceText From(string text, Encoding? encoding = null, ...);
    public static SourceText From(TextReader reader, int length, ...);
    public static SourceText From(Stream stream, ...);
    public static SourceText From(byte[] buffer, ...);
}
تا پیش از این، برای تبدیل StringBuilder به SourceText، کامپایلر ناچار به فراخوانی ToString() بود که منجر به ایجاد یک رشته غول‌پیکر و تخصیص حافظه سنگین در زمان کامپایل می‌شد. پیشنهاد شد که اورلود جدیدی به شکل زیر اضافه شود:
public static SourceText From(StringBuilder stringBuilder, ...);
اما یک مشکل بزرگ وجود داشت: عدم تضمین تغییرناپذیری (Immutability). اگر برنامه‌نویس پس از ارسال StringBuilder به Roslyn، محتوای آن را در نخ (Thread) دیگری تغییر می‌داد، ساختار کدهای تولید شده توسط کامپایلر دچار از هم گسیختگی می‌شد. Roslyn برای امنیت خود باید یک کپی از داده‌ها می‌گرفت که این کار مزیت کارایی را از بین می‌برد.
اینجاست که MoveChunks نقش نجات‌دهنده را ایفا می‌کند. با این متد، Roslyn محتوای داخلی StringBuilder را به مالکیت خود درآورده و شیء در اختیار کاربر را کاملاً خالی می‌کند. در نتیجه، کاربر با وجود داشتن مرجع قدیمی، دیگر نمی‌تواند کدی که در اختیار Roslyn قرار گرفته را دستکاری و ویرایش کند.

چالش فعلی و چشم‌انداز آینده
در حال حاضر، ابزارهای Source Generator به دلیل الزامات سازگاری، ناچار به هدف قرار دادن netstandard2.0 هستند. از آنجا که MoveChunks یک قابلیت اختصاصی در دات‌نت ۱۱ است، استفاده مستقیم از آن در ژنراتورهای عمومی فعلاً با محدودیت مواجه است. با این حال، تیم دات‌نت در حال بررسی مکانیزم‌هایی است تا بتوان از طریق آنالایزرها، کدهایی مانند SourceText.From(sb.ToString()) را شناسایی کرده و آن‌ها را به متدهای بهینه‌تری مانند SourceText.DrainFrom(sb) هدایت کند.

نکته:
اگر در معماری سیستم خود از الگوهایی مانند اشیای اشتراکی مکرر (Object Pooling) برای StringBuilder استفاده می‌کنید، توجه داشته باشید که استفاده از MoveChunks باعث می‌شود آرایه‌های داخلی شیء اولیه آزاد شوند. بنابراین در استفاده‌های بعدی، آرایه‌های جدیدی تخصیص خواهند یافت. این متد باید صرفاً در گلوگاه‌هایی استفاده شود که قصد دارید تخصیص حافظه حاصل از ToString() روی رشته‌های بزرگ را به صفر برسانید.

نتیجه‌گیری
متد StringBuilder.MoveChunks یک ابزار پیشرفته و کم‌هزینه برای جابه‌جایی مالکیت داده‌های متنی بدون کپی کردن آن‌ها در حافظه است. اگرچه این قابلیت در ابتدا با هدف بهینه‌سازی داخلی کامپایلر Roslyn و سورس ژنراتورها طراحی شده‌است، اما در معماری باز و توسعه‌پذیر برنامه‌های بزرگ دات‌نت که با حجم وسیعی از داده‌های متنی سروکار دارند، می‌تواند به عنوان یک الگوی کارآمد برای طراحی APIهای سریع‌تر و بدون نیاز به Allocation (Zero-allocation APIs) مورد استفاده قرار گیرد.