Kodokon kodokon.com

الأداء: تحليل الأداء، وGIL، والمولّدات مقابل القوائم، وفخاخ الذاكرة

قِس قبل أن تُحسِّن: timeit وcProfile، وما يحجبه GIL فعليًا، واقتصاد المولّدات في الذاكرة، وفخاخ الذاكرة في السلاسل النصية والشرائح.

11 دقيقة · 3 أسئلة

افتح هذا الدرس في Kodokon

لا تخمّن أبدًا، بل قِس. تعزل timeit قياسًا مصغّرًا موثوقًا: فهي تعطّل جامع القمامة وتكرّر التنفيذ لتنعيم الضجيج. أما cProfile فترسم خريطة استدعاءات برنامج كامل - رتّب حسب cumtime (الزمن التراكمي، بما فيه الاستدعاءات الفرعية) لتحديد المذنبين الحقيقيين. والقاعدة الذهبية في أيّ عمل على الأداء: السطر البطيء لا يكاد يكون أبدًا السطر الذي تشكّ فيه.

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
الخيار -s في timeit يُهيّئ السياق خارج القياس.

يضمن GIL (القفل العام للمفسّر) أن خيطًا واحدًا فقط ينفّذ بايت كود Python في كل لحظة داخل العملية الواحدة. والنتيجة: الخيوط لا تسرّع الحساب الصرف. لكنها تبقى مثالية للإدخال/الإخراج، لأن GIL يُحرَّر أثناء القراءة من الشبكة أو القرص، وكذلك من قِبل مكتبات C كثيرة مثل NumPy. ولإشباع عدة أنوية بالحساب، مُرّ عبر multiprocessing أو concurrent.futures.ProcessPoolExecutor. ويقدّم Python 3.13 نسخة تجريبية بلا GIL تُسمى "free-threaded"، لا تزال نادرة في الإنتاج.

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
نحو 8 ميغابايت للقائمة، ونحو 200 بايت للمولّد.

يزن المولّد بضع مئات من البايتات مهما بلغ طول التسلسل: فهو لا يخزّن سوى حالة تنفيذه ويُنتِج كل قيمة عند الطلب. اربطها في خطوط معالجة - sum(x * x for x in data) - لمعالجة التدفقات دون تجسيد أيّ قائمة وسيطة. وهناك مقايضة يجب الانتباه إليها: المولّد يُستخدَم مرة واحدة. فما إن يُستنفَد، لا يُنتِج شيئًا بعد ذلك، وبصمت - وهو مصدر كلاسيكي لأخطاء برمجية يبدو فيها التكرار الثاني "فارغًا".

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
قِس على جهازك: يبقى join الخيار المحمول والآمن.

اختبار المعرفة

تأكّد من أنك تذكّرت النقاط الأساسية في هذا الدرس.

  1. ما الذي يمنعه GIL بالضبط؟
    • أيّ شكل من أشكال التوازي في Python، بما في ذلك multiprocessing
    • التنفيذ المتزامن لبايت كود Python من قِبل عدة خيوط في العملية نفسها - أما الإدخال/الإخراج وكثير من كود C فتُحرّره
    • استخدام عدة خيوط في برنامج واحد
    • الكتابات المتزامنة على المتغير نفسه
  2. لماذا تبقى قيمة sys.getsizeof لمولّد ضئيلة مقارنة بالقائمة المكافئة؟
    • لأن المولّد لا يخزّن أيّ عنصر: فهو لا يحتفظ إلا بحالة تنفيذه ويحسب كل قيمة عند الطلب
    • لأن getsizeof لا تستطيع قياس المولّدات
    • لأن المولّدات تضغط بياناتها داخليًا
  3. لماذا يتفوّق ''.join(parts) على out += part المكرَّر داخل حلقة؟
    • لأن join تستخدم عدة خيوط لتجميع السلسلة
    • هذه خرافة: فالأداء متطابق منذ Python 3
    • لأن السلاسل str غير قابلة للتغيير، فكل += يعيد نسخ السلسلة كاملة (كلفة تربيعية)؛ أما join فتحسب الحجم النهائي وتخصّص الذاكرة مرة واحدة فقط
    • لأن join تتجنّب إنشاء كائنات str الوسيطة الخاصة بالمولّد