Levelwise
فارسی
پایه‌های C# و .NET

Performance و Span

در کد پرفشار، هر شیء کوچک روی Heap کار GC را بیشتر می‌کند. اول اندازه بگیر، بعد فقط نقطه داغ را درست کن. ابزارهایی مثل Span و ArrayPool کمک می‌کنند بدون کپی و بدون شیء تازه کار کنی.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۴ دقیقهمثال فایل قیمت تأمین‌کننده‌ها در فروشگاهکد C# و .NET 10

نویسنده: bezzad

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

تأمین‌کننده‌ها قیمت و موجودی کالا را برای فروشگاه ما می‌فرستند. هر پیام یک خط متن است: شناسه کالا، قیمت و موجودی، جدا شده با ویرگول. سرویس ما در هر ثانیه ده‌ها هزار خط را این‌طور parse می‌کند:

public static PriceUpdate ParseWithSplit(string line)
{
    var parts = line.Split(',');
    return new PriceUpdate(
        parts[0],
        decimal.Parse(parts[1], CultureInfo.InvariantCulture),
        int.Parse(parts[2], CultureInfo.InvariantCulture));
}

بیشتر وقت‌ها سرعت خوب است. ولی هر چند ثانیه یک بار، همه چیز برای مدت کوتاهی می‌ایستد. ابزار dotnet-counters نشان می‌دهد تعداد GC خیلی زیاد است.

چرا allocation زیاد کند است؟

قدم به قدم:

  1. متد Split برای هر خط یک آرایه و سه string تازه روی Heap می‌سازد.
  2. ده‌ها هزار خط در ثانیه یعنی صدها هزار شیء در ثانیه. همه چند لحظه بعد زباله هستند.
  3. هر شیء تازه در نسل ۰ ساخته می‌شود. نسل ۰ با این سرعت خیلی زود پر می‌شود.
  4. هر بار که پر شود، GC اجرا می‌شود. GC نسل ۰ ارزان است، ولی وقتی هزاران بار اجرا شود، جمع زمانش زیاد است.
  5. چند شیء هم که هنگام GC زنده‌اند، به نسل‌های بالاتر می‌روند. گاهی هم یک GC کامل و گران لازم می‌شود.

نتیجه: هر چه زباله کمتری بسازیم، GC کمتر کار می‌کند.

line.Split(',')P-907,129.50,12string[3]"P-907""129.50""12"چهار شیء تازه در حافظه، برای هر خطReadOnlySpan<char>P-907,129.50,12[0..5][6..12][13..]فقط سه پنجره روی همان حافظه. هیچ کپی و هیچ شیء تازه‌ای نیست
متد Split برای هر خط چهار شیء تازه می‌سازد. Span فقط به تکه‌هایی از همان رشته اصلی اشاره می‌کند.

اول اندازه بگیر

مهم‌ترین قانون Performance این است: حدس نزن. بیشتر وقت‌ها جایی که فکر می‌کنیم کند است، کند نیست.

۱. اندازه بگیرdotnet-counters۲. نقطه داغ را پیدا کنdotnet-trace۳. یک تغییر کوچکفقط همان نقطه۴. مقایسه کنBenchmarkDotNetاگر بهتر نشد، تغییر را برگردان. کد ساده ارزش دارد
بهینه‌سازی یک چرخه است. هر تغییر باید با عدد ثابت شود.
  1. با ابزار dotnet-counters وضعیت کلی را ببین: تعداد GC، اندازه Heap، مصرف CPU.
  2. با ابزار dotnet-trace یا PerfView پیدا کن بیشتر زمان یا بیشتر allocation در کدام متد است. به آن نقطه داغ (Hot Path) می‌گوییم.
  3. فقط همان نقطه را تغییر بده. بقیه کد را ساده نگه دار.
  4. با کتابخانه BenchmarkDotNet قبل و بعد را مقایسه کن.

کتابخانه BenchmarkDotNet

این کتابخانه کد را بارها اجرا می‌کند، گرم کردن (warm-up) را انجام می‌دهد و نتیجه آماری می‌دهد. ویژگی MemoryDiagnoser مقدار allocation را هم نشان می‌دهد:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<ParseBenchmarks>();

[MemoryDiagnoser]
public class ParseBenchmarks
{
    private const string Line = "P-907,129.50,12";

