قبل موسم رمضان بأسبوعين طلب فريق المتجر، وهو الفريق الذي يبيع القهوة والتمور ويكتب خدماته بـ C#، ميزتين صغيرتين. الأولى مهمة تحدث أسعار مئة منتج من واجهة المورد كل ساعة، وكل استدعاء لواجهة المورد يأخذ قرابة 50 ملي ثانية، فكتبها أحد الزملاء بحلقة عادية تنتظر كل طلب قبل الذي بعده، فصارت تأخذ أكثر من خمس ثوان. وحاول زميل آخر أن يسرعها فلف كل استدعاء في Task.Run وأخذ النتيجة بـ .Result، وفي أول تشغيل على السيرفر تجمدت الخدمة كلها أكثر من عشرين ثانية، ولم ترد حتى على فحص الصحة Health Check. والميزة الثانية عرض محدود لعشر دلال نحاسية، وفي أول دقيقة من العرض باعت الخدمة عشرين دلة. والمشكلتان سببهما نفس الموضوع: كيف تنفذ C# أكثر من عمل في نفس الوقت، وما الذي يضمنه كل أسلوب وما الذي لا يضمنه.
وهذا هو المقال الثالث في مرحلة لغة C# للمطورين، بعد الأدوات والأساسيات والأنواع والذاكرة والاستثناءات. وسوف نقيس فيه نموذج التزامن في .NET بنفس المهمتين اللتين نقيسهما في كل لغات هذا المسار حتى تقارن بينها: مهمة تنتظر الشبكة I/O-bound، وهي مئة طلب HTTP إلى نقطة نهاية Endpoint محلية بطيئة ترد بعد 50 ملي ثانية، ومهمة حسابية CPU-bound، وهي هاش SHA-256 لألفي نص، كل نص ألفي جولة. ثم نرى سباق بيانات حقيقياً وقفلاً مميتاً حقيقياً وإصلاح كل منهما. والمفاهيم العامة للخيوط والأقفال شرحناها في مقال الخيوط و async/await، وما يعنيه التزامن للمعالج والسيرفر في مقال التزامن والخيوط، فلن نكررها، وإنما نركز على ما تفعله .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 مشترك، فيه عدد قليل من الخيوط الجاهزة تأخذ الأعمال من طابور وتنفذها واحداً بعد الآخر، والمجمع يضيف خيوطاً عندما يرى أن الطابور لا يتحرك، ويحذفها عندما يقل العمل. وإذا كنت تأتي من Java فهذا يشبه ExecutorService مشتركاً للعملية كلها، وإذا كنت تأتي من Node.js فالفرق الكبير أن .NET ليس فيها حلقة أحداث Event Loop واحدة على خيط واحد كما شرحنا في مقال حلقة الأحداث، وإنما عدة خيوط تعمل فعلاً في نفس اللحظة على عدة أنوية.
وفوق المجمع يوجد Task، وهو كائن يمثل عملاً سوف ينتهي في المستقبل، ويحمل نتيجته أو استثناءه عندما ينتهي، وهو ما تسميه JavaScript الـ Promise وتسميه Java الـ Future. والمهمة لا تعني خيطاً، وهذه أهم نقطة في الموضوع كله: المهمة قد تكون عملاً حسابياً يعمل على خيط من المجمع، وقد تكون انتظاراً لرد شبكة لا يحجز أي خيط إطلاقاً. وفوق المهام بنت .NET مكتبة TPL، وفيها 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، وكل هذه الأنماط مذكورة في صفحة أنماط البرمجة غير المتزامنة. والمشكلة فيها كلها هي نفس مشكلة الـ 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، ونسخته المختصرة في صفحة Async in depth. والصورة التالية تبين ما يحدث لثلاثة من الطلبات المئة:

