تكتب خدمة ترسل الإشعارات، وتجربها على جهازك فتعمل، ثم ترفعها على السيرفر ويأتيها مئة طلب في الثانية، فيبدأ عداد الرسائل المرسلة يعطي رقماً أقل من الحقيقي، أو يتجمد التطبيق كله فجأة دون أي رسالة خطأ ولا يعود إلا بإعادة التشغيل، أو تكتشف أن السيرفر يرد ببطء والمعالج شبه فارغ. وفي أغلب هذه الحالات لم يتغير المنطق في الكود، وإنما أصبح الكود يعمل في أكثر من خيط Thread في نفس الوقت، والكود الذي لم يكتب لذلك يتصرف بطريقة لا يتوقعها كاتبه.
وفي مقال المعالج والخيوط والتزامن شرحنا الجانب الآخر من الموضوع: الأنوية والخيوط العتادية، وكيف يجدول نظام التشغيل الخيوط، والفرق بين التزامن Concurrency والتوازي Parallelism، ومتى تسرع الخيوط العمل ومتى لا تسرعه، لذلك لن نعيد ذلك هنا. وهذا المقال من جهة المبرمج: كيف تنشئ الخيوط وتستخدمها في الكود، وما الأخطاء التي تظهر عندما يتشارك خيطان نفس البيانات، وكيف تصلحها، وكل مثال فيه شغلناه فعلاً وأمامك المخرج كما ظهر.
وسوف نناقش في هذا المقال ما يلي:
- الفرق بين العملية Process والخيط Thread من جهة الكود: من يرى ذاكرة من.
- إنشاء الخيوط، ولماذا نستخدم مجمع الخيوط Thread Pool بدلاً منها في أغلب الحالات.
- الكلمتان async/await في C#، وحلقة الأحداث Event Loop في JavaScript، وasyncio في Python ووضع قفل GIL في الإصدارات 3.13 و3.14 و3.15.
- حالة السباق Race Condition بتجارب حقيقية في C# وPython وNode.js، ثم الأقفال Locks والعمليات الذرية Atomic Operations.
- الجمود Deadlock: نصنعه ثم نصلحه بترتيب الأقفال.
- المجموعات الآمنة للخيوط Thread-Safe Collections، والكائنات الثابتة Immutable Objects.
- أخطاء شائعة: async void، وانتظار المهمة بـ
.Result، وforEach مع async.
تجهيز المختبر Lab
أنشئ مجلداً فارغاً وضع فيه ملفات الأمثلة كما تأتي في المقال، وكل الأوامر تشغل من داخله. أمثلة C# ملفات مفردة تعمل مباشرة بالأمر dotnet run file.cs دون مشروع، وهي ميزة التطبيقات المبنية على ملف File-based Apps في .NET 10، ونشغلها في Container من .NET SDK الرسمي:
docker run -d --name lab-prog-dotnet -v "$PWD":/lab -w /lab \
-e DOTNET_CLI_TELEMETRY_OPTOUT=1 mcr.microsoft.com/dotnet/sdk:10.0.401 sleep infinity
وأمثلة Python في Container ثان، ونستخدم فيه uv لتنزيل Python 3.14 ونسختها الحرة Free-threaded من القفل GIL، لأننا سنقارن بينهما:
docker run -d --name lab-prog-py -v "$PWD":/lab -w /lab python:3.13-slim sleep infinity
docker exec lab-prog-py sh -c 'pip install -q uv && uv python install -q 3.14 3.14t'
أما أمثلة JavaScript فتعمل بأي Node.js 22 على جهازك بالأمر node file.mjs. والجهاز الذي شغلنا عليه فيه ثماني معالجات منطقية، وكل الأزمنة في المقال للمقارنة داخل التجربة الواحدة فقط، وتختلف على جهازك.
العملية والخيط: من يرى ذاكرة من؟
العملية Process هي برنامج يعمل، ولها ذاكرتها الخاصة التي لا تراها أي عملية أخرى، والخيط Thread هو مسار تنفيذ داخل العملية، وكل عملية فيها خيط واحد على الأقل هو الخيط الرئيسي Main Thread. والنقطة التي تهم المبرمج أن كل الخيوط داخل العملية تشترك في نفس الذاكرة: نفس المتغيرات العامة ونفس الكائنات، ولكل خيط مكدسه Stack الخاص بمتغيراته المحلية فقط. والمثال التالي يضيف عنصراً إلى نفس القائمة مرة من خيط ومرة من عملية جديدة، احفظه باسم shared.py:
# shared.py: a thread and a process both append to the same list
import threading, multiprocessing, os
items = []
def worker(name):
items.append(f"{name} (pid {os.getpid()})")
if __name__ == "__main__":
print("main pid", os.getpid())
t = threading.Thread(target=worker, args=("thread",))
t.start(); t.join()
print("after thread: ", items)
p = multiprocessing.Process(target=worker, args=("process",))
p.start(); p.join()
print("after process:", items)
docker exec lab-prog-py python shared.py
والمخرج سوف يكون كما يلي:
main pid 110
after thread: ['thread (pid 110)']
after process: ['thread (pid 110)']
الخيط أضاف إلى القائمة فرأى البرنامج الإضافة مباشرة، ورقم العملية pid نفسه لأن الخيط داخلها، أما العملية الجديدة فأضافت إلى نسختها الخاصة من القائمة، وانتهت النسخة معها ولم يتغير شيء عندنا. وهذا الفرق هو كل القصة: مشاركة الذاكرة هي ما يجعل الخيوط سريعة وخفيفة في تبادل البيانات، وهي نفسها ما يجعلها خطيرة، لأن خيطين قد يعدلان نفس الكائن في نفس اللحظة. والصورة التالية تبين ما يتشاركه الخيوط وما يخص كل خيط:

