عنوان:

‫بازبینی و ارتقای کدهای ناهمگام (Async/Await) در #C با GitHub Copilot


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۲۹ ۱۲:۳۲
آدرس: www.dntips.ir
برنامه‌نویسی ناهمگام در دات‌نت از حساس‌ترین بخش‌های توسعه است؛ خطاهای به ظاهر کوچک در پیاده‌سازی متدهای async می‌توانند به سادگی منجر به قفل شدن نخ‌ها (Thread-Pool Starvation)، بن‌بست (Deadlock)، مصرف افسارگسیخته حافظه یا از دست رفتن استثناها (Unobserved Exceptions) شوند. درخواست‌های کلی مانند «این متد async را بازنویسی کن» معمولاً بدون ارزیابی بار سیستم، کدهای ترتیبی را کورکورانه به همزمانی‌های سنگین تبدیل می‌کنند. استفاده اصولی از Copilot نیازمند هدایت مدل به سمت تحلیل گلوگاه‌های همزمانی، رفتار کنترل خطا و موازنه‌های فشار بر زیرساخت است.

کالبدشکافی یک نمونه کد آسیب‌پذیر

متد اولیه زیر را در نظر بگیرید:
public async Task ProcessAsync()
{
    var users = GetUsersAsync().Result;
    foreach (var user in users)
    {
        ProcessUserAsync(user);
    }
}
ایرادات بحرانی این پیاده‌سازی:
  • فراخوانی مسدودکننده (Blocking Call): استفاده از .Result نخ جاری را بلاک می‌کند و در صورت وجود SynchronizationContext (مانند فریم‌ورک‌های UI یا ساختارهای قدیمی ASP.NET) می‌تواند به بن‌بست فوری منجر شود.
  • فراخوانی رهاشده (Fire-and-Forget): متد ProcessUserAsync بدون await فراخوانی شده است؛ در نتیجه اجرای متد بدون انتظار برای پایان پردازش‌ها به اتمام می‌رسد، استثناها بلعیده می‌شوند و وضعیت پردازش نامشخص می‌ماند.
  • فقدان لغو عملیات (Cancellation): هیچ پارامتر CancellationToken برای متوقف‌سازی وظایف معلق وجود ندارد.

ساختار پرامپت مهندسی برای بازبینی Async/Await
این متد ناهمگام در C# را از نظر الزامات مهندسی همزمانی بازبینی کن:
محورهای ارزیابی:
۱. شناسایی فراخوانی‌های بلاک‌کننده (.Result یا .Wait()) و خطرات Thread-Pool Starvation
۲. بررسی تسک‌های رهاشده (Fire-and-Forget بدون await) و نحوه انتشار استثناها (Exception Propagation)
۳. پشتیبانی کامل از الگوی توقف اضطراری (CancellationToken)
۴. ماشین وضعیت غیرضروری (Unnecessary Async State Machines) و سربار تخصیص تسک
۵. مقایسه جامع اجرای ترتیبی (Sequential) در برابر اجرای هم‌زمان (Concurrent/Parallel) با بررسی:
  • فشار بر کانکشن‌پول دیتابیس و پایگاه‌های داده رابطه‌ای
  • محدودیت نرخ فراخوانی سرویس‌های جانبی (Rate Limiting)
  • رفتار سیستم در زمان وقوع خطا (Fail-Fast در برابر AggregateException)

پیاده‌سازی نسخه‌های اصلاح‌شده بر مبنای نیاز سناریو
۱. نسخه ترتیبی ایمن (حداقل مصرف منابع و بیشترین سازگاری با پایگاه داده)
اگر عملیات ProcessUserAsync نیازمند دسترسی به دیتابیس با همان DbContext است، اجرای ترتیبی الزامی است؛ چرا که نمونه‌های DbContext در Entity Framework Core به هیچ وجه Thread-safe نیستند:
public async Task ProcessSequentialAsync(CancellationToken cancellationToken = default)
{
    var users = await GetUsersAsync(cancellationToken);

    foreach (var user in users)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await ProcessUserAsync(user, cancellationToken);
    }
}

۲. نسخه هم‌زمان کنترل‌شده (کنترل بار با Parallel.ForEachAsync یا SemaphoreSlim)
فرضیه غلطی وجود دارد که استفاده از Task.WhenAll همیشه بهتر است. ارسال صدها تسک هم‌زمان به Task.WhenAll می‌تواند پورت‌های اتصال شبکه یا استخر کانکشن‌های دیتابیس را اشباع کند. الگوی بهینه مدرن در دات‌نت، محدودسازی درجه همزمانی (Throttling) است:
public async Task ProcessConcurrentThrottledAsync(
    int maxDegreeOfParallelism = 8, 
    CancellationToken cancellationToken = default)
{
    var users = await GetUsersAsync(cancellationToken);

    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = maxDegreeOfParallelism,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(users, options, async (user, ct) =>
    {
        await ProcessUserAsync(user, ct);
    });
}

نکات تکمیلی برای بهینه‌سازی کدهای ناهمگام
  • استفاده از ValueTask برای مسیرهای پرتکرار یا همگام: از Copilot بپرسید آیا متدهایی که داده را غالباً از کش حافظه می‌خوانند می‌توانند برای کاهش تخصیص حافظه (Zero-Allocation) از ValueTask استفاده کنند یا خیر.
  • مدیریت استثناها در عملیات گروهی: در اجرای هم‌زمان، بروز خطا در یک تسک نباید وضعیت باقی عملیات را به شکل تعریف‌نشده رها کند؛ استفاده از ExceptionDispatchInfo یا ساختارهای تجمیعی را ارزیابی کنید.
  • بررسی سربار async/await در متدهای Wrapper: اگر یک متد صرفاً تسک بازگردانده‌شده از یک متد دیگر را به لایه بالاتر پاس می‌دهد (بدون نیاز به try/catch یا using)، می‌توان کلمه کلیدی async را حذف کرده و مستقیماً خود شیء Task را بازگرداند تا از سربار ایجاد State Machine جلوگیری شود.

قاعده کلیدی: اجرای هم‌زمان یک «تصمیم معماری» است، نه یک ترفند افزایش کارایی. هرگز بدون در نظر گرفتن محدودیت‌های زیرساختی و Thread Safety به سمت الگوهای موازی حرکت نکنید.