ברוכים הבאים ליחידת הלימוד בנושא ריבוי תהליכונים (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()לא תיצור תהליכון חדש, אלא תבצע את הקוד באותו תהליכון שקרא לה, כאילו הייתה מתודה רגילה.
run(). הוא מייצג משימה שניתן לבצע על ידי תהליכון.סנכרון ובעיות בביצוע מקבילי
כאשר מספר תהליכונים חולקים משאבים (כמו משתנים או אובייקטים), עלולות להיווצר בעיות של חוסר עקביות בנתונים או מצבי מירוץ (Race Conditions).
מצבי מירוץ ונעילות
מצב מירוץ (Race Condition): מתרחש כאשר מספר תהליכונים מנסים לגשת ולשנות משאב משותף בו-זמנית, והתוצאה הסופית תלויה בסדר הלא-צפוי שבו התהליכונים מבצעים את פעולותיהם.
כדי למנוע מצבי מירוץ, אנו משתמשים במנגנוני סנכרון. המנגנון הבסיסי ביותר ב-Java הוא מילת המפתח synchronized.
שימוש ב-synchronized
- מתודה מסונכרנת: כאשר מתודה מסומנת כ-
synchronized, רק תהליכון אחד יכול לבצע אותה על אובייקט נתון בכל רגע נתון. המנעול נלקח על ידי האובייקט עצמו (this). - בלוק מסונכרן: ניתן לסנכרן בלוק קוד ספציפי על אובייקט מסוים. זה מאפשר שליטה עדינה יותר על הסנכרון, ומאפשר לתהליכונים אחרים לגשת לחלקים לא מסונכרנים של אותו אובייקט.
synchronized (someObject) { // קוד קריטי }
תקשורת בין תהליכונים: wait(), notify(), notifyAll()
מתודות אלו, המוגדרות במחלקת Object, מאפשרות לתהליכונים לתקשר ביניהם ולשנות את מצבם בהתאם לתנאים מסוימים. הן חייבות להיקרא מתוך בלוק או מתודה מסונכרנים.
wait(): גורמת לתהליכון הנוכחי לשחרר את המנעול ולהיכנס למצב המתנה (WAITING) עד שתהליכון אחר יקרא ל-notify()אוnotifyAll()על אותו אובייקט.notify(): מעירה תהליכון אחד (שנבחר באופן שרירותי) מבין התהליכונים הממתינים על אותו אובייקט.notifyAll(): מעירה את כל התהליכונים הממתינים על אותו אובייקט.
מצבי תהליכון
תהליכון ב-Java עובר מספר מצבים במהלך מחזור חייו. הבנת המצבים הללו חיונית לאיתור באגים ולתכנון נכון של יישומים מקביליים.
- NEW: התהליכון נוצר, אך טרם הופעל (טרם נקראה המתודה
start()). - RUNNABLE: התהליכון מוכן לביצוע וייתכן שהוא רץ כעת, או ממתין למתזמן המערכת (scheduler) שיקצה לו זמן מעבד.
- BLOCKED: התהליכון חסום וממתין למנעול (monitor lock) כדי להיכנס לבלוק או מתודה מסונכרנים.
- WAITING: התהליכון ממתין באופן בלתי מוגבל לפעולה של תהליכון אחר (לדוגמה, קריאה ל-
wait()ללא פרמטר זמן,join(),LockSupport.park()). - TIMED_WAITING: התהליכון ממתין לפרק זמן מוגבל לפעולה של תהליכון אחר (לדוגמה, קריאה ל-
sleep(),wait(long timeout),join(long timeout)). - TERMINATED: התהליכון סיים את ביצועו או הופסק.
ניהול תהליכונים עם Thread Pools
יצירה והשמדה של תהליכונים הם תהליכים יקרים מבחינת משאבי מערכת. יצירת מספר רב של תהליכונים קצרי-חיים עלולה לפגוע בביצועים. Thread Pools מספקים פתרון יעיל לניהול תהליכונים.
היתרונות של 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 עם תהליכון יחיד, המבטיח ביצוע סדרתי של משימות.
שאלות לדיון
- הסבירו מדוע עדיף לממש את הממשק
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ועדיין להיות משימה הניתנת להרצה בתהליכון.