أفضل الممارسات لـ C# async/await - جدول قرار لـ Task.Run و ConfigureAwait

· · C#, async/await, .NET, التصميم

نستخدم async / await في C# طوال الوقت، لكنّ الجزء المربك في المشاريع الفعليّة عادةً ليس البنية النحويّة نفسها.
ما يجعل الناس يتردّدون هو أيّ نمط نختار في أيّ موقف.

الأسئلة التي تتكرّر باستمرار تكون من النوع:

  • متى نستخدم Task.Run
  • أين نضع ConfigureAwait(false)
  • هل fire-and-forget مقبول أصلاً

الأخطاء الشائعة في الممارسة عادةً تبدو هكذا:

  • التفاف عمل I/O-bound داخل Task.Run
  • انتظار عمل مستقلّ بشكل تسلسليّ عنصراً واحداً في كلّ مرّة
  • إضافة fire-and-forget بشكل عابر ثمّ فقدان أثر الاستثناءات وتوقيت الإيقاف
  • تطبيق ConfigureAwait(false) في كلّ مكان بنفس الطريقة
  • اختيار ValueTask فقط لأنّه يبدو أخفّ

عادةً يكون من الأسهل ترتيب هذه الاختيارات بـ تحديد نوع العمل الذي تتعامل معه أوّلاً بدلاً من حفظ قواعد منعزلة.

يفترض هذا المقال في معظمه تطوير تطبيقات C# / .NET العامّة على .NET 6 وما بعد ويرتّب قرارات async / await بترتيب عمليّ.

السيناريوهات المستهدفة المعتادة تشمل:

  • تطبيقات سطح المكتب مثل WinForms و WPF
  • تطبيقات الويب وواجهات API الخاصّة بـ ASP.NET Core
  • workers وخدمات الخلفيّة
  • تطبيقات console
  • مكتبات الفئات القابلة لإعادة الاستخدام

المحتويات

  1. النسخة المختصرة
  2. المصطلحات المستخدمة في هذا المقال
  3. جدول القرار الأوّل
  4. القواعد الأساسيّة لكتابة كود غير متزامن
  5. الأنماط المضادّة الشائعة
  6. قائمة فحص لمراجعة الكود
  7. دليل تقريبيّ للقاعدة العامّة
  8. الخلاصة
  9. المراجع

1. النسخة المختصرة

  • async / await هي طريقة لتجنّب حجز thread أثناء الانتظار، وليست آليّة تجعل كلّ شيء أسرع تلقائيّاً أو تنقل كلّ شيء إلى thread آخر
  • افصل أوّلاً ما إذا كان العمل I/O-bound أم CPU-bound
  • إذا كان I/O-bound، فالاختيار الأساسيّ هو انتظار async API مباشرةً
  • إذا كان CPU-bound، فكّر في أين ينبغي تشغيل ذلك الحساب. في كود UI، يمكن أن يساعد Task.Run، لكن في معالجة طلبات ASP.NET Core، عادةً لا يكون التفاف العمل في Task.Run ثمّ انتظاره مباشرةً هو الخيار الصحيح
  • بالنسبة لعدّة عمليّات مستقلّة، فكّر في Task.WhenAll قبل awaits التسلسليّة
  • إذا كانت هناك عناصر كثيرة، لا ترمِ كلّ شيء في Task.WhenAll؛ حدّد حدّاً للتزامن
  • يبدو fire-and-forget سهلاً لكنّه صعب الإدارة. إذا أردت حقّاً فصل العمل عن عمر المتّصل، فعادةً يكون أكثر استقراراً إرساله إلى مكان مُدار مثل Channel أو خدمة مستضافة
  • اجعل الافتراض Task / Task<T> لأنواع الإرجاع. اختر ValueTask فقط بعد أن يُظهر القياس حاجة فعليّة
  • ConfigureAwait(false) قويّ في كود المكتبات العامّ، لكن في كود UI وكود التطبيق، يكون await العاديّ هو الافتراض الصحيح عادةً
  • استخدم async void فقط لمعالجات الأحداث

بعبارة أخرى، أهمّ شيء حول async / await هو ألّا تلجأ افتراضيّاً إلى:

«فقط استخدم Task.Run»، أو «فقط استخدم fire-and-forget»، أو «فقط استخدم ValueTask».

