AzLearn

Threading vs Multiprocessing

Threading vs Multiprocessing

مفهوم ~22 دقيقة

Threading vs Multiprocessing

لديك ثلاثة أدوات للتزامن في Python: asyncio (تعلّمته)، و threading (الخيوط)، و multiprocessing (العمليات). اختيار الأداة الخاطئة لمشكلتك يعني كوداً أبطأ أو أعقد دون فائدة حقيقية. في هذا الدرس ستفهم الفرق الحقيقي وتعرف متى تختار كل أسلوب.

قانون GIL — العائق الخفي

GIL اختصار لـ Global Interpreter Lock — قفل يمتلكه مترجم Python ولا يُطلقه إلا في لحظات محددة. القاعدة البسيطة: خيط Python واحد فقط يُنفّذ كود Python في أي لحظة، حتى لو كان جهازك يمتلك 8 أنوية.

لماذا يوجد GIL؟ لأن إدارة ذاكرة Python (reference counting) ليست آمنة للخيوط بدون قفل مركزي. إزالة GIL مهمة ضخمة تعمل عليها Python 3.13+ لكنها لم تكتمل بعد.

ماذا يعني هذا عملياً؟

                   ┌──────────────────────────────┐
                   │         GIL (القفل)           │
                   └──────────────────────────────┘
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
         [خيط-1]          [خيط-2]         [خيط-3]
         ينتظر           يُنفّذ           ينتظر
                        (يملك GIL)

الخيوط تتبادل GIL — لكن واحداً فقط يعمل في كل لحظة. لذلك:

  • مهام CPU-bound مع threading = بطيء (الخيوط تتنافس على نفس القفل)
  • مهام I/O-bound مع threading = مناسب (الخيط يُطلق GIL أثناء الانتظار)

threading — الخيوط في Python

threading.Thread تُنشئ خيطاً جديداً. كل الخيوط تشترك في نفس الذاكرة (نفس المتغيرات العالمية) لكن لديها مكدس تنفيذ (call stack) مستقل.

import threading
import time

def download_file(filename, delay):
    # محاكاة تنزيل ملف — Simulate file download
    print(f"بدء تنزيل {filename}...")
    time.sleep(delay)  # انتظار I/O — I/O wait
    print(f"اكتمل: {filename}")

# إنشاء خيطين — Create two threads
t1 = threading.Thread(target=download_file, args=("تقرير.pdf", 2))
t2 = threading.Thread(target=download_file, args=("صورة.jpg", 1))

start = time.time()
t1.start()
t2.start()

# انتظر انتهاء الخيطين — Wait for both threads
t1.join()
t2.join()

print(f"الوقت الكلي: {time.time() - start:.1f}s")  # ~2s لا ~3s

ملاحظة Pyodide: بيئة الأكواد التفاعلية في هذه الصفحة تعمل داخل Web Worker ذو خيط واحد — لا يمكن تشغيل threading.Thread حقيقية فيها. الأمثلة أعلاه للقراءة وتنفيذها على جهازك.

متى تستخدم threading:

  • تنزيل أو رفع ملفات متعددة في نفس الوقت
  • استدعاء APIs متعددة تحتاجها معاً
  • مهام تحتوي استدعاءات مكتبات C تُطلق GIL (مثل بعض عمليات numpy)
  • كود قديم يستخدم مكتبات blocking لا تدعم asyncio

multiprocessing — العمليات المتعددة

multiprocessing.Process تُنشئ عملية (Process) مستقلة تماماً — ذاكرة مستقلة، مترجم Python مستقل، وبالتالي GIL مستقل لكل عملية. العمليات المتعددة هي الطريقة الوحيدة للحصول على تشغيل Python حقيقي على أنوية متعددة.

import multiprocessing
import time

def heavy_computation(n, result_list, index):
    # حساب مكثف — CPU-intensive computation
    total = sum(i * i for i in range(n))
    result_list[index] = total
    print(f"حساب {n}: {total}")