وفي برامج سطر الأوامر وفي ASP.NET Core لا يوجد ما يسمى سياق التزامن SynchronizationContext، فالتكملة تعمل على أي خيط من المجمع. أما في تطبيقات الواجهات مثل WinForms وWPF، وفي ASP.NET القديم على .NET Framework، فيوجد سياق يجبر التكملة أن ترجع إلى خيط الواجهة أو خيط الطلب، وهذا سبب نصيحة ConfigureAwait(false) التي تقرؤها في كل مكان، والتفاصيل في أسئلة ConfigureAwait الشائعة. وإذا كان كودك خدمة ويب حديثة فلا تحتاجها، وإذا كنت تكتب مكتبة قد يستعملها تطبيق واجهات فاكتبها.
ومتى تحتاج ValueTask؟
سوف ترى في مكتبات .NET دوالاً ترجع ValueTask<T> بدل Task<T>، والسبب يعود لما رأيناه في المقال السابق عن التخصيص: Task كائن على الـ Heap، فإذا كانت الدالة تنتهي فوراً في أغلب الاستدعاءات، مثل قراءة من ذاكرة مؤقتة أو من Buffer فيه بيانات، فإنشاء كائن مهمة جديد في كل استدعاء ثمن لا داعي له، وValueTask نوع قيمة يحمل النتيجة مباشرة في هذه الحالة دون أي تخصيص. ولكن له قيود: لا تنتظره مرتين، ولا تنتظره من مكانين، ولا تحفظه لتنتظره لاحقاً. والقاعدة العملية: في كودك أنت اكتب Task دائماً، واستعمل ValueTask فقط عندما يقول القياس إن هذا التخصيص في مسار ساخن، وانتظر أي ValueTask ترجعه المكتبة مباشرة بـ await ولا تفعل به شيئاً آخر.
التجربة العملية Lab: نقطة النهاية البطيئة Slow Endpoint
نحتاج أولاً سيرفراً يرد بعد 50 ملي ثانية بالضبط، وكتبناه بـ ASP.NET Core نفسه في ملف واحد، ويعمل في Container مستقل على شبكة Docker خاصة:
// 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 ميجابايت:
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:
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(); }
}));وشغلنا القسم مرتين متتاليتين، والمخرج سوف يكون كما يلي:
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هي نواتان وليست الأنوية الثمانية في الجهاز، وعلى هذا الرقم يبني المجمع والـ GC وParallelقراراتهم.- التتابع أخذ 5.3 ثانية، وهو قريب من الحد الأدنى النظري 5 ثوان، لأن كل طلب ينتظر الذي قبله. ولاحظ أن الكود فيه
await، ومع ذلك لم يتسارع، لأنawaitفي حلقة تعني «انتظر هذا قبل أن تبدأ التالي»، فهو كود غير متزامن Asynchronous ولكنه ليس متوازياً. Task.WhenAllأطلق المئة طلب معاً، فانتهت في 153 ملي ثانية، أي قرابة ثلاثة أضعاف زمن الطلب الواحد، والفرق عن 50 هو تكلفة فتح اتصالات جديدة وجدولة مئة تكملة على نواتين. والأهم السطرthreads in pool 3: مئة طلب جارية في نفس الوقت، وفي المجمع ثلاثة خيوط فقط، لأن الانتظار لا يحجز خيطاً.Parallel.ForEachAsyncبحد عشرة أخذ 534 ملي ثانية، أي عشر دفعات كل منها قرابة 53، وSemaphoreSlim(20)أخذ 252، أي خمس دفعات.- التشغيل الأول أبطأ في كل الطرق، لأن الـ JIT كان ما زال يترجم الدوال بالمستوى الأول، والاتصالات تفتح لأول مرة، وهذا سبب أن أي قياس جاد يكرر التجربة ويأخذ التشغيل الثاني.