تصبح الأمور أسهل بكثير في الاستيعاب عندما تسأل:

  1. ما الذي ينتظره هذا العمل فعلاً؟
  2. مَن يملك عمر هذا العمل؟
  3. أين يُحدَّد التزامن؟

2. المصطلحات المستخدمة في هذا المقال

2.1. التمييز الأوّل الذي يجب القيام به

فصل هاتين الفكرتين أوّلاً يزيل الكثير من الالتباس:

المصطلح المعنى في هذا المقال
I/O-bound عمل ينتظر بشكل أساسيّ اكتمالاً خارجيّاً، مثل HTTP، أو قاعدة بيانات، أو ملفّ، أو socket
CPU-bound عمل يقضي بشكل أساسيّ وقتاً على الحساب نفسه، مثل الضغط، ومعالجة الصور، والتجزئة (hashing)، والتحويلات الثقيلة

async / await فعّال بشكل خاصّ لانتظارات I/O لأنّه يمكن إعادة الـ thread إلى أعمال أخرى أثناء انتظار العمليّة.

عمل CPU-bound مختلف. إنّه ليس عن الانتظار؛ بل عن أين يجب تشغيل الحساب وكم تزامناً تريد.

2.2. مصطلحات أخرى تظهر بشكل متكرّر

المصطلح المعنى في هذا المقال
blocking احتلال thread أثناء انتظار الاكتمال
fire-and-forget بدء عمل دون انتظار المتّصل لرصد الاكتمال
SynchronizationContext آليّة تستخدمها نماذج UI والبيئات المشابهة للعودة إلى سياق التنفيذ الأصليّ
backpressure منع الإرهاق بإجبار الكتّاب على الانتظار عندما لا يستطيع جانب المستهلك مواكبة السرعة

نقطة مهمّة بشكل خاصّ هي أنّ عدم التزامن والتوازي شيئان مختلفان.

  • عدم التزامن: كيف تنتظر
  • التوازي: كم شيئاً تشغّل في نفس الوقت

إذا اختلطت هاتان الفكرتان، يبدأ الناس باللجوء إلى Task.Run في كلّ مكان. وهذا غالباً المنعطف الخاطئ الأوّل.

3. جدول القرار الأوّل

3.1. الصورة العامّة

البدء بهذا الجدول عادةً يعطيك الاتّجاه الصحيح بسرعة:

الموقف الأداة الأولى للنظر فيها ما يجب الانتباه إليه
انتظارات HTTP / DB / ملفّ انتظر async API مباشرةً لا تلفّه في Task.Run
حساب ثقيل لا يجب أن يجمّد UI Task.Run انقل عمل CPU خارج thread الـ UI
معالجة طلبات ASP.NET Core await عاديّ لا تنتظر Task.Run مباشرةً
بضع عمليّات async مستقلّة Task.WhenAll ابدأها كلّها أوّلاً، ثمّ انتظر معاً
فقط النتيجة الأولى المكتملة مهمّة Task.WhenAny فكّر في الإلغاء ورصد الاستثناءات للمهامّ المتبقّية
عناصر كثيرة مع حدّ Parallel.ForEachAsync / SemaphoreSlim اجعل التزامن صريحاً
معالجة خلفيّة مرتّبة Channel<T> فكّر في طوابير محدودة و backpressure
معالجة async دوريّة PeriodicTimer تذكّر timer واحد، مستهلك واحد
نتائج تصل تدريجيّاً IAsyncEnumerable<T> / await foreach عالج دون انتظار كلّ النتائج
تنظيف غير متزامن await using استخدم IAsyncDisposable
استبعاد متبادل عبر نقاط await SemaphoreSlim.WaitAsync حرّر دائماً في try/finally
كود مكتبة عامّ فكّر في ConfigureAwait(false) تجنّب الاعتماد على سياق UI / تطبيق محدّد
flowchart TD
    start["What kind of work do you want to do?"] --> q1{"Waiting for external I/O?"}
    q1 -- "Yes" --> p1["Await the async API directly"]
    q1 -- "No" --> q2{"Heavy CPU work?"}
    q2 -- "Yes" --> q3{"Where does it run?"}
    q3 -- "UI event / desktop app" --> p2["Consider Task.Run"]
    q3 -- "ASP.NET Core request" --> p3["Do not wrap it in Task.Run<br/>If needed, move it to a worker or queue"]
    q3 -- "worker / background process" --> p4["Run it directly or make concurrency explicit"]
    q2 -- "No" --> q4{"Multiple pieces of work?"}
    q4 -- "Wait for all" --> p5["Task.WhenAll"]
    q4 -- "Use the first winner" --> p6["Task.WhenAny"]
    q4 -- "There are many items" --> p7["Parallel.ForEachAsync<br/>or SemaphoreSlim"]
    q4 -- "Need ordered flow" --> p8["Channel<T>"]
    q4 -- "Need fixed interval" --> p9["PeriodicTimer"]
    q4 -- "Need progressive stream" --> p10["IAsyncEnumerable<T>"]

