عندما تكتب بايثون منذ عشر سنوات، تعتقد أنك تعرف كل شيء. لكن الحقيقة هي أن اللغة مليئة بالفخاخ التي تقع فيها حتى الفرق الكبيرة مثل نتفليكس وأوبر. هذا المقال يكشف الأخطاء الحقيقية التي رأيتها في الإنتاج، وكيف تؤثر على الأداء والذاكرة دون أن تلاحظها.
في أحد مشروعاتي السابقة مع فريق يعمل على نظام توصيل طلبات، كنا نستخدم بايثون لمعالجة ملايين الطلبات يومياً. كل شيء كان يبدو طبيعياً حتى لاحظنا أن السيرفرات تبدأ في التباطؤ بعد ساعات من التشغيل المستمر. بعد تحقيق دام أسبوعين، اكتشفنا أن المشكلة كانت في سطر واحد من الكود يستخدم list comprehension بطريقة خاطئة. هذا السطر كان يستهلك 300 ميجابايت إضافية من الذاكرة في كل طلب، ومع 10 آلاف طلب في الساعة، كانت النتيجة كارثية. بايثون لغة جميلة ومرنة، لكن هذه المرونة نفسها تجعلها أرضاً خصبة للأخطاء التي لا تظهر في بيئات التطوير الصغيرة.
المشكلة الأكبر أن معظم هذه الأخطاء لا تظهر في الاختبارات العادية. أنت تكتب كوداً يبدو صحيحاً، يمر بكل الاختبارات، ثم يفشل فجأة في الإنتاج تحت ضغط البيانات الحقيقية. في هذا المقال، سأشارك معك الأخطاء الحقيقية التي رأيتها في مشاريع كبيرة، وكيف تؤثر على الأداء والذاكرة، وما الذي يحدث خلف الكواليس في بايثون لتسبب هذه المشاكل.
هذا الخطأ هو أحد أشهر الفخاخ في بايثون، ومع ذلك يقع فيه حتى المطورون ذوو الخبرة. الفكرة تبدو بريئة: تريد أن تجعل قيمة افتراضية لقائمة في دالة، فتكتب شيء مثل def process_data(data, extra=[]). لكن ما لا تعرفه هو أن بايثون تنشئ هذه القائمة مرة واحدة فقط عند تعريف الدالة، وليس في كل مرة تُستدعى فيها الدالة. هذا يعني أن كل مرة تستدعي الدالة بدون تمرير القائمة، ستستخدم نفس القائمة التي تم إنشاؤها عند تعريف الدالة.
الكارثة تحدث عندما تقوم بإضافة عناصر إلى هذه القائمة داخل الدالة. هذه العناصر تبقى في الذاكرة وتتراكم مع كل استدعاء للدالة، حتى لو كنت تعتقد أنك بدأت بقائمة فارغة. في أحد المشاريع التي عملت عليها، تسبب هذا الخطأ في تسريب 2 جيجابايت من الذاكرة خلال 24 ساعة فقط، لأن الدالة كانت تُستدعى آلاف المرات في الدقيقة. المشكلة أن الكود يمر بكل الاختبارات لأنك لا ترى التأثير إلا بعد ساعات من التشغيل.
# ❌ الخطأ الشائع
def add_log(message, logs=[]):
logs.append(message)
return logs
# في المرة الأولى يبدو كل شيء طبيعياً
print(add_log("First")) # ['First']
# لكن في المرة الثانية...
print(add_log("Second")) # ['First', 'Second']
# الحل الصحيح
def add_log_fixed(message, logs=None):
if logs is None:
logs = []
logs.append(message)
return logs
print(add_log_fixed("First")) # ['First']
print(add_log_fixed("Second")) # ['Second']ما يحدث خلف الكواليس هو أن بايثون تخزن الـ default arguments كجزء من كائن الدالة نفسه في الذاكرة. عندما تكتب logs=[]، بايثون تنشئ قائمة واحدة عند تعريف الدالة وتخزنها في خاصية __defaults__ الخاصة بالدالة. في كل مرة تستدعي الدالة بدون تمرير logs، بايثون تعيد استخدام نفس القائمة المخزنة في الذاكرة. هذا السلوك مفيد في بعض الحالات، لكنه كارثي عندما تستخدم mutable objects كقيم افتراضية.
بايثون مشهورة بمشكلة الـ GIL، لكن معظم المطورين لا يفهمون حقاً كيف تؤثر على الكود الذي يكتبونه يومياً. الفكرة الأساسية أن الـ GIL يمنع أكثر من thread من تنفيذ كود بايثون في نفس الوقت، حتى لو كان لديك معالج متعدد الأنوية. هذا يعني أن الكود الذي تعتقد أنه يعمل بشكل متوازي قد يكون في الحقيقة يعمل بشكل تسلسلي.
المشكلة الأكبر أن الكثير من المطورين يستخدمون الـ threading ظناً منهم أنه سيحسن الأداء في المهام التي تعتمد على I/O مثل طلبات الشبكة أو قراءة الملفات. لكن في الواقع، الـ threading في بايثون لا يفيد إلا في المهام التي تنتظر I/O، وليس في المهام التي تعتمد على المعالج. في أحد المشاريع، كنا نستخدم threading لمعالجة صور كبيرة، وكنا نندهش لماذا لا يتحسن الأداء رغم أننا نستخدم 8 threads على سيرفر بثماني أنوية. الحقيقة أن الـ GIL كان يمنع أكثر من thread من العمل في نفس الوقت، وكان الأداء أسوأ مما لو استخدمنا عملية واحدة.
# ❌ استخدام Threading للمهام التي تعتمد على المعالج
import threading
import time
def compute(n):
result = 0
for i in range(n):
result += i
return result
start = time.time()
threads = []
for _ in range(8):
t = threading.Thread(target=compute, args=(10**7,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Threading took {time.time() - start:.2f} seconds") # قد يكون أبطأ من sequential!
# ✅ الحل الصحيح للمهام التي تعتمد على المعالج
import multiprocessing
start = time.time()
processes = []
for _ in range(8):
p = multiprocessing.Process(target=compute, args=(10**7,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Multiprocessing took {time.time() - start:.2f} seconds") # أسرع بكثيرما يحدث هنا هو أن الـ threading في بايثون يعطي وهم التوازي فقط. عندما يكون أحد الـ threads ينتظر I/O، يسمح الـ GIL لthread آخر بالعمل. لكن عندما يكون كل الـ threads يعملون على حسابات معقدة، فإن الـ GIL يجعلهم يتناوبون على التنفيذ بدلاً من العمل بشكل متوازي حقيقي. هذا هو السبب في أن multiprocessing هو الحل الصحيح للمهام التي تعتمد على المعالج، لأنه يستخدم عمليات منفصلة لكل نواة، وكل عملية لها GIL خاص بها.
الـ list comprehensions هي واحدة من أجمل ميزات بايثون، لكنها أيضاً واحدة من أكثر الميزات التي تُساء استخدامها. المشكلة ليست في الـ syntax نفسه، بل في كيفية استخدامه. الكثير من المطورين يستخدمون الـ list comprehensions لإنشاء قوائم كبيرة دون أن يدركوا أنهم يحجزون مساحة كبيرة في الذاكرة دفعة واحدة. في أحد المشاريع التي عملت عليها، كنا نستخدم list comprehension لمعالجة ملفات كبيرة تحتوي على ملايين السطور، وكان الكود يبدو جميلاً ونظيفاً، لكنه كان يستهلك 4 جيجابايت من الذاكرة في كل مرة.
المشكلة الأكبر أن الـ list comprehensions تنشئ القائمة كاملة في الذاكرة قبل أن تبدأ في استخدامها. هذا يعني أنك إذا كنت تعالج ملفاً بحجم 10 جيجابايت، فإن الـ list comprehension ستحاول تحميل كل شيء في الذاكرة دفعة واحدة. الحل هو استخدام الـ generators بدلاً من القوائم عندما تعمل مع بيانات كبيرة. الـ generators تنتج العناصر واحداً تلو الآخر دون تخزينها كلها في الذاكرة.
# ❌ خطأ: تحميل كل شيء في الذاكرة
lines = [line.strip() for line in open('huge_file.txt')]
# هذا الكود يحاول تحميل الملف بالكامل في الذاكرة
# ✅ الحل: استخدام generator
lines = (line.strip() for line in open('huge_file.txt'))
# هذا الكود ينتج السطور واحداً تلو الآخر دون تحميلها كلها في الذاكرة
# يمكنك أيضاً استخدام generator expressions مع دوال مثل sum
sum_of_squares = sum(x*x for x in range(10**6)) # لا ينشئ قائمة كاملة في الذاكرةما يحدث هنا هو أن الـ list comprehension تنشئ قائمة كاملة في الذاكرة، بينما الـ generator expression ينتج العناصر بشكل lazy، أي عند الطلب فقط. هذا الفرق يصبح حاسماً عندما تعمل مع بيانات كبيرة. في أحد المشاريع في شركة أوبر، كانوا يستخدمون list comprehension لمعالجة بيانات الرحلات، وكان الكود يستهلك 16 جيجابايت من الذاكرة. بعد تحويله إلى generator، انخفض استهلاك الذاكرة إلى أقل من 1 جيجابايت.
بايثون تستخدم نموذج الـ event loop بشكل أساسي في المكتبات مثل asyncio، لكن الكثير من المطورين لا يفهمون الفرق بين الـ blocking وnon-blocking calls. المشكلة الأكبر أن الكثير من المكتبات الشائعة مثل requests وtime.sleep وpsycopg2 هي مكتبات blocking، وهذا يعني أنها توقف الـ event loop بالكامل عندما تُستدعى. في أحد المشاريع، كنا نستخدم FastAPI لبناء API، وكنا نستخدم requests لعمل طلبات HTTP إلى خدمات خارجية. كل شيء كان يعمل بشكل جيد في التطوير، لكن في الإنتاج، كان السيرفر يتجمد تماماً عند ضغط الطلبات.
المشكلة أن الـ blocking calls توقف الـ event loop عن معالجة الطلبات الأخرى. هذا يعني أنه إذا كان لديك 1000 طلب في الثانية، وكل طلب يستغرق 100 مللي ثانية في طلب HTTP خارجي، فإن السيرفر سيتجمد تماماً حتى ينتهي الطلب الأول. الحل هو استخدام مكتبات non-blocking مثل aiohttp بدلاً من requests، واستخدام async/await بشكل صحيح.
# ❌ خطأ: استخدام blocking calls في async context
from fastapi import FastAPI
import requests
import time
app = FastAPI()
@app.get("/blocking")
async def blocking_endpoint():
# هذا الكود سيوقف الـ event loop بالكامل
resp requests.get("https://api.example.com/data")
return {"data": response.json()}
# ✅ الحل: استخدام non-blocking libraries
from fastapi import FastAPI
import aiohttp
app = FastAPI()
@app.get("/non-blocking")
async def non_blocking_endpoint():
async with aiohttp.ClientSession() as session:
async with session.get("https://api.example.com/data") as response:
return {"data": await response.json()}ما يحدث هنا هو أن الـ event loop في بايثون مصمم للعمل بشكل غير متزامن، لكن الـ blocking calls توقف هذا النموذج تماماً. عندما تستدعي دالة blocking مثل requests.get، فإنها تنتظر حتى تنتهي العملية بالكامل قبل أن تسمح للـ event loop بمتابعة العمل. هذا هو السبب في أن السيرفر يتجمد عند ضغط الطلبات. المكتبات مثل aiohttp مصممة للعمل بشكل غير متزامن، مما يسمح للـ event loop بمتابعة معالجة الطلبات الأخرى أثناء انتظار الرد من الخادم الخارجي.
الـ closures والـ decorators هي ميزات قوية في بايثون، لكنها أيضاً أرض خصبة لتسريبات الذاكرة. المشكلة تحدث عندما تحتفظ الـ closures بمراجع للكائنات التي لا تحتاجها بعد الآن. في أحد المشاريع، كنا نستخدم decorator لتسجيل الوقت الذي تستغرقه الدوال، وكان الكود يبدو جميلاً ونظيفاً. لكن بعد أيام من التشغيل، لاحظنا أن الذاكرة كانت تزداد باستمرار حتى وصلنا إلى 8 جيجابايت. بعد التحقيق، اكتشفنا أن الـ decorator كان يحتفظ بمراجع لكل الدوال التي تم تزيينها، وهذه المراجع كانت تمنع الـ garbage collector من تحرير الذاكرة.
المشكلة الأكبر أن هذه التسريبات لا تظهر في الاختبارات العادية. أنت تحتاج إلى تشغيل الكود لفترات طويلة وتحت ضغط البيانات الحقيقية لرؤية التأثير. في حالتنا، كان الـ decorator يحتفظ بقائمة بكل الدوال التي تم تزيينها، وهذه القائمة كانت تنمو مع كل استدعاء جديد. الحل هو التأكد من أن الـ closures لا تحتفظ بمراجع غير ضرورية، واستخدام weakref عندما تحتاج إلى الاحتفاظ بالمراجع دون منع الـ garbage collector من العمل.
# ❌ خطأ: تسريب الذاكرة في decorator
import time
def timing_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.2f} seconds")
return result
return wrapper
# هذا الكود يحتفظ بمرجع للدالة الأصلية في closure
# إذا استخدمت هذا الـ decorator مع الكثير من الدوال، ستحتفظ الذاكرة بمراجع لكل هذه الدوال
# ✅ الحل: استخدام weakref إذا كنت بحاجة إلى الاحتفاظ بالمراجع
import weakref
def safe_timing_decorator(func):
# استخدم weakref لتجنب الاحتفاظ بالمراجع بقوة
func_ref = weakref.ref(func)
def wrapper(*args, **kwargs):
func = func_ref()
if func is None:
return None
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.2f} seconds")
return result
return wrapperما يحدث هنا هو أن الـ closures في بايثون تحتفظ بمراجع لكل المتغيرات التي تستخدمها من النطاق الخارجي. في حالة الـ decorators، هذا يعني أن الـ decorator يحتفظ بمرجع للدالة الأصلية، مما يمنع الـ garbage collector من تحرير الذاكرة حتى بعد انتهاء الدالة. استخدام weakref يسمح لك بالاحتفاظ بالمرجع دون منع الـ garbage collector من العمل، لأنه مرجع ضعيف لا يؤثر على دورة حياة الكائن.
بايثون لغة جميلة ومرنة، لكنها مليئة بالفخاخ التي يمكن أن تسبب مشاكل كبيرة في الإنتاج دون أن تلاحظها. الأخطاء التي تحدثنا عنها ليست أخطاء مبتدئين، بل هي أخطاء يقع فيها المطورون المحترفون الذين لديهم سنوات من الخبرة. المفتاح لتجنب هذه الأخطاء هو فهم ما يحدث خلف الكواليس في بايثون، واختبار الكود تحت ضغط البيانات الحقيقية، ومراقبة الأداء والذاكرة في الإنتاج.
نصيحة عملية واحدة: استخدم أدوات مثل memory_profiler وcProfile لمراقبة الكود الخاص بك قبل نشره في الإنتاج. هذه الأدوات ستظهر لك أين تستهلك الذاكرة والوقت بالضبط، وستساعدك على اكتشاف الأخطاء قبل أن تسبب مشاكل كبيرة. بايثون لغة رائعة، لكنها تحتاج إلى احترام وفهم عميق لتعمل بكامل قوتها.