لماذا تحد التزامن؟ SemaphoreSlim مع Parallel.ForEachAsync
قد يتساءل البعض: إذا كان Task.WhenAll هو الأسرع، فلماذا نستعمل الحدود؟ والإجابة أن السرعة هنا على حساب طرف آخر. فإطلاق مئة طلب دفعة واحدة يعني مئة اتصال مفتوح في نفس اللحظة على سيرفر المورد، وفي الإنتاج لا تكون مئة وإنما عشرة آلاف منتج، وسيرفر المورد قد يكون عنده حد للطلبات Rate Limit يرد بعده بالخطأ 429، أو قد يسقط. ونفس الشيء مع قاعدة البيانات، فمجمع الاتصالات Connection Pool في Npgsql حده الافتراضي مئة اتصال، وإذا أطلقت ألف استعلام معاً فسوف ينتظر تسعمئة منها دورها، وبعضها يتجاوز المهلة. لذلك القاعدة: أطلق بالتوازي ما يتحمله الطرف الآخر، وليس كل ما تستطيعه أنت.
والأداتان في الكود مختلفتان قليلاً: Parallel.ForEachAsync تأخذ قائمة وحداً وتدير كل شيء، وهي الأسهل عندما يكون عندك مجموعة معروفة، وSemaphoreSlim إشارة تسمح لعدد محدد بالمرور، ويمكن أن تشترك فيها أجزاء مختلفة من الخدمة، مثل كل الطلبات التي تذهب إلى نفس المورد من أي مكان في الكود. ولاحظ في الكود أن Release في finally، لأن طلباً واحداً يرمي استثناء دون أن يحرر الإشارة يعني أن الحد صار 19 إلى الأبد.
ولاحظ أيضاً أن الكود كله يستعمل HttpClient واحداً للطلبات كلها، وهذا ليس تفصيلاً، فإنشاء HttpClient جديد لكل طلب يترك الـ Sockets في حالة الانتظار بعد الإغلاق، وقد يستنفد المنافذ تحت الضغط، كما تشرح إرشادات HttpClient. وفي خدمة ASP.NET Core تأخذه من IHttpClientFactory بدل أن تنشئه بنفسك.
المشكلة التي جمدت الخدمة: جوع مجمع الخيوط Thread Pool Starvation
نعود الآن إلى الزميل الذي لف كل طلب في Task.Run وأخذ النتيجة بـ .Result. ولكي نرى ما حدث بالضبط كتبنا القسم starve، وهو نفس المئة طلب بنفس السيرفر، ولكن بأسلوب يسمى المزامنة فوق اللاتزامن Sync over Async، مع مهمة مراقبة تطبع التقدم كل نصف ثانية:
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);والمخرج سوف يكون كما يلي:
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، والخيطان كلاهما متجمدان ينتظران. والمجمع يرى الطابور لا يتحرك فيضيف خيوطاً، ولكنه لا يضيفها دفعة واحدة كما تشرح صفحة مجمع الخيوط: بعد الحد الأدنى إما أن ينشئ خيطاً إضافياً أو ينتظر أن تنتهي بعض المهام، وفي تجربتنا وصل إلى 101 خيط بعد قرابة 23 ثانية، أي بمعدل أربعة خيوط تقريباً في الثانية.
ولاحظ سطر المراقبة الوحيد: مهمة المراقبة كان يفترض أن تطبع سطراً كل نصف ثانية، فطبعت سطراً واحداً بعد 23.8 ثانية، لأنها هي أيضاً تحتاج خيطاً من المجمع لتعمل، ولم تجد. وهذا ما يحدث لخدمة ويب كاملة: كل طلب جديد، وكل فحص صحة، وكل مؤقت، ينتظر في نفس الطابور، فتبدو الخدمة ميتة مع أن المعالج شبه فارغ، وهذه هي العلامة المميزة لهذا النوع من المشاكل: زمن استجابة عال جداً واستهلاك معالج منخفض وعدد خيوط يزيد ببطء. وMicrosoft عندها دليل كامل لتشخيصه بالأدوات dotnet-counters وdotnet-trace، ومنذ .NET 9 يوجد حدث WaitHandleWait يسجل كل خيط يتجمد في Task.Result أو Task.Wait.
ماذا يحدث إذا رفعنا الحد الأدنى للخيوط SetMinThreads؟
أول حل يجده الناس على الإنترنت هو ThreadPool.SetMinThreads، فجربناه في القسم starve-min، وهو نفس الكود بعد أن رفعنا الحد الأدنى إلى 110 خيوط، وشغلنا القسمين مرة أخرى متتاليين:
### 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. والصورة التالية تجمع الحالات الثلاث:

وبالعودة إلى مهمة تحديث الأسعار في القصة، فالإصلاح الذي دخل المستودع لم يكن رفع الحد الأدنى للخيوط، وإنما حذف Task.Run و.Result معاً، وكتابة الحلقة بـ Parallel.ForEachAsync بحد عشرة طلبات، لأن واجهة المورد تسمح بعشرة طلبات متزامنة لكل عميل بحسب عقدهم، مع تمرير توكن الإلغاء ومهلة ثانيتين لكل طلب. وبهذا صارت المهمة تأخذ قرابة نصف ثانية بدل خمس ثوان، كما في السطر الثالث من القياس الأول، ولم تعد تحجز أي خيط أثناء الانتظار، فلم يعد فحص الصحة يتأخر وهي تعمل. والدرس الذي كتبه الفريق في سجل الحادثة: الكود الذي يبدو أسرع في التجربة على جهاز المطور، حيث الأنوية كثيرة والطلب واحد، قد يكون أبطأ بمئة مرة على سيرفر بنواتين تحت الضغط، ولذلك لا يقبل أي تغيير في التزامن دون قياس في Container بنفس حدود الإنتاج.
المهمة الثانية CPU-bound: SHA-256 على نواة ونواتين وأربع
الآن العكس: عمل حسابي خالص لا ينتظر أي شيء، وهو هاش SHA-256 لألفي نص، كل نص يمرر على الدالة ألفي مرة، أي أربعة ملايين هاش. وجربناه بأربع طرق:
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، والمخرج سوف يكون كما يلي:
### 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 يشغل الطلبات أصلاً على خيوط المجمع بالتوازي، فأنت فقط تنقل العمل من خيط إلى خيط وتدفع ثمن النقل.