3.2. إذا كان I/O-bound، انتظر async API مباشرةً

هذا هو النمط الأكثر أساسيّة.

بالنسبة لـ HTTP وقواعد البيانات وقراءات الملفّات والعمليّات المماثلة، تحقّق أوّلاً ممّا إذا كان هناك بالفعل async API.
إذا كان هناك، فالاختيار الافتراضيّ هو انتظاره مباشرةً.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

ما تريد عادةً تجنّبه هو التفاف I/O الذي هو async بالفعل داخل Task.Run.

// Usually a poor choice
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

ذلك في معظمه يقفز بـ I/O فقط إلى thread آخر ويجعل البنية أصعب في الاستيعاب دون فائدة كبيرة.

3.3. إذا كان حمل الـ CPU ثقيلاً، قرّر بعناية أين ينتمي Task.Run

Task.Run مفيد عندما تريد نقل عمل CPU بعيداً عن الـ thread الحاليّ.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

لكنّ السؤال المهمّ هو أين يُستدعى.

  • كود UI لـ WinForms / WPF: هناك حالات حقيقيّة يساعد فيها Task.Run
  • معالجة طلبات ASP.NET Core: انتظار Task.Run مباشرةً عادةً افتراض سيّئ
  • معالجة worker / خلفيّة: إمّا شغّله في مكانه أو صمّم مستوى التزامن صراحةً

معالجة طلبات ASP.NET Core تعمل بالفعل على ThreadPool.
إضافة طبقة Task.Run أخرى وانتظارها مباشرةً غالباً يضيف فقط حملاً إضافيّاً للجدولة.

لذلك في ASP.NET Core، هذه القاعدة العامّة أسهل في العمل بها:

  • await عاديّ لانتظارات I/O
  • شغّل عمل CPU القصير مباشرةً
  • إذا كان العمل طويلاً أو يجب أن يعيش بعد انتهاء الطلب، انقله إلى طابور أو خدمة مستضافة

لاحظ أيضاً أنّه إذا كان هناك API متزامن فقط والمتّصل تطبيق UI، فإنّ استخدام Task.Run لاستجابة UI لا يزال منطقيّاً.
لكنّ ذلك ليس I/O غير متزامن. إنّه ببساطة استخدام thread آخر لتجنّب تجميد UI. على جانب الخادم، عادةً لا يتسع هذا المخرج جيّداً.

3.4. إذا كانت العمليّات المتعدّدة مستقلّة، استخدم Task.WhenAll

من الشائع جدّاً رؤية كود مثل هذا:

// Independent work, but awaited serially
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

إذا كانت تلك العمليّات مستقلّة حقّاً، فمن الأطبع بدؤها كلّها أوّلاً ثمّ الانتظار مرّة واحدة.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

التفصيل المهمّ هنا هو ToArray().
لأنّ LINQ يستخدم تنفيذاً مؤجّلاً، فإنّ مجرّد استدعاء Select لا يعني بالضرورة أنّ المهامّ قد بدأت بعد.
ToArray() أو ToList() يثبّت التسلسل بحيث تبدأ كلّها عند تلك النقطة.

sequenceDiagram
    participant Caller as Caller
    participant T1 as Task 1
    participant T2 as Task 2
    participant T3 as Task 3

    Caller->>T1: Start
    Caller->>T2: Start
    Caller->>T3: Start
    Caller->>Caller: await Task.WhenAll(...)
    T1-->>Caller: Complete
    T2-->>Caller: Complete
    T3-->>Caller: Complete

