Smart-World Surf

יחידה 7: ריבוי תהליכונים (Multithreading)

ביצוע מקבילי של משימות וניהול תהליכים ב-Java.
יצירת תהליכונים (ThreadRunnable)סנכרון (synchronized)מצבי תהליכוןThread Pools

ברוכים הבאים ליחידת הלימוד בנושא ריבוי תהליכונים (Multithreading) בקורס "תכנות מתקדם בשפת Java". יחידה זו חיונית להבנת אופן ביצוע מקבילי של משימות וניהול תהליכים ב-Java, יכולת המאפשרת בניית יישומים מגיבים, יעילים וחזקים יותר. נלמד כיצד ליצור תהליכונים, לסנכרן את פעולתם כדי למנוע בעיות נפוצות, להבין את מצבי חייהם השונים ולנהל אותם ביעילות באמצעות Thread Pools. שליטה בחומר זה קריטית להצלחה בקורס ובתכנות מערכות מודרניות.

יצירת תהליכונים ב-Java

Java מספקת שתי דרכים עיקריות ליצירת תהליכונים (Threads) המאפשרים לתוכנית לבצע מספר משימות במקביל.

הרחבת המחלקה Thread

דרך זו כוללת יצירת מחלקה חדשה המרחיבה את המחלקה java.lang.Thread ודורסת את המתודה run(). בתוך run() נכתוב את הלוגיקה שהתהליכון אמור לבצע. כדי להפעיל את התהליכון, יוצרים מופע של המחלקה וקוראים למתודה start().

מימוש הממשק Runnable

דרך זו כוללת יצירת מחלקה המממשת את הממשק java.lang.Runnable ודורסת את המתודה run(). לאחר מכן, יוצרים מופע של מחלקה זו, מעבירים אותו כפרמטר לבנאי של אובייקט Thread חדש, וקוראים למתודה start() של אובייקט ה-Thread. זו הדרך המועדפת בדרך כלל, מכיוון שהיא מאפשרת למחלקה לרשת ממחלקה אחרת.

ההבדל בין run() ל-start()

  • start(): זו המתודה שמתחילה את ביצוע התהליכון החדש. היא יוצרת תהליכון מערכת חדש, מקצה לו משאבים, וקוראת למתודה run() שלו בתהליכון החדש.
  • run(): זו המתודה שמכילה את הקוד שיתבצע על ידי התהליכון. קריאה ישירה ל-run() לא תיצור תהליכון חדש, אלא תבצע את הקוד באותו תהליכון שקרא לה, כאילו הייתה מתודה רגילה.
Thread: יחידת ביצוע עצמאית בתוך תהליך (Process). לכל תהליכון יש מחסנית קריאות (call stack) משלו, אך הוא חולק את זיכרון התהליך עם תהליכונים אחרים באותו תהליך.
Runnable: ממשק פונקציונלי המגדיר מתודה יחידה בשם run(). הוא מייצג משימה שניתן לבצע על ידי תהליכון.

סנכרון ובעיות בביצוע מקבילי

כאשר מספר תהליכונים חולקים משאבים (כמו משתנים או אובייקטים), עלולות להיווצר בעיות של חוסר עקביות בנתונים או מצבי מירוץ (Race Conditions).

מצבי מירוץ ונעילות

מצב מירוץ (Race Condition): מתרחש כאשר מספר תהליכונים מנסים לגשת ולשנות משאב משותף בו-זמנית, והתוצאה הסופית תלויה בסדר הלא-צפוי שבו התהליכונים מבצעים את פעולותיהם.

Race Condition: מצב בו מספר תהליכונים ניגשים למשאב משותף, והתוצאה תלויה בסדר הלא-דטרמיניסטי של הגישות.

כדי למנוע מצבי מירוץ, אנו משתמשים במנגנוני סנכרון. המנגנון הבסיסי ביותר ב-Java הוא מילת המפתח synchronized.