    [Benchmark(Baseline = true)]
    public PriceUpdate WithSplit() => PriceParser.ParseWithSplit(Line);

    [Benchmark]
    public PriceUpdate WithSpan() => PriceParser.ParseWithSpan(Line);
}

برنامه را همیشه در حالت Release اجرا کن:

dotnet run -c Release
با Stopwatch اندازه نگیر. یک حلقه ساده با Stopwatch گرم کردن JIT، نویز سیستم و حذف کد توسط کامپایلر را در نظر نمی‌گیرد. عددی که می‌دهد معمولاً گمراه‌کننده است.

Span چیست؟

نوع Span یک پنجره روی یک تکه حافظه است. حافظه می‌تواند یک آرایه، یک string یا حتی حافظه روی Stack باشد. Span فقط دو چیز را نگه می‌دارد: کجا شروع می‌شود و چقدر طول دارد.

  • نوع ReadOnlySpan فقط خواندنی است. برای string از این نوع استفاده می‌کنیم.
  • برش یک Span (با محدوده، مثل دو نقطه پشت سر هم) هیچ چیزی کپی نمی‌کند. فقط یک پنجره کوچک‌تر می‌سازد.
  • بیشتر متدهای Parse در .NET یک نسخه دارند که ReadOnlySpan می‌گیرد.

حالا همان parse، بدون Split:

public readonly record struct PriceUpdate(string Sku, decimal Price, int Stock);

public static PriceUpdate ParseWithSpan(ReadOnlySpan<char> line)
{
    int first = line.IndexOf(',');
    var sku = line[..first];

    var rest = line[(first + 1)..];
    int second = rest.IndexOf(',');

    var price = decimal.Parse(rest[..second], CultureInfo.InvariantCulture);
    var stock = int.Parse(rest[(second + 1)..], CultureInfo.InvariantCulture);

    return new PriceUpdate(sku.ToString(), price, stock);
}

در این نسخه آرایه‌ای ساخته نمی‌شود و string قیمت و موجودی هم ساخته نمی‌شود. فقط یک string برای شناسه کالا می‌سازیم، چون واقعاً آن را لازم داریم.

محدودیت‌های Span

نوع Span یک ref struct است. یعنی فقط می‌تواند روی Stack باشد. دلیلش این است که ممکن است به حافظه Stack اشاره کند و آن حافظه بعد از تمام شدن متد دیگر معتبر نیست. پس:

  1. نمی‌تواند فیلد یک class باشد.
  2. یک lambda نمی‌تواند آن را بگیرد (capture کند).
  3. نمی‌تواند از یک await عبور کند. متغیرهای یک متد async در طول await روی Heap نگه داشته می‌شوند.
  4. برای این حالت‌ها، از Memory استفاده کن. نوع Memory روی Heap می‌ماند و هر وقت لازم شد، با خاصیت Span آن یک Span می‌گیری.

استفاده دوباره از بافر با ArrayPool

سرویس ما فایل‌های بزرگ قیمت را هم از تأمین‌کننده‌ها دانلود می‌کند. اگر برای هر فایل یک آرایه ۶۴ کیلوبایتی تازه بسازیم، این آرایه‌ها هم زباله می‌شوند. آرایه‌های ۸۵ هزار بایت و بزرگ‌تر حتی بدتر هستند: مستقیم به Large Object Heap می‌روند و فقط با GC کامل پاک می‌شوند.

ArrayPool<byte>.Sharedآرایه‌های آماده برای قرضخواندن فایل قیمتبا یک آرایه قرضیRentReturnهیچ آرایه تازه‌ای ساخته نمی‌شود
آرایه را قرض بگیر، استفاده کن و پس بده. استخر آن را برای کار بعدی نگه می‌دارد.
public static async Task CopyAsync(Stream input, Stream output, CancellationToken ct)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
    try
    {
        int read;
        while ((read = await input.ReadAsync(buffer, ct)) > 0)
            await output.WriteAsync(buffer.AsMemory(0, read), ct);
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}
دو نکته مهم: آرایه‌ای که Rent می‌دهد ممکن است بزرگ‌تر از اندازه درخواستی باشد. پس همیشه با تعداد واقعی خوانده‌شده کار کن. بعد از Return هم هیچ وقت از آن آرایه استفاده نکن. ممکن است الان دست کد دیگری باشد.

stackalloc برای بافرهای کوچک

