לדלג לתוכן

תהליכונים מול אסינכרוניות בפייתון - סוף סוף הסבר שמובן

בואו נדבר בכנות. הנושא הזה מבלבל כמעט כל מי שלומד פייתון, כולל מתכנתים עם ניסיון שעברו משפות אחרות. threading, asyncio, multiprocessing - שלושה שמות, שלוש דרכים לכתוב קוד "מקבילי", ואף אחד לא טורח להסביר למה יש בכלל שלוש ומתי להשתמש בכל אחת. אז בואו נפרק את זה בלי מילים גדולות מיותרות.

הבעיה שכולם מדלגים עליה - ה-GIL

בפייתון (בפרשן הרגיל, CPython) יש מנגנון שנקרא ה-Global Interpreter Lock, או בקיצור GIL. המשמעות הפרקטית שלו פשוטה - בכל רגע נתון, רק תהליכון אחד בתוכנית שלכם יכול להריץ קוד פייתון בפועל. גם אם פתחתם עשרה תהליכונים, הם לא רצים במקביל על עשרה ליבות מעבד. הם מתחלפים ביניהם, מהר מאוד, אבל בפועל רץ תמיד רק אחד.

זו הסיבה שבגללה תהליכונים בפייתון לא נותנים לכם מקביליות אמיתית (parallelism) בחישובים כבדים. אם תפצלו חישוב מתמטיך כבד לחמישה תהליכונים ותצפו שהוא ירוץ פי חמישה מהר יותר, תתאכזבו. לפעמים זה אפילו יהיה יותר איטי, בגלל התקורה של מעבר בין תהליכונים.

אז למה בכלל יש threading בפייתון?

כי לא כל מקביליות היא חישובית. הרבה מהזמן שתוכנית מבלה הוא לא חישוב, אלא המתנה - המתנה לתשובה משרת, המתנה לקריאה מקובץ, המתנה לתגובה ממסד נתונים. זה נקרא עבודה מוגבלת קלט/פלט - I/O-bound.

בזמן שתהליכון אחד מחכה לתשובה מהרשת, ה-GIL משתחרר, ותהליכון אחר יכול לרוץ. זה בדיוק המקום שבו threading עוזר בפייתון - לא כדי לחשב מהר יותר, אלא כדי לא לבזבז זמן בהמתנה.

import threading
import requests

def download(url):
    response = requests.get(url)
    print(url, response.status_code)

urls = ["https://example.com", "https://example.org", "https://example.net"]
threads = [threading.Thread(target=download, args=(url,)) for url in urls]

for t in threads:
    t.start()
for t in threads:
    t.join()

שלוש בקשות רשת, שרצות בו זמנית במקום אחת אחרי השנייה. כל הזמן שהתהליכון מחכה לתשובה מהשרת, תהליכונים אחרים יכולים לעבוד.

אסינכרוניות - אותו רעיון, מודל שונה

הפתרון של asyncio לאותה בעיה בדיוק - עבודה מוגבלת קלט/פלט - שונה לגמרי, ובלי תהליכונים בכלל. במקום זה יש לולאת אירועים אחת שמנהלת הרבה משימות "בו זמנית", כשכל משימה מוותרת מרצונה על התור בכל פעם שהיא ממתינה למשהו.

import asyncio
import aiohttp

async def download(session, url):
    async with session.get(url) as response:
        print(url, response.status)

async def main():
    urls = ["https://example.com", "https://example.org", "https://example.net"]
    async with aiohttp.ClientSession() as session:
        await asyncio.gather(*(download(session, url) for url in urls))

asyncio.run(main())

המילה await היא בעצם הצהרה - "כאן אני עומד לחכות, שחררו אותי ותנו למשימה אחרת לרוץ בינתיים". זה הבדל תפיסתי חשוב מ-threading. באסינכרוניות אתם, המתכנתים, מציינים בדיוק את נקודות ההמתנה. במערכת ההפעלה או בספריית threading, ההחלטה מתי להחליף תהליכון היא לא בשליטתכם.

היתרון של asyncio הוא שהוא קל משמעותית יותר מתהליכונים כשמדובר באלפי פעולות במקביל - למשל שרת שמטפל באלפי חיבורים בו זמנית. החיסרון הוא שהוא דורש שכל הקוד לאורך הדרך "ישתף פעולה" עם async ו-await, וזה משנה את האופן שבו כותבים כמעט כל פונקציה.

ומה עם עבודה שדורשת חישוב אמיתי?

אם המשימה שלכם היא לא המתנה אלא חישוב כבד - עיבוד תמונה, חישובים מתמטיים מסיביים, ניתוח נתונים גדול - לא threading ולא asyncio יעזרו לכם, כי שניהם עדיין כפופים לאותו GIL. הפתרון שם הוא multiprocessing, שמריץ תהליכים נפרדים לגמרי, כל אחד עם הפרשן וה-GIL שלו, ואז באמת מקבל מקביליות על כמה ליבות מעבד. זה נושא רחב בפני עצמו, אבל חשוב שתדעו שהוא קיים ושהוא הכלי הנכון כשהבעיה היא חישוב ולא המתנה.

איך זוכרים את זה בפועל

כלל אצבע פשוט - אם הבעיה שלכם היא "אני מחכה למשהו חיצוני", תשתמשו ב-threading או ב-asyncio. אם הבעיה שלכם היא "המעבד שלי עובד קשה", תשתמשו ב-multiprocessing. ואם אתם לא בטוחים, כנראה שאתם עדיין לא זקוקים לאף אחד מהם - קוד רגיל, סינכרוני, פשוט יותר לכתוב, לדבג ולתחזק, ורוב התוכניות שאתם כותבים בתחילת הדרך פשוט לא צריכות את המורכבות הזאת.

מי שרק מתחיל בפייתון, כדאי לבנות קודם בסיס יציב בכתיבת קוד רגיל וסינכרוני, ורק אז לגשת לנושאים כמו concurrency. אם אתם עוד בשלבים המוקדמים, שווה להתחיל מקורס התכנות הבסיסי שלנו, שבונה את היסודות לפני שקופצים לנושאים המתקדמים והמבלבלים יותר.

לסיכום

ה-GIL הוא לא אויב, הוא רק מגביל את מה ש-threading יכול לתת לכם. עבור עבודה מוגבלת קלט/פלט, גם threading וגם asyncio יעזרו, כל אחד עם מודל חשיבה שונה. עבור עבודה מוגבלת חישוב, שניהם לא רלוונטיים ו-multiprocessing הוא הכלי. ברגע שהמפה הזאת ברורה לכם, כל שאר הפרטים הטכניים נופלים למקום הרבה יותר בקלות.

יש לכם שאלות על הנושא הזה או תקועים על קוד אסינכרוני שמתנהג מוזר? בואו לשתף בקהילה.

הצטרפו לקהילה בדיסקורד