synchronized: מילת מפתח ב-Java המשמשת להבטחת גישה בלעדית למקטע קוד או למתודה על ידי תהליכון אחד בלבד בכל רגע נתון. היא מבוססת על מנגנון של מנעולים (monitors).

שימוש ב-synchronized

  • מתודה מסונכרנת: כאשר מתודה מסומנת כ-synchronized, רק תהליכון אחד יכול לבצע אותה על אובייקט נתון בכל רגע נתון. המנעול נלקח על ידי האובייקט עצמו (this).
  • בלוק מסונכרן: ניתן לסנכרן בלוק קוד ספציפי על אובייקט מסוים. זה מאפשר שליטה עדינה יותר על הסנכרון, ומאפשר לתהליכונים אחרים לגשת לחלקים לא מסונכרנים של אותו אובייקט.
    synchronized (someObject) {
        // קוד קריטי
    }

תקשורת בין תהליכונים: wait(), notify(), notifyAll()

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

  • wait(): גורמת לתהליכון הנוכחי לשחרר את המנעול ולהיכנס למצב המתנה (WAITING) עד שתהליכון אחר יקרא ל-notify() או notifyAll() על אותו אובייקט.
  • notify(): מעירה תהליכון אחד (שנבחר באופן שרירותי) מבין התהליכונים הממתינים על אותו אובייקט.
  • notifyAll(): מעירה את כל התהליכונים הממתינים על אותו אובייקט.
Deadlock (מבוי סתום): מצב קריטי בביצוע מקבילי שבו שני תהליכונים או יותר חוסמים זה את זה באופן הדדי, כאשר כל אחד מהם ממתין למשאב שהשני מחזיק בו. לדוגמה, תהליכון A מחזיק במשאב X וממתין למשאב Y, בעוד תהליכון B מחזיק במשאב Y וממתין למשאב X. מצב זה מוביל לקיפאון של היישום כולו. הבנה וזיהוי דדלוקים חיוניים לתכנון מערכות מקביליות יציבות.

מצבי תהליכון

תהליכון ב-Java עובר מספר מצבים במהלך מחזור חייו. הבנת המצבים הללו חיונית לאיתור באגים ולתכנון נכון של יישומים מקביליים.

  • NEW: התהליכון נוצר, אך טרם הופעל (טרם נקראה המתודה start()).
  • RUNNABLE: התהליכון מוכן לביצוע וייתכן שהוא רץ כעת, או ממתין למתזמן המערכת (scheduler) שיקצה לו זמן מעבד.
  • BLOCKED: התהליכון חסום וממתין למנעול (monitor lock) כדי להיכנס לבלוק או מתודה מסונכרנים.
  • WAITING: התהליכון ממתין באופן בלתי מוגבל לפעולה של תהליכון אחר (לדוגמה, קריאה ל-wait() ללא פרמטר זמן, join(), LockSupport.park()).
  • TIMED_WAITING: התהליכון ממתין לפרק זמן מוגבל לפעולה של תהליכון אחר (לדוגמה, קריאה ל-sleep(), wait(long timeout), join(long timeout)).
  • TERMINATED: התהליכון סיים את ביצועו או הופסק.
Thread State: המצב הנוכחי של תהליכון במחזור חייו, המצביע על פעילותו או על סיבת ההמתנה שלו.

ניהול תהליכונים עם Thread Pools

יצירה והשמדה של תהליכונים הם תהליכים יקרים מבחינת משאבי מערכת. יצירת מספר רב של תהליכונים קצרי-חיים עלולה לפגוע בביצועים. Thread Pools מספקים פתרון יעיל לניהול תהליכונים.

Thread Pool: אוסף של תהליכונים שנוצרו מראש ומוכנים לבצע משימות. במקום ליצור תהליכון חדש לכל משימה, המשימות מוגשות ל-pool, והתהליכונים הקיימים מבצעים אותן.

היתרונות של Thread Pools

  • שיפור ביצועים: מפחית את העלות של יצירה והשמדה חוזרת ונשנית של תהליכונים.
  • ניהול משאבים: מגביל את מספר התהליכונים הפעילים בו-זמנית, מונע עומס יתר על המערכת.
  • גמישות: מאפשר תזמון משימות, ביצוע משימות עתידיות (Future) וניהול חריגים.