هذا النمط يلائم عندما:

  • يكون عدد العناصر صغيراً أو معتدلاً
  • تريد انتظار كلّ النتائج معاً
  • يكون تشغيلها كلّها دفعة واحدة مقبولاً

إذا كان عدد العناصر كبيراً، فعادةً يكون أكثر أماناً إضافة حدّ للتزامن كما في القسم التالي.

3.5. إذا أردت أوّل عمليّة تنتهي، استخدم Task.WhenAny

إذا أردت استخدام أيّ مرآة أو endpoint يستجيب أوّلاً، فإنّ Task.WhenAny سهل القراءة.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    Task<byte[]>[] tasks = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToArray();

    Task<byte[]> winner = await Task.WhenAny(tasks);
    cts.Cancel();

    try
    {
        return await winner;
    }
    finally
    {
        try
        {
            await Task.WhenAll(tasks);
        }
        catch
        {
            // Observe cancellation or failure from the non-winning tasks
        }
    }
}

الشيء الأساسيّ الذي يجب تذكّره هو أنّ WhenAny يُرجِع فائزاً واحداً، ولا شيء أكثر.
المهامّ المتبقّية تستمرّ في العمل ما لم تقرّر ما تفعله بها.

لذلك تحتاج إلى أن تقرّر:

  • هل ينبغي إلغاء العمل المتبقّي؟
  • هل ينبغي رصد الاستثناءات؟

Task.WhenAny مفيد، لكنّه يضيف التزامات تصميم أكثر من WhenAll.
من الأفضل استخدامه فقط عندما تهتمّ حقّاً بأوّل نتيجة مكتملة.

3.6. إذا كانت هناك عناصر كثيرة وتريد حدّاً للتزامن، استخدم Parallel.ForEachAsync أو SemaphoreSlim

Task.WhenAll يبدأ كلّ المهامّ المُنشَأة دفعة واحدة.
لذلك إذا كانت هناك عناصر كثيرة، فقد ترتفع اتّصالات HTTP، واتّصالات DB، واستخدام الذاكرة، والحمل على الخدمات الخارجيّة كلّها دفعة واحدة.

في تلك المواقف، يكون أكثر استقراراً تحديد كم شيئاً يمكن أن يعمل في نفس الوقت.

Parallel.ForEachAsync يجعل تلك النيّة سهلة القراءة.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

إذا احتجت إلى تحكّم مخصّص أكثر، فإنّ SemaphoreSlim عمليّ أيضاً.

3.7. إذا أردت تدفّقاً مرتّباً، استخدم Channel<T>

أحياناً تريد فصل العمل عن المتّصل رغم أنّه لا يحتاج إلى الانتهاء فوراً: تسليم البريد الإلكترونيّ، وشحن السجلّات، ومعالجة متابعة webhook، وتحويل الملفّات، وما إلى ذلك.

إذا تعاملت مع ذلك بـ Task.Run خام من نوع fire-and-forget، تصبح كلّ هذه غامضة:

  • أين تُرصَد الاستثناءات؟
  • هل ننتظر أثناء الإيقاف؟
  • ماذا يحدث عندما يزداد حجم الإدخال؟

هذا النوع من العمل عادةً أسهل في الإدارة عندما تضعه في طابور وتدع مستهلكاً مخصّصاً يعالجه بالترتيب.

flowchart LR
    p["producer"] --> w["WriteAsync"]
    w --> q{"Space available in queue?"}
    q -- "Yes" --> c["Enter Channel"]
    q -- "No" --> b["Wait until there is space"]
    c --> d["consumer reads with ReadAsync"]
    d --> e["process items in order with await"]
public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

3.8. إذا أردت معالجة بفترات ثابتة، استخدم PeriodicTimer

بالنسبة للعمل غير المتزامن بفترات ثابتة، فإنّ PeriodicTimer قابل للقراءة جدّاً.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

تحذير مهمّ هو أنّ PeriodicTimer يفترض أنّك لا تُصدِر استدعاءات WaitForNextTickAsync متزامنة متعدّدة على نفس الـ timer.

3.9. إذا كانت البيانات تصل تدريجيّاً، استخدم IAsyncEnumerable<T>

أحياناً يكون من الأفضل معالجة البيانات عند وصولها بدلاً من جمع كلّ شيء في List<T> وإرجاع كلّ شيء دفعة واحدة.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