أما كيف يوزع نظام التشغيل هذه الخيوط على الأنوية، وكم يكلف تبديل السياق Context Switch، فقد شرحناه في جزء العملية والخيط من مقال المعالج.
إنشاء الخيوط ومجمع الخيوط Thread Pool
أبسط طريقة أن تنشئ خيطاً بنفسك، تعطيه الدالة التي ينفذها ثم تبدأه، وتنتظره بالدالة Join إذا احتجت نتيجته. ولكن إنشاء خيط من خيوط نظام التشغيل له ثمن، فكل خيط يحجز ذاكرة لمكدسه ويحتاج من النظام عملاً لإنشائه وجدولته، لذلك لا تقم بإنشاء خيط لكل مهمة صغيرة، وإنما استخدم مجمع الخيوط Thread Pool: عدد من الخيوط الجاهزة تأخذ المهام من طابور، وكلما انتهى خيط من مهمة أخذ التالية. والمثال التالي ينشئ خيطاً بنفسه، ثم يرسل 16 مهمة إلى مجمع الخيوط في .NET عبر Task.Run، وكل مهمة تنام ثانية بالدالة Thread.Sleep كأنها تنتظر قاعدة البيانات بالطريقة القديمة، احفظه باسم threads.cs:
// threads.cs: a dedicated thread, then 16 blocking jobs on the thread pool
using System.Diagnostics;
Console.WriteLine($".NET {Environment.Version}, cores {Environment.ProcessorCount}, main thread id {Environment.CurrentManagedThreadId}");
var t = new Thread(() =>
Console.WriteLine($"new Thread: id {Environment.CurrentManagedThreadId}, pool thread: {Thread.CurrentThread.IsThreadPoolThread}"));
t.Start();
t.Join(); // wait until it finishes
ThreadPool.GetMinThreads(out int minWorkers, out _);
Console.WriteLine($"thread pool: min worker threads {minWorkers}");
var sw = Stopwatch.StartNew();
var jobs = Enumerable.Range(1, 16)
.Select(_ => Task.Run(() => { Thread.Sleep(1000); return Environment.CurrentManagedThreadId; }))
.ToArray();
Task.WaitAll(jobs);
Console.WriteLine($"16 jobs x Thread.Sleep(1000): {sw.Elapsed.TotalSeconds:F2} s on {jobs.Select(j => j.Result).Distinct().Count()} pool threads");
docker exec lab-prog-dotnet dotnet run threads.cs
.NET 10.0.12, cores 8, main thread id 1
new Thread: id 4, pool thread: False
thread pool: min worker threads 8
16 jobs x Thread.Sleep(1000): 2.02 s on 9 pool threads
في المخرج أعلاه لاحظ التالي:
- الخيط الذي أنشأناه بأنفسنا ليس من المجمع
pool thread: False، ويموت عندما تنتهي دالته. - الحد الأدنى للمجمع يساوي عدد المعالجات، وهو العدد الذي ينشئه المجمع فوراً عند الطلب، وبعده كما يقول توثيق مجمع الخيوط في .NET يقرر المجمع بنفسه هل يضيف خيطاً أم ينتظر أن تنتهي مهمة.
- المهام الست عشرة احتاجت ثانيتين وليس ثانية واحدة، لأنها عملت على تسعة خيوط فقط، فانتظرت السبع الباقية حتى تفرغ خيوط من الدفعة الأولى. ولاحظ أن الخيوط كانت نائمة لا تعمل شيئاً، ومع ذلك حجزت المجمع، وسوف نرى في جزء الأخطاء الشائعة ماذا يحدث عندما يصبح هذا مئتي طلب.

وفي Python نفس الفكرة عبر ThreadPoolExecutor، وعدد العمال فيه تحدده أنت أو يأخذ قيمة افتراضية، احفظ المثال التالي باسم pool.py:
# pool.py: a thread pool with a fixed number of workers, and how many it uses by default
import os, time, threading
from concurrent.futures import ThreadPoolExecutor
def job(i):
time.sleep(1) # waiting for I/O
return threading.current_thread().name
with ThreadPoolExecutor() as pool:
print("default max_workers:", pool._max_workers, "cpu count:", os.cpu_count()) # _max_workers is internal, read here only to show the default
for workers in (4, 12):
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as pool:
names = list(pool.map(job, range(12)))
print(f"12 jobs of 1 s on {workers:>2} workers: {time.perf_counter() - start:.2f} s, threads used: {len(set(names))}")
docker exec lab-prog-py python pool.py
default max_workers: 12 cpu count: 8
12 jobs of 1 s on 4 workers: 3.06 s, threads used: 4
12 jobs of 1 s on 12 workers: 1.00 s, threads used: 12
القيمة الافتراضية 12 هي المعادلة التي يذكرها التوثيق min(32, (os.process_cpu_count() or 1) + 4)، وأربعة عمال أنهوا 12 مهمة في ثلاث دفعات. وحجم المجمع قرار مهم في أي سيرفر: مجمع صغير يجعل الطلبات تنتظر والمعالج فارغ، ومجمع كبير جداً يفتح اتصالات بقاعدة البيانات أكثر مما تحتمل، وقد شرحنا هذا الحساب في جزء اتصالات قاعدة البيانات.
الانتظار دون حجز خيط: async/await
أغلب وقت تطبيقات الويب ليس حساباً وإنما انتظار: انتظار قاعدة البيانات، وانتظار خدمة HTTP خارجية، وانتظار القرص. والطريقة البسيطة أن يقف الخيط حتى يصل الرد كما فعلنا بـ Thread.Sleep، وهي تعمل، ولكن كل طلب ينتظر يحجز خيطاً كاملاً، فإذا كان عندك ألف طلب ينتظر فأنت تحتاج ألف خيط. والكلمتان async/await تحلان هذه المشكلة: عند await يسجل البرنامج أين توقف، ويرجع الخيط إلى المجمع ليخدم غيره، وعندما يصل الرد يكمل الدالة من حيث توقفت على أي خيط فارغ. احفظ المثال التالي باسم async.cs، وفيه عشرة آلاف استدعاء كل منها ينتظر ثانية:
// async.cs: 10,000 waits of one second each, with await and no blocked threads
using System.Diagnostics;
int before = Environment.CurrentManagedThreadId;
await Task.Delay(100);
Console.WriteLine($"before await: thread {before}, after await: thread {Environment.CurrentManagedThreadId}");
var sw = Stopwatch.StartNew();
var calls = Enumerable.Range(1, 10_000).Select(_ => CallServiceAsync()).ToArray();
await Task.WhenAll(calls);
Console.WriteLine($"10,000 x await Task.Delay(1000): {sw.Elapsed.TotalSeconds:F2} s, process threads now: {Process.GetCurrentProcess().Threads.Count}");
static async Task CallServiceAsync()
{
await Task.Delay(1000); // stands for a database or HTTP call
}
docker exec lab-prog-dotnet dotnet run async.cs
before await: thread 1, after await: thread 5
10,000 x await Task.Delay(1000): 1.05 s, process threads now: 19
عشرة آلاف انتظار لثانية انتهت كلها في ثانية تقريباً، والعملية كلها فيها 19 خيطاً، ولو استخدمنا Thread.Sleep لاحتجنا عشرة آلاف خيط أو انتظرنا دقائق. ولاحظ السطر الأول: الدالة بدأت على الخيط 1 وأكملت بعد await على الخيط 5، وهذا طبيعي ومهم، لأن أي شيء تفترض أنه مرتبط بالخيط، مثل متغير [ThreadStatic] أو قفل أخذته قبل await، لم يعد صحيحاً بعدها، ولهذا السبب يمنعك C# من استخدام await داخل lock.