برای یک بافر کوچک و موقت، می‌شود حافظه را روی Stack گرفت. هیچ چیزی روی Heap ساخته نمی‌شود:

Span<char> buffer = stackalloc char[32];
if (price.TryFormat(buffer, out int written, "N0", CultureInfo.InvariantCulture))
    writer.Write(buffer[..written]);

فقط برای اندازه‌های کوچک و ثابت. Stack کوچک است و اگر پر شود، کل process از کار می‌افتد.

منابع رایج دیگر allocation

  • چسباندن string در حلقه. هر بار یک string تازه ساخته می‌شود. از StringBuilder استفاده کن.
  • ساختن List بدون ظرفیت اولیه. وقتی تعداد را می‌دانی، ظرفیت را بده. وگرنه List چند بار آرایه داخلی‌اش را بزرگ و کپی می‌کند.
  • تبدیل struct به object (Boxing). مثلاً گذاشتن int در یک متغیر از نوع object یا یک اینترفیس.
  • استفاده از LINQ و lambda در نقطه داغ. هر مرحله و هر lambda که متغیری را می‌گیرد، شیء کوچکی می‌سازد. در کد عادی مهم نیست. در نقطه داغ، یک حلقه ساده بهتر است.
  • متد async که بیشتر وقت‌ها بدون انتظار تمام می‌شود. مثلاً ۹۹ درصد وقت‌ها از کش جواب می‌دهد. اینجا ValueTask allocation نوع Task را حذف می‌کند. ولی ValueTask را فقط یک بار await کن.

قانون‌های مهم

  1. اول اندازه بگیر. بدون عدد، بهینه‌سازی نکن.
  2. فقط نقطه داغ را بهینه کن. کد Span خواناتر از کد ساده نیست. بقیه کد را ساده نگه دار.
  3. قبل و بعد را با BenchmarkDotNet مقایسه کن. در حالت Release و با MemoryDiagnoser.
  4. بافر قرضی را همیشه در finally پس بده. و بعد از پس دادن، از آن استفاده نکن.
  5. از stackalloc فقط برای اندازه کوچک و ثابت استفاده کن. هیچ وقت با اندازه‌ای که از کاربر می‌آید.
  6. تنظیمات GC را آخر نگاه کن. اول کد را درست کن، بعد تنظیمات.

اشتباه‌های رایج

اشتباه نتیجه راه درست
بهینه‌سازی بدون اندازه‌گیری کد پیچیده، بدون سود واقعی. اول پیدا کردن نقطه داغ.
اندازه‌گیری با Stopwatch در حالت Debug عددهای گمراه‌کننده. کتابخانه BenchmarkDotNet در حالت Release.
استفاده از Span در همه جای کد کد سخت‌خوان، بدون سود. فقط در نقطه داغ.
کار با کل آرایه Rent شده داده اضافه و قدیمی خوانده می‌شود. فقط تا تعداد واقعی.
استفاده از آرایه بعد از Return خراب شدن داده کد دیگر. پس دادن در finally، بعد دیگر نه.
استفاده از stackalloc با اندازه بزرگ پر شدن Stack و از کار افتادن process. اندازه کوچک و ثابت، یا ArrayPool.

چه وقت این ابزارها؟

مناسب

  • کدی که در هر ثانیه هزاران بار اجرا می‌شود.
  • اندازه‌گیری نشان داده allocation یا GC مشکل است.
  • خواندن و parse متن، کار با فایل و شبکه، کتابخانه‌های پایه.

نامناسب

  • یک endpoint معمولی که بیشتر زمانش منتظر دیتابیس است.
  • کدی که در روز چند بار اجرا می‌شود.
  • وقتی هنوز چیزی اندازه نگرفته‌ای.

خلاصه در شش خط

  1. هر شیء تازه روی Heap کار GC را بیشتر می‌کند. در کد پرفشار این جمع می‌شود.
  2. اول اندازه بگیر و نقطه داغ را پیدا کن. بعد فقط همان را تغییر بده.
  3. با BenchmarkDotNet و MemoryDiagnoser، قبل و بعد را در حالت Release مقایسه کن.
  4. نوع Span یک پنجره روی حافظه است. برش آن هیچ چیزی کپی نمی‌کند.
  5. نوع Span فقط روی Stack است. برای فیلد و کد async از Memory استفاده کن.
  6. بافرهای بزرگ را با ArrayPool قرض بگیر و در finally پس بده.