# بالتوازي على أنوية مختلفة — Parallel on different CPU cores
if __name__ == "__main__":
    # Manager لمشاركة البيانات بين العمليات
    # Manager for sharing data between processes
    manager = multiprocessing.Manager()
    results = manager.list([0, 0, 0])

    processes = [
        multiprocessing.Process(target=heavy_computation, args=(1_000_000, results, 0)),
        multiprocessing.Process(target=heavy_computation, args=(2_000_000, results, 1)),
        multiprocessing.Process(target=heavy_computation, args=(3_000_000, results, 2)),
    ]

    for p in processes:
        p.start()

    for p in processes:
        p.join()

    print(f"النتائج: {list(results)}")

لماذا if __name__ == "__main__":؟ لأن multiprocessing على Windows وmacOS يستخدم spawn لإنشاء العمليات — يُعيد تشغيل الملف من الصفر في كل عملية. بدون هذا الشرط، كل عملية جديدة ستُنشئ عمليات أخرى بشكل لانهائي.

ملاحظة Pyodide: كذلك multiprocessing لا تعمل في Pyodide. الأمثلة للتنفيذ على جهازك المحلي.

Pool: أسهل من Process

multiprocessing.Pool تُدير مجموعة عمليات تلقائياً:

from multiprocessing import Pool

def square(n):
    # تربيع العدد — Square the number
    return n * n

if __name__ == "__main__":
    numbers = [1, 2, 3, 4, 5, 6, 7, 8]

    with Pool(processes=4) as pool:
        # توزيع العمل على 4 عمليات — Distribute work to 4 processes
        results = pool.map(square, numbers)

    print(results)  # [1, 4, 9, 16, 25, 36, 49, 64]

pool.map تعمل مثل map العادية لكنها تُوزع العمل على العمليات في pool. نتائجها بنفس ترتيب المدخلات.

ThreadPoolExecutor و ProcessPoolExecutor

المكتبة concurrent.futures تُقدم واجهة موحدة أبسط للخيوط والعمليات:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time

def fetch_url(url):
    # محاكاة طلب HTTP — Simulate HTTP request
    time.sleep(1)
    return f"بيانات من {url}"

urls = ["url-1", "url-2", "url-3", "url-4"]

# مع خيوط (مناسب للـ I/O) — With threads (good for I/O)
with ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(fetch_url, urls))

print(results)  # جُمعت في ~1 ثانية لا ~4 ثواني

# مع عمليات (مناسب للـ CPU) — With processes (good for CPU)
# with ProcessPoolExecutor(max_workers=4) as executor:
#     results = list(executor.map(cpu_heavy_task, data))

مقارنة شاملة: اختر أداتك

المعيارasynciothreadingmultiprocessing
نوع المهمةI/O-boundI/O-boundCPU-bound
خيوط حقيقيةلا (خيط واحد)نعم (GIL يحدّ)عمليات منفصلة
تعدد أنويةلالا (GIL)نعم
مشاركة الذاكرةسهلةسهلة (بحذر)صعبة (تحتاج IPC)
استهلاك الذاكرةمنخفضمتوسطمرتفع
التعقيدمتوسطمتوسطمرتفع
مكتبات قديمةيحتاج مكتبات asyncيعمل مع أي مكتبةيعمل مع أي مكتبة

خوارزمية الاختيار

هل المهمة تنتظر شبكة/ملف/قاعدة بيانات؟
    نعم → هل المكتبة تدعم async؟
              نعم → asyncio (الخيار الأفضل)
              لا  → threading
    لا  → هل هي حسابات مكثفة؟
              نعم → multiprocessing (الخيار الوحيد)
              لا  → ربما لا تحتاج تزامناً أصلاً

محاكاة التزامن: playground تعليمي

هذا المثال يُحاكي منطق التزامن بكود متزامن — يمكّنك من رؤية التأثير بدون خيوط حقيقية:

main.go

مزالق threading الشائعة

Race Condition: خيطان يُعدّلان نفس المتغير في نفس الوقت:

import threading

counter = 0

def increment():
    global counter
    # ❌ هذا غير آمن — خيطان قد يقرآن نفس القيمة
    # Unsafe — two threads may read the same value
    for _ in range(100_000):
        counter += 1  # read → modify → write (3 خطوات!)

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()

print(counter)  # قد لا يكون 200_000! — May not be 200,000!

الحل: threading.Lock

import threading

counter = 0
lock = threading.Lock()

def safe_increment():
    global counter
    for _ in range(100_000):
        with lock:  # فقط خيط واحد يدخل في كل مرة — Only one thread enters at a time
            counter += 1
تحدي — Challenge