وقد يتساءل البعض: هل async تجعل الحساب أسرع؟ والإجابة لا، فهي تفيد في الانتظار فقط، والدالة التي تحسب عشر ثوان تحتاج عشر ثوان من خيط ما سواء كتبت async أم لا، والحساب يحتاج أنوية وخيوطاً حقيقية كما رأينا في مقال المعالج.
حلقة الأحداث في JavaScript
في JavaScript لا يوجد إلا خيط واحد ينفذ كودك، وحلقة الأحداث Event Loop هي التي تقرر ما الذي ينفذ بعد أن ينتهي الكود الحالي، من طوابير بأولويات مختلفة. وقد شرحنا في مقال المعالج كيف يوقف حساب طويل الحلقة كلها، وهنا نرى ترتيب الطوابير نفسه، فما ترتيب الأسطر التي يطبعها هذا الملف؟ احفظه باسم order.cjs واكتب توقعك قبل أن تشغله:
// order.cjs: in which order does Node.js run these lines?
console.log("1 sync: start");
setTimeout(() => console.log("5 timer (macrotask)"), 0);
Promise.resolve().then(() => console.log("4 promise .then (microtask)"));
queueMicrotask(() => console.log("4 queueMicrotask (microtask)"));
process.nextTick(() => console.log("3 process.nextTick"));
async function handler() {
console.log("1 sync: inside handler, before await");
await null; // the rest of the function becomes a microtask
console.log("4 handler, after await");
}
handler();
console.log("2 sync: end of the script");
node order.cjs
1 sync: start
1 sync: inside handler, before await
2 sync: end of the script
3 process.nextTick
4 promise .then (microtask)
4 queueMicrotask (microtask)
4 handler, after await
5 timer (macrotask)
في المخرج أعلاه لاحظ التالي:
- الكود المتزامن يعمل كله أولاً، بما فيه الجزء من
handlerالذي قبل await، فالدالة async تعمل متزامنة حتى أول await. - بعد أن يفرغ المكدس تعمل طوابير المهام الصغيرة: طابور
process.nextTickفي Node.js ثم طابور الوعود Microtasks الذي فيه.thenوباقي الدالة بعد await. - المؤقت
setTimeout(..., 0)جاء أخيراً مع أن مدته صفر، لأنه في طابور المهام الكبيرة Macrotasks الذي لا يعمل إلا بعد أن تفرغ الطوابير الصغيرة كلها.
والآن انسخ نفس الملف باسم order.mjs وشغله كوحدة ES Module:
cp order.cjs order.mjs && node order.mjs
1 sync: start
1 sync: inside handler, before await
2 sync: end of the script
4 promise .then (microtask)
4 queueMicrotask (microtask)
4 handler, after await
3 process.nextTick
5 timer (macrotask)
نفس الكود أعطى ترتيباً مختلفاً، والسبب كما يذكر توثيق process.nextTick أن وحدات ESM تنفذ أصلاً كجزء من طابور الوعود، فتفرغ الوعود قبل أن يأتي دور nextTick. والدرس العملي: لا تبن منطق برنامجك على ترتيب الطوابير بين nextTick والوعود، وإذا احتجت أن ينتظر شيء شيئاً فاستخدم await صراحة.
وقد يتساءل البعض: إذا كان Node.js خيطاً واحداً، فكيف يقرأ الملفات ويحسب الهاش دون أن يتوقف؟ والإجابة أن Node.js فيه مجمع خيوط داخلي من مكتبة libuv، ويقول توثيق UV_THREADPOOL_SIZE إن حجمه الافتراضي أربعة، وإن واجهات fs وdns.lookup() وzlib والتشفير غير المتزامن مثل crypto.pbkdf2() تعمل عليه. والمثال التالي يطلب ثمانية هاشات PBKDF2 في نفس اللحظة، احفظه باسم uvpool.mjs:
// uvpool.mjs: eight password hashes with the async crypto API run on libuv's thread pool
import { pbkdf2 } from "node:crypto";
const start = performance.now();
for (let i = 1; i <= 8; i++) {
pbkdf2("secret", "salt", 600_000, 32, "sha256", () => {
console.log(`hash ${i} done after ${Math.round(performance.now() - start)} ms`);
});
}
console.log(`UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE ?? "(default)"}, the main thread is free now`);
node uvpool.mjs
UV_THREADPOOL_SIZE=8 node uvpool.mjs
UV_THREADPOOL_SIZE=(default), the main thread is free now
hash 4 done after 1152 ms
hash 3 done after 1152 ms
hash 1 done after 1190 ms
hash 2 done after 1265 ms
hash 7 done after 2177 ms
hash 6 done after 2274 ms
hash 5 done after 2289 ms
hash 8 done after 2316 ms
UV_THREADPOOL_SIZE=8, the main thread is free now
hash 6 done after 1207 ms
hash 7 done after 1207 ms
hash 8 done after 1208 ms
hash 5 done after 1208 ms
hash 3 done after 1425 ms
hash 4 done after 1465 ms
hash 1 done after 1533 ms
hash 2 done after 1533 ms
بالحجم الافتراضي انتهت الهاشات على دفعتين من أربعة، فالدفعة الثانية انتظرت خيوطاً فارغة، ومع ثمانية خيوط انتهت كلها في الدفعة الأولى تقريباً. وهذا مهم في سيرفر Node.js يحسب هاش كلمات المرور عند تسجيل الدخول، لأن أربعة تسجيلات دخول في نفس اللحظة تكفي لتأخير قراءة أي ملف في نفس العملية. وتذكر أن الاتصالات الشبكية لا تستخدم هذا المجمع، فنظام التشغيل نفسه يخبر الحلقة عندما تصل البيانات. والصورة التالية تجمع هذه الأجزاء:

ال asyncio وقفل GIL في Python
في Python نفس الفكرة عبر asyncio: حلقة أحداث على خيط واحد، ودوال async def تتوقف عند await. والمثال التالي يستدعي ثلاث خدمات تنتظر كل منها ثانية، مرة بالطريقة الصحيحة await asyncio.sleep، ومرة بخطأ شائع هو استدعاء دالة تحجز الخيط مثل time.sleep أو مكتبة HTTP غير متزامنة داخل دالة async، احفظه باسم aio.py:
# aio.py: three calls that wait one second each, with asyncio, then the same with a blocking call inside
import asyncio, time, threading
async def call_service(name, blocking=False):
if blocking:
time.sleep(1) # blocks the event loop thread: nothing else can run
else:
await asyncio.sleep(1) # gives the loop back while waiting
return f"{name} on thread {threading.current_thread().name}"
async def main():
for blocking in (False, True):
start = time.perf_counter()
results = await asyncio.gather(*(call_service(n, blocking) for n in ("users", "orders", "stock")))
label = "time.sleep(1) " if blocking else "await asyncio.sleep(1)"
print(f"{label}: {time.perf_counter() - start:.2f} s {results}")
asyncio.run(main())
docker exec lab-prog-py python aio.py
await asyncio.sleep(1): 1.01 s ['users on thread MainThread', 'orders on thread MainThread', 'stock on thread MainThread']
time.sleep(1) : 3.00 s ['users on thread MainThread', 'orders on thread MainThread', 'stock on thread MainThread']
الاستدعاءات الثلاثة كلها على الخيط الرئيسي، ومع await تداخل الانتظار فانتهت في ثانية، أما time.sleep فأوقفت الحلقة نفسها، فلم يبدأ الاستدعاء الثاني إلا بعد أن انتهى الأول، وأصبحت ثلاث ثوان. والكود لم يعط أي خطأ، وهذا ما يجعل الخطأ صعب الاكتشاف. والحل كما يشرح توثيق تطوير asyncio في جزء الكود الذي يحجز الخيط Blocking Code أن تستخدم مكتبات تدعم async، أو ترسل الدالة التي تحجز إلى خيط منفصل بالدالة asyncio.to_thread أو loop.run_in_executor.
أما قفل GIL واختصار Global Interpreter Lock فهو قفل داخل CPython لا يسمح إلا لخيط واحد بتنفيذ كود Python في كل لحظة، لذلك لا تسرع الخيوط الحساب في النسخة المعتادة، وقد قسنا ذلك في جزء Python وقفل GIL من مقال المعالج. ووضعه حتى أكتوبر 2026 كما يلي:
- في Python 3.13 ظهرت نسخة تجريبية دون القفل تسمى Free-threaded بعد قبول PEP 703، وتنصب كخيار منفصل.
- في Python 3.14 أصبحت هذه النسخة مدعومة رسمياً وليست تجريبية عبر PEP 779، ولكنها ما زالت اختيارية وليست الافتراضية، ويقول دليلها الرسمي إنك تعرفها من عبارة free-threading build في مخرج
python -VV، وإن الكود ذا الخيط الواحد يصبح فيها أبطأ بنسبة بين 1% و8% بحسب النظام، وإن المتغيرPYTHON_GILيعيد القفل فيها عند التشغيل. - في Python 3.15 التي يحدد جدول إصدارها موعد صدورها في 9 أكتوبر 2026، ما زالت النسخة الحرة اختيارية في ملاحظات الإصدار وقت كتابة المقال (الإصدار المرشح 3.15.0rc3)، ومن الجديد فيها واجهة ABI مستقرة للنسخة الحرة اسمها abi3t تسهل على المكتبات المكتوبة بلغة C دعمها.
والسؤال الذي يهمنا في هذا المقال ليس السرعة وإنما الأمان: هل القفل GIL يحمي كودك من حالة السباق؟ وهذا ما نجربه في الجزء التالي.
حالة السباق Race Condition
حالة السباق هي أن تعتمد نتيجة البرنامج على الترتيب الذي تصادف أن نفذت به الخيوط، وأشهر مثال لها عداد يزيده أكثر من خيط. وقد رأيت في التمرين 14 من تمارين البرمجة عداداً يزيده Parallel.For، وهنا نكتب المثال بأربعة خيوط صريحة، ونقارن الطرق الثلاث في الصحة والزمن، احفظه باسم race.cs:
// race.cs: four threads, each adds 1 a million times to a shared counter, three ways
using System.Diagnostics;
const int Threads = 4, PerThread = 1_000_000;
int plain = 0, atomic = 0, locked = 0;
Lock gate = new(); // System.Threading.Lock, .NET 9 and later
Run("count++ ", () => { for (int i = 0; i < PerThread; i++) plain++; });
Run("Interlocked", () => { for (int i = 0; i < PerThread; i++) Interlocked.Increment(ref atomic); });
Run("lock ", () => { for (int i = 0; i < PerThread; i++) lock (gate) { locked++; } });
Console.WriteLine($"expected {Threads * PerThread:N0}: count++ {plain:N0}, Interlocked {atomic:N0}, lock {locked:N0}");
void Run(string label, Action body)
{
var sw = Stopwatch.StartNew();
var ts = Enumerable.Range(0, Threads).Select(_ => new Thread(() => body())).ToList();
ts.ForEach(t => t.Start());
ts.ForEach(t => t.Join());
Console.WriteLine($"{label} {sw.ElapsedMilliseconds,5} ms");
}
وشغلناه ثلاث مرات:
docker exec lab-prog-dotnet dotnet run race.cs
count++ 44 ms
Interlocked 73 ms
lock 300 ms
expected 4,000,000: count++ 3,152,235, Interlocked 4,000,000, lock 4,000,000
count++ 27 ms
Interlocked 66 ms
lock 308 ms
expected 4,000,000: count++ 1,537,145, Interlocked 4,000,000, lock 4,000,000
count++ 34 ms
Interlocked 75 ms
lock 278 ms
expected 4,000,000: count++ 3,758,579, Interlocked 4,000,000, lock 4,000,000
العداد العادي فقد في إحدى المرات أكثر من 60% من الزيادات، وأعطى رقماً مختلفاً في كل تشغيل. والسبب أن السطر plain++ ليس خطوة واحدة في المعالج وإنما ثلاث: يقرأ القيمة من الذاكرة إلى سجل Register، ثم يضيف واحداً، ثم يكتبها، فإذا قرأ خيطان نفس القيمة قبل أن يكتب أي منهما، كتب كل منهما 42 وضاعت زيادة كاملة، كما في الصورة التالية:

هل يحمي القفل GIL كودك في Python؟
الآن نفس التجربة في Python بثلاث طرق: counter += 1، ثم قراءة القيمة وتمريرها لدالة ثم كتابتها، ثم نفس الشيء داخل قفل، احفظه باسم race.py:
# race.py: four threads add 1 to a shared counter a million times each, three ways
import sys, sysconfig, threading
N, THREADS = 1_000_000, 4
counter = 0
lock = threading.Lock()
def add_one(v):
return v + 1
def plus_equals():
global counter
for _ in range(N):
counter += 1 # read, add, write
def with_call():
global counter
for _ in range(N):
counter = add_one(counter) # read, call a function, write
def with_lock():
global counter
for _ in range(N):
with lock:
counter = add_one(counter)
def run(fn):
global counter
counter = 0
ts = [threading.Thread(target=fn) for _ in range(THREADS)]
for t in ts: t.start()
for t in ts: t.join()
return f"{counter:>9,}"
gil = sys._is_gil_enabled() if hasattr(sys, "_is_gil_enabled") else True
print(f"{sys.version.split()[0]} free-threaded build: {bool(sysconfig.get_config_var('Py_GIL_DISABLED'))}, GIL enabled: {gil}")
print(f" counter += 1 {run(plus_equals)} of {N * THREADS:,}")
print(f" counter = add_one(...) {run(with_call)} of {N * THREADS:,}")
print(f" with lock {run(with_lock)} of {N * THREADS:,}")
docker exec lab-prog-py sh -c 'python race.py; $(uv python find 3.14) race.py; $(uv python find 3.14t) race.py'
3.13.16 free-threaded build: False, GIL enabled: True
counter += 1 4,000,000 of 4,000,000
counter = add_one(...) 1,909,196 of 4,000,000
with lock 4,000,000 of 4,000,000
3.14.8 free-threaded build: False, GIL enabled: True
counter += 1 4,000,000 of 4,000,000
counter = add_one(...) 1,763,938 of 4,000,000
with lock 4,000,000 of 4,000,000
3.14.8 free-threaded build: True, GIL enabled: False
counter += 1 1,423,649 of 4,000,000
counter = add_one(...) 1,349,594 of 4,000,000
with lock 4,000,000 of 4,000,000
في المخرج أعلاه لاحظ التالي:
- مع القفل GIL أعطى
counter += 1الرقم الصحيح في تجربتنا، ولكن لا تعتمد على ذلك، فالقسم أي تعديلات المتغيرات العامة آمنة مع الخيوط؟ في الأسئلة الشائعة الرسمية يذكرi = i+1صراحة بين العمليات غير الذرية، والنتيجة الصحيحة هنا تفصيل في طريقة تبديل CPython بين الخيوط وليست وعداً. - يكفي أن يكون بين القراءة والكتابة استدعاء دالة واحد حتى تضيع أكثر من نصف الزيادات حتى مع القفل GIL، وفي الكود الحقيقي يوجد دائماً شيء بين القراءة والكتابة: تحقق، أو حساب، أو سطر في السجل.
- في النسخة الحرة 3.14t ضاعت الزيادات في الطريقتين، فالكود الذي كان يبدو سليماً سنوات بسبب القفل GIL قد يظهر خطؤه عندما تنتقل إلى النسخة الحرة، والطريقة الوحيدة التي أعطت الرقم الصحيح في كل الإصدارات هي القفل الصريح threading.Lock.
ولكي تتأكد أن الفرق سببه القفل GIL وليس شيئاً آخر في النسخة الحرة، شغلناها مع إعادة القفل بالمتغير PYTHON_GIL=1:
docker exec lab-prog-py sh -c 'PYTHON_GIL=1 $(uv python find 3.14t) race.py'
3.14.8 free-threaded build: True, GIL enabled: True
counter += 1 4,000,000 of 4,000,000
counter = add_one(...) 2,164,361 of 4,000,000
with lock 4,000,000 of 4,000,000
عاد counter += 1 إلى الرقم الكامل، وبقي الضياع في السطر الثاني، أي أن القفل GIL يخفي بعض حالات السباق ولا يمنعها.
وفي JavaScript أيضاً
قد يظن مطور JavaScript أنه في أمان لأن كوده على خيط واحد، وهذا صحيح داخل حلقة الأحداث، أما سباق التحقق ثم التنفيذ حول await فقد رأيته في تمرين السحب من الحساب. ولكن Node.js فيه خيوط حقيقية عبر worker_threads، وإذا تشاركت الخيوط ذاكرة عبر SharedArrayBuffer فهي نفس المشكلة تماماً، احفظ المثال التالي باسم shared-counter.mjs:
// shared-counter.mjs: four worker threads add 1 a million times to one shared Int32Array cell
import { Worker, isMainThread, workerData } from "node:worker_threads";
const N = 1_000_000;
if (isMainThread) {
for (const mode of ["plain", "atomics"]) {
const shared = new Int32Array(new SharedArrayBuffer(4));
const workers = Array.from({ length: 4 }, () =>
new Worker(new URL(import.meta.url), { workerData: { shared, mode } }));
await Promise.all(workers.map(w => new Promise(r => w.on("exit", r))));
console.log(`${mode.padEnd(8)} ${shared[0].toLocaleString("en")} of ${(4 * N).toLocaleString("en")}`);
}
} else {
const { shared, mode } = workerData;
for (let i = 0; i < N; i++) {
if (mode === "plain") shared[0]++; // read, add, write
else Atomics.add(shared, 0, 1); // one indivisible step
}
}
وشغلناه مرتين:
node shared-counter.mjs
plain 4,000,000 of 4,000,000
atomics 4,000,000 of 4,000,000
plain 3,907,713 of 4,000,000
atomics 4,000,000 of 4,000,000
المرة الأولى أعطت الرقم الصحيح صدفة، والثانية فقدت قرابة مئة ألف زيادة، وهذا أخطر ما في حالة السباق: الاختبار ينجح مرة ويفشل مرة، ومن يشغله مرة واحدة يظن أن الكود سليم. أما Atomics.add فأعطت الرقم الصحيح في كل مرة.
الأقفال Locks والعمليات الذرية Atomic Operations
القفل Lock، ويسمى أيضاً Mutex اختصاراً لعبارة Mutual Exclusion، يضمن أن خيطاً واحداً فقط ينفذ جزءاً معيناً من الكود في كل لحظة، ويسمى هذا الجزء القسم الحرج Critical Section، وكل خيط آخر يصل إليه ينتظر عند الباب حتى يخرج الخيط الذي بداخله. وفي C# يكتب هذا بالجملة lock كما في race.cs، ويوصي التوثيق منذ .NET 9 وC# 13 أن تقفل على كائن مخصص من النوع System.Threading.Lock للحصول على أفضل أداء، وفي الإصدارات الأقدم على كائن object خاص لا يستخدم لشيء آخر، وأن تتجنب القفل على this أو على نوع أو نص، لأن كوداً آخر لا تعرفه قد يقفل على نفس الكائن.
أما العمليات الذرية فهي تعليمات في المعالج نفسه تنفذ القراءة والتعديل والكتابة كخطوة واحدة لا يمكن أن يدخل بينها خيط آخر، وفي .NET تجدها في الكلاس Interlocked: Increment وAdd وExchange، وأهمها CompareExchange التي تكتب القيمة الجديدة فقط إذا كانت القيمة الحالية ما زالت كما قرأتها، وعليها تبنى أغلب المجموعات الآمنة دون أقفال. وفي JavaScript تجدها في Atomics كما رأيت.

وفي أرقام race.cs كان القفل أبطأ بنحو أربع مرات من Interlocked في هذا المثال المتطرف، لأن كل خيط يأخذ القفل ويتركه مليون مرة ولا يفعل شيئاً آخر، أما في الكود الحقيقي فالعمل داخل القفل أكبر، والفرق أقل أهمية من الصحة. والقواعد العملية للأقفال ثلاث:
- اجعل القسم الحرج قصيراً، فلا تقم بانتظار الشبكة أو قاعدة البيانات أو كتابة ملف داخل القفل، والسبب أن كل الخيوط الأخرى تنتظر معك.
- القفل يحمي فقط إذا استخدمه كل من يقرأ ويكتب نفس البيانات، فالخيط الذي يقرأ دون قفل قد يرى القيمة في منتصف التعديل.
- لا يمكن استخدام await داخل
lockفي C#، وإذا احتجت قفلاً حول كود async فاستخدمSemaphoreSlimمعWaitAsync.
الجمود Deadlock: نصنعه ثم نصلحه
الأقفال تحل حالة السباق، ولكنها تفتح مشكلة جديدة: خيطان كل منهما يمسك قفلاً وينتظر القفل الذي مع الآخر، فلا يتحرك أي منهما للأبد، ولا يظهر أي خطأ، والبرنامج ببساطة يتوقف. ولنأخذ مثالاً تحويل المال بين حسابين، حيث يقفل الكود حساب المرسل ثم حساب المستقبل حتى لا يغير أحد الرصيدين أثناء التحويل، وفي نفس اللحظة يحول خيط من الحساب 1 إلى 2 وخيط آخر من 2 إلى 1، احفظ المثال باسم deadlock.cs:
// deadlock.cs: two transfers in opposite directions, first without and then with a fixed lock order
Run(ordered: false);
Run(ordered: true);
void Run(bool ordered)
{
var a = new Account(1, 1000);
var b = new Account(2, 1000);
var t1 = new Thread(() => Transfer(a, b, 100, ordered)) { IsBackground = true, Name = "t1" };
var t2 = new Thread(() => Transfer(b, a, 50, ordered)) { IsBackground = true, Name = "t2" };
t1.Start(); t2.Start();
bool done = t1.Join(3000) & t2.Join(3000);
Console.WriteLine(done
? $"ordered={ordered}: done, balances {a.Balance} and {b.Balance}"
: $"ordered={ordered}: still waiting after 3 s, t1 {t1.ThreadState}, t2 {t2.ThreadState}: deadlock");
}
static void Transfer(Account from, Account to, int amount, bool ordered)
{
// the fix: every thread takes the locks in the same order (lowest account id first)
var (first, second) = ordered && from.Id > to.Id ? (to, from) : (from, to);
lock (first)
{
Console.WriteLine($" {Thread.CurrentThread.Name} locked account {first.Id}, wants account {second.Id}");
Thread.Sleep(100); // any real work here: a log line, a database call
lock (second)
{
from.Balance -= amount;
to.Balance += amount;
}
}
}
class Account(int id, int balance)
{
public int Id { get; } = id;
public int Balance { get; set; } = balance;
}
docker exec lab-prog-dotnet dotnet run deadlock.cs
t1 locked account 1, wants account 2
t2 locked account 2, wants account 1
ordered=False: still waiting after 3 s, t1 Background, WaitSleepJoin, t2 Background, WaitSleepJoin: deadlock
t1 locked account 1, wants account 2
t2 locked account 1, wants account 2
ordered=True: done, balances 950 and 1050
في المخرج أعلاه لاحظ التالي:
- في التشغيل الأول أخذ t1 الحساب 1 وأخذ t2 الحساب 2، ثم انتظر كل منهما الآخر، وبعد ثلاث ثوان ما زال الخيطان في حالة
WaitSleepJoin. وجعلنا الخيطين Background فقط حتى يستطيع البرنامج أن ينتهي، أما في سيرفر حقيقي فيبقى الخيطان معلقين ومعهما كل طلب يحتاج أحد الحسابين. - الإصلاح سطر واحد: كل خيط يقفل الحساب ذا الرقم الأصغر أولاً مهما كان اتجاه التحويل، فأصبح t2 ينتظر عند الحساب 1 وهو لا يمسك شيئاً، ولا يمكن أن تتكون دائرة.
- السطر
Thread.Sleep(100)يجعل الجمود يظهر في كل تشغيل، ولولاه لظهر مرة في الألف، وهذا ما يحدث في الواقع: الجمود يظهر تحت الضغط في الإنتاج ولا يظهر في الاختبار.

وإذا لم يكن الترتيب الثابت ممكناً، فاستخدم مهلة Timeout عند أخذ القفل، مثل Monitor.TryEnter(obj, TimeSpan) في .NET، فإذا لم تحصل على القفل خلال المهلة فاترك ما تمسكه وأعد المحاولة، وهذا يحول التعليق الصامت إلى خطأ تراه في السجلات. ونفس المشكلة تحدث في قاعدة البيانات بين المعاملات التي تقفل الصفوف بترتيب مختلف، والفرق أن PostgreSQL يكتشفها ويلغي إحدى المعاملات، وقد رأيت رسالتها في التمرين 16 من تمارين SQL، أما الخيوط في تطبيقك فلا يكتشف جمودها أحد.
المجموعات الآمنة للخيوط Thread-Safe Collections
القائمة List والقاموس Dictionary في .NET، ومثلهما في أغلب اللغات، ليست آمنة للكتابة من أكثر من خيط، ويقول توثيق Dictionary صراحة إنها تتحمل عدة قراء في نفس الوقت ما دام لا أحد يعدلها. والمثال التالي تكتب فيه ثمانية خيوط مئة ألف عنصر لكل منها في القائمة والقاموس العاديين، ثم في المجموعات من System.Collections.Concurrent، احفظه باسم collections.cs:
// collections.cs: 8 threads add 100,000 items each to List, Dictionary and their concurrent versions
using System.Collections.Concurrent;
const int Threads = 8, PerThread = 100_000;
Try("List<int> ", () => { var l = new List<int>(); Fill(i => l.Add(i)); return l.Count; });
Try("Dictionary<int,int> ", () => { var d = new Dictionary<int, int>(); Fill(i => d[i] = i); return d.Count; });
Try("ConcurrentBag<int> ", () => { var c = new ConcurrentBag<int>(); Fill(i => c.Add(i)); return c.Count; });
Try("ConcurrentDictionary", () => { var c = new ConcurrentDictionary<int, int>(); Fill(i => c[i] = i); return c.Count; });
void Fill(Action<int> add)
{
Exception? error = null;
var ts = Enumerable.Range(0, Threads)
.Select(t => new Thread(() =>
{
try { for (int i = 0; i < PerThread; i++) add(t * PerThread + i); }
catch (Exception e) { error ??= e; } // an exception inside a thread would end the whole process
}))
.ToList();
ts.ForEach(t => t.Start());
ts.ForEach(t => t.Join());
if (error != null) throw error;
}
void Try(string label, Func<int> run)
{
try { Console.WriteLine($"{label} count {run():N0} (expected {Threads * PerThread:N0})"); }
catch (Exception e) { Console.WriteLine($"{label} {e.GetType().Name}: {e.Message}"); }
}
وشغلناه ثلاث مرات، وهذا مخرج المرة الأولى والثالثة:
docker exec lab-prog-dotnet dotnet run collections.cs
List<int> count 367,628 (expected 800,000)
Dictionary<int,int> InvalidOperationException: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct.
ConcurrentBag<int> count 800,000 (expected 800,000)
ConcurrentDictionary count 800,000 (expected 800,000)
List<int> count 317,399 (expected 800,000)
Dictionary<int,int> IndexOutOfRangeException: Index was outside the bounds of the array.
ConcurrentBag<int> count 800,000 (expected 800,000)
ConcurrentDictionary count 800,000 (expected 800,000)
في المخرج أعلاه لاحظ التالي:
- القائمة فقدت أكثر من نصف العناصر دون أي خطأ، والسبب أن
Addتكتب العنصر في الخانة رقم Count ثم تزيده، فهي حالة سباق مثل العداد تماماً، وعندما تكبر المصفوفة الداخلية قد يكتب خيط في المصفوفة القديمة فيضيع ما كتبه. - القاموس اكتشف أن حالته الداخلية فسدت في المرة الأولى ورمى خطأ واضحاً، وفي المرة الثالثة رمى خطأ مختلفاً من داخله، ولا تعتمد على أي من الرسالتين، فالقاموس الذي فسد قد يعلق أيضاً في حلقة لا تنتهي.
- لاحظ في الكود أننا أمسكنا الخطأ داخل كل خيط، والسبب أن الاستثناء الذي لا يمسكه أحد داخل خيط ينهي العملية كلها، وقد حدث ذلك فعلاً في أول تشغيل لنا قبل أن نضيف
try/catch. - المجموعات من
System.Collections.Concurrentأعطت الرقم الصحيح دائماً، وفي Python الطابورqueue.Queueآمن للخيوط، وفي Java تجدConcurrentHashMap.
ولكن تذكر أن المجموعة الآمنة تحمي كل عملية وحدها، ولا تحمي تسلسل عمليتين، فالكود if (!dict.ContainsKey(k)) dict[k] = v; على ConcurrentDictionary ما زال حالة سباق بين التحقق والكتابة، والصحيح أن تستخدم العملية الواحدة التي تجمعهما مثل GetOrAdd أو AddOrUpdate.
الكائنات الثابتة Immutable Objects
أسهل طريقة لتجنب حالة السباق ألا يوجد شيء يتغير أصلاً، فالكائن الذي لا يمكن تعديله بعد إنشائه يمكن أن يقرأه ألف خيط في نفس الوقت دون أي قفل. والمثال التالي فيه خيط يكتب ونطاق Range له حدان Min وMax يجب أن يكونا متساويين دائماً، مرة ككائن عادي نعدل حقليه، ومرة كسجل ثابت record ننشئ منه نسخة جديدة كاملة ثم نستبدل المرجع إليها بالدالة Volatile.Write، والخيط الرئيسي يقرأ خمسة ملايين مرة ويعد كم مرة رأى الحدين مختلفين، احفظه باسم immutable.cs:
// immutable.cs: a reader checks that Min == Max while a writer keeps changing both
var mutable = new MutableRange();
var shared = new Range(0, 0);
int tornMutable = 0, tornImmutable = 0;
bool stop = false;
var writer = new Thread(() =>
{
for (int i = 1; !Volatile.Read(ref stop); i++)
{
mutable.Min = i; mutable.Max = i; // two separate writes to one object
Volatile.Write(ref shared, new Range(i, i)); // a new object, published with one reference write
}
});
writer.Start();
for (int n = 0; n < 5_000_000; n++)
{
var m = mutable; if (m.Min != m.Max) tornMutable++;
var r = Volatile.Read(ref shared); if (r.Min != r.Max) tornImmutable++;
}
Volatile.Write(ref stop, true);
writer.Join();
Console.WriteLine($"5,000,000 reads: mutable object seen half-updated {tornMutable:N0} times, immutable record {tornImmutable:N0} times");
class MutableRange { public int Min; public int Max; }
record Range(int Min, int Max);
docker exec lab-prog-dotnet dotnet run immutable.cs
5,000,000 reads: mutable object seen half-updated 176,434 times, immutable record 0 times
والتشغيل الثاني أعطى 16,862 للكائن العادي وصفراً للسجل الثابت. فالقارئ رأى الكائن العادي في منتصف التعديل، بعد أن تغير Min وقبل أن يتغير Max، أما السجل الثابت فالقارئ يرى إما النسخة القديمة كاملة أو الجديدة كاملة، لأن تبديل المرجع كتابة واحدة. وهذه هي الفكرة خلف الإعدادات التي تقرأها كل الطلبات وتتغير نادراً: ابن كائن الإعدادات الجديد كاملاً ثم استبدله دفعة واحدة، ولهذا الغرض في .NET مجموعات ثابتة جاهزة في System.Collections.Immutable، وفي Python تجد tuple وfrozenset وdataclass(frozen=True).

أخطاء شائعة في الكود غير المتزامن
الدالة async void
الدالة async التي ترجع void بدلاً من Task لا يستطيع من يستدعيها أن ينتظرها، ولا أن يمسك أخطاءها، ويقول توثيق أنواع الإرجاع إنها لمعالجات الأحداث Event Handlers فقط. والمثال التالي يستدعي دالة ترسل إيصالاً وتفشل، داخل try/catch، احفظه باسم asyncvoid.cs:
// asyncvoid.cs: an exception thrown inside async void cannot be caught by the caller
try
{
SendReceipt(); // async void: the caller gets nothing to await
}
catch (Exception e)
{
Console.WriteLine($"caught: {e.Message}");
}
await Task.Delay(2000);
Console.WriteLine("the program is still running");
static async void SendReceipt()
{
await Task.Delay(100);
throw new InvalidOperationException("mail server refused the connection");
}
docker exec lab-prog-dotnet sh -c 'dotnet run asyncvoid.cs; echo "exit code $?"'
Unhandled exception. System.InvalidOperationException: mail server refused the connection
at Program.<<Main>$>g__SendReceipt|0_0() in /lab/asyncvoid.cs:line 16
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
at System.Threading.Thread.StartCallback()
exit code 134
الجملة catch لم تمسك شيئاً، لأن الدالة رجعت إلى من استدعاها عند أول await قبل أن يحدث الخطأ، ثم رمي الخطأ بعد 100 ms على خيط من المجمع لا يمسكه أحد، فانتهت العملية كلها، ولم يطبع السطر الأخير أبداً. والإصلاح أن ترجع الدالة Task وأن تنتظرها، احفظه باسم asynctask.cs:
// asynctask.cs: the same method returning Task, awaited by the caller
try
{
await SendReceiptAsync();
}
catch (Exception e)
{
Console.WriteLine($"caught: {e.Message}");
}
Console.WriteLine("the program is still running");
static async Task SendReceiptAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("mail server refused the connection");
}
docker exec lab-prog-dotnet dotnet run asynctask.cs
caught: mail server refused the connection
the program is still running
انتظار المهمة بـ Result أو Wait
الخطأ الثاني أن تستدعي دالة async ثم تنتظرها بطريقة تحجز الخيط، مثل .Result أو .Wait() أو GetAwaiter().GetResult()، وغالباً لأن الدالة التي تكتبها ليست async ولا تريد أن تغير توقيعها. وفي تطبيقات الواجهات الرسومية وASP.NET القديم يؤدي هذا إلى جمود، لأن في هذه البيئات SynchronizationContext يعيد باقي الدالة بعد await إلى نفس الخيط. وحتى نرى ذلك في تطبيق Console كتبنا حلقة رسائل بسيطة على خيط واحد تقلد خيط الواجهة، احفظ المثال باسم blocking.cs:
// blocking.cs: .Result on a thread that has a SynchronizationContext (a UI thread, classic ASP.NET)
using System.Collections.Concurrent;
RunOnUiThread("await inside, .Result outside ", () => Wait(GetPriceAsync(continueOnAnyThread: false)));
RunOnUiThread("ConfigureAwait(false) inside ", () => Wait(GetPriceAsync(continueOnAnyThread: true)));
static string Wait(Task<int> t) =>
t.Wait(3000) ? $"price {t.Result}" : "UI thread blocked for 3 s: deadlock"; // .Result with a timeout, so the demo ends
static async Task<int> GetPriceAsync(bool continueOnAnyThread)
{
await Task.Delay(100).ConfigureAwait(!continueOnAnyThread); // where should the rest of the method run?
return 450;
}
static void RunOnUiThread(string label, Func<string> body)
{
var ctx = new SingleThreadContext();
var ui = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(ctx);
ctx.Post(_ => { Console.WriteLine($"{label}: {body()}"); ctx.Complete(); }, null);
ctx.Run();
});
ui.Start();
ui.Join();
}
// A minimal message loop: like a UI thread, it runs what is posted to it, one item at a time, on one thread
sealed class SingleThreadContext : SynchronizationContext
{
readonly BlockingCollection<(SendOrPostCallback, object?)> queue = new();
public override void Post(SendOrPostCallback d, object? state) { if (!queue.IsAddingCompleted) queue.Add((d, state)); }
public void Run() { foreach (var (d, s) in queue.GetConsumingEnumerable()) d(s); }
public void Complete() => queue.CompleteAdding();
}
docker exec lab-prog-dotnet dotnet run blocking.cs
await inside, .Result outside : UI thread blocked for 3 s: deadlock
ConfigureAwait(false) inside : price 450
في الحالة الأولى انتظر خيط الواجهة المهمة، وبعد 100 ms أرادت المهمة أن تكمل return 450 على نفس الخيط فوجدته مشغولاً بانتظارها، فلا تنتهي المهمة ولا يتحرر الخيط، وجعلنا الانتظار بمهلة ثلاث ثوان فقط حتى ينتهي المثال، أما .Result الحقيقية فتنتظر للأبد. وفي الحالة الثانية ConfigureAwait(false) تقول للمهمة «أكملي على أي خيط»، فأكملت على خيط من المجمع وانتهت. والصورة التالية تبين الدائرة:

وقد يتساءل البعض: تطبيقي ASP.NET Core، فهل أنا في أمان؟ والإجابة أن ASP.NET Core لا يوجد فيه SynchronizationContext كما يذكر دليل ConfigureAwait من فريق .NET، فلا يحدث هذا الجمود، ولكن يحدث ما هو أهدأ وأسوأ: كل .Wait() تحجز خيطاً من المجمع وهو لا يعمل شيئاً. والمثال التالي فيه مئتا طلب كل منها ينتظر نصف ثانية، مرة بـ await ومرة بـ Wait، احفظه باسم starvation.cs:
// starvation.cs: 200 "requests" that wait 500 ms each, blocking a pool thread or awaiting
using System.Diagnostics;
await Measure("await Task.Delay(500) ", () => Task.Run(async () => await Task.Delay(500)));
await Measure("Task.Delay(500).Wait()", () => Task.Run(() => Task.Delay(500).Wait()));
static async Task Measure(string label, Func<Task> request)
{
var sw = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(0, 200).Select(_ => request()));
Console.WriteLine($"{label}: 200 requests in {sw.Elapsed.TotalSeconds:F2} s, pool threads {ThreadPool.ThreadCount}");
}
docker exec lab-prog-dotnet dotnet run starvation.cs
await Task.Delay(500) : 200 requests in 0.88 s, pool threads 5
Task.Delay(500).Wait(): 200 requests in 39.65 s, pool threads 201
نفس الانتظار ونفس الطلبات: مع await انتهت في أقل من ثانية بخمسة خيوط، ومع Wait احتاجت قرابة أربعين ثانية وارتفع المجمع إلى 201 خيط، لأن المجمع يضيف الخيوط تدريجياً، وكل خيط جديد يحجزه طلب آخر ينتظر. وهذا يسمى تجويع المجمع Thread Pool Starvation، وعلامته على السيرفر أن الطلبات بطيئة جداً والمعالج شبه فارغ وعدد الخيوط يرتفع باستمرار. والقاعدة: async على طول الطريق Async All the Way، فإذا أصبحت دالة async فاجعل كل من يستدعيها async حتى نقطة الدخول.
الحلقة forEach مع async في JavaScript
والخطأ الثالث في JavaScript، فالدالة forEach كما يقول توثيقها في MDN تتوقع دالة متزامنة ولا تنتظر الوعود، فإذا أعطيتها دالة async بدأت كل الاستدعاءات ثم أكملت فوراً، احفظ المثال باسم foreach.mjs:
// foreach.mjs: forEach does not wait for async callbacks
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function save(id) { await sleep(100); console.log(` saved ${id}`); }
const ids = [1, 2, 3];
console.log("forEach:");
ids.forEach(async (id) => { await save(id); });
console.log(" report: all saved"); // printed before any save finished
await sleep(300);
console.log("for...of with await (one after another):");
for (const id of ids) await save(id);
console.log(" report: all saved");
console.log("Promise.all (together):");
await Promise.all(ids.map(save));
console.log(" report: all saved");
node foreach.mjs
forEach:
report: all saved
saved 1
saved 2
saved 3
for...of with await (one after another):
saved 1
saved 2
saved 3
report: all saved
Promise.all (together):
saved 1
saved 2
saved 3
report: all saved
مع forEach طبع التقرير «all saved» قبل أن يحفظ أي شيء، ولو فشل الحفظ لما عرف أحد، لأن الوعود التي رجعت لم ينتظرها أحد. والصحيح for...of مع await إذا كان الترتيب مهماً، أو Promise.all إذا كانت العمليات مستقلة وتريدها معاً. ونفس الخطأ في Python هو أن تستدعي دالة async دون await فلا تعمل أصلاً، ويحذرك المفسر بالرسالة RuntimeWarning: coroutine ... was never awaited.
وعندما تنتهي من التجارب احذف المختبر:
docker rm -f lab-prog-dotnet lab-prog-py
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما تخرج به منه:
- الخيوط داخل العملية تتشارك الذاكرة، وهذا مصدر سرعتها ومصدر كل مشاكلها، والعملية الأخرى تعمل على نسختها.
- استخدم مجمع الخيوط بدل إنشاء خيط لكل مهمة، ولا تقم بحجز خيوطه بانتظار، واستخدم async/await في كل ما ينتظر الشبكة أو قاعدة البيانات أو القرص.
- العملية
count++ثلاث خطوات، فأي بيانات يعدلها أكثر من خيط تحتاج قفلاً أو عملية ذرية أو مجموعة آمنة، والقفل GIL في Python يخفي بعض حالات السباق ولا يمنعها، والنسخة الحرة 3.14t تكشفها. - الجمود دائرة انتظار، وأسهل وقاية منه ترتيب ثابت لأخذ الأقفال، وقسم حرج قصير لا ينتظر فيه الكود شيئاً خارجياً.
- الكائن الثابت لا يحتاج قفلاً للقراءة، فابن نسخة جديدة كاملة واستبدل المرجع إليها.
- لا تكتب async void إلا لمعالجات الأحداث، ولا تنتظر المهام بـ
.Resultأو.Wait()، ولا تعط forEach دالة async.
ولمن يريد التطبيق والجانب الآخر من الموضوع:
دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.
دليلتمارين Kata في البرمجة بثلاثة مستويات مع الحلولتمارين برمجة تحلها بيدك دون ذكاء اصطناعي، من FizzBuzz إلى النصوص العربية وحالة السباق، ولكل تمرين حل مجرب في لغتين.
دليلأساسيات SQL للمطورين من SELECT إلى المعاملات Transactionsتتعلم SQL من الصفر على قاعدة بيانات صغيرة في PostgreSQL، من أول SELECT إلى المعاملات ومستويات العزل التي تحمي بياناتك.
دليلالبرمجة الآمنة Secure Coding بأمثلة من أربع لغاتكيف تكتب الكود الذي يتعامل مع مدخلات المستخدم والملفات والأخطاء بحيث لا يحتاج إصلاحاً أمنياً لاحقاً، بأمثلة في أربع لغات.وفي مقالات قادمة من مرحلة لغات البرمجة سوف نتحدث بإذن الله عن إدارة الذاكرة، يدوياً أو بجامع القمامة Garbage Collector أو بالملكية Ownership في Rust، وهي التي تحدد في كثير من اللغات كيف تتشارك الخيوط البيانات بأمان.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتشغيل كل الأمثلة: C# على .NET SDK 10.0.401 (runtime 10.0.12) في mcr.microsoft.com/dotnet/sdk:10.0.401، وPython 3.13.16 (python:3.13-slim) و3.14.8 والنسخة الحرة Free-threaded من 3.14.8 عبر uv 0.12.23، وNode.js 22.14.0، على جهاز بثمانية معالجات منطقية مع Docker Engine 29.8.0. الأزمنة للمقارنة داخل التجربة الواحدة فقط.