ממשק ExecutorService ומחלקת Executors

Java מספקת את ה-Executor Framework לניהול Thread Pools. הממשק המרכזי הוא ExecutorService, ומחלקת ה-Executors מספקת שיטות נוחות ליצירת סוגים שונים של Thread Pools:

  • Executors.newFixedThreadPool(int nThreads): יוצר Pool עם מספר קבוע של תהליכונים.
  • Executors.newCachedThreadPool(): יוצר Pool שיכול לגדול לפי הצורך, אך הורג תהליכונים שלא היו בשימוש זמן מה.
  • Executors.newSingleThreadExecutor(): יוצר Pool עם תהליכון יחיד, המבטיח ביצוע סדרתי של משימות.
ExecutorService: ממשק ב-Java המספק שיטות לניהול תהליכונים ב-Thread Pool, כולל הגשת משימות, כיבוי ה-Pool וקבלת תוצאות.
Future: אובייקט המייצג את התוצאה של חישוב אסינכרוני. הוא מאפשר לבדוק אם המשימה הושלמה, לבטל אותה ולקבל את התוצאה כאשר היא זמינה.

שאלות לדיון

  • הסבירו מדוע עדיף לממש את הממשק Runnable מאשר להרחיב את המחלקה Thread בעת יצירת תהליכון חדש. תנו דוגמה למצב שבו הרחבת Thread עלולה ליצור בעיה.
  • תארו מצב מירוץ קלאסי (Race Condition) בתוכנית Java הכוללת מספר תהליכונים. הציעו פתרון באמצעות מילת המפתח synchronized.
  • הסבירו את ההבדל בין מצבי התהליכון BLOCKED ו-WAITING. מתי תהליכון יעבור לכל אחד מהמצבים הללו?
  • כיצד Thread Pools תורמים לשיפור ביצועים ויציבות של יישומים מקביליים? אילו סוגי Thread Pools אתם מכירים ומתי נשתמש בכל אחד מהם?

נקודות לתשובת מודל

לשאלה: הסבירו מדוע עדיף לממש את הממשק Runnable מאשר להרחיב את המחלקה Thread בעת יצירת תהליכון חדש. תנו דוגמה למצב שבו הרחבת Thread עלולה ליצור בעיה.

  • עקרון ירושה יחידה: Java תומכת בירושה יחידה בלבד. אם מחלקה כבר יורשת ממחלקה אחרת, היא לא יכולה להרחיב גם את Thread. מימוש Runnable עוקף מגבלה זו.
  • הפרדת אחריויות: מימוש Runnable מפריד בין הלוגיקה של המשימה לבין המנגנון של התהליכון. המחלקה שמממשת Runnable אחראית רק על ה"מה" (מה לבצע), ואילו אובייקט ה-Thread אחראי על ה"איך" (איך לבצע את המשימה בתהליכון חדש).
  • גמישות ושימוש חוזר: אותו אובייקט Runnable יכול להיות מוגש למספר אובייקטי Thread שונים, או ל-Thread Pool, מה שמאפשר גמישות ושימוש חוזר בקוד המשימה.
  • דוגמה לבעיה: נניח שיש לנו מחלקה MyTask שצריכה לבצע משימה מסוימת וגם לרשת ממחלקה BaseClass (לדוגמה, מחלקה שמספקת פונקציונליות בסיסית ליישום). אם MyTask תנסה להרחיב גם את Thread וגם את BaseClass, הדבר יגרום לשגיאת קומפילציה עקב מגבלת הירושה היחידה של Java. מימוש Runnable מאפשר ל-MyTask לרשת מ-BaseClass ועדיין להיות משימה הניתנת להרצה בתהליכון.
מצאתם טעות או שחסר משהו?
→ הקודמת
קבצים וזרמי מידע
הבאה ←
ממשקי משתמש גרפיים (GUI)