هذا يلائم عندما تريد معالجة العناصر واحداً بواحد، وتجنّب تخزين كلّ شيء في الذاكرة، وتجنّب انتظار كلّ النتائج قبل البدء.

3.10. إذا أردت تخلّصاً غير متزامن، استخدم await using

إذا احتاج نوع إلى عمل غير متزامن أثناء التخلّص، استخدم await using.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

3.11. إذا احتجت إلى استبعاد متبادل عبر نقاط await، استخدم SemaphoreSlim

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

ادخل بـ WaitAsync، وحرّر دائماً في finally.

3.12. اكتب await بشكل مختلف في كود UI وكود التطبيق وكود المكتبة

ConfigureAwait(false) ليس شيئاً يُضاف في كلّ مكان بشكل افتراضيّ.

flowchart LR
    a["UI / application code"] --> b["await someAsync()"]
    b --> c["continue on the original context"]

    d["general-purpose library"] --> e["await someAsync().ConfigureAwait(false)"]
    e --> f["does not assume a specific context"]
  • كود UI / تطبيق: await العاديّ هو الافتراض الصحيح عادةً
  • كود تطبيق ASP.NET Core: await العاديّ يكفي عادةً
  • كود مكتبة عامّ: إذا لم يعتمد على UI أو نموذج تطبيق، فإنّ ConfigureAwait(false) غالباً خيار قويّ

4. القواعد الأساسيّة لكتابة كود غير متزامن

4.1. اجعل الافتراض Task / Task<T> لأنواع الإرجاع

نوع الإرجاع المعنى الافتراضيّ العمليّ
Task الاختيار القياسيّ للطرق غير المتزامنة دون نتيجة
Task<T> الاختيار القياسيّ للطرق غير المتزامنة التي تُرجِع قيمة
ValueTask / ValueTask<T> اختر فقط بعد أن يُظهر القياس حاجة فعليّة

ValueTask ليس أفضل تلقائيّاً من Task.
إنّه struct، لذلك له تكلفة نسخ وقيود استخدام، وهو مصمّم أساساً ليُنتظَر مرّة واحدة.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

إذا لم يكن هناك عمل غير متزامن فعليّ بالداخل، فإنّ إرجاع Task.CompletedTask أو Task.FromResult(...) عادةً أنظف من إضافة async بلا سبب.

4.2. استخدم async void فقط لمعالجات الأحداث

كقاعدة، تجنّب async void خارج معالجات الأحداث.

  • لا يستطيع المتّصل انتظاره
  • لا يمكن تتبّع الاكتمال
  • معالجة الاستثناءات تصبح أصعب
  • الاختبار يصبح أصعب
private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "Saved.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "Canceled.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "Save Error");
    }
}

4.3. اقبل CancellationToken ومرّره إلى الطبقات الأدنى

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

خطأ شائع هو قبول token في المستوى الأعلى لكن عدم تمريره إلى الطبقات الأدنى.

  • إذا أردت فقط تحديد كم تنتظر، استخدم WaitAsync
  • إذا أردت إيقاف العمليّة الأساسيّة نفسها، استخدم CancellationTokenSource.CancelAfter ومرّر الـ token

4.4. حافظ على async API غير متزامناً حتّى النهاية

النمط المغري البديل الأفضل
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

خاصّة في كود UI و ASP.NET Core، فإنّ خلط الانتظار المتزامن يجعل التوقّفات الناتجة أصعب بكثير في الاستيعاب.

4.5. ثبّت تسلسلات المهامّ بـ ToArray / ToList

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

لأنّ LINQ يستخدم تنفيذاً مؤجّلاً، فإنّ التثبيت بـ ToArray() أو ToList() عادةً هو الاختيار الأكثر أماناً.

5. الأنماط المضادّة الشائعة

