حتى بعد عشر سنوات من كتابة Python، لا يزال المطورون المحترفون يرتكبون أخطاء تبدو بسيطة لكنها تكلف الشركات آلاف الدولارات في الإنتاج. إليك أخطرها وكيف تتجنبها في كودك التالي.
في أحد ليالي الإنتاج المشؤومة، تلقى فريقنا تنبيهاً من مراقبة الأداء: سيرفر الـ API الرئيسي كان يستهلك 100% من الـ CPU دون أي زيادة في حركة المرور. بعد ساعتين من التنقيب، اكتشفنا أن سطراً واحداً من كود Python كان يخلق 50 ألف thread في الثانية بسبب خطأ في التعامل مع الـ async/await. المشكلة؟ لم يكن الخطأ في منطق الكود، بل في فهم سطحي لكيفية عمل الـ Event Loop تحت الغطاء. هذا ليس سيناريو خيالياً - إنه واقع يواجهه المطورون المحترفون يومياً، حتى أولئك الذين يحملون شهادات من Google وNetflix.
الخطأ الأكبر الذي يرتكبه المحترفون ليس في كتابة كود خاطئ، بل في افتراض أنهم يفهمون Python جيداً. اللغة تبدو بسيطة على السطح، لكن تحت غطائها تكمن تفاصيل معقدة تجعل حتى أكثر الكودات بريئة المظهر قادرة على تدمير نظام كامل. في هذا المقال، سنفكك أخطر الأخطاء التي رأيتها في بيئات الإنتاج - من الـ Memory Leaks التي تظهر بعد أسبوعين من التشغيل، إلى الـ Deadlocks التي تحدث فقط في أوقات الذروة - ونكشف بالضبط ما يحدث خلف الكواليس في المعالج والذاكرة.
الكود التالي يبدو بريئاً تماماً، أليس كذلك؟ دالة تأخذ قائمة وتضيف عنصراً إليها. لكن إذا استخدمت هذه الدالة أكثر من مرة، ستجد أن القائمة لا تُعاد تهيئتها في كل استدعاء، بل تحتفظ بقيم الاستدعاءات السابقة. هذا ليس خطأ في منطق الكود، بل في كيفية تعامل Python مع الـ default arguments خلف الكواليس.
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] - المفاجأة هنا!
print(add_item(3)) # [1, 2, 3]ما يحدث بالضبط هو أن Python تقيّم الـ default arguments مرة واحدة فقط عند تعريف الدالة، وليس في كل استدعاء. الـ list الذي تراه كقيمة افتراضية يتم إنشاؤه مرة واحدة عند تحميل الـ module، ويتم الاحتفاظ به في ذاكرة الـ function object نفسه. هذا يعني أن كل استدعاء للدالة يستخدم نفس الـ list في الذاكرة، مما يؤدي إلى تراكم البيانات بشكل غير متوقع. المشكلة تصبح كارثية عندما تستخدم هذه الدوال في بيئات طويلة الأمد مثل السيرفرات أو الـ background workers، حيث يمكن أن يؤدي هذا السلوك إلى تسرب ذاكرة بطيء يصعب اكتشافه.
الحل؟ استخدم None كقيمة افتراضية وتحقق منها داخل الدالة. بهذه الطريقة، يتم إنشاء قائمة جديدة في كل استدعاء. هذا النمط شائع جداً لدرجة أنه أصبح من أفضل الممارسات في Python، لكن الكثير من المطورين لا يفهمون سبب وجوده أصلاً. في إحدى المرات، رأيت هذا الخطأ يتسبب في فشل نظام دفع إلكتروني لأنه كان يجمع معاملات متعددة في نفس القائمة، مما أدى إلى خصم مبالغ مكررة من حسابات العملاء.
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item(1)) # [1]
print(add_item(2)) # [2] - الآن يعمل كما هو متوقعالمطورون الذين ينتقلون إلى Python من لغات مثل Java أو C++ غالباً ما يحاولون حل مشاكل الأداء باستخدام الـ multithreading، فقط ليجدوا أن الأداء يزداد سوءاً بدلاً من التحسن. السبب؟ الـ Global Interpreter Lock أو GIL. هذا القفل يمنع أكثر من thread من تنفيذ كود Python في نفس الوقت، حتى على معالجات متعددة النوى. المشكلة ليست في وجود GIL نفسه، بل في سوء فهم متى يؤثر ومتى لا يؤثر على الأداء.
في أحد المشاريع التي عملت عليها، كان فريق التطوير يحاول تحسين معالجة الصور باستخدام مكتبة PIL. قاموا بإنشاء 8 threads لمعالجة مجموعة من الصور، متوقعين تحسناً في الأداء بنسبة 800%. لكن النتيجة كانت مفاجئة: الوقت الإجمالي زاد بدلاً من أن ينقص. عند فحص الـ CPU usage، وجدنا أن الـ threads كانت تنتظر بعضها البعض بسبب GIL، مما أدى إلى زيادة الـ context switching دون أي فائدة حقيقية. الحقيقة هي أن GIL يؤثر فقط على الـ CPU-bound tasks، بينما يمكن أن يكون الـ multithreading مفيداً للـ I/O-bound tasks مثل قراءة الملفات أو إرسال طلبات HTTP.
import threading
import time
def cpu_intensive_task(n):
count = 0
for _ in range(n):
count += 1
def io_intensive_task():
time.sleep(1)
# مثال سيء: استخدام threads لعمليات CPU-bound
start = time.time()
threads = [threading.Thread(target=cpu_intensive_task, args=(10**7,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"CPU-bound threads took: {time.time() - start:.2f} seconds") # قد يكون أبطأ من sequential
# مثال جيد: استخدام threads لعمليات I/O-bound
start = time.time()
threads = [threading.Thread(target=io_intensive_task) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"I/O-bound threads took: {time.time() - start:.2f} seconds") # تقريباً 1 ثانيةالحل الأمثل للـ CPU-bound tasks في Python هو استخدام الـ multiprocessing بدلاً من الـ multithreading. كل process لديه GIL الخاص به، مما يسمح بالاستفادة الحقيقية من المعالجات متعددة النوى. لكن حتى هذا الحل له مشاكله الخاصة، مثل زيادة استهلاك الذاكرة ووقت بدء الـ processes. في أحد المشاريع الكبيرة، اضطررنا لإعادة كتابة جزء كبير من الكود لاستخدام asyncio بدلاً من الـ multiprocessing لأن الـ overhead كان كبيراً جداً بالنسبة لحجم البيانات الذي كنا نتعامل معه.
الـ async/await في Python هو سلاح ذو حدين. من ناحية، يسمح لك بكتابة كود غير متزامن يبدو متزامناً، مما يجعل التعامل مع الـ I/O أسهل بكثير. من ناحية أخرى، أي استدعاء blocking داخل الـ event loop يمكن أن يتسبب في تجميد السيرفر بالكامل دون أي خطأ ظاهر. المشكلة تكمن في أن الكثير من المكتبات لا تزال تستخدم الـ synchronous I/O تحت الغطاء، حتى لو كانت واجهتها غير متزامنة.
في أحد المشاريع التي استخدمت فيها FastAPI، واجهنا مشكلة غريبة: السيرفر كان يتجمد تماماً عند معالجة عدد كبير من الطلبات، لكن الـ CPU usage كان منخفضاً جداً. بعد الكثير من التحقيق، اكتشفنا أن أحد الـ middleware كان يستخدم مكتبة خارجية تقوم باستدعاء blocking لـ file I/O داخل الـ event loop. هذا الاستدعاء كان يمنع الـ event loop من معالجة أي طلبات أخرى، مما أدى إلى تجميد السيرفر. المشكلة كانت صعبة الاكتشاف لأن الكود كان يبدو غير متزامناً تماماً من الخارج.
import asyncio
from fastapi import FastAPI
import time
app = FastAPI()
# مثال سيء: استدعاء blocking داخل event loop
@app.get("/bad")
async def bad_endpoint():
time.sleep(1) # هذا blocking call!
return {"message": "This blocks the event loop!"}
# مثال جيد: استخدام async I/O
@app.get("/good")
async def good_endpoint():
await asyncio.sleep(1) # هذا غير blocking
return {"message": "This doesn't block the event loop!"}
# لتشغيل: uvicorn main:app --workers 1الحل هو استخدام مكتبات غير متزامنة بالكامل مثل aiohttp بدلاً من requests، و aiofiles بدلاً من الـ built-in file I/O. لكن حتى هذا ليس كافياً في بعض الأحيان. في أحد المشاريع، اضطررنا لإعادة كتابة جزء من الكود باستخدام Rust لأن المكتبة التي كنا نحتاجها لم تكن متوفرة بنسخة غير متزامنة. القاعدة الذهبية هي: إذا كنت تستخدم async/await، يجب أن يكون كل شيء في الكود غير متزامن، من قاعدة البيانات إلى الـ file system وحتى الـ logging.
الـ generators في Python هي أداة قوية لتوفير الذاكرة عند التعامل مع البيانات الكبيرة. لكن استخدامها بشكل خاطئ يمكن أن يؤدي إلى تسرب ذاكرة بطيء يصعب اكتشافه. المشكلة تحدث عندما تحتفظ بـ generator في ذاكرة دون أن تستهلكه بالكامل، أو عندما تستخدم closures تحتفظ بمراجع لكائنات كبيرة دون أن تدري.
في أحد المشاريع التي عملت عليها، كان لدينا نظام معالجة بيانات يقرأ ملفات ضخمة باستخدام generators. الكود كان يعمل بشكل جيد في التطوير، لكن في الإنتاج، كانت الذاكرة تزداد ببطء حتى ينفجر السيرفر بعد بضعة أيام. بعد الكثير من التحقيق، اكتشفنا أن أحد الـ closures كان يحتفظ بمرجع لقائمة ضخمة من البيانات داخل الـ generator. المشكلة كانت في أن الـ generator لم يكن يُستهلك بالكامل في كل مرة، مما أدى إلى تراكم البيانات في الذاكرة دون أن يتم تحريرها.
# مثال سيء: تسرب ذاكرة بسبب closure
import sys
def bad_generator(data):
large_list = [i for i in range(10**6)] # قائمة ضخمة
def inner():
for item in data:
yield item, large_list # تحتفظ بـ large_list في الذاكرة
return inner()
gen = bad_generator(range(10))
print(f"Memory usage before: {sys.getsizeof(gen) / 1024:.2f} KB")
# استهلاك جزء من الـ generator
next(gen)
print(f"Memory usage after partial consumption: {sys.getsizeof(gen) / 1024:.2f} KB") # لا يزال مرتفعاً
# مثال جيد: تجنب الاحتفاظ بالمراجع الكبيرة
import weakref
def good_generator(data):
large_list = [i for i in range(10**6)]
large_list_ref = weakref.ref(large_list) # مرجع ضعيف
def inner():
for item in data:
yield item, large_list_ref() # لا يحتفظ بالمرجع
return inner()الحل هو استخدام أدوات مثل tracemalloc و memory_profiler لمراقبة استخدام الذاكرة في الكود الخاص بك. في المثال السابق، كان استخدام weakref يمكن أن يحل المشكلة، لكنه ليس حلاً عاماً. في بعض الحالات، قد تحتاج إلى إعادة تصميم الكود بالكامل لتجنب الاحتفاظ بالمراجع الكبيرة. في أحد المشاريع، اضطررنا لاستبدال الـ generators بالكامل بـ chunked processing باستخدام قواعد البيانات، مما أدى إلى تقليل استخدام الذاكرة بشكل كبير.
الـ context managers في Python هي طريقة رائعة لضمان أن الموارد مثل ملفات الشبكة أو قواعد البيانات يتم إغلاقها بشكل صحيح، حتى إذا حدث خطأ. لكن الكثير من المطورين يستخدمونها بشكل خاطئ، إما بتجاهلها تماماً أو باستخدامها بطريقة تؤدي إلى مشاكل في الإنتاج.
في أحد المشاريع، كان لدينا نظام معالجة ملفات يستخدم context managers بشكل صحيح في معظم الأماكن، لكن في جزء واحد من الكود، استخدم المطور with statement مع دالة تقوم برمي استثناء قبل إرجاع الـ context manager. هذا أدى إلى أن الـ __exit__ لم يتم استدعاؤه أبداً، مما تسبب في تسرب ملفات مفتوحة في النظام. المشكلة ظهرت فقط بعد تشغيل النظام لعدة أيام، عندما بدأ النظام في رفض فتح ملفات جديدة بسبب الوصول إلى الحد الأقصى لعدد الملفات المفتوحة.
# مثال سيء: context manager غير موثوق
class BadContextManager:
def __enter__(self):
raise Exception("Something went wrong!") # لا يصل إلى هنا أبداً
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print("Cleaning up...") # لا يتم استدعاؤه أبداً
try:
with BadContextManager():
print("Inside context")
except Exception as e:
print(f"Caught exception: {e}") # __exit__ لم يتم استدعاؤه
# مثال جيد: context manager موثوق
from contextlib import contextmanager
@contextmanager
def good_context_manager():
try:
print("Setting up...")
yield "resource"
finally:
print("Cleaning up...") # يتم استدعاؤه دائماً
try:
with good_context_manager() as resource:
print(f"Using {resource}")
raise Exception("Something went wrong!")
except Exception as e:
print(f"Caught exception: {e}") # الـ cleanup تم بالفعلالحل هو استخدام contextlib عند الحاجة، والتأكد من أن الـ __exit__ الخاص بك يتم استدعاؤه دائماً. لكن حتى هذا ليس كافياً في بعض الحالات. في أحد المشاريع، اضطررنا لكتابة custom context manager للتعامل مع موارد غير قياسية مثل اتصالات الـ WebSocket، حيث كان الـ cleanup يتطلب خطوات متعددة ومعقدة. القاعدة العامة هي: إذا كنت تتعامل مع مورد خارجي، استخدم context manager. وإذا كنت تكتب context manager خاص بك، اختبره جيداً تحت ظروف الخطأ المختلفة.
الـ __slots__ في Python هي ميزة تسمح بتقليل استخدام الذاكرة للكائنات عن طريق منع إنشاء الـ __dict__ لكل كائن. لكن استخدامها بشكل خاطئ يمكن أن يؤدي إلى نتائج عكسية، خاصة عند التعامل مع الوراثة أو عندما تحتاج إلى إضافة سمات ديناميكياً.
في أحد المشاريع، قرر فريق التطوير استخدام __slots__ لتقليل استخدام الذاكرة في نظام يتعامل مع ملايين الكائنات. لكنهم لم يفهموا تماماً كيف تعمل مع الوراثة، مما أدى إلى زيادة استخدام الذاكرة بدلاً من تقليله. المشكلة كانت في أن كل فئة في سلسلة الوراثة كانت تحدد __slots__ الخاص بها، مما أدى إلى إنشاء مساحة ذاكرة لكل فئة بدلاً من تقليلها. بالإضافة إلى ذلك، أدى استخدام __slots__ إلى كسر بعض المكتبات الخارجية التي تعتمد على وجود __dict__.
# مثال سيء: سوء استخدام __slots__ مع الوراثة
class Base:
__slots__ = ['x']
class Derived(Base):
__slots__ = ['y'] # الآن كل كائن لديه slots من Base و Derived
obj = Derived()
obj.x = 1
obj.y = 2
# obj.z = 3 # AttributeError: 'Derived' object has no attribute 'z'
# مثال جيد: استخدام __slots__ بشكل صحيح
class GoodBase:
__slots__ = ['x']
class GoodDerived(GoodBase):
__slots__ = [] # لا تضيف slots جديدة
obj = GoodDerived()
obj.x = 1
# obj.y = 2 # AttributeError
# مثال أفضل: تجنب __slots__ إذا كنت بحاجة إلى مرونة
class Flexible:
pass # يسمح بإضافة سمات ديناميكياً
obj = Flexible()
obj.x = 1
obj.y = 2
obj.z = 3 # يعمل بدون مشاكلالحل هو استخدام __slots__ فقط عندما تكون متأكداً من أنك لن تحتاج إلى إضافة سمات ديناميكياً، وتجنب استخدامها مع الوراثة إلا إذا كنت تفهم تماماً كيف تعمل. في معظم الحالات، يكون استخدام __slots__ مبالغاً فيه، خاصة مع وجود مكتبات مثل pydantic و dataclasses التي توفر بدائل أفضل. في أحد المشاريع، استبدلنا __slots__ بـ dataclasses مع frozen=True، مما أعطى لنا نفس فوائد تقليل الذاكرة مع الحفاظ على المرونة في إضافة السمات عند الحاجة.
بعد عشر سنوات من كتابة Python في بيئات الإنتاج، تعلمت درساً واحداً مهماً: اللغة بسيطة فقط على السطح. تحت الغطاء، تكمن تفاصيل معقدة تجعل حتى أكثر الكودات بريئة المظهر قادرة على تدمير نظام كامل. الحل ليس في تجنب هذه الميزات، بل في فهمها بعمق واختبارها تحت ظروف حقيقية قبل نشرها في الإنتاج.
قبل أن تكتب سطراً واحداً من كود Python، اسأل نفسك هذه الأسئلة: هل أفهم تماماً كيف تعمل هذه الميزة تحت الغطاء؟ هل اختبرت هذا الكود تحت ظروف الخطأ المختلفة؟ هل لدي آلية لمراقبة سلوك هذا الكود في الإنتاج؟ إذا كانت الإجابة على أي من هذه الأسئلة هي لا، فأنت على وشك ارتكاب خطأ قد يكلفك ساعات من وقت الإنتاج. لا تعتمد على أن الكود يعمل في التطوير - اختبره تحت حمل حقيقي، مع بيانات حقيقية، وفي بيئة قريبة من الإنتاج قدر الإمكان. هذه هي الطريقة الوحيدة لتجنب الأخطاء التي يخجل المطورون المحترفون من الاعتراف بها.