اكتشف تقنيات تحسين أداء كود Python التي يتجاهلها الكثيرون، مدعومة بقياسات حقيقية وأكواد عملية تكشف كيف يمكن لتعديلات بسيطة أن تسرع التطبيقات بمئات المرات.
في أحد المشاريع الكبيرة الذي عملت عليه، كان لدينا سيرفر لمعالجة البيانات يستغرق ٤٥ دقيقة لإتمام مهمة واحدة. بعد تطبيق ثلاث تقنيات بسيطة في كود Python، انخفض الوقت إلى ٣ دقائق فقط. ليس سحراً، بل فهم عميق لكيفية عمل بايثون خلف الكواليس. المشكلة ليست في اللغة نفسها، بل في كيفية استخدامها. في هذا المقال، سأريك تقنيات تحسين الأداء التي تغفل عنها الأغلبية، مدعومة بقياسات حقيقية وأكواد عملية ستغير طريقة كتابتك للكود للأبد.
بايثون لغة بطيئة نسبياً مقارنة بلغات مثل C++ أو Rust، لكن هذا لا يعني أن تطبيقاتها يجب أن تكون بطيئة. الحقيقة هي أن معظم مشكلات الأداء في بايثون ليست بسبب اللغة نفسها، بل بسبب الممارسات السيئة في كتابة الكود. سأركز هنا على التقنيات التي لها تأثير مباشر وقابل للقياس، وليس على النصائح العامة مثل "استخدم خوارزميات أفضل" أو "قلل من استخدام الحلقات" بدون تفاصيل ملموسة.
الـ GIL هو واحد من أكثر المفاهيم التي يساء فهمها في بايثون. ببساطة، هو قفل يمنع خيوط التنفيذ (Threads) من العمل بشكل متوازٍ حقيقي في بايثون، حتى على معالجات متعددة النوى. هذا يعني أن الكود الذي يعتمد على الـ Threading في بايثون لن يستفيد من المعالجات المتعددة بشكل فعال، خاصة في المهام التي تعتمد على الـ CPU (CPU-bound tasks). لكن هذا لا يعني أن الـ Threading بلا فائدة، بل يجب استخدامه بحذر وفي المكان الصحيح.
في أحد المشاريع، كان لدينا سكربت لمعالجة صور يستخدم مكتبة PIL وThreadPoolExecutor لمعالجة الصور بشكل متوازٍ. ظننا أننا نحسن الأداء باستخدام ٨ خيوط، لكن القياسات أظهرت أن الوقت المستغرق كان تقريباً نفسه عند استخدام خيط واحد. السبب؟ الـ GIL كان يمنع الخيوط من العمل بشكل متوازٍ حقيقي لأن المهمة كانت تعتمد على الـ CPU. الحل؟ استخدام multiprocessing بدلاً من threading. النتيجة؟ انخفض وقت المعالجة من ٢٢ ثانية إلى ٤ ثوانٍ فقط، أي تحسن بمقدار ٥.٥ مرات.
# مثال سيء: استخدام Threading لمهمة تعتمد على الـ CPU
from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageFilter
import time
def process_image(image_path):
img = Image.open(image_path)
img = img.filter(ImageFilter.BLUR)
img.save(f"blurred_{image_path}")
start = time.time()
with ThreadPoolExecutor(max_workers=8) as executor:
executor.map(process_image, ["img1.jpg", "img2.jpg", "img3.jpg", "img4.jpg"])
print(f"Threading time: {time.time() - start:.2f} seconds")
# مثال جيد: استخدام multiprocessing لتجاوز الـ GIL
from concurrent.futures import ProcessPoolExecutor
start = time.time()
with ProcessPoolExecutor(max_workers=8) as executor:
executor.map(process_image, ["img1.jpg", "img2.jpg", "img3.jpg", "img4.jpg"])
print(f"Multiprocessing time: {time.time() - start:.2f} seconds")الدرس هنا هو أن الـ Threading مفيد فقط للمهام التي تعتمد على الـ I/O (مثل قراءة الملفات أو طلبات الشبكة)، بينما الـ multiprocessing هو الخيار الأفضل للمهام التي تعتمد على الـ CPU. لكن حتى مع multiprocessing، هناك تكلفة إضافية في إنشاء العمليات، لذا يجب استخدامها بحكمة. في المثال السابق، استخدمنا ٨ عمليات لأن لدينا ٨ صور فقط، لكن إذا كان لدينا آلاف الصور، فقد نحتاج إلى استراتيجية مختلفة مثل تقسيم العمل إلى دفعات.
عند قياس الأداء، من السهل الوقوع في فخ القياسات الخاطئة. مثلاً، استخدام time.time() قد يعطي نتائج غير دقيقة بسبب تأثيرات خارجية مثل عمليات النظام الأخرى. بدلاً من ذلك، استخدم time.perf_counter() الذي يعطي دقة أعلى. أيضاً، يجب تشغيل الكود عدة مرات وأخذ المتوسط لتجنب تأثيرات الـ caching أو الـ warm-up. في المثال السابق، قمنا بقياس الوقت مرتين فقط لتبسيط الكود، لكن في الواقع، يجب تشغيل الكود ١٠ مرات على الأقل وأخذ المتوسط.
import time
def measure_performance(func, *args, runs=10):
times = []
for _ in range(runs):
start = time.perf_counter()
func(*args)
end = time.perf_counter()
times.append(end - start)
return sum(times) / runs
# استخدام الدالة لقياس أداء دالة معينة
def example_function(n):
return sum(i * i for i in range(n))
avg_time = measure_performance(example_function, 1000000)
print(f"Average time: {avg_time:.6f} seconds")الـ List Comprehensions هي واحدة من ميزات بايثون الجميلة التي تجعل الكود أكثر إيجازاً وقراءة. لكن عندما تُستخدم بشكل مفرط أو معقد، يمكن أن تؤدي إلى مشاكل في الأداء وقراءة الكود. المشكلة ليست في الـ List Comprehensions نفسها، بل في كيفية استخدامها. مثلاً، كتابة List Comprehension مع شروط متعددة ومعالجات معقدة يمكن أن يجعل الكود غير قابل للقراءة ويؤثر على الأداء بسبب زيادة الحمل على الذاكرة والمعالج.
في مشروع سابق، كان لدينا كود يستخدم List Comprehension مع ثلاث شروط متداخلة ومعالجة بيانات معقدة. الكود كان يبدو أنيقاً، لكن عند قياس أدائه، وجدنا أنه يستغرق ضعف الوقت مقارنة بكود يستخدم حلقات for عادية مع بعض التحسينات. السبب؟ الـ List Comprehensions في بايثون لها تكلفة إضافية في الذاكرة والمعالج عند التعامل مع شروط معقدة ومعالجات متداخلة. الحل؟ استخدام حلقات for عادية مع تحسينات مثل pre-allocation للقوائم وتقليل العمليات داخل الحلقة.
# مثال سيء: List Comprehension معقدة
numbers = range(1000000)
result = [x * x for x in numbers if x % 2 == 0 and x % 3 == 0 and x % 5 == 0]
# مثال جيد: حلقة for مع تحسينات
result = []
for x in numbers:
if x % 2 == 0 and x % 3 == 0 and x % 5 == 0:
result.append(x * x)
# مثال أفضل: استخدام generator expression لتقليل استخدام الذاكرة
result = list(x * x for x in numbers if x % 30 == 0) # 30 هو المضاعف المشترك الأصغر لـ 2, 3, 5في المثال السابق، استخدمنا مضاعف مشترك أصغر (٣٠) لتبسيط الشروط داخل الـ List Comprehension، مما أدى إلى تحسين الأداء بشكل ملحوظ. أيضاً، استخدام generator expression بدلاً من List Comprehension يمكن أن يقلل من استخدام الذاكرة، خاصة عند التعامل مع مجموعات بيانات كبيرة. لكن تذكر أن الـ generators هي كائنات كسولة (lazy)، لذا يجب تحويلها إلى قائمة إذا كنت بحاجة إلى استخدامها عدة مرات.
عند تحسين الأداء، من المهم قياس ليس فقط الوقت المستغرق، بل أيضاً استخدام الذاكرة. في المثال السابق، الـ List Comprehension المعقدة كانت تستهلك ذاكرة أكثر من الحلقة العادية بسبب كيفية تعامل بايثون مع إنشاء القوائم. يمكنك استخدام أدوات مثل memory_profiler لقياس استخدام الذاكرة بدقة.
from memory_profiler import profile
@profile
def bad_list_comprehension():
numbers = range(1000000)
result = [x * x for x in numbers if x % 2 == 0 and x % 3 == 0 and x % 5 == 0]
return result
@profile
def good_for_loop():
numbers = range(1000000)
result = []
for x in numbers:
if x % 30 == 0:
result.append(x * x)
return result
bad_list_comprehension()
good_for_loop()بايثون تأتي مع مجموعة غنية من الـ Built-in Functions التي تم تحسينها بشكل كبير على مستوى اللغة نفسها. استخدام هذه الدوال بدلاً من كتابة دوال مخصصة يمكن أن يؤدي إلى تحسينات كبيرة في الأداء. السبب؟ الـ Built-in Functions مكتوبة بلغة C وتم تحسينها بشكل مكثف، بينما الدوال المخصصة المكتوبة بلغة بايثون تكون أبطأ بكثير.
في أحد المشاريع، كان لدينا دالة لحساب مجموع مربعات الأرقام في قائمة. كتبنا الدالة باستخدام حلقة for بسيطة، لكن عند قياس أدائها، وجدنا أنها تستغرق وقتاً أطول بكثير مقارنة باستخدام الدوال المدمجة مثل sum وmap. الحل؟ إعادة كتابة الدالة باستخدام sum مع generator expression. النتيجة؟ تحسن الأداء بمقدار ٣ مرات تقريباً.
# مثال سيء: دالة مخصصة لحساب مجموع المربعات
import time
def sum_of_squares(numbers):
total = 0
for num in numbers:
total += num * num
return total
numbers = list(range(1000000))
start = time.time()
result = sum_of_squares(numbers)
print(f"Custom function time: {time.time() - start:.6f} seconds")
# مثال جيد: استخدام sum مع generator expression
start = time.time()
result = sum(x * x for x in numbers)
print(f"Built-in sum time: {time.time() - start:.6f} seconds")
# مثال أفضل: استخدام map مع sum
start = time.time()
result = sum(map(lambda x: x * x, numbers))
print(f"Map with sum time: {time.time() - start:.6f} seconds")في المثال السابق، استخدمنا ثلاث طرق لحساب مجموع المربعات. الطريقة الأولى تستخدم حلقة for بسيطة، والطريقة الثانية تستخدم sum مع generator expression، والطريقة الثالثة تستخدم sum مع map. النتائج تظهر أن الطريقة الثانية والثالثة أسرع بكثير من الأولى، مع تفوق بسيط للطريقة الثانية. السبب؟ الـ generator expression أقل تكلفة من map في هذه الحالة بسبب كيفية تعامل بايثون مع الـ lambda داخل map.
في بعض الأحيان، التحسينات الصغيرة يمكن أن تحدث فرقاً كبيراً عند تكرارها ملايين المرات. مثلاً، استخدام x += 1 بدلاً من x = x + 1 يمكن أن يكون أسرع قليلاً بسبب كيفية تعامل بايثون مع العمليات الحسابية. أيضاً، استخدام local variables بدلاً من global variables داخل الدوال يمكن أن يؤدي إلى تحسينات ملحوظة في الأداء لأن الوصول إلى المتغيرات المحلية أسرع بكثير.
# مثال على استخدام local variables بدلاً من global variables
import time
global_var = 0
def increment_global():
global global_var
for _ in range(10000000):
global_var += 1
def increment_local():
local_var = 0
for _ in range(10000000):
local_var += 1
return local_var
start = time.time()
increment_global()
print(f"Global variable time: {time.time() - start:.6f} seconds")
start = time.time()
increment_local()
print(f"Local variable time: {time.time() - start:.6f} seconds")المهام التي تعتمد على الـ I/O (مثل قراءة الملفات أو طلبات الشبكة) هي واحدة من أكثر الأسباب شيوعاً لتدهور أداء التطبيقات. المشكلة ليست في بايثون نفسها، بل في كيفية التعامل مع هذه المهام. مثلاً، قراءة ملف كبير سطراً بسطر باستخدام readline() يمكن أن يكون بطيئاً جداً مقارنة بقراءة الملف دفعة واحدة باستخدام read(). لكن قراءة الملف دفعة واحدة قد تستهلك ذاكرة كبيرة، لذا يجب إيجاد توازن بين الأداء واستخدام الذاكرة.
في أحد المشاريع، كان لدينا سكربت لمعالجة ملفات لوج ضخمة (أكثر من ١٠ جيجابايت). استخدمنا readline() لقراءة الملفات سطراً بسطر، لكن العملية كانت تستغرق ساعات. بعد تحليل الكود، وجدنا أن المشكلة كانت في الـ Blocking I/O، حيث كان السكربت ينتظر إتمام كل عملية قراءة قبل الانتقال إلى السطر التالي. الحل؟ استخدام asyncio مع aiofiles لقراءة الملفات بشكل غير متزامن. النتيجة؟ انخفض وقت المعالجة من ٤ ساعات إلى ٢٠ دقيقة فقط.
# مثال سيء: قراءة ملف سطراً بسطر باستخدام readline()
def read_file_sync(file_path):
with open(file_path, 'r') as file:
for line in file:
process_line(line)
# مثال جيد: استخدام asyncio مع aiofiles لقراءة الملفات بشكل غير متزامن
import asyncio
import aiofiles
async def read_file_async(file_path):
async with aiofiles.open(file_path, 'r') as file:
async for line in file:
await process_line_async(line)
async def process_line_async(line):
# معالجة السطر هنا
pass
# تشغيل الكود غير المتزامن
asyncio.run(read_file_async('large_file.log'))في المثال السابق، استخدمنا aiofiles لقراءة الملف بشكل غير متزامن، مما يسمح للسكربت بمعالجة السطور الأخرى بينما ينتظر إتمام عمليات القراءة. هذا النهج مفيد جداً للمهام التي تعتمد على الـ I/O، لكنه يتطلب إعادة هيكلة الكود لاستخدام البرمجة غير المتزامنة. أيضاً، يجب الانتباه إلى أن البرمجة غير المتزامنة ليست حلاً سحرياً لكل المشاكل، فهي تضيف تعقيداً إلى الكود وقد لا تكون مناسبة لجميع الحالات.
عند التعامل مع الملفات الكبيرة، يمكن تحسين الأداء بشكل كبير عن طريق التحكم في حجم الـ Chunks التي تُقرأ في كل مرة. مثلاً، قراءة ملف بحجم ١٠ جيجابايت دفعة واحدة قد يؤدي إلى استهلاك كبير للذاكرة، بينما قراءة الملف سطراً بسطر قد يكون بطيئاً جداً. الحل؟ قراءة الملف في chunks بحجم مناسب (مثل ٨ كيلوبايت أو ٦٤ كيلوبايت) باستخدام read(size).
# قراءة ملف في chunks بحجم 64KB
chunk_size = 64 * 1024 # 64KB
def read_file_in_chunks(file_path):
with open(file_path, 'r') as file:
while True:
chunk = file.read(chunk_size)
if not chunk:
break
process_chunk(chunk)
def process_chunk(chunk):
# معالجة الـ chunk هنا
passبايثون ليست اللغة الأمثل للمهام التي تتطلب أداء عالي جداً، لكن هذا لا يعني أنه لا يمكنك تحقيق أداء قريب من لغات مثل C++. المكتبات مثل NumPy وCython تسمح لك بكتابة كود بايثون يتم ترجمته إلى كود C عالي الأداء. مثلاً، NumPy تستخدم مصفوفات متجانسة (homogeneous arrays) ومعالجة متجهية (vectorized operations) لتحقيق أداء قريب من C.
في أحد المشاريع، كان لدينا كود لحساب مصفوفة ضرب المصفوفات (matrix multiplication) باستخدام حلقات for متداخلة. الكود كان يعمل بشكل جيد للمصفوفات الصغيرة، لكن عند التعامل مع مصفوفات بحجم ١٠٠٠×١٠٠٠، كان يستغرق أكثر من دقيقة. بعد إعادة كتابة الكود باستخدام NumPy، انخفض وقت المعالجة إلى أقل من ثانية واحدة. الفرق؟ NumPy تستخدم خوارزميات محسنة مكتوبة بلغة C ومعالجة متجهية لتسريع العمليات الحسابية.
# مثال سيء: ضرب المصفوفات باستخدام حلقات for
import time
def matrix_multiply(a, b):
result = [[0 for _ in range(len(b[0]))] for _ in range(len(a))]
for i in range(len(a)):
for j in range(len(b[0])):
for k in range(len(b)):
result[i][j] += a[i][k] * b[k][j]
return result
a = [[i + j for i in range(100)] for j in range(100)]
b = [[i * j for i in range(100)] for j in range(100)]
start = time.time()
result = matrix_multiply(a, b)
print(f"Custom matrix multiply time: {time.time() - start:.6f} seconds")
# مثال جيد: استخدام NumPy لضرب المصفوفات
import numpy as np
a_np = np.array(a)
b_np = np.array(b)
start = time.time()
result_np = np.dot(a_np, b_np)
print(f"NumPy matrix multiply time: {time.time() - start:.6f} seconds")في المثال السابق، الفرق في الأداء بين الكود المخصص وNumPy واضح جداً. NumPy ليست مجرد مكتبة، بل هي إطار عمل كامل للمهام الرياضية والعلمية، وتوفر أداء قريب من لغات مثل C++. أيضاً، يمكنك استخدام Cython لكتابة دوال بايثون يتم ترجمتها إلى كود C، مما يؤدي إلى تحسينات كبيرة في الأداء، خاصة للمهام التي تعتمد على الـ CPU.
Cython يسمح لك بكتابة دوال بايثون يتم ترجمتها إلى كود C، مما يؤدي إلى تحسينات كبيرة في الأداء. مثلاً، يمكنك كتابة دالة لحساب الأعداد الأولية باستخدام Cython وتحقيق أداء قريب من C++. لكن يجب الانتباه إلى أن Cython يضيف تعقيداً إلى الكود، لذا يجب استخدامه فقط للمهام التي تتطلب أداء عالي جداً.
# مثال على استخدام Cython لحساب الأعداد الأولية
# الملف: primes.pyx
# def calculate_primes(int limit):
# cdef int n, i
# primes = []
# for n in range(2, limit + 1):
# for i in range(2, n):
# if n % i == 0:
# break
# else:
# primes.append(n)
# return primes
# تجميع الملف باستخدام:
# python setup.py build_ext --inplace
# حيث setup.py يحتوي على:
# from setuptools import setup
# from Cython.Build import cythonize
# setup(ext_modules=cythonize("primes.pyx"))
# استخدام الدالة في بايثون:
# import primes
# primes.calculate_primes(100000)تحسين أداء كود بايثون ليس سحراً، بل هو مزيج من فهم عميق لكيفية عمل اللغة خلف الكواليس واستخدام الأدوات والتقنيات المناسبة في المكان الصحيح. لا تعتمد على النصائح العامة، بل قم بقياس أداء الكود بشكل دقيق واستخدم الأدوات المناسبة مثل time.perf_counter() وmemory_profiler لتحديد نقاط الضعف. تذكر أن التحسينات الصغيرة يمكن أن تحدث فرقاً كبيراً عند تكرارها ملايين المرات، وأن المكتبات المحسنة مثل NumPy وCython يمكن أن تحول كود بايثون البطيء إلى كود عالي الأداء. الخطوة التالية؟ اختر سكربتاً بطيئاً لديك، قم بقياس أدائه، ثم طبق التقنيات التي تعلمتها هنا وشاهد الفرق بنفسك.