عندما يتحول سكربت بايثون من 'يعمل' إلى 'يعلق السيرفر'، تبدأ رحلة البحث عن الأداء المفقود. اكتشف التقنيات الحقيقية التي تقيس وتحسن أداء الكود، بعيداً عن النصائح العامة التي لا تغير شيئاً.
في أحد المشاريع التي عملت عليها مع فريق في شركة ناشئة، كان لدينا سكربت بايثون لمعالجة بيانات العملاء. الكود كان 'يعمل'، لكن عندما وصلنا إلى ١٠ آلاف سجل، تحول التنفيذ من ثوانٍ إلى ٤٥ دقيقة. المشكلة؟ لم نكن نقيس الأداء بشكل صحيح. معظم المطورين يقيسون الوقت الكلي للتنفيذ، لكن هذا يخفي التفاصيل الحقيقية: أين بالضبط تضيع الثواني؟ هل هي في الـ I/O، الـ CPU، أم في الخوارزميات نفسها؟ في هذا المقال، سنفكك الكود كما فعلنا في ذلك المشروع، ونكشف عن التقنيات التي قللت وقت التنفيذ إلى أقل من دقيقة واحدة.
بايثون لغة جميلة وسهلة، لكنها ليست الأسرع. عندما يتعلق الأمر بالأداء، كل ميكروثانية مهمة. المشكلة الأكبر هي أن معظم المطورين يعتمدون على أو نصائح عامة مثل 'استخدم list بدلاً من tuple' دون قياس حقيقي. الحقيقة هي: بدون قياس، أنت تخمن فقط. وسنبدأ من هنا.
قبل أن تبدأ بتحسين أي شيء، يجب أن تعرف أين المشكلة بالضبط. معظم المطورين يستخدمون `time.time()` لقياس الوقت الكلي للتنفيذ، لكن هذا يشبه قياس وزن الجسم بالكامل دون معرفة نسبة الدهون والعضلات. الأدوات الصحيحة تعطيك تفاصيل دقيقة عن كل جزء من الكود. مثلاً، مكتبة `cProfile` تعطيك تقريراً كاملاً عن عدد مرات استدعاء كل دالة ووقت التنفيذ لكل منها. لكن المشكلة مع `cProfile` هي أنها تضيف overhead كبير، مما قد يشوه النتائج في الكود السريع جداً.
بديل أفضل هو `timeit`، الذي يقيس وقت تنفيذ جزء محدد من الكود بدقة عالية. لكن حتى `timeit` له حدوده: لا يعمل جيداً مع الكود الذي يعتمد على الـ I/O أو الـ Network. هنا يأتي دور `perf` على لينكس، الذي يقيس الأداء على مستوى النظام، بما في ذلك الـ CPU Cache Misses و الـ Branch Mispredictions. في أحد المشاريع، استخدمنا `perf` لاكتشاف أن ٣٠٪ من وقت التنفيذ كان يضيع في الـ Garbage Collection بسبب إنشاء كائنات كثيرة جداً في loop صغير.
# قياس دقيق باستخدام timeit
import timeit
def slow_function():
return [x * x for x in range(10000)]
def fast_function():
return (x * x for x in range(10000))
# قياس كل دالة 1000 مرة
slow_time = timeit.timeit(slow_function, number=1000)
fast_time = timeit.timeit(lambda: list(fast_function()), number=1000)
print(f"Slow: {slow_time:.4f} seconds")
print(f"Fast: {fast_time:.4f} seconds")
print(f"Improvement: {slow_time / fast_time:.2f}x")في المثال السابق، الفرق بين الـ list comprehension والـ generator واضح. لكن بدون قياس، قد تظن أن الفرق بسيط. الحقيقة هي أن الـ generator لا يحسب القيم إلا عند الطلب، مما يوفر الذاكرة والوقت في الـ Loops الكبيرة. لكن تذكر: الـ generator لا يفيد إذا كنت ستستخدم جميع القيم على أي حال، لأنك ستضطر لتحويلها إلى list في النهاية، مما يضيف overhead.
في بايثون، الـ Memory Leaks ليست مثل اللغات الأخرى. لا يوجد تسريب للذاكرة بالمعنى التقليدي، لكن هناك شيء أسوأ: الكائنات التي لا يتم تحريرها لأنها لا تزال مرتبطة بمراجع. مثلاً، إذا أضفت كائنات إلى list كبير ثم نسيت إفراغه، ستستمر الذاكرة في الزيادة حتى ينهار البرنامج. في أحد المشاريع، كان لدينا سيرفر Flask يتعامل مع ملايين الطلبات يومياً. بعد ساعات من التشغيل، كان الـ Memory Usage يصل إلى ١٠ جيجابايت. اكتشفنا أن الـ Session Objects كانت تُخزن في dictionary ولم تُحذف أبداً بعد انتهاء الـ Request.
أداة `tracemalloc` تساعدك في تتبع الـ Memory Allocations. يمكنك استخدامها لمعرفة بالضبط أين تُستهلك الذاكرة. مثلاً، إذا اكتشفت أن دالة معينة تستهلك ٥٠٪ من الذاكرة، يمكنك التركيز على تحسينها. لكن تذكر: ليس كل زيادة في الذاكرة هي تسريب. أحياناً، بايثون تحتفظ بالذاكرة لإعادة استخدامها لاحقاً، وهذا طبيعي. المشكلة الحقيقية هي عندما تستمر الذاكرة في الزيادة دون توقف.
import tracemalloc
def create_objects():
data = []
for i in range(100000):
data.append({'id': i, 'name': f'item_{i}'})
return data
# بدء تتبع الذاكرة
tracemalloc.start()
# أخذ لقطة قبل التنفيذ
snapshot1 = tracemalloc.take_snapshot()
# تنفيذ الكود
result = create_objects()
# أخذ لقطة بعد التنفيذ
snapshot2 = tracemalloc.take_snapshot()
# مقارنة اللقطات
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("[ Top 10 differences ]")
for stat in top_stats[:10]:
print(stat)في الكود السابق، `tracemalloc` يظهر لك بالضبط أين تُستهلك الذاكرة. إذا رأيت أن دالة معينة تستهلك الكثير، يمكنك البدء في تحسينها. مثلاً، بدلاً من تخزين كل البيانات في list، يمكنك استخدام generator أو كتابة البيانات إلى ملف إذا كانت كبيرة جداً. لكن تذكر: الـ Memory Leaks ليست دائماً واضحة. أحياناً، المشكلة تكون في مكتبة خارجية تحتفظ بمراجع للكائنات دون علمك.
بايثون مشهور بـ GIL (Global Interpreter Lock)، الذي يمنع أكثر من thread من تنفيذ كود بايثون في نفس الوقت. هذا يعني أن الـ Multithreading في بايثون لا يفيد في المهام التي تعتمد على الـ CPU. مثلاً، إذا كان لديك دالة تحسب الأعداد الأولية، فإن إضافة المزيد من threads لن يجعلها أسرع. في الواقع، قد يجعلها أبطأ بسبب الـ Overhead لإدارة الـ Threads.
لكن هناك استثناء: الـ I/O Bound Tasks. إذا كان الكود ينتظر الـ Network أو الـ Disk، فإن الـ GIL يُحرر أثناء الانتظار، مما يسمح لthreads أخرى بالعمل. مثلاً، إذا كان لديك سيرفر HTTP يتعامل مع طلبات متعددة، فإن استخدام `threading` يمكن أن يحسن الأداء بشكل كبير. لكن حتى هنا، هناك حدود. إذا كان لديك آلاف الـ Requests في الثانية، فإن الـ Thread Pool يصبح ثقيلاً جداً، وهنا يأتي دور `asyncio`.
# مثال على استخدام threading للـ I/O Bound Tasks
import threading
import time
import requests
def fetch_url(url):
start = time.time()
resp requests.get(url)
print(f"Fetched {url} in {time.time() - start:.2f} seconds")
urls = [
'https://www.google.com',
'https://www.github.com',
'https://www.python.org'
]
# التنفيذ المتسلسل
print("Sequential Execution:")
for url in urls:
fetch_url(url)
# التنفيذ المتوازي باستخدام threads
print("\nParallel Execution with Threads:")
threads = []
for url in urls:
thread = threading.Thread(target=fetch_url, args=(url,))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()في المثال السابق، الـ Threads تجعل الـ Requests تُنفذ في نفس الوقت، مما يقلل الوقت الكلي للتنفيذ. لكن إذا كانت المهمة تعتمد على الـ CPU، فإن الـ Threads لن تفيد. مثلاً، إذا استبدلت `requests.get` بدالة تحسب الأعداد الأولية، فلن ترى أي تحسن. الحل في هذه الحالة هو استخدام `multiprocessing`، الذي يتجاوز الـ GIL عن طريق تشغيل كل process في بيئة بايثون منفصلة.
الـ Caching هو أحد أقوى الأدوات لتحسين الأداء، لكنه غالباً ما يُستخدم بشكل خاطئ. الفكرة بسيطة: إذا كنت تحسب نفس الشيء مراراً وتكراراً، فلماذا لا تخزنه في مكان ما وتسترجعه عند الحاجة؟ مثلاً، في تطبيق ويب، إذا كان لديك صفحة تعرض بيانات من قاعدة البيانات، يمكنك تخزين النتيجة في Redis بدلاً من استدعاء قاعدة البيانات في كل مرة. لكن المشكلة هي أن الـ Caching يضيف تعقيداً. يجب أن تفكر في مدة الصلاحية، وكيفية تحديث البيانات، وماذا يحدث إذا تغيرت البيانات في قاعدة البيانات بينما الـ Cache لا يزال صالحاً.
في بايثون، مكتبة `functools.lru_cache` توفر طريقة سهلة لتخزين نتائج الدوال. مثلاً، إذا كان لديك دالة تحسب الـ Fibonacci، يمكنك تخزين النتائج لتجنب الحساب المتكرر. لكن تذكر: `lru_cache` يستخدم الذاكرة، لذلك إذا كانت الدالة تستقبل مدخلات كثيرة أو كبيرة، قد تستهلك الذاكرة بسرعة. أيضاً، `lru_cache` لا يعمل مع الدوال التي تعتمد على الـ Mutable Objects مثل lists أو dictionaries، لأنها لا يمكن أن تكون keys في الـ dictionary.
from functools import lru_cache
import time
# بدون caching
@lru_cache(maxsize=None)
def fibonacci_no_cache(n):
if n <= 1:
return n
return fibonacci_no_cache(n - 1) + fibonacci_no_cache(n - 2)
# مع caching
@lru_cache(maxsize=None)
def fibonacci_with_cache(n):
if n <= 1:
return n
return fibonacci_with_cache(n - 1) + fibonacci_with_cache(n - 2)
# قياس الأداء
start = time.time()
print(fibonacci_no_cache(35))
print(f"Without cache: {time.time() - start:.4f} seconds")
start = time.time()
print(fibonacci_with_cache(35))
print(f"With cache: {time.time() - start:.4f} seconds")في المثال السابق، الفرق واضح جداً. بدون caching، الدالة تستغرق ثوانٍ لحساب `fibonacci(35)`، بينما مع caching، النتيجة تأتي فوراً. لكن تذكر: الـ Caching ليس دائماً الحل الأمثل. إذا كانت الدالة تُستدعى بمدخلات مختلفة في كل مرة، فإن الـ Caching لن يفيد. أيضاً، إذا كانت الدالة تعتمد على بيانات خارجية تتغير باستمرار، فإن الـ Caching قد يسبب مشاكل في اتساق البيانات.
بايثون هي لغة مفسرة، وهذا يعني أنها أبطأ من اللغات المترجمة مثل C أو Rust. لكن هناك طريقة لجعل بايثون أسرع بكثير: الـ JIT Compilation. مكتبة `numba` هي واحدة من أفضل الأدوات لهذا الغرض. تعمل عن طريق ترجمة كود بايثون إلى كود آلة أثناء التشغيل، مما يجعله أسرع بعشرات أو حتى مئات المرات. مثلاً، في أحد المشاريع، كان لدينا دالة تحسب الـ Mandelbrot Set. باستخدام بايثون العادي، استغرق التنفيذ ١٥ ثانية. باستخدام `numba`، انخفض الوقت إلى ٠.٣ ثانية فقط.
لكن `numba` ليست سحرية. تعمل بشكل أفضل مع الكود العددي الذي يستخدم مكتبات مثل `numpy`. إذا كان الكود يعتمد على الـ Python Objects أو الـ Dynamic Typing بشكل كبير، فإن `numba` قد لا تعمل بشكل جيد. أيضاً، هناك overhead للـ JIT Compilation نفسه، لذلك لا تستخدمها مع الدوال التي تُستدعى مرة واحدة فقط. استخدمها مع الدوال التي تُستدعى مراراً وتكراراً في loops كبيرة.
from numba import jit
import numpy as np
import time
# بدون numba
def mandelbrot_no_jit(width, height, max_iter):
result = np.zeros((height, width))
for y in range(height):
for x in range(width):
c = complex(-2.0 + (x / width) * 3.0, -1.5 + (y / height) * 3.0)
z = 0.0j
for i in range(max_iter):
z = z * z + c
if z.real * z.real + z.imag * z.imag >= 4:
result[y, x] = i
break
return result
# مع numba
@jit(nopython=True)
def mandelbrot_with_jit(width, height, max_iter):
result = np.zeros((height, width))
for y in range(height):
for x in range(width):
c = complex(-2.0 + (x / width) * 3.0, -1.5 + (y / height) * 3.0)
z = 0.0j
for i in range(max_iter):
z = z * z + c
if z.real * z.real + z.imag * z.imag >= 4:
result[y, x] = i
break
return result
# قياس الأداء
width, height, max_iter = 800, 600, 100
start = time.time()
mandelbrot_no_jit(width, height, max_iter)
print(f"Without numba: {time.time() - start:.4f} seconds")
start = time.time()
mandelbrot_with_jit(width, height, max_iter)
print(f"With numba: {time.time() - start:.4f} seconds")في المثال السابق، الفرق مذهل. `numba` تجعل الكود أسرع بعشرات المرات. لكن تذكر: ليس كل كود بايثون يمكن ترجمته باستخدام `numba`. إذا استخدمت ميزات بايثون المتقدمة مثل الـ Generators أو الـ Decorators المعقدة، فقد لا تعمل `numba`. أيضاً، الـ First Run سيكون بطيئاً لأن `numba` تحتاج إلى ترجمة الكود. لكن بعد ذلك، الكود سيعمل بسرعة كبيرة.
بعد سنوات من تحسين أداء كود بايثون، هناك بعض القواعد التي لا أبدأ أي مشروع بدونها. أولاً: قس دائماً قبل أن تحسن. بدون قياس، أنت تخمن فقط، والحدس غالباً ما يكون خاطئاً. ثانياً: لا تفرط في التحسين. إذا كان الكود يعمل بشكل جيد ولا يتوقع أن ينمو كثيراً، فلا تضيع وقتك في تحسينه. ثالثاً: استخدم الأدوات المناسبة لكل مهمة. إذا كان الكود يعتمد على الـ I/O، استخدم `asyncio` أو `threading`. إذا كان يعتمد على الـ CPU، استخدم `multiprocessing` أو `numba`. رابعاً: راقب الذاكرة. الـ Memory Leaks قد لا تظهر إلا بعد ساعات أو أيام من التشغيل، لذلك استخدم أدوات مثل `tracemalloc` لمراقبتها منذ البداية. وأخيراً: لا تخف من تجربة أشياء جديدة. بايثون تتطور باستمرار، وهناك دائماً أدوات وتقنيات جديدة يمكن أن تجعل الكود أسرع وأكثر كفاءة.
في النهاية، تحسين الأداء ليس مجرد جعل الكود أسرع. إنه عن فهم كيف يعمل الكود خلف الكواليس، وكيف يتفاعل مع النظام. عندما تفهم ذلك، ستتمكن من كتابة كود ليس فقط سريعاً، بل أيضاً نظيفاً وقابلاً للصيانة. وهذا هو الفرق بين المطور الجيد والمطور الممتاز.