أفضل الممارسات لـ 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
- مكتبات الفئات القابلة لإعادة الاستخدام
المحتويات
- النسخة المختصرة
- المصطلحات المستخدمة في هذا المقال
- جدول القرار الأوّل
- 3.1. الصورة العامّة
- 3.2. إذا كان I/O-bound، انتظر async API مباشرةً
- 3.3. إذا كان حمل الـ CPU ثقيلاً، قرّر بعناية أين ينتمي
Task.Run - 3.4. إذا كانت العمليّات المتعدّدة مستقلّة، استخدم
Task.WhenAll - 3.5. إذا أردت أوّل عمليّة تنتهي، استخدم
Task.WhenAny - 3.6. إذا كانت هناك عناصر كثيرة وتريد حدّاً للتزامن، استخدم
Parallel.ForEachAsyncأوSemaphoreSlim - 3.7. إذا أردت تدفّقاً مرتّباً، استخدم
Channel<T> - 3.8. إذا أردت معالجة بفترات ثابتة، استخدم
PeriodicTimer - 3.9. إذا كانت البيانات تصل تدريجيّاً، استخدم
IAsyncEnumerable<T> - 3.10. إذا أردت تخلّصاً غير متزامن، استخدم
await using - 3.11. إذا احتجت إلى استبعاد متبادل عبر نقاط await، استخدم
SemaphoreSlim - 3.12. اكتب
awaitبشكل مختلف في كود UI وكود التطبيق وكود المكتبة
- القواعد الأساسيّة لكتابة كود غير متزامن
- الأنماط المضادّة الشائعة
- قائمة فحص لمراجعة الكود
- دليل تقريبيّ للقاعدة العامّة
- الخلاصة
- المراجع
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».
تصبح الأمور أسهل بكثير في الاستيعاب عندما تسأل:
- ما الذي ينتظره هذا العمل فعلاً؟
- مَن يملك عمر هذا العمل؟
- أين يُحدَّد التزامن؟
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 |
الثلاثة الشائعة بشكل خاصّ هي:
- I/O ملفوف في
Task.Run - awaits تسلسليّة لعمل مستقلّ حقّاً
- 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 تتعلّق بحفظ العديد من الحيل الصغيرة أقلّ ممّا تتعلّق بـ اختيار أشكال تطابق نوع العمل.
- افصل انتظارات I/O عن عمل CPU
- إذا كان I/O، انتظر async API مباشرةً
- إذا كان عمل CPU، قرّر أين يجب تشغيله
- إذا كانت هناك عمليّات متعدّدة، اختر بين
WhenAllوWhenAnyوالتزامن المحدود - إذا كان العمل يجب أن يعيش بعد المتّصل، استخدم طابوراً مُداراً بدلاً من fire-and-forget خام
- واءم بين أنواع الإرجاع والإلغاء والاستثناءات والاستبعاد المتبادل ومعالجة السياق
في المقابل، تصبح الأمور أوضح بكثير إذا فصلت:
- I/O كـ I/O
- CPU كـ CPU
- عمل الخلفيّة كعمل خلفيّة بعمر مملوك
9. المراجع
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
ما هو .NET Generic Host - شرح DI والإعدادات والـ logging و BackgroundService
مقدمة عمليّة إلى .NET Generic Host وكيف يجمع DI والإعدادات والـ logging و BackgroundService في تطبيقات console و worker، مع متى يفيد فعلا...
ما هو Native AOT في .NET - الفروق عن JIT و ReadyToRun و trimming
شرح Native AOT في .NET كنموذج نشر يقدّم الترجمة الأصليّة، مع الفروق عن JIT و ReadyToRun و trimming، والأنسب أن يكون أدوات CLI و workers و...
async/await و UI thread في WPF / WinForms في صفحة واحدة - أين تعود الاستكمالات، و Dispatcher، و ConfigureAwait، ولماذا تتعطّل .Result / .Wait()
شرح عمليّ موجز للعلاقة بين async / await و UI thread في WPF و WinForms، يوضّح أين تستأنف الاستكمالات، ودور Dispatcher و ConfigureAwait، و...
كيفيّة استخدام FileSystemWatcher بأمان - الأحداث المفقودة والإشعارات المكرّرة وفخاخ كشف الاكتمال
دليل عمليّ يشرح لماذا ينبغي اعتبار FileSystemWatcher مجرّد محفّز للمسح وليس إشارة اكتمال، ويقدّم أنماط المطالبة الذرّيّة و idempotency.
إلى أين ينتهي unit test وأين يبدأ integration test - دليل عمليّ لرسم الحدّ الفاصل
دليل عمليّ يميّز unit test وintegration test بأربعة أسئلة: نتحقّق من منطقنا أم من الغراء، ويبقى المعنى مع fake، وما طبيعة الاعتماد، ومدى ...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.
روابط عامة