async بلا await بقيت بزمن التتابعسباق البيانات Race Condition: عداد يفقد الزيادات
عندما تعمل عدة خيوط في نفس اللحظة على عدة أنوية فعلاً، كما رأينا، يصير أي متغير مشترك بينها خطراً. والمثال الأبسط عداد يزيده مليون عمل بالتوازي:
int counter = 0;
Parallel.For(0, 1_000_000, _ => counter++);
int safe = 0;
Parallel.For(0, 1_000_000, _ => Interlocked.Increment(ref safe));والمخرج من ثلاثة تشغيلات:
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 فتنفذ القراءة والزيادة والكتابة كعملية ذرية Atomic واحدة على مستوى المعالج، فأعطت مليوناً في كل مرة.
والسطر الأخير في المخرج مختلف: Dictionary عادي يكتب فيه عدة خيوط في نفس الوقت لم يعط نتيجة خاطئة فقط، وإنما رمى استثناء غريباً لا علاقة له بالكود الذي كتبناه، لأن بنيته الداخلية تلفت أثناء توسعه. وفي إصدارات قديمة من .NET كان هذا النوع من الاستخدام يدخل أحياناً في حلقة لا نهائية تأخذ نواة كاملة إلى الأبد. والمجموعات العادية في .NET ليست آمنة للخيوط Thread-safe للكتابة، والبديل للقواميس المشتركة هو ConcurrentDictionary.
فحص ثم تنفيذ Check-then-Act: كيف بعنا عشرين دلة من عشر
نعود إلى عرض الدلال النحاسية. المنطق في الخدمة كان بهذا الشكل تقريباً: إذا كان المخزون أكبر من صفر فأنقصه وسجل البيع. وكتبنا نفس المنطق في القسم stock، مع عشرين مشترياً بالتوازي وعشر قطع، وأضفنا await Task.Delay(1) بين الفحص والإنقاص لتمثل الوقت الذي يأخذه أي عمل حقيقي بينهما، مثل حساب السعر أو استدعاء آخر:
// 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++; } }والمخرج سوف يكون كما يلي:
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: فباعت عشراً بالضبط في كل مرة. ولاحظ النوع Lock، وهو نوع مخصص للأقفال أضيف في .NET 9، والكلمة lock تستعمله مباشرة إذا مررته لها، وهو أوضح وأسرع من القفل على أي object كما كان يكتب سابقاً.
ولكن تذكر أن lock تحمي الذاكرة داخل عملية واحدة فقط، والخدمة الحقيقية تعمل بعدة نسخ خلف موزع الطلبات، والمخزون في قاعدة البيانات وليس في متغير. لذلك الحل الحقيقي لعرض الدلال هو نفس الفكرة ولكن في قاعدة البيانات: أن تقفل صف المنتج بـ SELECT ... FOR UPDATE داخل معاملة Transaction، ثم تفحص وتنقص، وهذا ما سوف نبنيه ونختبره بعشرين طلباً متوازياً في المقال التالي، والأقفال في قواعد البيانات شرحناها بالتفصيل في مقال الأقفال في قواعد البيانات.
القفل المميت Deadlock: قفلان بترتيب معكوس
وعندما تحتاج أكثر من قفل في نفس العملية يظهر خطر جديد. لنفرض أن عندك قفلاً للمخزون وقفلاً لمحفظة العميل، وفي مكان من الكود تأخذ قفل المخزون ثم المحفظة، وفي مكان آخر، كاسترجاع مبلغ، تأخذ المحفظة ثم المخزون:
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"); } } });والمخرج سوف يكون كما يلي:
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. وفي خدمة حقيقية لا ترى شيئاً سوى طلبات لا ترد أبداً وخيطين ناقصين من المجمع. والإصلاح أن تأخذ كل الأقفال في كل مكان بنفس الترتيب، فلا يمكن أن يمسك أحدهم الثاني وينتظر الأول، وهذا ما حدث في الجزء الثاني فانتهى الخيطان. والصورة التالية تجمع الأخطاء الثلاثة وإصلاحاتها:

