سيرفر Python يتجمد تحت ضغط 1000 طلب متزامن، رغم استخدام asyncio. المشكلة ليست في المكتبة، بل في فهم خاطئ لكيفية عمل الـ Event Loop. هذا المقال يكشف الفخاخ الحقيقية التي يقع فيها حتى المطورون المحترفون، مع حلول هندسية مستعملة في شركات مثل Instagram وDropbox.
في إحدى ليالي الـ On-Call، تلقيت تنبيهاً من Datadog: متوسط زمن الاستجابة لواحد من الـ Endpoints الرئيسيين في الخدمة ارتفع من 80ms إلى 4500ms. السيرفر لم يسقط، لكنه كان يتصرف وكأن أحدهم علق الـ Main Thread بحلقة لا نهائية. المشكلة؟ كان فريق Backend يستخدم asyncio منذ شهور، وكتبوا مئات الأسطر من الكود باستخدام async/await. لكن عندما فحصت الـ CPU Profile، وجدت أن 68% من الوقت يضيع في انتظار عمليات I/O التي لم تُدار بشكل صحيح. الحقيقة المؤلمة: asyncio ليس سحراً، وهو بالتأكيد ليس بديلاً عن الـ Threading العادي إذا لم تفهم كيف يعمل تحت الغطاء.
المفارقة الأكبر أن معظم المطورين يقرؤون الوثائق الرسمية لasyncio، ويكتبون كوداً يبدو صحيحاً على السطح، لكنهم يفاجؤون بأن الأداء أسوأ من الكود الـ Synchronous العادي. لماذا؟ لأن asyncio ليس مجرد كتابة await قبل كل استدعاء لوظيفة بطيئة. إنه نموذج برمجة متكامل يتطلب فهماً عميقاً للـ Event Loop، وعمليات I/O غير المتزامنة، وكيفية إدارة الـ Coroutines بطريقة لا تسبب تسرباً في الذاكرة أو تجميداً للـ Thread الرئيسي.
عندما تكتب await fetch_data()، فإنك تخبر الـ Event Loop: "يمكنك التوقف عن تنفيذ هذا الـ Coroutine الآن والانتقال إلى شيء آخر، وسأعود إليك عندما تكتمل هذه العملية". لكن ما لا يفهمه الكثيرون هو أن هذا لا يعني أن العملية ستُنفذ في الخلفية على Thread آخر. في الواقع، في Python، الـ GIL (Global Interpreter Lock) يمنع تنفيذ أكثر من Thread واحد في نفس الوقت، حتى لو كان لديك عدة Cores. إذن كيف يعمل asyncio؟
السر يكمن في الـ Non-Blocking I/O. عندما تستدعي await على عملية I/O (مثل قراءة ملف أو طلب HTTP)، فإن asyncio يرسل الطلب إلى الـ Kernel باستخدام نظام مثل epoll على Linux أو kqueue على macOS، ثم يعلق الـ Coroutine الحالي ويبدأ في تنفيذ Coroutines أخرى. وعندما يعود الـ Kernel بالإشارة بأن العملية اكتملت، يستأنف الـ Event Loop تنفيذ الـ Coroutine من حيث توقف. لكن هنا تكمن المشكلة: إذا كتبت await بطريقة خاطئة، أو استخدمت مكتبات غير متوافقة مع asyncio، فإنك قد تتسبب في حظر الـ Event Loop بالكامل، مما يجعل السيرفر يتصرف وكأنك كتبت كوداً متزامناً تقليدياً.
لنأخذ مثالاً عملياً: عندما تستخدم requests.get() داخل دالة async بدلاً من aiohttp، فإنك تحجز الـ Event Loop بالكامل حتى تكتمل العملية، لأن requests مكتبة متزامنة. النتيجة؟ كل الـ Coroutines الأخرى تنتظر حتى تكتمل هذه العملية الواحدة، مما يجعل الأداء أسوأ من استخدام Threading العادي. هذا هو السبب في أن الكثير من المطورين يقولون: "asyncio لم يعطني أي تحسن في الأداء"، بينما الحقيقة أنهم استخدموه بشكل خاطئ منذ البداية.
لنبدأ بالكود السيئ الذي أراه في كل مكان. هذا النمط شائع جداً بين المطورين الذين ينتقلون من الـ Synchronous Code إلى asyncio دون فهم عميق للمفاهيم:
# Anti-Pattern: استخدام مكتبات متزامنة داخل دالة async
import asyncio
import requests # مكتبة متزامنة!
async def fetch_user_data(user_id):
# هذا السطر يحجز الـ Event Loop بالكامل حتى تكتمل العملية
resp requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
async def main():
# هذا يبدو صحيحاً، لكنه كارثة في الواقع
users = await asyncio.gather(
fetch_user_data(1),
fetch_user_data(2),
fetch_user_data(3)
)
print(users)
asyncio.run(main())المشكلة هنا ليست فقط في استخدام requests بدلاً من aiohttp، بل في الوهم بأن await سيجعل الكود غير متزامن. في الواقع، await في هذه الحالة لا يفعل شيئاً سوى انتظار اكتمال العملية المتزامنة، مما يحجز الـ Event Loop بالكامل. النتيجة؟ إذا كان لديك 1000 طلب متزامن، فإن كل طلب سينتظر دوره بدلاً من أن تُنفذ بشكل متوازٍ حقيقي.
النمط السيئ الآخر هو استخدام asyncio.sleep بدلاً من await عند التعامل مع عمليات I/O الحقيقية. هذا الكود يبدو بريئاً، لكنه كارثة في الانتظار:
# Anti-Pattern: استخدام sleep بدلاً من await حقيقي
import asyncio
async def process_data(data):
# محاكاة لوقت معالجة طويل
await asyncio.sleep(2) # هذا ليس I/O حقيقي!
return data * 2
async def main():
tasks = [process_data(i) for i in range(1000)]
results = await asyncio.gather(*tasks)
print(f"Processed {len(results)} items")
asyncio.run(main())المشكلة هنا أن asyncio.sleep لا يمثل عملية I/O حقيقية، بل مجرد مؤقت. هذا يعني أن الـ Event Loop يمكنه التبديل بين الـ Coroutines بحرية، لكن في الواقع، إذا استخدمت هذا النمط مع عمليات I/O حقيقية، فإنك تخدع نفسك بأن الكود يعمل بشكل غير متزامن بينما هو في الحقيقة لا يفعل شيئاً مفيداً. هذا النوع من الكود قد يمر في الاختبارات المحلية، لكنه سيفشل بشكل كارثي تحت ضغط الـ Production.
الآن، دعنا نرى كيف يكتب المحترفون الكود باستخدام asyncio. المفتاح هنا هو فهم أن asyncio ليس مجرد كتابة await قبل كل شيء، بل هو تصميم كامل للنظام بحيث لا يحجز أي عملية الـ Event Loop. هذا هو الكود الذي تستخدمه الشركات الكبيرة:
import asyncio
import aiohttp # المكتبة الصحيحة للـ HTTP غير المتزامن
from typing import List, Dict
# استخدام ClientSession لإعادة استخدام الاتصالات (Connection Pooling)
async def fetch_user_data(session: aiohttp.ClientSession, user_id: int) -> Dict:
async with session.get(f"https://api.example.com/users/{user_id}") as response:
# استخدام raise_for_status للتحقق من الأخطاء
response.raise_for_status()
return await response.json()
async def fetch_all_users(user_ids: List[int]) -> List[Dict]:
# استخدام timeout لمنع التجميد
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
# استخدام gather مع return_exceptiTrue لمنع فشل المهمة بأكملها
tasks = [fetch_user_data(session, user_id) for user_id in user_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
# فلترة النتائج لإزالة الاستثناءات
return [result for result in results if not isinstance(result, Exception)]
async def main():
user_ids = list(range(1, 1001)) # 1000 مستخدم
users = await fetch_all_users(user_ids)
print(f"Fetched {len(users)} users successfully")
if __name__ == "__main__":
# استخدام uvloop لتحسين الأداء (مستعمل في Instagram وDropbox)
try:
import uvloop
uvloop.install()
except ImportError:
pass # uvloop اختياري، لكن يوصى به بشدة
asyncio.run(main())هذا الكود يحتوي على عدة تحسينات هندسية مهمة:
لكن التحسين الحقيقي هنا ليس فقط في الكود نفسه، بل في الفلسفة وراءه: asyncio ليس أداة لجعل الكود "أسرع" بشكل سحري، بل هو أداة لإدارة العمليات غير المتزامنة بكفاءة دون حظر الـ Event Loop. هذا يعني أنك يجب أن تفكر في كل عملية I/O في نظامك وتحدد ما إذا كانت ستحجز الـ Event Loop أم لا. إذا كانت ستحجزه، فأنت بحاجة إلى إعادة تصميمها باستخدام أدوات متوافقة مع asyncio.
حتى بعد كتابة الكود بشكل صحيح، هناك فخاخ في بيئة الـ Production قد تدمر كل ما بنيته. سأشارككم تجربتين حقيقيتين من عملي كمهندس في شركات ناشئة كبيرة:
في إحدى الخدمات التي عملت عليها، لاحظنا أن الذاكرة ترتفع تدريجياً حتى تصل إلى 100% بعد حوالي 12 ساعة من التشغيل المستمر. استخدمنا أدوات مثل tracemalloc وmemory_profiler، ووجدنا أن آلاف الـ Coroutines تبقى في الذاكرة دون أن تكتمل. السبب؟ كان لدينا دالة async تتعامل مع WebSocket connections، وفي بعض الحالات، كانت الدالة تخرج من الـ Context دون انتظار اكتمال الـ Coroutine:
# المشكلة: تسرب الـ Coroutines
async def handle_websocket(websocket):
async for message in websocket:
if message["type"] == "disconnect":
# المشكلة هنا: لا await!
asyncio.create_task(process_disconnect(websocket))
return # الخروج دون انتظار اكتمال الـ Coroutine
await process_message(message)
# الحل: انتظار الـ Coroutine قبل الخروج
async def handle_websocket_fixed(websocket):
async for message in websocket:
if message["type"] == "disconnect":
# انتظار اكتمال المهمة قبل الخروج
await process_disconnect(websocket)
return
await process_message(message)في المثال الأول، عندما يستدعي المستخدم disconnect، فإن الدالة تخرج فوراً دون انتظار اكتمال process_disconnect، مما يترك الـ Coroutine معلقاً في الذاكرة. هذا النوع من الأخطاء يصعب اكتشافه في التطوير المحلي، لكنه كارثة في الـ Production حيث تتكرر آلاف المرات يومياً.
في مشروع آخر، كنا نكتب اختبارات لوحدة تستخدم asyncio، ووجدنا أن الاختبارات تتجمد أحياناً دون سبب واضح. بعد ساعات من التنقيب، اكتشفنا أن المشكلة كانت في استخدام pytest مع asyncio دون إعداد صحيح. كان لدينا كود مثل هذا:
# المشكلة: تداخل الـ Event Loops في الاختبارات
import pytest
import asyncio
async def fetch_data():
await asyncio.sleep(1)
return "data"
def test_fetch_data():
# هذا سيشل الاختبار!
result = asyncio.run(fetch_data())
assert result == "data"المشكلة هنا أن pytest-asyncio ينشئ Event Loop خاصاً به، وعندما نستدعي asyncio.run داخل الاختبار، فإننا نحاول إنشاء Event Loop جديد داخل الـ Event Loop الموجود أصلاً. الحل هو استخدام pytest-asyncio بشكل صحيح:
# الحل: استخدام pytest-asyncio بشكل صحيح
import pytest
@pytest.mark.asyncio
async def test_fetch_data():
result = await fetch_data()
assert result == "data"هذا الفخ شائع جداً بين المطورين الذين ينتقلون من كتابة الكود إلى كتابته واختباره. الوثائق الرسمية لا تشرح هذه التفاصيل بشكل كافٍ، مما يجعل الكثيرين يعتقدون أن هناك خطأ في الكود نفسه بينما المشكلة في بيئة الاختبار.
بعد أكثر من عشر سنوات في كتابة الكود باستخدام Python وasyncio، هذه هي النصيحتان اللتان أتمنى لو عرفتهما منذ البداية:
asyncio ليس أداة لجعل الكود "أسرع"، بل أداة لإدارة العمليات غير المتزامنة بكفاءة دون حظر الـ Event Loop. إذا لم تفهم هذا، فأنت ستكتب كوداً أسوأ من الـ Synchronous Code العادي.
— خبرة عشر سنوات في هندسة البرمجيات
النصيحة الثانية:
كل await هو وعد للـ Event Loop بأنك ستحرر الموارد في أقرب وقت ممكن. إذا لم تفِ بهذا الوعد، فإنك تخون الفلسفة الأساسية لـ asyncio، وسيرفرك سيدفع الثمن.
— أسلوب تفكير مهندس Backend محترف
إذا أخذت شيئاً واحداً من هذا المقال، فليكن هذا: لا تستخدم asyncio فقط لأن "الجميع يستخدمونه". استخدمه عندما تفهم تماماً كيف يعمل تحت الغطاء، وعندما تكون مستعداً لإعادة تصميم نظامك ليتناسب مع فلسفته. وإلا، فستكون أفضل حالاً مع Threading العادي أو حتى الـ Synchronous Code البسيط.