حول المحتوى:
شرح واضح لكيفية استغلال تعدد الأنوية في بايثون، متى تستخدم الـ threading ومتى تحتاج إلى multiprocessing، مع أمثلة عملية ومشكلات شائعة.
إذا بدأت مشاريعك في بايثون تكبر وتزداد العمليات الحسابية أو عمليات الإدخال والإخراج (قراءة الملفات، التعامل مع الشبكة، قواعد البيانات…) فغالبًا ستصل لنقطة تشعر فيها أن الأداء أصبح عنق زجاجة. هنا يظهر دور multiprocessing multithreading Python كأهم أدوات الاستفادة من تعدد الأنوية في المعالج، وتحسين استغلال وقت التنفيذ بشكل ذكي.
في هذا المقال سنتعرف على:
إذا كنت مهتمًا أكثر بجانب البرمجة غير المتزامنة (async/await) في بايثون، يمكنك الإطلاع أيضًا على مقالنا: البرمجة غير المتزامنة في بايثون: تحسين الأداء باستخدام async و await، لأنه يكمل الصورة الكاملة لأدوات تحسين الأداء في بايثون.
قبل الحديث عن multiprocessing multithreading Python، يجب فهم نقطة مهمة في تنفيذ بايثون وهي ما يُسمى بـ:
Global Interpreter Lock (GIL)
الـ GIL هو "قفل" على مستوى مفسر بايثون، يضمن أن خيطًا (Thread) واحدًا فقط هو من ينفذ كود بايثون في لحظة معينة داخل عملية واحدة، حتى لو كان لديك أكثر من نواة في المعالج.
النتيجة:
من هنا يأتي الفارق الجوهري:
استخدام الخيوط (Threads) مناسب عندما يكون عنق الزجاجة في انتظار الإدخال/الإخراج وليس في الحساب نفسه. أمثلة:
في هذه السيناريوهات، الخيط يقضي وقتًا طويلاً في الانتظار، وليس في استهلاك الـ CPU، فيمكن تشغيل عدة خيوط في نفس الوقت للاستفادة من الوقت الضائع.
المثال التالي يوضح استخدام threading لتنفيذ مهام I/O-bound (محاكاة عبر time.sleep):
import threading
import time
def download_file(file_id):
print(f"بدء تحميل الملف {file_id}")
time.sleep(2) # محاكاة عملية تحميل تستغرق وقت
print(f"انتهاء تحميل الملف {file_id}")
threads = []
start = time.time()
for i in range(5):
t = threading.Thread(target=download_file, args=(i,))
t.start()
threads.append(t)
for t in threads:
t.join()
end = time.time()
print(f"الوقت الكلي: {end - start:.2f} ثانية")
في هذا المثال:
time.sleep(2) (أي ~10 ثوانٍ)، سيتم الانتهاء تقريبًا في حوالي 2–3 ثوانٍ فقط، لأن الانتظار يتم بالتوازي.إذا كان تطبيقك يقوم بعمليات حسابية معقدة (معالجة صور، تحليل بيانات ضخمة، خوارزميات ذكاء اصطناعي، تشفير، ضغط ملفات…) فغالبًا هو CPU-bound. عندها:
الخيوط لن تعطيك تسريعًا حقيقيًا بسبب الـ GIL.
هنا يأتي دور multiprocessing حيث يقوم بإنشاء عمليات (Processes) مستقلة بدلاً من الخيوط. كل عملية تمتلك:
وبالتالي يمكن استغلال تعدد الأنوية بالكامل لتنفيذ كود بايثون المتعلق بالحسابات في توازي حقيقي.
لنفرض أن لدينا دالة تقوم بحساب عملية ثقيلة على CPU (محاكاة عن طريق حلقة كبيرة):
from multiprocessing import Process, cpu_count
import time
def cpu_heavy_task(n):
total = 0
for i in range(10_000_000):
total += i * n
return total
def run_in_process(n):
start = time.time()
result = cpu_heavy_task(n)
end = time.time()
print(f"العملية {n} انتهت في {end - start:.2f} ثانية")
if __name__ == "__main__":
processes = []
start_all = time.time()
for i in range(4): # تشغيل 4 عمليات
p = Process(target=run_in_process, args=(i+1,))
p.start()
processes.append(p)
for p in processes:
p.join()
end_all = time.time()
print(f"الوقت الكلي: {end_all - start_all:.2f} ثانية")
النقاط المهمة هنا:
if __name__ == "__main__" وهو ضروري عند استخدام multiprocessing خصوصًا على ويندوز حتى لا يحدث استدعاء ذاتي لا نهائي للسكريبت.Process يعمل في نواة (Core) مختلفة إن أمكن، وبالتالي يتم توزيع الحمل على الأنوية المتاحة.في كثير من الأحيان، تشغيل 4 عمليات على معالج رباعي الأنوية يمكن أن يقلل وقت التنفيذ إلى ما يقارب ربع الوقت (مع اعتبار بعض تكلفة إنشاء العمليات).
بدلًا من إنشاء العمليات يدويًا، يمكن استخدام multiprocessing.Pool لتوزيع قائمة من المهام على مجموعة من العمليات بسهولة.
from multiprocessing import Pool, cpu_count
import time
def cpu_heavy_task(n):
total = 0
for i in range(8_000_000):
total += i * n
return total
if __name__ == "__main__":
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
start = time.time()
with Pool(processes=cpu_count()) as pool:
results = pool.map(cpu_heavy_task, numbers)
end = time.time()
print("النتائج:", results)
print(f"الوقت الكلي: {end - start:.2f} ثانية")
المزايا:
pool.map توزع العناصر في numbers على العمليات المتاحة تلقائيًا.cpu_count() تستخدم عدد الأنوية المتوفرة على الجهاز.| العنصر | Multithreading | Multiprocessing |
|---|---|---|
| نوع المهام الأنسب | I/O-bound (شبكة، ملفات، قواعد بيانات) | CPU-bound (حسابات كثيفة، تحليل بيانات) |
| تأثير GIL | مقيَّد بالـ GIL في كود بايثون | يتجاوز الـ GIL باستخدام عدة عمليات |
| استهلاك الذاكرة | أقل، لأن الخيوط تشارك نفس الذاكرة | أعلى، كل عملية لها مساحة ذاكرة مستقلة |
| تكلفة الإنشاء | أقل | أعلى (إنشاء عملية أغلى من خيط) |
| سهولة مشاركة البيانات | أسهل (نفس الذاكرة) | أصعب (يحتاج إلى Queues، Pipes، أو shared memory) |
عندما تحاول عدة خيوط تعديل نفس المتغير أو نفس البنية في نفس اللحظة، قد تحصل نتائج غير متوقعة.
import threading
counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1
threads = []
for _ in range(5):
t = threading.Thread(target=increment)
t.start()
threads.append(t)
for t in threads:
t.join()
print("القيمة النهائية:", counter)
توقع أن تكون القيمة 500000، لكن غالبًا ستحصل على رقم أقل بسبب تداخل التحديثات.
الحل: استخدام أقفال (Locks).
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
with lock:
counter += 1
# بقية الكود كما هو
يحدث عندما تنتظر خيوط أقفالًا يحتفظ بها خيوط أخرى، ولا يتحرك أي منها. لتجنب ذلك:
queue.Queue عندما تحتاج إلى تبادل البيانات.كل عملية لها ذاكرة مستقلة، فلا يمكنك ببساطة مشاركة قائمة أو قاموس بين العمليات كما في الخيوط. الأدوات المتاحة:
multiprocessing.Queue: لتمرير الرسائل بين العمليات.multiprocessing.Value و multiprocessing.Array: لمشاركة قيم بسيطة.Manager: لتوفير كائنات مشتركة مثل list و dict.
from multiprocessing import Process, Manager
def worker(shared_list):
shared_list.append("عنصر جديد")
if __name__ == "__main__":
with Manager() as manager:
lst = manager.list()
processes = []
for _ in range(5):
p = Process(target=worker, args=(lst,))
p.start()
processes.append(p)
for p in processes:
p.join()
print("المحتوى:", list(lst))
عند تمرير دوال أو كائنات بين العمليات، يعتمد multiprocessing على آلية pickle. لذلك:
إنشاء عملية جديدة مكلف مقارنة بإنشاء خيط جديد، لذلك:
Pool لإعادة استخدام مجموعة محدودة من العمليات.باختصار:
threading أو فكر في البرمجة غير المتزامنة async/await (خاصة في تطبيقات الويب أو التعامل الكثيف مع الشبكة). يمكنك قراءة المزيد عن ذلك في مقال: البرمجة غير المتزامنة في بايثون. multiprocessing أو استعن بمكتبات تعتمد على C/NumPy والتي تستفيد من الأنوية داخليًا، أو فكر في نقل الجزء الثقيل إلى لغة أخرى أسرع مثل C++ أو C# (راجع مقالتنا مقارنة بين بايثون وسي شارب). concurrent.futures كواجهة أبسط لـ ThreadPool و ProcessPool.تسريع تطبيقات بايثون لا يعني دائمًا تغيير اللغة أو إعادة كتابة المشروع من الصفر. في كثير من الحالات، يمكن لتقنيات multiprocessing multithreading Python أن تمنحك دفعة كبيرة في الأداء إذا استخدمت في المكان الصحيح:
ابدأ بتحديد نوع حمل تطبيقك، ثم اختر الأداة المناسبة، وطبّقها على أجزاء صغيرة، مع اختبار دقيق وقياس مستمر للأداء، حتى تصل للتوازن الأفضل بين السرعة وتعقيد الكود.
شرح واضح لكيفية استغلال تعدد الأنوية في بايثون، متى تستخدم الـ threading ومتى تحتاج إلى multiprocessing، مع أمثلة عملية ومشكلات شائعة.
مساحة اعلانية