> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# التزامن في C#: ‏async/await والمهام Tasks بالأرقام
- URL: https://arabroot.io/articles/التزامن-في-csharp-async-await-والمهام-tasks/
- Published: 2026-10-31T07:31:32.000Z
- Updated: 2026-10-31T08:31:07.000Z
- Description: قسنا نموذج التزامن في .NET بمئة طلب HTTP ومهمة SHA-256 على نواة ونواتين وأربع، ورأينا ‏.Result يجمد مجمع الخيوط 23 ثانية، ثم سباق بيانات وقفلاً مميتاً وإصلاح كل منهما.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

قبل موسم رمضان بأسبوعين طلب فريق المتجر، وهو الفريق الذي يبيع القهوة والتمور ويكتب خدماته بـ C#، ميزتين صغيرتين. الأولى مهمة تحدث أسعار مئة منتج من واجهة المورد كل ساعة، وكل استدعاء لواجهة المورد يأخذ قرابة 50 ملي ثانية، فكتبها أحد الزملاء بحلقة عادية تنتظر كل طلب قبل الذي بعده، فصارت تأخذ أكثر من خمس ثوان. وحاول زميل آخر أن يسرعها فلف كل استدعاء في `Task.Run` وأخذ النتيجة بـ `.Result`، وفي أول تشغيل على السيرفر تجمدت الخدمة كلها أكثر من عشرين ثانية، ولم ترد حتى على فحص الصحة Health Check. والميزة الثانية عرض محدود لعشر دلال نحاسية، وفي أول دقيقة من العرض باعت الخدمة عشرين دلة. والمشكلتان سببهما نفس الموضوع: كيف تنفذ C# أكثر من عمل في نفس الوقت، وما الذي يضمنه كل أسلوب وما الذي لا يضمنه.

