مستوى المقال: محترف (Advanced)
GIL في Python: السبب الحقيقي وراء بطء الـ threading في كودك
لو شغّلت 8 threads في Python على سيرفر فيه 16 core علشان تسرّع حسبة CPU-bound، النتيجة هتبقى كارثية: الكود مش بس ما اتسرّعش، ده غالباً بقى أبطأ بنسبة 15-30% من الـ thread الواحد. ده مش غلط في كودك. ده اسمه Global Interpreter Lock، وهو القفل اللي بيخلي CPython ينفّذ bytecode واحد في كل لحظة بغض النظر عن عدد الأنوية.
المشكلة باختصار
المطورين الجداد على Python بيفترضوا إن threading في Python زي threading في Java أو Go: كل thread بيشتغل على core منفصل بشكل متوازي حقيقي. الافتراض ده غلط. في CPython (الـ implementation الرسمي اللي 99% من الناس بتستخدمه)، فيه قفل اسمه GIL بيمنع أكتر من thread واحد من تنفيذ Python bytecode في نفس اللحظة.
النتيجة العملية: لو شغلك CPU-bound (يعني بتعمل حسابات في الذاكرة)، الـ threading مش هيفيدك. لو شغلك I/O-bound (بتنتظر network أو disk)، الـ threading هيفيدك جداً. الفرق بين الحالتين هو اللي بيحدد قرار التصميم.
المفهوم بمثال بسيط جداً
تخيّل قاعة فيها 16 شخص (الـ cores) وميكروفون واحد بس. القاعدة: مفيش حد يقدر يتكلم من غير ما يمسك الميكروفون. لو 8 ناس عايزين يقولوا حاجة في نفس الوقت، الميكروفون بيتنقل بينهم واحد ورا التاني، والـ 7 الباقيين بيستنوا. عدد الناس مش مهم. الميكروفون هو الـ bottleneck.
الـ GIL هو الميكروفون. الـ threads هم الناس. كل ما يزيد عدد الـ threads، يزيد التنافس على الميكروفون، ويزيد overhead التنقل (context switching). ده اللي بيخلي الكود يبقى أبطأ من thread واحد في بعض الحالات.
التعريف العلمي الدقيق
الـ Global Interpreter Lock هو mutex على مستوى الـ interpreter في CPython بيحمي الوصول للـ Python objects. كل thread محتاج يمسك القفل ده قبل ما ينفّذ أي bytecode instruction. القفل بيتحرر تلقائياً في حالتين:
- كل 100 bytecode instruction (في Python 3.1 وأقدم) أو كل 5 milliseconds (من Python 3.2 ولحد 3.12).
- عند أي عملية I/O (قراءة ملف، طلب شبكة، sleep).
السبب التاريخي للـ GIL: تبسيط إدارة الذاكرة. CPython بيستخدم reference counting في الـ garbage collection. لو threadين زادوا أو نقّصوا الـ ref count لنفس الـ object في نفس اللحظة بدون قفل، الـ count هيبقى غلط والذاكرة هتفسد. الـ GIL بيحل المشكلة دي بطريقة بسيطة: قفل واحد يحمي كل حاجة.
كود يقيس الفرق بشكل عملي
الكود ده بيحسب مجموع تربيع 50 مليون رقم بطريقتين: thread واحد، و 8 threads. الافتراض إن الـ 8 threads هيقسموا الشغل على 8 cores ويخلصوا في 1/8 الوقت. هنشوف اللي بيحصل فعلاً.
import threading
import time
import multiprocessing
def cpu_heavy(n_iterations):
total = 0
for i in range(n_iterations):
total += i * i
return total
def run_with_threads(n_threads, work_per_thread):
threads = []
start = time.perf_counter()
for _ in range(n_threads):
t = threading.Thread(target=cpu_heavy, args=(work_per_thread,))
threads.append(t)
t.start()
for t in threads:
t.join()
return time.perf_counter() - start
def run_with_processes(n_procs, work_per_proc):
start = time.perf_counter()
with multiprocessing.Pool(processes=n_procs) as pool:
pool.map(cpu_heavy, [work_per_proc] * n_procs)
return time.perf_counter() - start
if __name__ == "__main__":
TOTAL_WORK = 50_000_000
single = cpu_heavy
s = time.perf_counter()
single(TOTAL_WORK)
print(f"Single thread: {time.perf_counter() - s:.2f}s")
t8 = run_with_threads(8, TOTAL_WORK // 8)
print(f"8 threads: {t8:.2f}s")
p8 = run_with_processes(8, TOTAL_WORK // 8)
print(f"8 processes: {p8:.2f}s")