الخيوط: تزامن داخل عملية واحدة
الخيط (thread) خط تنفيذ مستقل داخل برنامج واحد يتشارك ذاكرته مع الخيوط الأخرى. والبرنامج التداولي الذي يجب أن يجلب أسعارًا من عشر منصات ويكتب سجلًا ويُبقي الواجهة مستجيبة يستعمل الخيوط كي لا يوقف انتظار أحدها الباقي. وتبدأ وحدة threading في بايثون، وبراحة أكبر concurrent.futures.ThreadPoolExecutor، الخيوط وتديرها. ولأن الخيوط تتشارك المتغيرات فهي سريعة التواصل وخطيرة عند الخطأ.
# Ten slow "network calls" in a thread pool wait at the same time, so the total is about one call, not ten
import time
from concurrent.futures import ThreadPoolExecutor
def fetch_quote(symbol):
time.sleep(0.1) # stands in for waiting on a venue
return symbol, len(symbol) * 10.0
symbols = ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "META", "GOOG", "NFLX", "ORCL", "INTC"]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
quotes = list(pool.map(fetch_quote, symbols))
elapsed = time.perf_counter() - start
print(quotes[0], len(quotes)) # ('AAPL', 40.0) 10
print(elapsed < 0.5) # True about 0.1 s in total, not 10 x 0.1 sGIL: خيوط تنتظر وخيوط تحسب
في CPython قفل المفسّر العام (GIL): خيط واحد فقط ينفّذ بايت-كود بايثون في أي لحظة. ويتخلى الخيط عنه أثناء انتظاره الشبكة أو ملفًا أو time.sleep، فتتداخل الخيوط جيدًا في الانتظار: عشرة خيوط تجلب أسعارًا تنتهي في زمن خيط واحد تقريبًا. أما شيفرة بايثون المقيَّدة بالمعالج (حلقة بايثون خالصة تحسب) فتتناوب الخيوط عليها، فأربعة خيوط ليست أسرع من واحد وغالبًا أبطأ قليلًا. والشيفرة التي تعمل خارج بايثون، كـ NumPy أو امتداد C يحرر GIL، يمكنها العمل بالتوازي في الخيوط. وللحساب الثقيل في بايثون الخالصة استعمل عمليات منفصلة (الموضوع التالي) أو الإصدار الخالي من GIL (free-threaded) من بايثون 3.13+ حيث يتوفر.
# Splitting a list into chunks for workers: sizes differ by at most one, the first chunks get the extra items
def split_chunks(items, workers):
size, extra = divmod(len(items), workers)
chunks, start = [], 0
for index in range(workers):
stop = start + size + (1 if index < extra else 0)
chunks.append(list(items[start:stop]))
start = stop
return chunks
chunks = split_chunks(list(range(7)), 3)
print(chunks) # [[0, 1, 2], [3, 4], [5, 6]]
print([sum(chunk) for chunk in chunks]) # [3, 7, 11] one partial sum per worker
print(sum(sum(chunk) for chunk in chunks)) # 21البيانات المشتركة وحالات السباق والأقفال
قد تفسد الخيوط التي تحدّث المتغير نفسه قيمته، لأن total += amount ثلاث خطوات (قراءة، جمع، كتابة) وقد يحدث تبديل خيط بينها: خيطان يقرآن 100 ويضيفان 5 ويكتبان 105 فيضيع تحديث. هذه حالة سباق (race condition) تظهر نادرًا وبلا توقع فتكون أسوأ أنواع الأخطاء. والعلاج قفل: with lock: يسمح لخيط واحد في كل مرة بتنفيذ الكتلة، ويجب أن تكون القراءة والكتابة كلتاهما داخله. والأفضل تجنب المشاركة: أعطِ كل عامل جزءه ونتيجته ثم ادمج النتائج في النهاية، أو مرّر العمل والنتائج عبر queue.Queue الآمنة للخيوط أصلًا.
# A lock makes the read-modify-write a single step; the total comes out exact
import threading
totals = {"AAPL": 0}
lock = threading.Lock()
def safe_add(shared, key, amount, lock):
with lock:
shared[key] = shared.get(key, 0) + amount
return shared[key]
def worker():
for _ in range(10_000):
safe_add(totals, "AAPL", 1, lock)
threads = [threading.Thread(target=worker) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(totals["AAPL"]) # 40000 nothing lost: four threads x 10,000 additions# A back-of-envelope estimate before writing any threaded code
import math
def estimate_time(n_tasks, seconds, workers, kind):
if kind in ("io", "processes"):
return math.ceil(n_tasks / workers) * seconds
return n_tasks * seconds # "cpu_threads": the GIL makes the threads take turns
print(estimate_time(40, 0.5, 8, "io")) # 2.5 forty slow requests, eight at a time
print(estimate_time(40, 0.5, 8, "cpu_threads")) # 20.0 forty CPU-bound tasks in threads: no gain
print(estimate_time(40, 0.5, 8, "processes")) # 2.5 separate processes use separate coresasyncio (الذي مرّ في هذا المسار) عادة بصورة أفضل ولا سباقات فيه بين await. ولحساب المصفوفات استعمل NumPy. ولبايثون المقيَّدة بالمعالج استعمل العمليات. وإن شاركت بيانات بين الخيوط فاجعل المقطع المقفل قصيرًا ولا تستدعِ شيفرة بطيئة وأنت تمسك قفلًا، وخذ عدة أقفال بالترتيب نفسه في كل مكان وإلا انتظر خيطان بعضهما إلى الأبد (جمود deadlock).- تتشارك الخيوط الذاكرة وتتداخل جيدًا في الانتظار (الشبكة والملفات)؛ ويستطيع
ThreadPoolExecutorالجلب من منصات كثيرة معًا. - يسمح GIL لخيط واحد بتنفيذ بايت-كود بايثون في كل مرة: لا تسريع لبايثون الخالصة المقيَّدة بالمعالج لكن NumPy وامتدادات C تستطيع تحريره.
- احمِ التحديثات المشتركة بـ
with lock:حول القراءة والكتابة، وفضّل عدم المشاركة (أجزاء، طوابير)، وراقب حالات الجمود.
التالي: تعدد العمليات (multiprocessing)، استعمال عدة عمليات لتشغيل كل نواة في المعالج.
المختبر: ما الذي يُقيَّم
- وزّع العمل باستخدام
split_chunks25 نقطة - طبّق دالة على الأجزاء باستخدام
run_in_chunks25 نقطة - احمِ البيانات المشتركة باستخدام
safe_add25 نقطة - اعرف ما تقدمه الخيوط باستخدام
estimate_time25 نقطة