وهذا هو المقال الثالث في مرحلة [لغة C# للمطورين](https://arabroot.io/start/#stage-lang-csharp)، بعد [الأدوات والأساسيات](https://arabroot.io/articles/csharp-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA/) و[الأنواع والذاكرة والاستثناءات](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%AB%D9%86%D8%A7%D8%A1%D8%A7%D8%AA-%D9%81%D9%8A-csharp/). وسوف نقيس فيه نموذج التزامن في .NET بنفس المهمتين اللتين نقيسهما في كل لغات هذا المسار حتى تقارن بينها: مهمة تنتظر الشبكة I/O-bound، وهي مئة طلب HTTP إلى نقطة نهاية Endpoint محلية بطيئة ترد بعد 50 ملي ثانية، ومهمة حسابية CPU-bound، وهي هاش SHA-256 لألفي نص، كل نص ألفي جولة. ثم نرى سباق بيانات حقيقياً وقفلاً مميتاً حقيقياً وإصلاح كل منهما. والمفاهيم العامة للخيوط والأقفال شرحناها في مقال [الخيوط و async/await](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)، وما يعنيه التزامن للمعالج والسيرفر في مقال [التزامن والخيوط](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، فلن نكررها، وإنما نركز على ما تفعله .NET بالتحديد.

وسوف نناقش في هذا المقال ما يلي:

- نموذج التزامن في .NET: الخيوط Threads، ومجمع الخيوط Thread Pool، والمهام Tasks، والفرق بين التزامن Concurrency والتوازي Parallelism.
- كيف يعمل `async` و`await` من الداخل: آلة الحالات State Machine، ولماذا لا يحجز الانتظار أي خيط.
- التجربة: نقطة النهاية البطيئة، والمهمة الأولى بأربع طرق، وحدود التزامن بـ `SemaphoreSlim` و`Parallel.ForEachAsync`.
- المشكلة التي جمدت الخدمة: جوع مجمع الخيوط Thread Pool Starvation، بالأرقام، وماذا يحدث إذا رفعنا الحد الأدنى للخيوط.
- المهمة الحسابية على نواة ونواتين وأربع، ولماذا `async` لا تعني متوازياً.
- سباق البيانات Race Condition، وفحص ثم تنفيذ Check-then-Act في المخزون، والقفل المميت Deadlock، وإصلاح كل منها.
- الإلغاء Cancellation والمهلات Timeouts والقنوات Channels، والأخطاء الشائعة، وجدول قرار.

## نموذج التزامن في .NET: الخيوط Threads ومجمع الخيوط Thread Pool والمهام Tasks

في أسفل كل شيء في .NET خيوط نظام التشغيل الحقيقية، فالكلاس `Thread` يقابل خيطاً في Linux أو Windows واحداً بواحد، وكل خيط له Stack بحجم ميجابايت تقريباً، ويحتاج النظام وقتاً لينشئه ويبدل بينه وبين غيره. لذلك لا تنشئ خيطاً لكل عمل صغير، وإنما يوجد في كل عملية .NET [مجمع خيوط Thread Pool](https://learn.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool?ref=arabroot.io) مشترك، فيه عدد قليل من الخيوط الجاهزة تأخذ الأعمال من طابور وتنفذها واحداً بعد الآخر، والمجمع يضيف خيوطاً عندما يرى أن الطابور لا يتحرك، ويحذفها عندما يقل العمل. وإذا كنت تأتي من Java فهذا يشبه `ExecutorService` مشتركاً للعملية كلها، وإذا كنت تأتي من Node.js فالفرق الكبير أن .NET ليس فيها حلقة أحداث Event Loop واحدة على خيط واحد كما شرحنا في مقال [حلقة الأحداث](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)، وإنما عدة خيوط تعمل فعلاً في نفس اللحظة على عدة أنوية.

وفوق المجمع يوجد `Task`، وهو كائن يمثل عملاً سوف ينتهي في المستقبل، ويحمل نتيجته أو استثناءه عندما ينتهي، وهو ما تسميه JavaScript الـ Promise وتسميه Java الـ Future. والمهمة لا تعني خيطاً، وهذه أهم نقطة في الموضوع كله: المهمة قد تكون عملاً حسابياً يعمل على خيط من المجمع، وقد تكون انتظاراً لرد شبكة لا يحجز أي خيط إطلاقاً. وفوق المهام بنت .NET مكتبة [TPL](https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-parallel-library-tpl?ref=arabroot.io)، وفيها `Parallel.For` و`Parallel.ForEachAsync` و`Task.WhenAll`، ثم الكلمتان `async` و`await` في اللغة نفسها.

ولكي لا تختلط المصطلحات: التزامن Concurrency أن يكون عندك عدة أعمال جارية في نفس الفترة، حتى لو كان معظمها ينتظر، مثل مئة طلب HTTP ينتظر كل منها رداً، والتوازي Parallelism أن تنفذ عدة أعمال حسابية في نفس اللحظة على عدة أنوية. والمهمة الأولى في تجربتنا تحتاج التزامن ولا تحتاج التوازي، والثانية تحتاج التوازي، والخلط بينهما هو سبب أغلب الأخطاء التي سوف نراها.

## لماذا ظهر async/await؟ من الخيوط إلى نمط TAP

إذا كنت تعرف async/await من JavaScript أو Python فقد يفاجئك أن C# هي التي بدأت بها بين اللغات المشهورة، ومعرفة الطريق الذي وصلت به يفسر لماذا ترى في الكود القديم أشكالاً غريبة. ففي .NET الأولى كان عندك الخيوط مباشرة، ثم ما سمته Microsoft نمط البرمجة غير المتزامنة APM، وهو زوج من الدوال مثل `BeginRead` و`EndRead` مع دالة رد Callback تستدعى عند الانتهاء، ثم نمط آخر يعتمد على الأحداث Events، وكل هذه الأنماط مذكورة في [صفحة أنماط البرمجة غير المتزامنة](https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/?ref=arabroot.io). والمشكلة فيها كلها هي نفس مشكلة الـ Callbacks في JavaScript القديمة: كل خطوة انتظار تقسم المنطق إلى دالة جديدة، والأخطاء لا تصعد بشكل طبيعي، والحلقات والشروط حول الانتظار تصير صعبة جداً.

ثم جاءت مكتبة TPL مع .NET 4 في 2010 بالنوع `Task`، فصار للعمل الذي سينتهي في المستقبل كائن واحد تمرره وتركب عليه، وهذا هو النمط المعتمد على المهام TAP الذي تتبعه كل مكتبة حديثة: الدالة اسمها ينتهي بـ `Async`، وترجع `Task` أو `Task<T>`، وتقبل `CancellationToken`. وبعدها بسنتين جاءت C# 5 بالكلمتين `async` و`await` فوق هذا النمط، فصرت تكتب الكود غير المتزامن بنفس شكل الكود العادي، بالحلقات والشروط و`try` و`catch`، والمترجم يحوله إلى آلة الحالات. ثم أخذت الفكرة لغات أخرى، فجاءت إلى JavaScript وPython وRust بأشكال متقاربة، ولذلك تشعر بالألفة عندما تقرأ `await` في C#، مع فرق واحد مهم سوف نراه في التجربة: في JavaScript تعمل كل التكملات على خيط واحد، وفي .NET تعمل على أي خيط من المجمع وعلى عدة أنوية في نفس اللحظة، وهذا يعطيك توازياً مجانياً ويحملك مسؤولية حماية البيانات المشتركة.

## كيف يعمل async/await؟ آلة الحالات State Machine

عندما تكتب دالة `async` فيها `await`، فالمترجم لا يولد دالة عادية، وإنما يحولها إلى كلاس صغير هو آلة حالات State Machine، فيه حقل لكل متغير محلي، وحقل يحفظ عند أي `await` توقفت الدالة. وعندما يصل التنفيذ إلى `await http.GetStringAsync(url)` تحدث ثلاثة أشياء: الدالة ترسل الطلب، ثم تسأل المهمة الراجعة هل انتهت؟ فإذا لم تنته تسجل نفسها كتكملة Continuation على تلك المهمة، وترجع فوراً لمن استدعاها، والخيط الذي كان ينفذها يرجع إلى المجمع ليخدم عملاً آخر. وعندما يصل الرد من الشبكة يبلغ نظام التشغيل الـ Runtime، فيأخذ أي خيط حر من المجمع ويكمل الدالة من السطر الذي بعد `await`، وقد يكون خيطاً مختلفاً عن الأول. والشرح الكامل لهذه الآلية في مقال Stephen Toub [How Async/Await Really Works](https://devblogs.microsoft.com/dotnet/how-async-await-really-works/?ref=arabroot.io)، ونسخته المختصرة في [صفحة Async in depth](https://learn.microsoft.com/en-us/dotnet/standard/async-in-depth?ref=arabroot.io). والصورة التالية تبين ما يحدث لثلاثة من الطلبات المئة:

![ثلاثة مسارات زمنية للطلب 1 والطلب 2 والطلب 100، في كل منها جزء أزرق قصير للإرسال ثم جزء رمادي طويل للانتظار 50 ms بدون أي خيط ثم جزء أخضر قصير للتكملة، وتحتها ثلاثة صناديق: قبل await يعمل الكود على خيط من المجمع، وعند await تحول الدالة إلى آلة حالات تحفظ مكانها ويرجع الخيط للمجمع، وعند وصول الرد يأخذ الـ Runtime أي خيط حر ويكمل الدالة](https://arabroot.io/content/images/2026/10/csharp-concurrency-02-await.webp)

الانتظار لا يحجز خيطاً: الخيط يعمل فقط في الأجزاء الملونة

وفي برامج سطر الأوامر وفي ASP.NET Core لا يوجد ما يسمى سياق التزامن SynchronizationContext، فالتكملة تعمل على أي خيط من المجمع. أما في تطبيقات الواجهات مثل WinForms وWPF، وفي ASP.NET القديم على .NET Framework، فيوجد سياق يجبر التكملة أن ترجع إلى خيط الواجهة أو خيط الطلب، وهذا سبب نصيحة `ConfigureAwait(false)` التي تقرؤها في كل مكان، والتفاصيل في [أسئلة ConfigureAwait الشائعة](https://devblogs.microsoft.com/dotnet/configureawait-faq/?ref=arabroot.io). وإذا كان كودك خدمة ويب حديثة فلا تحتاجها، وإذا كنت تكتب مكتبة قد يستعملها تطبيق واجهات فاكتبها.

### ومتى تحتاج ValueTask؟

سوف ترى في مكتبات .NET دوالاً ترجع [ValueTask<T>](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1?ref=arabroot.io) بدل `Task<T>`، والسبب يعود لما رأيناه في المقال السابق عن التخصيص: `Task` كائن على الـ Heap، فإذا كانت الدالة تنتهي فوراً في أغلب الاستدعاءات، مثل قراءة من ذاكرة مؤقتة أو من Buffer فيه بيانات، فإنشاء كائن مهمة جديد في كل استدعاء ثمن لا داعي له، و`ValueTask` نوع قيمة يحمل النتيجة مباشرة في هذه الحالة دون أي تخصيص. ولكن له قيود: لا تنتظره مرتين، ولا تنتظره من مكانين، ولا تحفظه لتنتظره لاحقاً. والقاعدة العملية: في كودك أنت اكتب `Task` دائماً، واستعمل `ValueTask` فقط عندما يقول القياس إن هذا التخصيص في مسار ساخن، وانتظر أي `ValueTask` ترجعه المكتبة مباشرة بـ `await` ولا تفعل به شيئاً آخر.

## التجربة العملية Lab: نقطة النهاية البطيئة Slow Endpoint

نحتاج أولاً سيرفراً يرد بعد 50 ملي ثانية بالضبط، وكتبناه بـ ASP.NET Core نفسه في ملف واحد، ويعمل في Container مستقل على شبكة Docker خاصة:

```csharp
// The slow endpoint used by every concurrency lab in this track: each request waits 50 ms, then answers.
var builder = WebApplication.CreateSlimBuilder(args);
builder.Logging.ClearProviders();
var app = builder.Build();
long served = 0;
app.MapGet("/price/{id:int}", async (int id) =>
{
    await Task.Delay(50);
    Interlocked.Increment(ref served);
    return Results.Text($"{{\"id\":{id},\"price_halalas\":990}}", "application/json");
});
app.MapGet("/served", () => Interlocked.Read(ref served).ToString());
app.Run("http://0.0.0.0:8080");
```

ولاحظ أن نقطة النهاية نفسها تستعمل `await Task.Delay(50)` وليس `Thread.Sleep(50)`، فهي لا تحجز خيطاً أثناء الانتظار، وتستطيع أن تخدم مئة طلب في نفس الوقت بخيوط قليلة، وهذا شرط حتى لا يصير السيرفر نفسه عنق الزجاجة Bottleneck في القياس. وقبل أي قياس يجب أن تتأكد أن الطلب الواحد يأخذ فعلاً قرابة 50 ملي ثانية، لأن بعض السيرفرات البسيطة تكتب الرأس Headers والجسم Body في كتابتين منفصلتين على الـ Socket، فيتفاعل خوارزم Nagle مع الإقرار المتأخر Delayed ACK ويضيف عشرات الملي ثواني لكل طلب، فتقيس السيرفر وليس اللغة. وKestrel يكتب الرد مرة واحدة، والقياس التتابعي في الفقرة التالية يؤكد ذلك: مئة طلب في 5,296 ملي ثانية، أي قرابة 53 ملي ثانية للطلب، منها 50 للانتظار والباقي للشبكة والمعالجة.

والعميل برنامج سطر أوامر واحد فيه عدة أقسام، والسكربت `lab/run-host.sh` يشغل نقطة النهاية والعميل على شبكة Docker واحدة، وكل Container محدود بنواتين و512 ميجابايت:

```bash
docker network create slc-cs-net
docker run -d --name slc-cs-slow --network slc-cs-net --cpus=2 --memory=512m ... dotnet /app/Slow.dll
docker run --rm --network slc-cs-net --cpus=2 --memory=512m ... dotnet /app/Client.dll io
```

### المهمة الأولى I/O-bound: أربع طرق للانتظار

القسم `io` في العميل يرسل نفس المئة طلب بأربع طرق، بعد طلب تسخين واحد يفتح الاتصال ويترجم الدوال بالـ JIT:

```csharp
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
await http.GetStringAsync("/price/0");                                   // warm up

// 1. sequential
for (int i = 1; i <= 100; i++) await http.GetStringAsync($"/price/{i}");

// 2. all at once
var all = await Task.WhenAll(Enumerable.Range(1, 100).Select(i => http.GetStringAsync($"/price/{i}")));

// 3. at most 10 at a time
await Parallel.ForEachAsync(Enumerable.Range(1, 100), new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (i, ct) => await http.GetStringAsync($"/price/{i}", ct));

// 4. at most 20 at a time, by hand
using var gate = new SemaphoreSlim(20);
await Task.WhenAll(Enumerable.Range(1, 100).Select(async i =>
{
    await gate.WaitAsync();
    try { await http.GetStringAsync($"/price/{i}"); } finally { gate.Release(); }
}));
```

وشغلنا القسم مرتين متتاليتين، والمخرج سوف يكون كما يلي:

```text
processors=2 section=io
io sequential (await one by one): 6060 ms
io Task.WhenAll (100 at once):    240 ms, 100 replies, threads in pool 3
io Parallel.ForEachAsync (10):    583 ms
io WhenAll + SemaphoreSlim(20):   314 ms
processors=2 section=io
io sequential (await one by one): 5296 ms
io Task.WhenAll (100 at once):    153 ms, 100 replies, threads in pool 3
io Parallel.ForEachAsync (10):    534 ms
io WhenAll + SemaphoreSlim(20):   252 ms
```

في المخرج أعلاه لاحظ التالي:

- `processors=2` يعني أن .NET قرأ حد الـ Container، فالقيمة [Environment.ProcessorCount](https://learn.microsoft.com/en-us/dotnet/api/system.environment.processorcount?ref=arabroot.io) هي نواتان وليست الأنوية الثمانية في الجهاز، وعلى هذا الرقم يبني المجمع والـ GC و`Parallel` قراراتهم.
- التتابع أخذ 5.3 ثانية، وهو قريب من الحد الأدنى النظري 5 ثوان، لأن كل طلب ينتظر الذي قبله. ولاحظ أن الكود فيه `await`، ومع ذلك لم يتسارع، لأن `await` في حلقة تعني «انتظر هذا قبل أن تبدأ التالي»، فهو كود غير متزامن Asynchronous ولكنه ليس متوازياً.
- `Task.WhenAll` أطلق المئة طلب معاً، فانتهت في 153 ملي ثانية، أي قرابة ثلاثة أضعاف زمن الطلب الواحد، والفرق عن 50 هو تكلفة فتح اتصالات جديدة وجدولة مئة تكملة على نواتين. والأهم السطر `threads in pool 3`: مئة طلب جارية في نفس الوقت، وفي المجمع ثلاثة خيوط فقط، لأن الانتظار لا يحجز خيطاً.
- `Parallel.ForEachAsync` بحد عشرة أخذ 534 ملي ثانية، أي عشر دفعات كل منها قرابة 53، و`SemaphoreSlim(20)` أخذ 252، أي خمس دفعات.
- التشغيل الأول أبطأ في كل الطرق، لأن الـ JIT كان ما زال يترجم الدوال بالمستوى الأول، والاتصالات تفتح لأول مرة، وهذا سبب أن أي قياس جاد يكرر التجربة ويأخذ التشغيل الثاني.

![أعمدة أفقية لمئة طلب كل منها 50 ms: بالتتابع 5,296 ms والحد الأدنى النظري 5,000، وParallel.ForEachAsync بحد 10 في 534 ms والحد الأدنى 500، وWhenAll مع SemaphoreSlim(20) في 252 ms والحد الأدنى 250، وTask.WhenAll للمئة معاً في 153 ms، وتحتها ثلاث بطاقات: المجمع كان فيه 3 خيوط فقط، والتشغيل الأول أبطأ 6,060 و240 و583 و314 ms، ولماذا نحد التزامن](https://arabroot.io/content/images/2026/10/csharp-concurrency-01-io.webp)

نفس المئة طلب: من خمس ثوان إلى 153 ملي ثانية بثلاثة خيوط فقط

### لماذا تحد التزامن؟ SemaphoreSlim مع Parallel.ForEachAsync

قد يتساءل البعض: إذا كان `Task.WhenAll` هو الأسرع، فلماذا نستعمل الحدود؟ والإجابة أن السرعة هنا على حساب طرف آخر. فإطلاق مئة طلب دفعة واحدة يعني مئة اتصال مفتوح في نفس اللحظة على سيرفر المورد، وفي الإنتاج لا تكون مئة وإنما عشرة آلاف منتج، وسيرفر المورد قد يكون عنده حد للطلبات Rate Limit يرد بعده بالخطأ 429، أو قد يسقط. ونفس الشيء مع قاعدة البيانات، فمجمع الاتصالات Connection Pool في Npgsql حده الافتراضي مئة اتصال، وإذا أطلقت ألف استعلام معاً فسوف ينتظر تسعمئة منها دورها، وبعضها يتجاوز المهلة. لذلك القاعدة: أطلق بالتوازي ما يتحمله الطرف الآخر، وليس كل ما تستطيعه أنت.

والأداتان في الكود مختلفتان قليلاً: [Parallel.ForEachAsync](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.parallel.foreachasync?ref=arabroot.io) تأخذ قائمة وحداً وتدير كل شيء، وهي الأسهل عندما يكون عندك مجموعة معروفة، و[SemaphoreSlim](https://learn.microsoft.com/en-us/dotnet/api/system.threading.semaphoreslim?ref=arabroot.io) إشارة تسمح لعدد محدد بالمرور، ويمكن أن تشترك فيها أجزاء مختلفة من الخدمة، مثل كل الطلبات التي تذهب إلى نفس المورد من أي مكان في الكود. ولاحظ في الكود أن `Release` في `finally`، لأن طلباً واحداً يرمي استثناء دون أن يحرر الإشارة يعني أن الحد صار 19 إلى الأبد.

ولاحظ أيضاً أن الكود كله يستعمل `HttpClient` واحداً للطلبات كلها، وهذا ليس تفصيلاً، فإنشاء `HttpClient` جديد لكل طلب يترك الـ Sockets في حالة الانتظار بعد الإغلاق، وقد يستنفد المنافذ تحت الضغط، كما تشرح [إرشادات HttpClient](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines?ref=arabroot.io). وفي خدمة ASP.NET Core تأخذه من `IHttpClientFactory` بدل أن تنشئه بنفسك.

### المشكلة التي جمدت الخدمة: جوع مجمع الخيوط Thread Pool Starvation

نعود الآن إلى الزميل الذي لف كل طلب في `Task.Run` وأخذ النتيجة بـ `.Result`. ولكي نرى ما حدث بالضبط كتبنا القسم `starve`، وهو نفس المئة طلب بنفس السيرفر، ولكن بأسلوب يسمى المزامنة فوق اللاتزامن Sync over Async، مع مهمة مراقبة تطبع التقدم كل نصف ثانية:

```csharp
var tasks = Enumerable.Range(1, 100)
    .Select(i => Task.Run(() => http.GetStringAsync($"/price/{i}").Result))   // blocks a pool thread for ~50 ms
    .ToArray();
var probe = Task.Run(async () => { while (...) { Console.WriteLine($"  t={sw.ElapsedMilliseconds,5} ms done=... pool threads=..."); await Task.Delay(500); } });
Task.WaitAll(tasks);
```

والمخرج سوف يكون كما يلي:

```text
processors=2 section=starve
pool min worker threads: 2, threads now 2
  t=23820 ms done=  0 pool threads=101
sync-over-async (.Result in Task.Run x100): 24075 ms, pool threads at end 101
```

نفس المئة طلب التي انتهت في 153 ملي ثانية مع `await`، أخذت هنا 24 ثانية، أي أبطأ من التتابع العادي بأربع مرات ونصف، وهذا بالضبط التجمد الذي رآه الفريق. والسبب في السطر الأول: الحد الأدنى للخيوط في المجمع اثنان، وهو عدد الأنوية. فكل مهمة من المئة تأخذ خيطاً من المجمع، وترسل الطلب، ثم تجمد الخيط بـ `.Result` حتى يصل الرد، والرد نفسه يحتاج خيطاً حراً من المجمع ليكمل المهمة الداخلية التي ينتظرها `.Result`، والخيطان كلاهما متجمدان ينتظران. والمجمع يرى الطابور لا يتحرك فيضيف خيوطاً، ولكنه لا يضيفها دفعة واحدة كما تشرح [صفحة مجمع الخيوط](https://learn.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool?ref=arabroot.io): بعد الحد الأدنى إما أن ينشئ خيطاً إضافياً أو ينتظر أن تنتهي بعض المهام، وفي تجربتنا وصل إلى 101 خيط بعد قرابة 23 ثانية، أي بمعدل أربعة خيوط تقريباً في الثانية.

ولاحظ سطر المراقبة الوحيد: مهمة المراقبة كان يفترض أن تطبع سطراً كل نصف ثانية، فطبعت سطراً واحداً بعد 23.8 ثانية، لأنها هي أيضاً تحتاج خيطاً من المجمع لتعمل، ولم تجد. وهذا ما يحدث لخدمة ويب كاملة: كل طلب جديد، وكل فحص صحة، وكل مؤقت، ينتظر في نفس الطابور، فتبدو الخدمة ميتة مع أن المعالج شبه فارغ، وهذه هي العلامة المميزة لهذا النوع من المشاكل: زمن استجابة عال جداً واستهلاك معالج منخفض وعدد خيوط يزيد ببطء. وMicrosoft عندها [دليل كامل لتشخيصه](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/debug-threadpool-starvation?ref=arabroot.io) بالأدوات `dotnet-counters` و`dotnet-trace`، ومنذ .NET 9 يوجد حدث WaitHandleWait يسجل كل خيط يتجمد في `Task.Result` أو `Task.Wait`.

### ماذا يحدث إذا رفعنا الحد الأدنى للخيوط SetMinThreads؟

أول حل يجده الناس على الإنترنت هو [ThreadPool.SetMinThreads](https://learn.microsoft.com/en-us/dotnet/api/system.threading.threadpool.setminthreads?ref=arabroot.io)، فجربناه في القسم `starve-min`، وهو نفس الكود بعد أن رفعنا الحد الأدنى إلى 110 خيوط، وشغلنا القسمين مرة أخرى متتاليين:

```text
### starve, --cpus=2
pool min worker threads: 2, threads now 2
  t=22971 ms done=  0 pool threads=101
sync-over-async (.Result in Task.Run x100): 23073 ms, pool threads at end 101
### starve-min, --cpus=2
pool min worker threads: 110, threads now 3
  t=  960 ms done=  1 pool threads=107
sync-over-async (.Result in Task.Run x100): 1061 ms, pool threads at end 107
```

التشغيل الافتراضي تكرر بنفس الشكل، 23 ثانية و101 خيط، وهذا يعني أن النتيجة الأولى لم تكن صدفة. ومع الحد الأدنى 110 أنشأ المجمع الخيوط فوراً عند الطلب، فانتهت المئة في ثانية واحدة. ولكن لاحظ أن ثانية واحدة ما زالت أبطأ بسبع مرات من 153 ملي ثانية مع `await`، وأن المجمع صار فيه 107 خيوط، كل خيط منها Stack بحجم ميجابايت تقريباً، والمعالج يبدل بينها. والأخطر أن الحد الجديد يخفي المشكلة حتى يأتي يوم فيه 200 طلب بدل 100، فيعود التجمد كما كان. لذلك هذا الإعداد مسكن مؤقت تشتري به وقتاً في حادثة جارية، والعلاج الوحيد هو أن يكون الكود غير متزامن من أوله إلى آخره Async All the Way، وهذه أول نصيحة في [أفضل ممارسات ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices?ref=arabroot.io). والصورة التالية تجمع الحالات الثلاث:

![ثلاثة أعمدة أفقية: await الصحيح 153 ms بثلاثة خيوط، و.Result مع SetMinThreads(110) في 1,061 ms وانتهى بـ 107 خيوط، و.Result بالإعداد الافتراضي في 23,073 ms وانتهى بـ 101 خيط، وتحتها ثلاثة صناديق: كل مهمة أمسكت خيطاً وجمدته والمجمع فيه خيطان فانتظرت الباقية، والمجمع يضيف الخيوط تدريجياً فاحتاج 23 ثانية، والحل الصحيح await من أول الدالة إلى آخرها وSetMinThreads مسكن](https://arabroot.io/content/images/2026/10/csharp-concurrency-03-starvation.webp)

نفس المئة طلب: ‏.Result جمد الخيوط القليلة حتى أضاف المجمع مئة خيط

وبالعودة إلى مهمة تحديث الأسعار في القصة، فالإصلاح الذي دخل المستودع لم يكن رفع الحد الأدنى للخيوط، وإنما حذف `Task.Run` و`.Result` معاً، وكتابة الحلقة بـ `Parallel.ForEachAsync` بحد عشرة طلبات، لأن واجهة المورد تسمح بعشرة طلبات متزامنة لكل عميل بحسب عقدهم، مع تمرير توكن الإلغاء ومهلة ثانيتين لكل طلب. وبهذا صارت المهمة تأخذ قرابة نصف ثانية بدل خمس ثوان، كما في السطر الثالث من القياس الأول، ولم تعد تحجز أي خيط أثناء الانتظار، فلم يعد فحص الصحة يتأخر وهي تعمل. والدرس الذي كتبه الفريق في سجل الحادثة: الكود الذي يبدو أسرع في التجربة على جهاز المطور، حيث الأنوية كثيرة والطلب واحد، قد يكون أبطأ بمئة مرة على سيرفر بنواتين تحت الضغط، ولذلك لا يقبل أي تغيير في التزامن دون قياس في Container بنفس حدود الإنتاج.

### المهمة الثانية CPU-bound: ‏SHA-256 على نواة ونواتين وأربع

الآن العكس: عمل حسابي خالص لا ينتظر أي شيء، وهو هاش SHA-256 لألفي نص، كل نص يمرر على الدالة ألفي مرة، أي أربعة ملايين هاش. وجربناه بأربع طرق:

```csharp
static byte[] HashRounds(string s, int rounds)
{
    byte[] h = Encoding.UTF8.GetBytes(s);
    for (int r = 0; r < rounds; r++) h = SHA256.HashData(h);
    return h;
}
for (int i = 0; i < N; i++) seq[i] = HashRounds(inputs[i], R);                          // 1. sequential
Parallel.For(0, N, i => par[i] = HashRounds(inputs[i], R));                              // 2. Parallel.For
var fake = await Task.WhenAll(inputs.Select(async s => HashRounds(s, R)));               // 3. async lambda, no await
var real = await Task.WhenAll(inputs.Select(s => Task.Run(() => HashRounds(s, R))));     // 4. Task.Run
```

وشغلنا نفس البرنامج ثلاث مرات بحدود مختلفة للـ Container، والمخرج سوف يكون كما يلي:

```text
### cpu, --cpus=1
processors=1 section=cpu
cpu sequential:          8658 ms
cpu Parallel.For:        10086 ms, same: True
cpu WhenAll(async, no await): 10942 ms
cpu WhenAll(Task.Run):   10627 ms
### cpu, --cpus=2
processors=2 section=cpu
cpu sequential:          8461 ms
cpu Parallel.For:        5517 ms, same: True
cpu WhenAll(async, no await): 12928 ms
cpu WhenAll(Task.Run):   6730 ms
### cpu, --cpus=4
processors=4 section=cpu
cpu sequential:          10446 ms
cpu Parallel.For:        4279 ms, same: True
cpu WhenAll(async, no await): 10476 ms
cpu WhenAll(Task.Run):   5575 ms
first hash: C87ACC976E899685…
```

في المخرج أعلاه لاحظ التالي:

- مع نواة واحدة لا يوجد ما يوزع عليه العمل، فكل الطرق المتوازية أبطأ قليلاً من التتابع، لأنها تضيف تكلفة الجدولة دون أي فائدة.
- مع نواتين نزل `Parallel.For` من 8.5 ثانية إلى 5.5، ومع أربع أنوية إلى 4.3، وهذا أقل من التسارع المثالي، أي ضعفين وأربعة أضعاف، والسبب الأول أن الجهاز نفسه كان يشغل تجارب أخرى في نفس الوقت، والثاني أن تخصيص مصفوفة جديدة في كل جولة يضيف عمل الـ GC.
- التتابع مع أربع أنوية كان 10.4 ثانية، أي أبطأ من نواة واحدة، مع أن التتابع لا يستعمل إلا نواة واحدة في الحالتين. وهذا ضجيج الجهاز المشغول بوضوح، ولذلك اقرأ النسب داخل كل تشغيل، ولا تقارن الأرقام المطلقة بين التشغيلات.
- `Task.Run` مع `WhenAll` أعطى تسارعاً حقيقياً ولكنه أقل من `Parallel.For`، لأنه أنشأ ألفي مهمة منفصلة، بينما `Parallel.For` يقسم النطاق إلى قطع كبيرة على عدد الأنوية.
- والسطر الأهم هو `WhenAll(async, no await)`: 10.9 و12.9 و10.5 ثانية، أي نفس زمن التتابع في كل الحالات، حتى مع أربع أنوية.

والسبب في السطر الأخير أن الكلمة `async` لا تنقل أي شيء إلى خيط آخر، فالدالة `async` تعمل بشكل متزامن تماماً على الخيط الذي استدعاها حتى تصل إلى أول `await` على مهمة لم تكتمل، ولما لم يكن فيها `await` أصلاً حسبت الهاش كاملاً، ثم أرجعت مهمة منتهية، ثم بدأت التالية. والمترجم يحذرك من هذا بالتحذير CS1998 «This async method lacks 'await' operators»، وهذا أحد الأسباب التي تجعلنا نجعل التحذيرات أخطاء كما ذكرنا في المقال الأول. والقاعدة: `async` و`await` للانتظار، و`Parallel` أو `Task.Run` للحساب، وفي خدمة ويب لا تلف الحساب في `Task.Run` داخل الطلب، لأن ASP.NET Core يشغل الطلبات أصلاً على خيوط المجمع بالتوازي، فأنت فقط تنقل العمل من خيط إلى خيط وتدفع ثمن النقل.

![جدول بثلاثة أعمدة لنواة ونواتين وأربع أنوية وأربعة صفوف: بالتتابع 8,658 و8,461 و10,446 ms، وParallel.For في 10,086 و5,517 و4,279، وWhenAll مع Task.Run في 10,627 و6,730 و5,575، وWhenAll مع async بلا await في 10,942 و12,928 و10,476، وتحته ثلاث بطاقات: التوازي يحتاج أنوية، وasync لا تعني متوازياً، والجهاز مشغول فاقرأ النسب داخل كل عمود](https://arabroot.io/content/images/2026/10/csharp-concurrency-04-cpu.webp)

الحساب يتسارع بالأنوية فقط، و`async` بلا `await` بقيت بزمن التتابع

## سباق البيانات Race Condition: عداد يفقد الزيادات

عندما تعمل عدة خيوط في نفس اللحظة على عدة أنوية فعلاً، كما رأينا، يصير أي متغير مشترك بينها خطراً. والمثال الأبسط عداد يزيده مليون عمل بالتوازي:

```csharp
int counter = 0;
Parallel.For(0, 1_000_000, _ => counter++);
int safe = 0;
Parallel.For(0, 1_000_000, _ => Interlocked.Increment(ref safe));
```

والمخرج من ثلاثة تشغيلات:

```text
processors=2 section=race
run 1: counter++ = 810,483   Interlocked = 1,000,000
run 2: counter++ = 804,687   Interlocked = 1,000,000
run 3: counter++ = 1,000,000   Interlocked = 1,000,000
Dictionary: AggregateException: ArgumentException Source array was not long enough. Check the source index, length, and the array's lower bounds. (Parameter 'sourceArray')
```

العملية `counter++` تبدو عملية واحدة، ولكنها في الحقيقة ثلاث: اقرأ القيمة من الذاكرة، وأضف واحداً، واكتبها. فإذا قرأ خيطان نفس القيمة 500 في نفس اللحظة، يكتب كل منهما 501، وتضيع زيادة. وفي التشغيلين الأولين ضاع قرابة 19% من الزيادات، وفي الثالث جاء الرقم صحيحاً بالضبط، وهذا أخطر ما في سباق البيانات: الاختبار قد ينجح مرة ويفشل مرتين، فيمر الخطأ من المراجعة لأنه «اشتغل عندي». أما [Interlocked.Increment](https://learn.microsoft.com/en-us/dotnet/api/system.threading.interlocked?ref=arabroot.io) فتنفذ القراءة والزيادة والكتابة كعملية ذرية Atomic واحدة على مستوى المعالج، فأعطت مليوناً في كل مرة.

والسطر الأخير في المخرج مختلف: `Dictionary` عادي يكتب فيه عدة خيوط في نفس الوقت لم يعط نتيجة خاطئة فقط، وإنما رمى استثناء غريباً لا علاقة له بالكود الذي كتبناه، لأن بنيته الداخلية تلفت أثناء توسعه. وفي إصدارات قديمة من .NET كان هذا النوع من الاستخدام يدخل أحياناً في حلقة لا نهائية تأخذ نواة كاملة إلى الأبد. والمجموعات العادية في .NET ليست آمنة للخيوط Thread-safe للكتابة، والبديل للقواميس المشتركة هو [ConcurrentDictionary](https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2?ref=arabroot.io).

### فحص ثم تنفيذ Check-then-Act: كيف بعنا عشرين دلة من عشر

نعود إلى عرض الدلال النحاسية. المنطق في الخدمة كان بهذا الشكل تقريباً: إذا كان المخزون أكبر من صفر فأنقصه وسجل البيع. وكتبنا نفس المنطق في القسم `stock`، مع عشرين مشترياً بالتوازي وعشر قطع، وأضفنا `await Task.Delay(1)` بين الفحص والإنقاص لتمثل الوقت الذي يأخذه أي عمل حقيقي بينهما، مثل حساب السعر أو استدعاء آخر:

```csharp
// no lock: check, then act
if (stock > 0) { await Task.Delay(1); stock--; Interlocked.Increment(ref sold); }

// with a lock: the check and the change are one step
var gate = new Lock();
lock (gate) { if (stock2 > 0) { stock2--; sold2++; } }
```

والمخرج سوف يكون كما يلي:

```text
processors=2 section=stock
run 1: no lock sold 20 stock -10   |  lock sold 10 stock 0
run 2: no lock sold 20 stock -10   |  lock sold 10 stock 0
run 3: no lock sold 20 stock -10   |  lock sold 10 stock 0
```

كل المشترين العشرين فحصوا المخزون فوجدوه عشراً قبل أن ينقصه أحد، فباعت الخدمة عشرين وصار المخزون سالب عشرة، في كل تشغيل. ولاحظ أن الإنقاص نفسه `stock--` لم يكن المشكلة هنا، وإنما الفجوة بين الفحص والتنفيذ، ولذلك `Interlocked` وحدها لا تحل هذه المشكلة. والحل أن يكون الفحص والتعديل خطوة واحدة لا يدخل بينهما أحد، وهذا ما تفعله [lock](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock?ref=arabroot.io): فباعت عشراً بالضبط في كل مرة. ولاحظ النوع `Lock`، وهو [نوع مخصص للأقفال](https://learn.microsoft.com/en-us/dotnet/api/system.threading.lock?ref=arabroot.io) أضيف في .NET 9، والكلمة `lock` تستعمله مباشرة إذا مررته لها، وهو أوضح وأسرع من القفل على أي `object` كما كان يكتب سابقاً.

ولكن تذكر أن `lock` تحمي الذاكرة داخل عملية واحدة فقط، والخدمة الحقيقية تعمل بعدة نسخ خلف موزع الطلبات، والمخزون في قاعدة البيانات وليس في متغير. لذلك الحل الحقيقي لعرض الدلال هو نفس الفكرة ولكن في قاعدة البيانات: أن تقفل صف المنتج بـ `SELECT ... FOR UPDATE` داخل معاملة Transaction، ثم تفحص وتنقص، وهذا ما سوف نبنيه ونختبره بعشرين طلباً متوازياً في المقال التالي، والأقفال في قواعد البيانات شرحناها بالتفصيل في مقال [الأقفال في قواعد البيانات](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/).

## القفل المميت Deadlock: قفلان بترتيب معكوس

وعندما تحتاج أكثر من قفل في نفس العملية يظهر خطر جديد. لنفرض أن عندك قفلاً للمخزون وقفلاً لمحفظة العميل، وفي مكان من الكود تأخذ قفل المخزون ثم المحفظة، وفي مكان آخر، كاسترجاع مبلغ، تأخذ المحفظة ثم المخزون:

```csharp
object stockLock = new(), walletLock = new();
var t1 = Task.Run(() => { lock (stockLock) { Thread.Sleep(100); lock (walletLock) { Console.WriteLine("t1 done"); } } });
var t2 = Task.Run(() => { lock (walletLock) { Thread.Sleep(100); lock (stockLock) { Console.WriteLine("t2 done"); } } });
bool finished = Task.WaitAll([t1, t2], TimeSpan.FromSeconds(3));

// fix: every thread takes the locks in the same order
var f1 = Task.Run(() => { lock (a) { Thread.Sleep(100); lock (b) { Console.WriteLine("f1 done"); } } });
var f2 = Task.Run(() => { lock (a) { Thread.Sleep(100); lock (b) { Console.WriteLine("f2 done"); } } });
```

والمخرج سوف يكون كما يلي:

```text
processors=2 section=deadlock
deadlock: after 3 s t1=Running t2=Running
f1 done
f2 done
fixed: same lock order, both finished
```

المهمة الأولى أخذت قفل المخزون وانتظرت قفل المحفظة، والثانية أخذت المحفظة وانتظرت المخزون، وكل منهما تنتظر الأخرى إلى الأبد، ولم يطبع أي منهما شيئاً. ولاحظ أن الحالة بعد ثلاث ثوان `Running` وليست شيئاً يدل على مشكلة، فـ .NET لا تكتشف القفل المميت ولا ترمي استثناء، والخيطان سوف يبقيان متجمدين حتى تنتهي العملية، ولذلك أنهينا البرنامج بـ `Environment.Exit`. وفي خدمة حقيقية لا ترى شيئاً سوى طلبات لا ترد أبداً وخيطين ناقصين من المجمع. والإصلاح أن تأخذ كل الأقفال في كل مكان بنفس الترتيب، فلا يمكن أن يمسك أحدهم الثاني وينتظر الأول، وهذا ما حدث في الجزء الثاني فانتهى الخيطان. والصورة التالية تجمع الأخطاء الثلاثة وإصلاحاتها:

![ثلاثة صناديق: counter++ من عدة خيوط أعطى 810,483 و804,687 و1,000,000 والإصلاح Interlocked.Increment أعطى مليوناً في كل مرة، وعشرون مشترياً وعشر قطع بدون قفل باعوا 20 والمخزون -10 ومع lock باعوا 10 والمخزون 0، وقفلان بترتيب معكوس توقفا وحالة المهمتين Running بعد 3 ثوان والإصلاح نفس الترتيب، وتحتها بطاقة عن Dictionary العادي الذي رمى ArgumentException من عدة خيوط والبديل ConcurrentDictionary](https://arabroot.io/content/images/2026/10/csharp-concurrency-05-race-deadlock.webp)

ثلاثة أخطاء تزامن حقيقية وإصلاح كل منها

وهناك نوع آخر من القفل المميت خاص بـ `async` تقرأ عنه كثيراً، وهو أن تستدعي `.Result` على مهمة من خيط الواجهة في WinForms أو WPF، أو من طلب في ASP.NET القديم، فتنتظر المهمة أن ترجع تكملتها إلى نفس الخيط المتجمد، فلا ترجع أبداً. وهذا لا يحدث في ASP.NET Core ولا في برامج سطر الأوامر لأنها بلا سياق تزامن كما ذكرنا، ولكن نفس الكود يتحول هناك إلى جوع المجمع الذي رأيناه، فالنتيجة في الحالتين: لا تستعمل `.Result` ولا `.Wait()` على مهمة لم تنته.

## الإلغاء Cancellation والمهلات Timeouts

كل عمل متزامن في الخدمة يحتاج أن يعرف متى يتوقف: المستخدم أغلق الصفحة، أو سيرفر المورد لا يرد، أو الخدمة نفسها تغلق للتحديث. والطريقة في .NET هي `CancellationToken`، وهو إشارة تمررها من أعلى السلسلة إلى أسفلها، وكل دالة غير متزامنة في المكتبة تقبلها كمعامل أخير، كما تشرح [صفحة الإلغاء](https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads?ref=arabroot.io). ولاحظ في كود `Parallel.ForEachAsync` أعلاه المعامل `ct` الذي مررناه إلى `GetStringAsync`، فإذا ألغي العمل كله توقفت الطلبات الجارية أيضاً بدل أن تكمل في الخلفية. وفي ASP.NET Core يأتيك توكن جاهز لكل طلب، يلغى عندما يقطع العميل الاتصال، فتمرره إلى قاعدة البيانات ولا تكمل استعلاماً لن يقرأ نتيجته أحد.

والمهلة نوع من الإلغاء، والمثال الذي يفاجئ الجميع أن `HttpClient` مهلته الافتراضية [100 ثانية](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout?ref=arabroot.io)، فطلب واحد إلى مورد معطل يحجز مكاناً في الحد الذي وضعته بـ `SemaphoreSlim` دقيقة وأربعين ثانية. لذلك ضع مهلة تناسب عملك، إما على الـ `HttpClient` كله، أو لكل طلب بـ `CancellationTokenSource` مع `CancelAfter`، والمثال التالي يجمع الاثنين مع توكن الطلب:

```csharp
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
cts.CancelAfter(TimeSpan.FromSeconds(2));
var json = await http.GetStringAsync($"/price/{id}", cts.Token);   // OperationCanceledException after 2 s
```

وعندما تحتاج طابوراً داخل العملية بين جزء ينتج أعمالاً وجزء يستهلكها، مثل طلبات تكتب أحداثاً وعامل Worker في الخلفية يرسلها، فلا تبن ذلك بقائمة وقفل، وإنما استعمل [System.Threading.Channels](https://learn.microsoft.com/en-us/dotnet/core/extensions/channels?ref=arabroot.io)، وهي طابور غير متزامن آمن للخيوط، وله حد اختياري يجعل المنتج ينتظر عندما يمتلئ، بدل أن تكبر الذاكرة بلا نهاية.

وهناك أداة أخيرة تحتاجها عندما تقرأ كمية كبيرة من البيانات من مصدر غير متزامن، مثل كل طلبات الشهر من قاعدة البيانات، وهي [التدفقات غير المتزامنة Async Streams](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/generate-consume-asynchronous-stream?ref=arabroot.io)، أي الدالة التي ترجع `IAsyncEnumerable<T>` وتمر عليها بـ `await foreach`. فبدل أن تنتظر حتى تصل كل الصفوف إلى قائمة في الذاكرة، تعالج كل صف أو كل دفعة لحظة وصولها، وهذا بالضبط ما أصلح مهمة التقارير التي كانت تتجاوز حد الذاكرة في المقال السابق.

## كيف يبدو هذا في خدمة حقيقية؟ ASP.NET Core ومجمع الخيوط

كل ما رأيناه في برنامج سطر الأوامر يحدث بنفس الشكل داخل خدمة ASP.NET Core، لأن السيرفر Kestrel يستقبل كل طلب ويشغل معالجه على خيط من نفس المجمع. فالخدمة التي كل طلب فيها ينتظر قاعدة البيانات بـ `await` تخدم آلاف الطلبات المتزامنة بعدد قليل من الخيوط، كما خدمت نقطة النهاية البطيئة في تجربتنا 904 طلبات بأشكال مختلفة دون أن تشتكي. والخدمة التي في أي مكان من مسارها `.Result` أو `Thread.Sleep` أو استدعاء متزامن لمكتبة قديمة، تحمل نفس القنبلة التي رأيناها في قسم الجوع، وتنفجر في أول ارتفاع مفاجئ Spike في عدد الطلبات، والمشكلة أن الاختبارات العادية لا تراها، لأنها ترسل طلباً واحداً في كل مرة.

لذلك في أي فريق يشغل خدمات .NET في الإنتاج يوجد رقمان يجب أن يكونا في لوحة المراقبة بجانب زمن الاستجابة ونسبة الأخطاء: طول طابور مجمع الخيوط وعدد خيوطه، وهما المقياسان `dotnet.thread_pool.queue.length` و`dotnet.thread_pool.thread.count` في [مقاييس الـ Runtime المدمجة](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/built-in-metrics-runtime?ref=arabroot.io)، ويمكن قراءتهما من Container يعمل بالأداة [dotnet-counters](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters?ref=arabroot.io) دون إعادة تشغيل. وفي الخدمة السليمة يبقى الطابور قريباً من الصفر، وعدد الخيوط قريباً من عدد الأنوية أو ضعفه، وإذا رأيت الطابور يكبر وعدد الخيوط يزيد ببطء والمعالج شبه فارغ، فأنت أمام ما رأيناه في قسم الجوع بالضبط، وأول ما تبحث عنه في الكود هو `.Result` و`.Wait()`.

وفي المراجعة Code Review نتبع في الفريق قاعدة بسيطة تمنع أغلب هذه المشاكل قبل أن تصل للإنتاج: كل دالة تستدعي شيئاً ينتظر شبكة أو قرصاً يجب أن تكون `async` وترجع `Task` وتقبل `CancellationToken`، وكل من يستدعيها يكون `async` بدوره حتى معالج الطلب، ولا يوجد `.Result` في أي مكان. وهذا يبدو مرهقاً في البداية لأن `async` «تنتشر» في الكود، ولكنها الطريقة الوحيدة التي يبقى فيها مجمع الخيوط حراً.

## كيف يقارن هذا باللغات الأخرى؟ Concurrency Models

لأن هذه المهمتين نفسيهما نقيسهما في كل لغات المسار، فمن المفيد أن تعرف أين يقع نموذج .NET بين النماذج الأخرى قبل أن تقرأ أرقامها:

| اللغة                | انتظار الشبكة                            | الحساب على عدة أنوية                                                   |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
| C# (.NET)            | async/await فوق مجمع خيوط حقيقية         | نعم، بنفس المجمع عبر Parallel                                          |
| JavaScript (Node.js) | async/await على حلقة أحداث بخيط واحد     | لا، إلا بعمليات أو Worker Threads منفصلة                               |
| Python               | asyncio على خيط واحد، أو خيوط            | محدود بقفل GIL في الإصدار المعتاد، والعمليات المنفصلة هي الحل التقليدي |
| Go                   | Goroutines خفيفة بدون await              | نعم، الـ Runtime يوزعها على الأنوية                                    |
| Java                 | خيوط افتراضية Virtual Threads بدون await | نعم                                                                    |

والفرق بين .NET وGo وJava الحديثة أن الأخيرتين تخفيان الانتظار عنك، فتكتب كوداً يبدو متزامناً ويتوقف فيه الخيط الخفيف دون أن يحجز خيطاً حقيقياً، بينما .NET تطلب منك أن تكتب `await` صراحة في كل مكان ينتظر. ولهذا ميزة وهي أنك ترى من توقيع الدالة أنها تنتظر شيئاً، وعيب وهو أن نسيان `await` أو كسر سلسلتها بـ `.Result` يعيدك إلى خيوط النظام الثقيلة، كما رأينا في قسم الجوع.

## أخطاء شائعة Common Mistakes

- **`.Result` و`.Wait()` و`GetAwaiter().GetResult()` على مهمة لم تنته:** جوع المجمع في الخدمات، وقفل مميت في تطبيقات الواجهات. والاستثناء الوحيد المقبول هو `Main` قديم لا يستطيع أن يكون `async`، وحتى هذا لم يعد موجوداً.
- **`await` داخل حلقة عندما تكون الطلبات مستقلة:** فتأخذ خمس ثوان بدل 153 ملي ثانية.
- **`Task.WhenAll` على آلاف العناصر دون حد:** فتسقط الطرف الآخر أو تستنفد مجمع الاتصالات.
- **`async` على عمل حسابي بلا `await`:** يعمل بالتتابع، والمترجم يحذرك بـ CS1998.
- **`async void`:** لا يمكن انتظارها، واستثناؤها يسقط العملية كلها، ومكانها الوحيد معالجات الأحداث Event Handlers في الواجهات.
- **`Task.Run` داخل طلب ASP.NET Core:** نقل للعمل من خيط في المجمع إلى خيط آخر في نفس المجمع.
- **متغير مشترك بلا حماية:** عداد يفقد 19% من زياداته، وقاموس يرمي استثناء لا تفهمه.
- **فحص ثم تنفيذ بقفلين منفصلين أو بدون قفل:** عشرون دلة من عشر.
- **أقفال متعددة بترتيب مختلف:** قفل مميت لا يكتشفه أحد.
- **`lock` لحماية بيانات في قاعدة البيانات:** لا تحمي شيئاً بين نسختين من الخدمة.
- **عدم تمرير `CancellationToken`:** عمل يكمل بعد أن غادر المستخدم، ومهلة 100 ثانية لا تعرف بوجودها.
- **رفع `SetMinThreads` كحل دائم:** مسكن يخفي الكود المتجمد حتى يأتي ضغط أكبر.

## جدول قرار Decision Table: أي أداة لأي عمل؟

| العمل                                     | الأداة                                      | في التجربة                    |
| ----------------------------------------- | ------------------------------------------- | ----------------------------- |
| انتظار شبكة أو قاعدة بيانات، عناصر قليلة  | await Task.WhenAll                          | 100 طلب في 153 ms بثلاثة خيوط |
| انتظار شبكة، عناصر كثيرة أو طرف محدود     | Parallel.ForEachAsync بحد، أو SemaphoreSlim | 534 ms بحد 10، و252 بحد 20    |
| حساب على مجموعة كبيرة                     | Parallel.For أو Parallel.ForEach            | من 8.5 إلى 5.5 ثانية بنواتين  |
| حساب واحد طويل لا يجب أن يجمد خيط الواجهة | await Task.Run(...)                         | تسارع أقل من Parallel.For     |
| عداد أو رقم مشترك                         | Interlocked                                 | مليون في كل تشغيل             |
| فحص وتعديل في الذاكرة                     | lock على كائن Lock                          | عشر من عشر                    |
| فحص وتعديل في قاعدة البيانات              | معاملة مع SELECT ... FOR UPDATE             | المقال التالي                 |
| قاموس مشترك                               | ConcurrentDictionary                        | العادي رمى ArgumentException  |
| منتج ومستهلك داخل العملية                 | Channel بحد                                 |                               |
| أي عمل غير متزامن                         | CancellationToken ومهلة صريحة               |                               |

## الخلاصة

- .NET فيها خيوط حقيقية تعمل على عدة أنوية، ومجمع خيوط مشترك، ومهام تمثل عملاً سينتهي، والمهمة لا تعني خيطاً.
- `await` يحول الدالة إلى آلة حالات ويرجع الخيط للمجمع أثناء الانتظار، فمئة طلب متزامن احتاجت ثلاثة خيوط وانتهت في 153 ملي ثانية بدل 5.3 ثانية.
- حد التزامن يحمي الطرف الآخر: 534 ملي ثانية بحد عشرة، و252 بحد عشرين.
- `.Result` داخل `Task.Run` جمد خيطي المجمع، فأخذت نفس المئة طلب 23 ثانية حتى أضاف المجمع مئة خيط، ورفع الحد الأدنى إلى 110 جعلها ثانية واحدة، ولكن العلاج الوحيد هو `await` من أول الكود إلى آخره.
- الحساب يتسارع بالأنوية فقط: `Parallel.For` من 8.5 إلى 5.5 ثانية بنواتين و4.3 بأربع، و`async` بلا `await` بقي بزمن التتابع.
- `counter++` فقد قرابة 19% من الزيادات في تشغيلين ونجح في الثالث، والفحص ثم التنفيذ باع عشرين من عشر في كل مرة، والقفلان المعكوسان توقفا إلى الأبد دون أي استثناء.

وفي المقال التالي من مرحلة [لغة C# للمطورين](https://arabroot.io/start/#stage-lang-csharp) نبني الخدمة الحقيقية: واجهة طلبات بـ ASP.NET Core وPostgreSQL، فيها نفس مشكلة المخزون محلولة بقفل الصفوف، ونختبرها بعشرين طلباً متوازياً. وهذه مقالات تكمل هذا المقال:

[![](https://arabroot.io/content/images/2026/10/csharp-types-memory-cover-cover.webp)دليلالأنواع والذاكرة والاستثناءات Exceptions في C#قسنا ما تكلفه أنواع القيمة والمرجع وجامع القمامة داخل Container محدود، ثم شغلنا كل خطأ شائع حتى يسقط، وكشفنا أن double يخطئ في ربع مبالغ الضريبة الحدية.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%AB%D9%86%D8%A7%D8%A1%D8%A7%D8%AA-%D9%81%D9%8A-csharp/)[![](https://arabroot.io/content/images/2026/10/threading-cover-cover.webp)دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/cpu-concurrency-cover-cover.webp)دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/js-event-loop-cover-cover.webp)دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)[![](https://arabroot.io/content/images/2026/10/db-locking-cover-cover.webp)دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg\_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/csharp-for-devs-cover-cover.webp)دليلC# لمن يعرف البرمجة: الأدوات Toolchain والأساسياتتثبيت .NET 10 وجولة في C# مقارنة بـ Python وJavaScript، ثم حل كامل بالأمر dotnet من البناء والاختبار إلى أربعة أشكال للنشر، مع فخ تحذيرات حقيقي واجهناه.](https://arabroot.io/articles/csharp-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ .NET SDK 10.0.403 وRuntime 10.0.14 في Containers محدودة بنواة ونواتين وأربع أنوية.