وهناك نوع آخر من القفل المميت خاص بـ async تقرأ عنه كثيراً، وهو أن تستدعي .Result على مهمة من خيط الواجهة في WinForms أو WPF، أو من طلب في ASP.NET القديم، فتنتظر المهمة أن ترجع تكملتها إلى نفس الخيط المتجمد، فلا ترجع أبداً. وهذا لا يحدث في ASP.NET Core ولا في برامج سطر الأوامر لأنها بلا سياق تزامن كما ذكرنا، ولكن نفس الكود يتحول هناك إلى جوع المجمع الذي رأيناه، فالنتيجة في الحالتين: لا تستعمل .Result ولا .Wait() على مهمة لم تنته.
الإلغاء Cancellation والمهلات Timeouts
كل عمل متزامن في الخدمة يحتاج أن يعرف متى يتوقف: المستخدم أغلق الصفحة، أو سيرفر المورد لا يرد، أو الخدمة نفسها تغلق للتحديث. والطريقة في .NET هي CancellationToken، وهو إشارة تمررها من أعلى السلسلة إلى أسفلها، وكل دالة غير متزامنة في المكتبة تقبلها كمعامل أخير، كما تشرح صفحة الإلغاء. ولاحظ في كود Parallel.ForEachAsync أعلاه المعامل ct الذي مررناه إلى GetStringAsync، فإذا ألغي العمل كله توقفت الطلبات الجارية أيضاً بدل أن تكمل في الخلفية. وفي ASP.NET Core يأتيك توكن جاهز لكل طلب، يلغى عندما يقطع العميل الاتصال، فتمرره إلى قاعدة البيانات ولا تكمل استعلاماً لن يقرأ نتيجته أحد.
والمهلة نوع من الإلغاء، والمثال الذي يفاجئ الجميع أن HttpClient مهلته الافتراضية 100 ثانية، فطلب واحد إلى مورد معطل يحجز مكاناً في الحد الذي وضعته بـ SemaphoreSlim دقيقة وأربعين ثانية. لذلك ضع مهلة تناسب عملك، إما على الـ HttpClient كله، أو لكل طلب بـ CancellationTokenSource مع CancelAfter، والمثال التالي يجمع الاثنين مع توكن الطلب:
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، وهي طابور غير متزامن آمن للخيوط، وله حد اختياري يجعل المنتج ينتظر عندما يمتلئ، بدل أن تكبر الذاكرة بلا نهاية.
وهناك أداة أخيرة تحتاجها عندما تقرأ كمية كبيرة من البيانات من مصدر غير متزامن، مثل كل طلبات الشهر من قاعدة البيانات، وهي التدفقات غير المتزامنة Async Streams، أي الدالة التي ترجع 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 المدمجة، ويمكن قراءتهما من Container يعمل بالأداة dotnet-counters دون إعادة تشغيل. وفي الخدمة السليمة يبقى الطابور قريباً من الصفر، وعدد الخيوط قريباً من عدد الأنوية أو ضعفه، وإذا رأيت الطابور يكبر وعدد الخيوط يزيد ببطء والمعالج شبه فارغ، فأنت أمام ما رأيناه في قسم الجوع بالضبط، وأول ما تبحث عنه في الكود هو .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# للمطورين نبني الخدمة الحقيقية: واجهة طلبات بـ ASP.NET Core وPostgreSQL، فيها نفس مشكلة المخزون محلولة بقفل الصفوف، ونختبرها بعشرين طلباً متوازياً. وهذه مقالات تكمل هذا المقال:
دليلالأنواع والذاكرة والاستثناءات Exceptions في C#قسنا ما تكلفه أنواع القيمة والمرجع وجامع القمامة داخل Container محدود، ثم شغلنا كل خطأ شائع حتى يسقط، وكشفنا أن double يخطئ في ربع مبالغ الضريبة الحدية.
دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.
دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.
دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.
دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.
دليلC# لمن يعرف البرمجة: الأدوات Toolchain والأساسياتتثبيت .NET 10 وجولة في C# مقارنة بـ Python وJavaScript، ثم حل كامل بالأمر dotnet من البناء والاختبار إلى أربعة أشكال للنشر، مع فخ تحذيرات حقيقي واجهناه.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ .NET SDK 10.0.403 وRuntime 10.0.14 في Containers محدودة بنواة ونواتين وأربع أنوية.