النمط المضادّ لماذا يضرّ البديل الأوّل
Task.Run(async () => await IoAsync()) يقفز بـ I/O دون داعٍ إلى thread آخر await IoAsync()
Task.Result / Wait() يحجز threads ويسبّب توقّفات بسهولة await
خلط Thread.Sleep() في تدفّق async يحتلّ thread حتّى أثناء الانتظار Task.Delay()
استخدام async void للطرق العاديّة لا يمكن انتظاره، صعب إدارة الاستثناءات Task / Task<T>
awaits تسلسليّة حيث يلائم Task.WhenAll بطء غير ضروريّ ابدأ كلّ المهامّ أوّلاً، ثمّ WhenAll
رمي عدد ضخم من العناصر في WhenAll ارتفاعات حمل Parallel.ForEachAsync / SemaphoreSlim
محاولة عبور await بـ lock الأداة الخاطئة للمهمّة SemaphoreSlim.WaitAsync
fire-and-forget خام بـ Task.Run معالجة استثناءات وإيقاف وحدود غامضة Channel<T> / BackgroundService
إضافة ConfigureAwait(false) آليّاً إلى كود UI تحديثات UI بعد await تصبح هشّة await عاديّ
اعتماد ValueTask افتراضيّاً في كلّ مكان غالباً تعقيد أكبر من الفائدة ابدأ بـ Task

الثلاثة الشائعة بشكل خاصّ هي:

  1. I/O ملفوف في Task.Run
  2. awaits تسلسليّة لعمل مستقلّ حقّاً
  3. fire-and-forget دون مالك للعمر

6. قائمة فحص لمراجعة الكود

  • هل يستطيع المؤلّف أن يشرح بالكلمات ما إذا كان العمل I/O-bound أم CPU-bound؟
  • هل لا تزال Task.Result / Task.Wait() / Thread.Sleep() موجودة؟
  • هل I/O ملفوف في Task.Run؟
  • هل العمليّات المستقلّة تُنتظَر تسلسليّاً دون سبب؟
  • من ناحية أخرى، هل يُرسَل عدد كبير من العناصر إلى WhenAll غير محدود؟
  • إذا قبلت الطريقة CancellationToken، فهل هو فعلاً مُمرَّر إلى الطبقات الأدنى؟
  • هل async void مستخدم في أيّ مكان غير معالجات الأحداث؟
  • إذا كان fire-and-forget موجوداً، فمَن يملك معالجة الاستثناءات والإيقاف والحدود؟
  • إذا كان SemaphoreSlim مستخدماً، فهل Release داخل finally؟
  • إذا كان ValueTask مستخدماً، فهل هناك سبب مقاس؟
  • هل استخدام أو عدم استخدام ConfigureAwait(false) يطابق نوع الكود؟

7. دليل تقريبيّ للقاعدة العامّة

ما تريد فعله الاختيار الأوّل
عمليّة HTTP / DB / ملفّ I/O واحدة انتظر async API مباشرةً
حساب ثقيل لا يجب أن يجمّد UI Task.Run
بضع عمليّات async مستقلّة Task.WhenAll
فقط النتيجة الأولى مهمّة Task.WhenAny
عناصر كثيرة بتزامن محدود Parallel.ForEachAsync / SemaphoreSlim
معالجة خلفيّة مرتّبة Channel<T>
معالجة بفترات ثابتة PeriodicTimer
معالجة تدفّق تدريجيّ IAsyncEnumerable<T> / await foreach
استبعاد متبادل عبر نقاط await SemaphoreSlim
كود مكتبة عامّ فكّر في ConfigureAwait(false)
غير متأكّد من نوع الإرجاع ابدأ بـ Task / Task<T>

8. الخلاصة

أفضل الممارسات لـ async / await تتعلّق بحفظ العديد من الحيل الصغيرة أقلّ ممّا تتعلّق بـ اختيار أشكال تطابق نوع العمل.

  1. افصل انتظارات I/O عن عمل CPU
  2. إذا كان I/O، انتظر async API مباشرةً
  3. إذا كان عمل CPU، قرّر أين يجب تشغيله
  4. إذا كانت هناك عمليّات متعدّدة، اختر بين WhenAll و WhenAny والتزامن المحدود
  5. إذا كان العمل يجب أن يعيش بعد المتّصل، استخدم طابوراً مُداراً بدلاً من fire-and-forget خام
  6. واءم بين أنواع الإرجاع والإلغاء والاستثناءات والاستبعاد المتبادل ومعالجة السياق

في المقابل، تصبح الأمور أوضح بكثير إذا فصلت:

  • I/O كـ I/O
  • CPU كـ CPU
  • عمل الخلفيّة كعمل خلفيّة بعمر مملوك

9. المراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة