لغات البرمجة عملياً الأدلة التقنية

التزامن Concurrency في Python: الخيوط و asyncio وقفل GIL

قسنا مئة طلب HTTP وأربعة ملايين عملية هاش بالخيوط وasyncio والعمليات والبناء الخالي من القفل في Python 3.15، ثم أعدنا تسابقاً باع عشرين قطعة من مخزون عشرة وجموداً بين قفلين وأصلحناهما.

التزامن Concurrency في Python: الخيوط و asyncio وقفل GIL

خدمة الأسعار التي بنيناها في المقالين السابقين لمتجر القهوة والتمور كبرت، وصار عليها عملان جديدان: الأول تحديث الكتالوج كل ساعة، حيث تسأل الخدمة واجهة المورد عن سعر كل منتج من مئة منتج، وكل طلب يأخذ حوالي 50 ميلي ثانية، فيأخذ التحديث كله أكثر من خمس ثوان والفريق يريده أسرع. والثاني توقيع ملفات الفواتير التي تصدر لنظام المحاسبة، حيث تحسب الخدمة سلسلة هاش SHA-256 لكل فاتورة، وألفا فاتورة تأخذ أكثر من عشر ثوان من المعالج. واقترح أحد المطورين القادمين من Java الحل المعتاد: «نستعمل Threads»، فرد عليه آخر قرأ في مكان ما أن Python فيها قفل اسمه GIL يمنع الخيوط من العمل معاً، واقترح ثالث asyncio لأنه «الطريقة الحديثة». وفي نفس الأسبوع حدثت مشكلة ثالثة أخطر: في عرض ساعة على التمر السكري باعت الخدمة عشرين كيساً بينما المخزون عشرة فقط، لأن طلبين قرآ المخزون في نفس اللحظة.

وهذا المقال الثالث في مرحلة لغة Python للمطورين، وقد شرحنا مفاهيم الخيوط والأقفال وasync في مقال Threads وasync/await، وحلقة الأحداث في JavaScript في مقال حلقة الأحداث Event Loop، أما هنا فنقيس نموذج التزامن في Python 3.15.2 نفسه بنفس المهمتين اللتين نقيس بهما كل لغات المسار: مئة طلب HTTP إلى سيرفر محلي بطيء يرد بعد 50 ميلي ثانية، وSHA-256 لألفي نص كل واحد ألفي مرة. وسوف نناقش في هذا المقال ما يلي:

  • ما هو قفل المفسر العام GIL، ولماذا وجد، وما الذي تغير مع البناء الخالي من القفل Free-threaded.
  • المهمة المقيدة بالانتظار I/O-bound: متتابعة، ثم بالخيوط Threads، ثم بـ asyncio مع مكتبتين مختلفتين، ومشكلة حقيقية ظهرت في إحداهما.
  • المهمة المقيدة بالمعالج CPU-bound: الخيوط مع GIL، والعمليات Processes، والخيوط على البناء الخالي من القفل، وكيف تقيس التوازي على جهاز مشغول.
  • حالة تسابق Race Condition حقيقية تبيع عشرين قطعة من مخزون عشرة، وعداد يفقد أكثر من نصف قيمه، وإصلاحهما.
  • جمود Deadlock بين قفلين وإصلاحه بترتيب الأقفال.
  • ثمن آلاف الخيوط مقابل آلاف المهام، والتزامن المنظم بـ TaskGroup والمهلة.
  • ماذا يحدث إذا وضعت عملاً حسابياً داخل دالة async، وجدول قرار لاختيار النموذج المناسب.

نماذج التزامن Concurrency Models في Python: ثلاثة طرق وقفل واحد

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

  • الخيوط Threads عبر الوحدة threading أو ThreadPoolExecutor: خيوط حقيقية من نظام التشغيل، تشترك في نفس الذاكرة، ويجدولها النظام.
  • الـ asyncio: خيط واحد فيه حلقة أحداث Event Loop، تتناوب فيه دوال async عند كل await، وهو قريب جداً من نموذج Node.js.
  • العمليات Processes عبر multiprocessing أو ProcessPoolExecutor: عدة نسخ من المفسر، لكل واحدة ذاكرتها، وتتبادل البيانات بالنسخ.

والطريقة الموحدة لاستعمال الخيوط والعمليات هي الوحدة concurrent.futures، فالكود نفسه يعمل مع ThreadPoolExecutor وProcessPoolExecutor بتغيير اسم الكلاس فقط، وهذا ما سوف نستعمله في المختبر.

قفل المفسر العام GIL: لماذا وجد؟

رأينا في المقال السابق أن CPython يدير الذاكرة بعداد مراجع في كل كائن، وهذا العداد يتغير مع كل إسناد وكل استدعاء دالة تقريباً، فإذا عدل خيطان نفس العداد في نفس اللحظة دون حماية فقد يضيع تعديل منهما، فيحرر كائن ما زال مستعملاً أو يبقى كائن لا يستعمله أحد. والحل الذي اختاره CPython منذ بداياته هو قفل واحد كبير اسمه قفل المفسر العام Global Interpreter Lock واختصاراً GIL: لا ينفذ كود Python إلا الخيط الذي يحمل هذا القفل، والخيط يتركه عندما ينتظر عملية إدخال وإخراج I/O مثل قراءة من الشبكة أو القرص، أو بعد فترة قصيرة من التنفيذ ليعطي غيره فرصة. وهذا القرار جعل المفسر بسيطاً وسريعاً للخيط الواحد، وجعل كتابة الإضافات بلغة C سهلة، ولكنه جعل الخيوط عديمة الفائدة للأعمال الحسابية في كود Python، وهذا ما سوف نقيسه بالأرقام.

وفي 2023 قبل الفريق PEP 703 الذي يجعل الـ GIL اختيارياً، فصار هناك بناء منفصل من CPython اسمه البناء الخالي من القفل Free-threaded، يغير طريقة عد المراجع لكي تعمل بأمان من عدة خيوط، وانتقل هذا البناء من تجريبي إلى مدعوم رسمياً بحسب PEP 779. ولكنه ما زال بناءً منفصلاً، فالـ Image الرسمية في Docker تأتي بالبناء العادي، والبناء الخالي من القفل له ملف تنفيذي باسم python3.15t، وثمنه بحسب دليل البناء الخالي من القفل بطء في الخيط الواحد يتراوح بين حوالي 1% و8% بحسب النظام، وأن بعض الإضافات المكتوبة بـ C قد تعيد تفعيل القفل تلقائياً عند استيرادها إذا لم تعلن أنها تدعم هذا البناء.

رسمان زمنيان لأربعة خيوط T1 إلى T4: في البناء العادي مع GIL تعمل الخيوط بالتناوب ولا يعمل إلا خيط واحد في كل لحظة، والقياس cores busy 1.01 و1.02، وفي البناء الخالي من القفل 3.15t تعمل الخيوط الأربعة معاً، والقياس cores busy من 3.08 إلى 3.40، مع شرح أن الرقم هو زمن المعالج مقسوماً على الزمن الفعلي
نفس الكود على أربعة خيوط: مع الـ GIL يعمل معالج واحد، وبدونه تعمل ثلاثة معالجات وأكثر

متى يبدل الـ GIL الخيوط؟ فترة التبديل Switch Interval

وقد يتساءل البعض: إذا كان خيط واحد فقط يحمل القفل، فكيف يحصل الخيط الآخر على فرصته في عمل حسابي طويل لا ينتظر أي شبكة؟ والإجابة أن الخيط الذي ينتظر القفل لا ينتظر إلى الأبد، وإنما يطلب من الخيط الحامل أن يتركه بعد فترة اسمها فترة التبديل Switch Interval، وقيمتها الافتراضية كما طبعها المختبر بالدالة sys.getswitchinterval() هي 0.005 ثانية، أي خمس ميلي ثوان. فإذا مرت هذه الفترة يترك الخيط الحامل القفل عند أول نقطة آمنة بين تعليمات الـ Bytecode، ويأخذه خيط آخر. وهذا يعني شيئين عمليين: الأول أن الخيوط في عمل حسابي تتناوب كل بضع ميلي ثوان فلا يتجمد أحدها، والثاني أن الخيط الذي ينتظر الشبكة ثم يعود رده قد ينتظر حتى خمس ميلي ثوان أخرى ليحصل على القفل إذا كان هناك خيط حسابي مشغول، وهذا هو سبب التأخير الصغير الذي سوف نراه في تجربة حلقة الأحداث. ويمكن تغيير هذه القيمة بـ sys.setswitchinterval، ولكن لا تقم بذلك إلا بعد قياس، لأن تقليلها يزيد عدد التبديلات وثمنها، وزيادتها تؤخر الخيوط التي تنتظر.

كيف يعمل asyncio من الداخل؟ Coroutines وحلقة الأحداث

الدالة المعرفة بـ async def لا تنفذ عندما تستدعيها، وإنما ترجع كائناً اسمه Coroutine، وهو دالة يمكن أن تتوقف في منتصفها ثم تكمل من نفس النقطة، والتوقف يحدث فقط عند كلمة await. وحلقة الأحداث Event Loop هي الكود الذي يدير هذه الدوال: يحتفظ بقائمة المهام Tasks الجاهزة للعمل، فيشغل واحدة حتى تصل إلى await على شيء لم يجهز بعد، كرد من الشبكة، فيسجل الحلقة كمهتمة بهذا الاتصال عند نظام التشغيل، ثم ينتقل إلى المهمة التالية. وعندما لا تبقى مهمة جاهزة تسأل الحلقة نظام التشغيل عبر epoll على Linux: أي الاتصالات وصلتها بيانات؟ فتوقظ المهام المرتبطة بها. ولهذا السبب يستطيع خيط واحد أن يدير عشرات الآلاف من الاتصالات المفتوحة، لأن الاتصال الذي ينتظر لا يكلف خيطاً، وإنما سجلاً في قائمة.

وهذا النموذج نفسه هو الذي شرحناه في حلقة الأحداث في JavaScript، مع فرقين مهمين: الأول أن Node.js كله مبني على الحلقة، فكل مكتبة فيه غير متزامنة من البداية، أما في Python فالحلقة وحدة اختيارية، ونصف المكتبات المشهورة متزامنة، فإذا استدعيت مكتبة متزامنة مثل requests داخل دالة async فسوف تحجز الحلقة كلها. والثاني أن Python تجبرك أن تكتب async وawait في كل الطريق من أعلى الاستدعاء إلى أسفله، وهذا ما يسميه الناس «تلوين الدوال» Function Coloring، فالدالة العادية لا تستطيع أن تنتظر دالة async مباشرة، ولذلك يصعب إدخال asyncio في مشروع كبير مكتوب بالطريقة المتزامنة، بينما تدخل الخيوط فيه دون تغيير الدوال.

تجهيز المختبر Lab: سيرفر بطيء وبناءان من Python

نحتاج أولاً سيرفراً يرد بعد 50 ميلي ثانية بالضبط، ولا يصبح هو نفسه العائق عندما يصله مئة طلب معاً. ولذلك كتبناه بـ asyncio من المكتبة القياسية، يقرأ رأس الطلب، ينتظر 50 ميلي ثانية بـ asyncio.sleep، ثم يكتب الرد كاملاً في عملية كتابة واحدة، ويدعم إبقاء الاتصال مفتوحاً Keep-Alive:

import asyncio, os

DELAY = float(os.environ.get("DELAY_MS", "50")) / 1000
BODY = b'{"price_halalas": 990}'


async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    try:
        while True:
            head = await reader.readuntil(b"\r\n\r\n")
            await asyncio.sleep(DELAY)
            close = b"connection: close" in head.lower()
            writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
                         b"Content-Length: " + str(len(BODY)).encode() + b"\r\n"
                         + (b"Connection: close\r\n" if close else b"") + b"\r\n" + BODY)
            await writer.drain()
            if close:
                break
    except (asyncio.IncompleteReadError, ConnectionResetError):
        pass
    finally:
        writer.close()


async def main() -> None:
    server = await asyncio.start_server(handle, "0.0.0.0", 8080, backlog=1024)
    print(f"slow server on :8080, delay {DELAY * 1000:.0f} ms", flush=True)
    async with server:
        await server.serve_forever()

asyncio.run(main())

والسبب في كتابة الرد مرة واحدة أن إرسال الرأس في كتابة والجسم في كتابة ثانية على اتصال مفتوح يجعل خوارزمية Nagle في TCP تنتظر تأكيد الاستلام المتأخر Delayed ACK من الطرف الآخر، فيضيف ذلك حوالي 40 ميلي ثانية لكل طلب، وتظن أن السيرفر بطيء بينما المشكلة في طريقة الكتابة. وإذا استعملت http.server من المكتبة القياسية لتجربة مشابهة فاضبط disable_nagle_algorithm = True في كلاس المعالج، وتأكد دائماً من زمن طلب واحد قبل أن تقيس أي شيء آخر. وأما الـ Image الخاصة بالعميل فهي الـ Image الرسمية مع المكتبتين httpx وaiohttp، ومعها البناء الخالي من القفل الذي ثبتناه بـ uv كما في توثيق إصدارات Python في uv:

FROM python:3.15.2-slim-trixie
RUN pip install --root-user-action=ignore --no-cache-dir uv==0.13.6 httpx==0.28.1 aiohttp==3.14.5 \
 && uv python install 3.15t \
 && ln -s "$(uv python find 3.15t)" /usr/local/bin/python3.15t \
 && python3.15t -m ensurepip >/dev/null 2>&1; python3.15t -VV
WORKDIR /lab

ثم شغلنا السيرفر والعميل على شبكة Docker واحدة، والعميل محدود بأربعة معالجات وواحد جيجابايت من الذاكرة:

docker build -t slp-py-conc:1 lab
docker network create slp-py-net
docker run -d --name slp-py-slow --network slp-py-net --network-alias slow \
  -v "$PWD/lab:/lab:ro" python:3.15.2-slim-trixie python /lab/slow_server.py
docker run --rm --network slp-py-net --cpus 4 --memory 1g -v "$PWD/lab:/lab" slp-py-conc:1 sh /lab/run-all.sh
docker run --rm slp-py-conc:1 sh -c 'python3.15t -VV; python3.15t -c "import sys; print(sys._is_gil_enabled())"'

والمخرج من الأمر الأخير يؤكد أن البناء الثاني خال من القفل فعلاً:

Python 3.15.2 free-threading build (main, Oct 26 2026, 14:06:54) [Clang 23.1.3 ]
False

لاحظ أن هذا البناء مترجم بـ Clang ويأتي من مشروع python-build-standalone الذي ينزل منه uv، بينما البناء العادي في الـ Image الرسمية مترجم بـ GCC، فالمقارنة بينهما في الخيط الواحد ليست مقارنة البناءين فقط وإنما المترجمين أيضاً، ولذلك لن نبني على فرق الخيط الواحد أي حكم.

المهمة المقيدة بالانتظار I/O-bound: مئة طلب HTTP

الملف io_bound.py ينفذ نفس العمل بعدة طرق: مئة طلب إلى السيرفر البطيء، ويطبع الزمن ومجموع البايتات للتأكد أن كل الطرق قرأت نفس الردود. وأهم أجزائه كما يلي:

def fetch(i: int) -> int:
    with urllib.request.urlopen(f"{URL}?i={i}") as r:
        return len(r.read())


def sequential() -> int:
    return sum(fetch(i) for i in range(N))


def threads(workers: int) -> int:
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return sum(pool.map(fetch, range(N)))


async def with_aiohttp(limit: int | None) -> int:
    sem = asyncio.Semaphore(limit or N)
    async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=limit or N)) as session:
        async def one(i: int) -> int:
            async with sem:
                async with session.get(URL, params={"i": i}) as r:
                    return len(await r.read())
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(one(i)) for i in range(N)]
    return sum(t.result() for t in tasks)

والدالة with_asyncio تفعل نفس الشيء بالمكتبة httpx بدل aiohttp، وكلتاهما تحدد عدد الطلبات المتزامنة بـ asyncio.Semaphore. شغلنا الملف مرتين، والمخرج كما يلي:

### io (run 1)
3.15.2 GIL
sequential (urllib)                   6.06 s   bytes=2200
threads, 10 workers (urllib)          0.69 s   bytes=2200
threads, 100 workers (urllib)         0.33 s   bytes=2200
asyncio + aiohttp, 10 at a time       0.58 s   bytes=2200
asyncio + aiohttp, 100 at a time      0.33 s   bytes=2200
asyncio + httpx, 10 at a time         1.85 s   bytes=2200
asyncio + httpx, 100 at a time        1.57 s   bytes=2200
### io (run 2)
3.15.2 GIL
sequential (urllib)                   5.42 s   bytes=2200
threads, 10 workers (urllib)          0.66 s   bytes=2200
threads, 100 workers (urllib)         0.33 s   bytes=2200
asyncio + aiohttp, 10 at a time       0.58 s   bytes=2200
asyncio + aiohttp, 100 at a time      0.31 s   bytes=2200
asyncio + httpx, 10 at a time         1.59 s   bytes=2200
asyncio + httpx, 100 at a time        2.33 s   bytes=2200
أعمدة أفقية لمئة طلب HTTP كل طلب 50 ms: متتابع مع urllib بين 5.42 و6.06 ثانية، وThreads بعشرة Workers بين 0.66 و0.69، وبمئة Worker 0.33، وasyncio مع aiohttp عشرة معاً 0.58 ومئة معاً بين 0.31 و0.33، وasyncio مع httpx عشرة معاً بين 1.59 و1.85 ومئة معاً بين 1.57 و2.33، وتحتها ثلاث بطاقات عن الحد الأدنى النظري وأن الـ GIL لا يضر هنا وأن المكتبة تهم
في الانتظار تتقارب الخيوط وasyncio، والفرق الكبير جاء من مكتبة العميل

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

  • الطريقة المتتابعة أخذت بين 5.4 و6.1 ثانية، أي حوالي 55 إلى 60 ميلي ثانية للطلب: الخمسون التي ينتظرها السيرفر والباقي فتح الاتصال والبحث عن اسم slow في DNS الداخلي لـ Docker، لأن urlopen يفتح اتصالاً جديداً في كل مرة.
  • الخيوط العشرة أنزلت الزمن إلى 0.66 ثانية، وهذا قريب من الحد الأدنى النظري: مئة طلب على عشرة مسارات تعني عشر دفعات، وكل دفعة 50 ميلي ثانية، أي نصف ثانية تقريباً. ومع مئة خيط نزل الزمن إلى 0.33 ثانية، والفرق عن الخمسين ميلي ثانية النظرية هو ثمن إنشاء مئة خيط ومئة اتصال في نفس اللحظة.
  • الـ GIL لم يمنع الخيوط هنا من التسريع عشر مرات وأكثر، والسبب أن كل خيط يترك القفل وهو ينتظر الشبكة، فالقفل لا يحمي إلا الأجزاء القصيرة التي يعمل فيها كود Python فعلاً بين الطلب والرد. ولذلك القاعدة التي يكررها الناس «الخيوط لا تفيد في Python» خاطئة في المهام المقيدة بالانتظار، وهي أغلب ما تفعله خدمات الويب.
  • asyncio مع aiohttp أعطى نفس أرقام الخيوط تقريباً: 0.58 ثانية بعشرة طلبات متزامنة و0.31 إلى 0.33 بمئة، ولكن في خيط واحد فقط، وبدون ثمن إنشاء مئة خيط من نظام التشغيل، وهذا الفرق يظهر عندما يصبح العدد آلافاً وليس مئة.
  • كل الطرق قرأت 2200 بايت، أي مئة رد كل واحد 22 بايت، فلا توجد طريقة ضيعت طلباً.

ثمن الانتظار Memory Cost: آلاف الخيوط أم آلاف المهام؟

رأينا أن الخيوط وasyncio متقاربان في مئة طلب، فمتى يظهر الفرق؟ الملف costs.py ينشئ عدداً من الخيوط أو المهام التي تنتظر حدثاً فقط ولا تعمل شيئاً، ويقيس الزيادة في الذاكرة الفعلية RSS وزمن الإنشاء:

1000 sleeping threads: +  10.5 MB RSS, started in 4.54 s
5000 sleeping threads: +  71.6 MB RSS, started in 8.14 s
1000 sleeping asyncio tasks: +   1.1 MB RSS, started in 0.03 s
100000 sleeping asyncio tasks: +  83.8 MB RSS, started in 3.44 s

ألف خيط أضافت 10.5 ميجابايت فقط، وهذا أقل مما يتوقعه كثيرون، لأن نظام التشغيل يحجز لكل خيط مكدساً Stack بحجم 8 ميجابايت كما طبعه الأمر ulimit -s بالقيمة 8192 كيلوبايت، ولكنه حجز افتراضي لا يتحول إلى ذاكرة حقيقية إلا عند استعماله. ولكن إنشاء الألف خيط أخذ 4.54 ثانية على هذا الجهاز المشغول، وخمسة آلاف أخذت 72 ميجابايت و8.14 ثانية. أما المهام فألف منها أضافت 1.1 ميجابايت وأنشئت في 0.03 ثانية، ومئة ألف مهمة أخذت 84 ميجابايت، أي تقريباً نفس ذاكرة خمسة آلاف خيط لعشرين ضعفاً من الاتصالات المنتظرة. وهذا هو المكان الحقيقي لـ asyncio: ليس أنه أسرع في مئة طلب، وإنما أنه يحتمل عشرات الآلاف من الاتصالات المفتوحة معاً، كما في خدمة WebSocket أو بوابة API، بثمن صغير لكل اتصال.

مشكلة واجهناها: httpx أبطأ بثلاث مرات أو أكثر

في البداية كتبنا نسخة asyncio بالمكتبة httpx وحدها، لأنها شائعة جداً وتعطي نفس الواجهة للكود المتزامن وغير المتزامن، فجاءت النتيجة 1.7 ثانية لمئة طلب متزامن، أي أبطأ من الخيوط بخمس مرات، وهذا لا يتفق مع أي شيء نعرفه عن asyncio. فقمنا بتقسيم المشكلة: كتبنا عميلاً بسيطاً بـ asyncio.open_connection من المكتبة القياسية يرسل الطلب ويقرأ الرد، وقارناه بـ httpx على نفس السيرفر وبعشرين طلباً متزامناً، مرة باسم السيرفر ومرة بعنوان IP مباشرة لنستبعد DNS:

raw streams 20 concurrent slow 154 ms
raw streams 20 concurrent 192.168.176.2 72 ms
httpx 20 concurrent slow 683 ms
httpx 20 concurrent 192.168.176.2 959 ms

العميل البسيط أنهى العشرين طلباً في 72 ميلي ثانية مع عنوان IP، أي أن الحلقة والسيرفر يعملان بالتزامن كما يجب، بينما أخذت httpx بين 683 و959 ميلي ثانية مع الاسم ومع العنوان، فالمشكلة ليست في DNS ولا في السيرفر ولا في asyncio. ثم جربنا aiohttp بنفس الطريقة:

aiohttp 3.14.5 httpx 0.28.1 httpcore 1.0.9
aiohttp 20 concurrent 196 ms
aiohttp 100 concurrent 345 ms
httpx 20 concurrent 945 ms

ولذلك أضفنا aiohttp إلى المختبر وأبقينا httpx في الجدول كما هي، لأن الدرس هنا أهم من الرقم: في هذه التجربة كانت مكتبة العميل هي العائق وليس اللغة ولا نموذج التزامن، ولم نتتبع السبب داخل httpx وhttpcore إلى نهايته، فقد يكون في هذه التركيبة من الإصدارات بالذات. والقاعدة التي تخرج بها: قبل أن تقرر أن asyncio أو الخيوط أو اللغة كلها بطيئة، قس طلباً واحداً، ثم عشرين طلباً بعميل بسيط، ثم بمكتبتك، فالفرق بين الخطوتين يقول لك أين المشكلة.

المهمة المقيدة بالمعالج CPU-bound: SHA-256 في سلسلة

المهمة الثانية حسابية بالكامل: ألفا نص، وكل نص نحسب له SHA-256، ثم الهاش للهاش، وهكذا ألفي مرة، أي أربعة ملايين عملية هاش. والملف cpu_bound.py يقسم النصوص على أربعة Workers ويقارن الطرق:

STRINGS = [f"order-{i}-تمر-سكري" for i in range(2000)]
ROUNDS = 2000
WORKERS = int(os.environ.get("WORKERS", "4"))


def chain(s: str) -> str:
    h = s.encode()
    for _ in range(ROUNDS):
        h = hashlib.sha256(h).digest()
    return h.hex()


def run(label, fn, own_cpu=True):
    c, t = time.process_time(), time.perf_counter()
    out = fn()
    dt = time.perf_counter() - t
    busy = f"cores busy {(time.process_time() - c) / dt:4.2f}" if own_cpu else "cores busy (in child processes)"
    print(f"{label:22s} {dt:7.2f} s   {busy}   first={out[0][:12]}", flush=True)
    return dt


def chunks(n):
    return [STRINGS[i::n] for i in range(n)]


def chain_many(part):
    return [chain(s) for s in part]

ولاحظ الدالة run: إلى جانب الزمن الفعلي تحسب زمن المعالج الذي استهلكته العملية بـ time.process_time() وتقسمه على الزمن الفعلي، فإذا عمل خيط واحد طوال الوقت كان الناتج قريباً من 1، وإذا عملت أربعة خيوط معاً كان قريباً من 4. وسبب هذا العمود مشكلة حقيقية سوف نشرحها بعد المخرج. شغلنا الملف مرتين على كل بناء، والمخرج كما يلي:

### run 1, GIL build
3.15.2 GIL cpus: 8 workers: 4
sequential               12.73 s   cores busy 1.00   first=2a6f39adad9c
threads x4                6.54 s   cores busy 1.02   first=2a6f39adad9c
                       speed-up x1.95
processes x4              4.14 s   cores busy (in child processes)   first=2a6f39adad9c
                       speed-up x3.07
### run 1, free-threaded build
3.15.2 free-threaded cpus: 8 workers: 4
sequential               10.03 s   cores busy 1.00   first=2a6f39adad9c
threads x4                3.07 s   cores busy 3.21   first=2a6f39adad9c
                       speed-up x3.27
processes x4              3.26 s   cores busy (in child processes)   first=2a6f39adad9c
                       speed-up x3.07
### run 2, GIL build
3.15.2 GIL cpus: 8 workers: 4
sequential                9.17 s   cores busy 1.00   first=2a6f39adad9c
threads x4                7.19 s   cores busy 1.02   first=2a6f39adad9c
                       speed-up x1.28
processes x4              4.05 s   cores busy (in child processes)   first=2a6f39adad9c
                       speed-up x2.26
### run 2, free-threaded build
3.15.2 free-threaded cpus: 8 workers: 4
sequential                9.83 s   cores busy 1.00   first=2a6f39adad9c
threads x4                3.47 s   cores busy 3.61   first=2a6f39adad9c
                       speed-up x2.83
processes x4              3.35 s   cores busy (in child processes)   first=2a6f39adad9c
                       speed-up x2.93
جدول لست طرق: متتابع مع GIL بين 9.17 و12.73 ثانية ومعالج واحد، وThreads × 4 مع GIL بين 6.54 و7.19 ثانية ومعالجات مشغولة 1.02، وProcesses × 4 مع GIL بين 4.05 و4.14 ثانية، ومتتابع على البناء الخالي من القفل بين 9.83 و10.03، وThreads × 4 عليه بين 3.07 و3.47 ثانية ومعالجات مشغولة بين 3.21 و3.61، وProcesses × 4 عليه بين 3.26 و3.35، وملاحظة أن كل الطرق أعطت نفس أول هاش 2a6f39adad9c
مع الـ GIL لا تتجاوز الخيوط معالجاً واحداً، والعمليات أو البناء الخالي من القفل يعطيان توازياً حقيقياً

مشكلة القياس Benchmark Noise: الجهاز المشغول يكذب في الزمن

عندما رأينا السطر threads x4 6.54 s مع speed-up x1.95 في البناء العادي توقفنا، فالخيوط مع الـ GIL لا يجب أن تسرع عملاً حسابياً، والوحدة hashlib يقول توثيقها صراحة إنها تترك الـ GIL فقط عندما يكون حجم البيانات أكثر من 2047 بايت، والهاش في سلسلتنا 32 بايت فقط. ثم جاء التشغيل الثاني بتسريع 1.28 فقط، والزمن المتتابع نفسه تغير من 12.73 إلى 9.17 ثانية لنفس الكود. والسبب أن الجهاز الذي يعمل عليه Docker كان مشغولاً بأعمال أخرى في نفس الوقت، فالمعالج الذي حصل عليه الخيط الواحد في مرحلة كان أبطأ أو أكثر ازدحاماً منه في مرحلة أخرى. ولذلك كتبنا اختباراً أصغر يقيس زمن المعالج مع الزمن الفعلي، على عمل هاش وعلى حلقة Python عادية:

3.15.2 GIL
sha256 chain      1 job:  1.20 s, cores busy 1.00 | 4 jobs on 4 threads:  3.44 s, cores busy 1.01
pure-Python loop  1 job:  0.97 s, cores busy 1.00 | 4 jobs on 4 threads:  2.92 s, cores busy 1.02
3.15.2 free-threaded
sha256 chain      1 job:  1.30 s, cores busy 1.00 | 4 jobs on 4 threads:  1.52 s, cores busy 3.08
pure-Python loop  1 job:  0.96 s, cores busy 1.00 | 4 jobs on 4 threads:  1.11 s, cores busy 3.30
3.15.2 GIL
sha256 chain      1 job:  0.85 s, cores busy 1.00 | 4 jobs on 4 threads:  3.61 s, cores busy 1.01
pure-Python loop  1 job:  1.33 s, cores busy 1.00 | 4 jobs on 4 threads:  2.61 s, cores busy 1.02
3.15.2 free-threaded
sha256 chain      1 job:  1.26 s, cores busy 1.00 | 4 jobs on 4 threads:  1.66 s, cores busy 3.24
pure-Python loop  1 job:  1.06 s, cores busy 1.00 | 4 jobs on 4 threads:  1.19 s, cores busy 3.40

وهنا الصورة واضحة ولا يغيرها انشغال الجهاز: في البناء العادي لم يتجاوز عدد المعالجات المشغولة 1.02 مع أربعة خيوط في الحالتين، أي أن الخيوط تناوبت على معالج واحد كما يقول تعريف الـ GIL، وأربع مهام أخذت تقريباً أربعة أضعاف المهمة الواحدة. أما البناء الخالي من القفل فشغل بين 3.08 و3.40 معالجات، وأربع مهام أخذت تقريباً نفس زمن مهمة واحدة. والدرس العام في القياس أهم من Python نفسها: على جهاز مشترك أو سيرفر سحابي فيه جيران، الزمن الفعلي وحده قد يعطيك تسريعاً غير موجود، فقس معه زمن المعالج، وكرر التشغيل، ولا تثق برقم واحد.

ماذا تقول النتائج Results عن كل طريقة؟

  • العمليات Processes مع البناء العادي أعطت توازياً حقيقياً بين 2.26 و3.07 ضعفاً على أربعة معالجات، والسبب أن كل عملية لها مفسرها وقفلها الخاص. وثمنها أن البيانات تنسخ بين العمليات بالتسلسل Pickle، وأن كل عملية تحمل نسخة من المفسر والمكتبات في الذاكرة، وأن إنشاء العمليات نفسه يأخذ وقتاً، وكل هذا داخل الرقم الذي قسناه. ولذلك تناسب العمليات المهام الكبيرة التي يكون فيها الحساب أكبر بكثير من البيانات المنقولة، كما في سلسلة الهاش هنا حيث ينتقل نص قصير ويرجع هاش قصير بعد ألفي عملية.
  • الخيوط على البناء الخالي من القفل أعطت 2.83 إلى 3.27 ضعفاً، أي مثل العمليات أو أفضل قليلاً، ولكن داخل عملية واحدة وبذاكرة مشتركة دون نسخ، وهذا ما يجعل هذا البناء مهماً لأعمال مثل معالجة الصور والبيانات داخل خدمة واحدة.
  • كل الطرق أعطت نفس أول هاش 2a6f39adad9c، وهذا فحص بسيط تضعه في كل قياس لتتأكد أن الطريقة الأسرع لم تسرع لأنها تقوم بعمل أقل.
  • لم نصل إلى أربعة أضعاف في أي طريقة، لأن الـ Container محدود بأربعة معالجات على جهاز فيه أعمال أخرى، ولأن التقسيم نفسه وجمع النتائج لهما ثمن.

وقد يتساءل البعض: إذا كان البناء الخالي من القفل يحل المشكلة، لماذا لا يكون هو الافتراضي؟ والإجابة في ثلاث نقاط من التوثيق الرسمي: بطء في الخيط الواحد يصل إلى حوالي 8% على Linux، وأن كل إضافة مكتوبة بـ C في مشروعك يجب أن تعلن دعمها لهذا البناء وإلا أعاد المفسر تفعيل القفل، وأن الكود الذي كان يعمل صحيحاً «بالصدفة» بسبب الـ GIL قد يظهر فيه تسابق جديد، وهذا ما سوف نراه حالاً. ومشروع py-free-threading يتابع حالة دعم المكتبات المشهورة لهذا البناء.

كيف تبدأ العمليات؟ طرق البدء Start Methods

عندما تنشئ ProcessPoolExecutor فهناك سؤال لا يراه أغلب المطورين: كيف تنشأ العملية الجديدة؟ في Linux توجد ثلاث طرق في الوحدة multiprocessing: الطريقة fork تنسخ العملية الحالية كما هي بكل ذاكرتها، والطريقة spawn تشغل مفسراً جديداً من البداية يستورد ملفك من جديد، والطريقة forkserver تشغل عملية خادمة صغيرة نظيفة في البداية، ثم تنسخ منها كل عملية جديدة. وقد كانت fork هي الافتراضية على Linux لسنوات، ولكن نسخ عملية فيها خيوط تعمل خطر، لأن الخيط الذي كان يمسك قفلاً لا ينتقل إلى العملية الجديدة بينما القفل نفسه ينتقل مقفلاً، فتتجمد العملية الابن عند أول محاولة لأخذه. ولذلك غيرت Python 3.14 الطريقة الافتراضية على أنظمة POSIX إلى forkserver كما في توثيق multiprocessing. والملف start_methods.py يقيس زمن إنشاء أربع عمليات واستعمالها وإغلاقها بكل طريقة، والمخرج من تشغيلين كما يلي:

default start method: forkserver
fork       pool of 4 started+used+closed in  0.33 s, child sees BIG: True
forkserver pool of 4 started+used+closed in  1.39 s, child sees BIG: True
spawn      pool of 4 started+used+closed in  0.88 s, child sees BIG: True
default start method: forkserver
fork       pool of 4 started+used+closed in  0.15 s, child sees BIG: True
forkserver pool of 4 started+used+closed in  0.65 s, child sees BIG: True
spawn      pool of 4 started+used+closed in  0.54 s, child sees BIG: True

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

  • الطريقة الافتراضية في 3.15.2 هي forkserver كما طبعها السطر الأول، فإذا نقلت كوداً قديماً يعتمد على أن العملية الابن ترى متغيرات أنشأها الأب وقت التشغيل فقد يتوقف عن العمل بعد الترقية، والحل أن تطلب fork صراحة بـ get_context("fork") إذا كنت متأكداً أنه لا توجد خيوط، أو الأفضل أن تمرر البيانات كمعاملات.
  • fork كانت الأسرع في التشغيلين، لأنها لا تشغل مفسراً جديداً ولا تستورد شيئاً، وforkserver كانت الأبطأ هنا لأن التجربة تدفع ثمن تشغيل الخادم نفسه أول مرة، وهذا ثمن يدفع مرة واحدة في عمر الخدمة.
  • القيمة True في العمود الأخير لها معنيان مختلفان: في fork ورثت العملية الابن القائمة BIG من ذاكرة الأب مباشرة، أما في spawn وforkserver فقد أعادت العملية الابن تنفيذ السطر BIG = list(range(2_000_000)) عندما استوردت الملف من جديد، أي أن كل عملية بنت نسختها بنفسها ودفعت ثمنها. ولهذا السبب يجب أن يكون كود إنشاء العمليات داخل if __name__ == "__main__":، وإلا أعادت كل عملية ابن إنشاء عمليات جديدة عند استيراد الملف.

حالة التسابق Race Condition: عشرون كيساً من مخزون عشرة

الآن نعود إلى أخطر مشكلة في القصة: الخدمة باعت عشرين كيساً من مخزون عشرة. والملف race.py يعيد ما حدث بأبسط شكل: عشرون خيطاً تبدأ في نفس اللحظة بفضل threading.Barrier، وكل خيط يقرأ المخزون، فإذا وجده أكثر من صفر ينتظر ميلي ثانية واحدة ثم ينقصه:

def attempt(lock: threading.Lock | None, results: list, stock: dict, barrier: threading.Barrier) -> None:
    barrier.wait()                        # start all threads at the same moment
    if lock:
        lock.acquire()
    try:
        left = stock["dates"]
        if left >= 1:
            time.sleep(0.001)             # the gap a real service has: a DB call, a log line
            stock["dates"] = left - 1
            results.append("ok")
        else:
            results.append("409")
    finally:
        if lock:
            lock.release()

والميلي ثانية هنا ليست حيلة لإظهار الخطأ، وإنما تمثل ما يحدث في كل خدمة حقيقية بين القراءة والكتابة: استعلام إلى قاعدة البيانات، أو سطر في السجل، أو استدعاء خدمة الدفع. والمخرج على البناءين كما يلي:

### race GIL build
3.15.2 GIL
no lock    sold=20  rejected= 0  stock left=9
no lock    sold=20  rejected= 0  stock left=9
no lock    sold=20  rejected= 0  stock left=9
with lock  sold=10  rejected=10  stock left=0
counter: expected 800000, got 800000
counter: expected 800000, got 800000
counter: expected 800000, got 800000
### race free-threaded build
3.15.2 free-threaded
no lock    sold=20  rejected= 0  stock left=9
no lock    sold=20  rejected= 0  stock left=9
no lock    sold=20  rejected= 0  stock left=9
with lock  sold=10  rejected=10  stock left=0
counter: expected 800000, got 284666
counter: expected 800000, got 412556
counter: expected 800000, got 321183
صندوقان: بدون قفل يقرأ T1 وT2 المخزون 10 معاً ثم ينتظران ثم يكتب كل واحد 9، والنتيجة sold=20 rejected=0 stock left=9 في البناءين، ومع threading.Lock تصبح القراءة والكتابة خطوة واحدة والنتيجة sold=10 rejected=10 stock left=0، وتحتهما نتيجة العداد مع أربعة خيوط: 800000 مع GIL في المحاولات الثلاث، و284666 و412556 و321183 على البناء الخالي من القفل
الـ GIL لا يحمي منطق «افحص ثم نفذ»، ويحمي العداد البسيط بالصدفة فقط

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

  • بدون قفل باعت الخيوط عشرين قطعة في كل محاولة وعلى البناءين، وبقي المخزون 9 وليس صفراً أو سالباً، والسبب أن كل خيط قرأ 10 قبل أن يكتب أي خيط، ثم كتب كل واحد 10 ناقص 1. وهذا يسمى «افحص ثم نفذ» Check-then-Act، والـ GIL لا يحمي منه إطلاقاً، لأنه يحمي تعليمة واحدة من تعليمات المفسر وليس منطق دالتك.
  • مع threading.Lock صارت القراءة والفحص والكتابة منطقة حرجة Critical Section لا يدخلها إلا خيط واحد، فبيعت عشر قطع ورفضت عشر، وبقي المخزون صفراً. وفي الخدمة الحقيقية لن يكون القفل في ذاكرة Python، لأن الخدمة تعمل بعدة عمليات وربما عدة سيرفرات، وإنما في قاعدة البيانات بـ SELECT ... FOR UPDATE، وهذا بالضبط ما سوف نختبره في المقال التالي بعشرين طلباً متوازياً على منتج مخزونه عشرة.
  • العداد box[0] += 1 أعطى 800000 كاملة مع الـ GIL في المحاولات الثلاث، وعلى البناء الخالي من القفل فقد أكثر من نصف القيم. والسبب أن += ثلاث خطوات: اقرأ، أضف، اكتب، والمفسر في البناء العادي لا يبدل الخيوط في وسط هذه الخطوات في الواقع العملي لهذا الكود، فبدا كأنه آمن. وهذا هو الكود الذي يعمل «بالصدفة»، فلا يوجد في اللغة أي وعد بأن += عملية ذرية Atomic، وأول ما يتغير المفسر يظهر الخطأ، ولذلك احم كل حالة مشتركة بقفل حتى لو بدت آمنة اليوم.

الجمود Deadlock: قفلان بترتيبين مختلفين

وبعد أن يضيف الفريق أقفالاً في كل مكان تظهر المشكلة المعاكسة: الجمود. والملف deadlock.py فيه عمليتان، الدفع Checkout يأخذ قفل المخزون ثم قفل الطلبات، والاسترجاع Refund يأخذ قفل الطلبات ثم قفل المخزون، وهو ترتيب طبيعي جداً لمن كتب كل دالة في يوم مختلف:

stock_lock = threading.Lock()
order_lock = threading.Lock()

def reserve_then_record(name: str, first: threading.Lock, second: threading.Lock, ordered: bool) -> None:
    a, b = (first, second)
    if ordered:                           # the fix: always take the locks in one global order
        a, b = sorted((first, second), key=id)
    with a:
        time.sleep(0.05)
        if b.acquire(timeout=2):
            b.release()
            print(f"  {name}: done")
        else:
            print(f"  {name}: waited 2 s for the second lock, giving up (deadlock)")

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

opposite order
  checkout: waited 2 s for the second lock, giving up (deadlock)
  refund  : done
  took 2.05 s
ordered locks
  checkout: done
  refund  : done
  took 0.10 s
رسم لخيطين checkout وrefund وقفلين stock_lock وorder_lock: checkout يمسك stock_lock وينتظر order_lock، وrefund يمسك order_lock وينتظر stock_lock، والمخرج checkout waited 2 s for the second lock giving up deadlock وtook 2.05 s، وعلى اليسار الإصلاح بترتيب واحد للأقفال sorted بـ key=id والمخرج done للاثنين وtook 0.10 s
كل خيط يمسك قفلاً وينتظر الآخر، والإصلاح ترتيب واحد للأقفال في كل الكود

أمسك كل خيط قفله الأول، ثم انتظر القفل الثاني الذي يمسكه الآخر، فلم يتقدم أحد. ولولا timeout=2 لبقي الخيطان متوقفين إلى الأبد، ولما ظهرت أي رسالة خطأ ولا استهلاك معالج، وإنما طلبات معلقة تتراكم حتى يعيد أحدهم تشغيل الخدمة، وهذا ما يجعل الجمود من أصعب المشاكل تشخيصاً في الإنتاج. وبعد أن استسلم checkout وترك قفله أكمل refund عمله، فأخذت التجربة 2.05 ثانية. والإصلاح أن تأخذ الأقفال دائماً بترتيب واحد في كل الكود، وفي المثال رتبناها بـ id، فأخذ الخيطان القفلين بنفس الترتيب وانتهيا في 0.10 ثانية. وفي الخدمة الحقيقية التي تقفل صفوفاً في قاعدة البيانات يكون الحل نفسه: اقفل صفوف المنتجات مرتبة بالمعرف ORDER BY id، وإلا اكتشفت قاعدة البيانات الجمود وألغت أحد الطلبين.

ماذا لو What If: عمل حسابي داخل دالة async؟

رأينا أن asyncio سريع في الانتظار، ولكنه يعمل في خيط واحد، فماذا يحدث إذا وضع أحدهم حساباً ثقيلاً داخل دالة async، كتوقيع فاتورة داخل معالج طلب HTTP؟ الملف blocked_loop.py يشغل مهمة نبض Heartbeat تستيقظ كل 10 ميلي ثانية وتسجل الفجوة الفعلية بين كل نبضتين، ثم ينفذ 300 ألف عملية هاش مرة مباشرة ومرة عبر asyncio.to_thread:

async def heartbeat(gaps: list) -> None:
    last = time.perf_counter()
    for _ in range(40):
        await asyncio.sleep(0.01)
        now = time.perf_counter()
        gaps.append((now - last) * 1000)
        last = now

async def main(offload: bool) -> None:
    gaps: list[float] = []
    hb = asyncio.create_task(heartbeat(gaps))
    await asyncio.sleep(0.05)
    if offload:
        await asyncio.to_thread(hash_chain, 300_000)
    else:
        hash_chain(300_000)                    # blocks the event loop
    await hb
    print(f"{'asyncio.to_thread' if offload else 'called directly':18s} heartbeat every 10 ms: worst gap {max(gaps):6.0f} ms")

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

called directly    heartbeat every 10 ms: worst gap   1046 ms
asyncio.to_thread  heartbeat every 10 ms: worst gap     25 ms
صندوقان: الاستدعاء المباشر لـ hash_chain داخل دالة async مع أسوأ فجوة في النبض 1046 ms، أي أن النبض المبرمج كل 10 ms لم يعمل أكثر من ثانية، ونقل العمل إلى Thread بـ asyncio.to_thread مع أسوأ فجوة 25 ms
سطر حسابي واحد داخل async أوقف الحلقة كلها أكثر من ثانية

النبض المبرمج كل 10 ميلي ثانية انتظر أكثر من ثانية كاملة، لأن الحلقة لا تستطيع أن تنتقل إلى مهمة أخرى إلا عند await، والدالة hash_chain لا يوجد فيها أي await. وفي خدمة حقيقية تعني هذه الثانية أن كل الطلبات الأخرى في نفس العملية توقفت، بما فيها فحص الصحة Health Check، فقد يظن الـ Load Balancer أن الخدمة ماتت ويخرجها من الخدمة. ومع asyncio.to_thread انتقل الحساب إلى خيط آخر، فبقيت الحلقة حرة، وأسوأ فجوة كانت 25 ميلي ثانية، والتأخير القليل لأن الخيط الآخر ينافس الحلقة على الـ GIL. ولاحظ أن to_thread يحرر الحلقة ولكنه لا يجعل الحساب متوازياً مع الـ GIL، فإذا كان الحساب كبيراً ومتكرراً فمكانه ProcessPoolExecutor عبر loop.run_in_executor، أو عملية Worker منفصلة. ويذكر دليل التطوير مع asyncio وضع التصحيح Debug Mode الذي يطبع تحذيراً لأي استدعاء يحجز الحلقة أكثر من 100 ميلي ثانية، وهو مفيد جداً في بيئة التطوير.

التزامن المنظم Structured Concurrency: TaskGroup والمهلة Timeout

في تحديث الكتالوج نرسل مئة طلب إلى المورد معاً، فماذا يحدث إذا فشل طلب واحد؟ في الكود القديم المكتوب بـ asyncio.gather كانت بقية المهام تستمر في الخلفية حتى بعد أن يصلك الخطأ، وقد تبقى مهام معلقة لا يملكها أحد. ولذلك أضافت Python 3.11 الكلاس asyncio.TaskGroup الذي استعملناه في المختبر، وفكرته أن المهام التي تنشأ داخل الكتلة لا تعيش بعدها: إذا فشلت مهمة ألغيت أخواتها، ولا تخرج الكتلة إلا بعد أن تنتهي كل المهام أو تلغى، والأخطاء تجمع في ExceptionGroup كما رأينا في المقال السابق. ومعه asyncio.timeout الذي يضع مهلة على أي جزء من الكود، والتفاصيل في توثيق المهام في asyncio. والجزء الأخير من costs.py يبين الاثنين:

async def fetch(i):
    await asyncio.sleep(0.05 * i)
    if i == 2:
        raise ConnectionError(f"supplier timeout on product {i}")
    print(f"  product {i} done")

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            for i in range(1, 6):
                tg.create_task(fetch(i))
    except* ConnectionError as eg:
        print("  TaskGroup cancelled the rest:", [str(e) for e in eg.exceptions])
    try:
        async with asyncio.timeout(0.1):
            await asyncio.sleep(1)
    except TimeoutError:
        print("  asyncio.timeout(0.1) stopped a 1 s wait")

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

  product 1 done
  TaskGroup cancelled the rest: ['supplier timeout on product 2']
  asyncio.timeout(0.1) stopped a 1 s wait

انتهت المهمة الأولى بعد 50 ميلي ثانية، ثم فشلت الثانية بعد 100، فألغى الـ TaskGroup المهام الثلاث الباقية قبل أن تنتهي، ولذلك لم تطبع المنتجات 3 و4 و5 أي سطر، ووصل الخطأ الوحيد في المجموعة إلى except*. وهذا السلوك هو ما تريده في أغلب الحالات، فلا فائدة من إكمال تحديث نصف كتالوج فشل، أما إذا أردت أن تكمل المهام رغم فشل بعضها فالتقط الخطأ داخل كل مهمة وأرجعه كقيمة. والمهلة أوقفت انتظاراً مدته ثانية بعد 0.1 ثانية برمي TimeoutError، والقاعدة أن كل طلب يخرج إلى الشبكة يجب أن تكون له مهلة، لأن طلباً واحداً معلقاً بلا مهلة يحجز Semaphore أو اتصالاً إلى الأبد.

من المختبر إلى الخدمة Service: عمليات وحلقة أحداث معاً

كل ما قسناه حتى الآن كان في سكربتات منفصلة، فكيف يجتمع في خدمة ويب حقيقية مثل خدمة الطلبات التي سوف نبنيها في المقال التالي؟ الشكل الشائع اليوم لخدمة Python هو طبقتان: في الأعلى عدة عمليات Workers يديرها سيرفر مثل Uvicorn أو Gunicorn، وفي كل عملية حلقة أحداث واحدة تخدم مئات الطلبات المتزامنة بـ asyncio. والسبب في هذا الشكل هو بالضبط ما رأيناه في المختبر: الحلقة الواحدة ممتازة في الانتظار لأن الطلب الذي ينتظر قاعدة البيانات لا يكلف إلا مهمة صغيرة، ولكنها لا تستعمل إلا معالجاً واحداً بسبب الـ GIL، ولذلك نضاعف العمليات لنستعمل كل المعالجات. والقاعدة الشائعة أن تبدأ بعدد عمليات يساوي عدد المعالجات المتاحة للـ Container، ثم تقيس، فإذا كانت الخدمة تقضي أغلب وقتها في الانتظار فقد تكفيها عمليات أقل.

وهذا الشكل يغير طريقة تفكيرك في الحالة المشتركة: كل عملية لها ذاكرتها، فالقفل في ذاكرة Python يحمي البيانات داخل عملية واحدة فقط، والذاكرة المؤقتة Cache في قاموس عام تتكرر في كل عملية، والمخزون الذي يحميه threading.Lock في عملية سوف يقرؤه طلب آخر في عملية ثانية بدون أي قفل. ولذلك كل حالة يجب أن تكون صحيحة بين الطلبات، كالمخزون والطلبات والأرصدة، مكانها قاعدة البيانات بمعاملاتها Transactions وأقفال صفوفها، وهذا ما سوف نختبره بعشرين طلباً متوازياً. وأما العمل الحسابي الثقيل مثل توقيع الفواتير فلا تضعه في معالج الطلب أصلاً، وإنما في طابور مهام Job Queue تعالجه عمليات منفصلة، فلا يؤخر الطلبات الأخرى ولا يحتاج أن يعرف عنه المستخدم إلا أنه انتهى.

جدول القرار Decision Table: أي نموذج تختار؟

نوع العملالنموذج المناسبما رأيناه في المختبر
طلبات شبكة أو قاعدة بيانات، عشرات إلى مئاتThreadPoolExecutorمئة طلب من 5.4 ثانية إلى 0.33 ثانية، والكود عادي بدون async
آلاف الاتصالات المتزامنة، أو خدمة ويب حديثةasyncio مع مكتبة تقيسهانفس سرعة الخيوط في خيط واحد، وaiohttp أسرع من httpx بثلاث مرات أو أكثر هنا
حساب ثقيل في كود Python، وبيانات قليلةProcessPoolExecutorبين 2.26 و3.07 ضعفاً على أربعة معالجات
حساب ثقيل مع بيانات كبيرة مشتركةالبناء الخالي من القفل بعد فحص مكتباتكبين 2.83 و3.27 ضعفاً بالخيوط، وأكثر من ثلاثة معالجات مشغولة
حساب داخل خدمة asyncioasyncio.to_thread أو عملية منفصلةفجوة النبض من 1046 إلى 25 ميلي ثانية
حالة مشتركة بين الخيوطLock وترتيب ثابت للأقفالبدون قفل بيع 20 من 10، وقفلان بترتيب معاكس جمدا ثانيتين

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

  • رفض الخيوط في كل الحالات بسبب الـ GIL، بينما هي الحل الأبسط للمهام التي تنتظر الشبكة أو القرص.
  • استعمال الخيوط للحساب الثقيل في البناء العادي وتوقع تسريع، ثم قياس الزمن وحده على جهاز مشغول فيظهر تسريع وهمي.
  • كتابة دالة async تستدعي مكتبة متزامنة مثل requests أو time.sleep، فتحجز الحلقة كما حجزها الحساب في التجربة.
  • الاعتماد على أن += على متغير مشترك آمن لأن الاختبار نجح مع الـ GIL.
  • حماية المخزون بقفل في ذاكرة Python بينما الخدمة تعمل بعدة عمليات أو عدة سيرفرات، والقفل الصحيح هنا في قاعدة البيانات.
  • أخذ عدة أقفال بترتيب يختلف من دالة لأخرى، أو انتظار قفل بدون timeout فيتحول الجمود إلى خدمة معلقة بلا رسالة.
  • استعمال asyncio.gather على آلاف الطلبات بدون حد، فيفتح آلاف الاتصالات معاً ويضغط على الخدمة الأخرى، والحل Semaphore كما في المختبر.

قائمة تحقق Checklist قبل أن تضيف التزامن

  • هل قست زمن طلب واحد أولاً، وتأكدت أنه قريب من المتوقع قبل أن تقيس مئة؟
  • هل حددت نوع العمل: انتظار أم حساب؟ وهل اخترت النموذج من الجدول أعلاه؟
  • هل قست زمن المعالج مع الزمن الفعلي، وكررت التشغيل أكثر من مرة؟
  • هل يوجد حد لعدد العمليات المتزامنة في كل مكان يخرج إلى الشبكة؟
  • هل كل حالة مشتركة محمية بقفل، أو الأفضل أنها غير مشتركة أصلاً؟
  • هل الأقفال تؤخذ بترتيب واحد، ولكل انتظار حد زمني؟
  • هل يعمل وضع التصحيح في asyncio في بيئة التطوير ليكشف الاستدعاءات التي تحجز الحلقة؟
  • إذا فكرت في البناء الخالي من القفل: هل كل مكتباتك المكتوبة بـ C تدعمه؟

تدريب عملي

  • غير DELAY_MS في السيرفر إلى 200، وتوقع قبل التشغيل زمن كل طريقة في io_bound.py، ثم قارن توقعك بالنتيجة.
  • شغل cpu_bound.py بالمتغير WORKERS=8 مع --cpus 4، ولاحظ هل يزيد التسريع أم لا، ولماذا.
  • شغل race.py على البناء الخالي من القفل مع المتغير PYTHON_GIL=1، وقارن نتيجة العداد بالتشغيل بدونه.
  • أزل timeout=2 من deadlock.py وشغله، ثم استعمل py-spy dump أو faulthandler لترى أين يقف كل خيط.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • في الانتظار لا يضر الـ GIL: مئة طلب نزلت من حوالي 5.5 ثانية إلى 0.33 ثانية بالخيوط وبـ asyncio على حد سواء.
  • مكتبة العميل قد تكون العائق الحقيقي: httpx أخذت بين 1.57 و2.33 ثانية لنفس العمل الذي أنهته aiohttp في 0.31 ثانية في هذه التجربة.
  • في الحساب لا تتجاوز الخيوط مع الـ GIL معالجاً واحداً، والعمليات أو البناء الخالي من القفل أعطت تقريباً ثلاثة أضعاف على أربعة معالجات.
  • الزمن الفعلي على جهاز مشغول قد يظهر تسريعاً غير موجود، وقياس زمن المعالج معه يكشف الحقيقة.
  • «افحص ثم نفذ» بدون قفل باع عشرين من عشرة على البناءين، والعداد البسيط آمن مع الـ GIL بالصدفة فقط، وفقد أكثر من نصف قيمه بدونه.
  • قفلان بترتيبين متعاكسين جمدا خيطين حتى انتهت المهلة، وترتيب واحد للأقفال أصلح ذلك.
  • مئة ألف مهمة asyncio منتظرة أخذت 84 ميجابايت، أي تقريباً ذاكرة خمسة آلاف خيط، وهذا هو المكان الحقيقي لـ asyncio: آلاف الاتصالات المفتوحة وليس مئة طلب.
  • الطريقة الافتراضية لإنشاء العمليات على Linux صارت forkserver منذ 3.14، فالعملية الابن تستورد ملفك من جديد ولا ترث متغيرات وقت التشغيل.
  • سطر حسابي واحد داخل async أوقف الحلقة أكثر من ثانية، وasyncio.to_thread أعادها إلى 25 ميلي ثانية.

وفي المقال التالي من مرحلة لغة Python للمطورين سوف نبني خدمة الطلبات كاملة بـ FastAPI وPostgreSQL، ونختبر فيها نفس مشكلة المخزون بعشرين طلباً متوازياً على قاعدة بيانات حقيقية:

دليلPython للمطورين: التركيب Syntax والأدوات في يوم واحددليل لمن يبرمج بلغة أخرى ويريد أن يبدأ بـ Python 3.15 من أول يوم: التثبيت، وما يختلف في التركيب، والأدوات uv وruff وmypy وpytest على مشروع حقيقي بمخرجه الفعلي.دليلPython من الداخل: الأنواع Types والذاكرة والاستثناءاتما هو المتغير فعلاً في Python، ومن يحرر الذاكرة، وكيف تعبر اللغة عن الخطأ، بقياسات حقيقية على Python 3.15 من حجم مليون سجل إلى Container قتلته النواة دون رسالة.دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على Python 3.15.2 بالبناء العادي والبناء الخالي من القفل 3.15t، مع aiohttp 3.14.5 وhttpx 0.28.1، داخل Container محدود بـ 4 CPU و1 GB.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.