היי! אנו ממשיכים במחקר שלנו על ריבוי הליכים. היום נכיר את
כשאנחנו קוראים לשיטה
volatile
מילת המפתח ואת yield()
השיטה. בואו נצלול פנימה :)
מילת המפתח ההפכפכה
בעת יצירת יישומים מרובים, אנו יכולים להיתקל בשתי בעיות חמורות. ראשית, כאשר פועלת אפליקציה מרובה הליכי, שרשורים שונים יכולים לאחסן את הערכים של משתנים (כבר דיברנו על כך בשיעור שכותרתו 'שימוש בהפכפך' ). אתה יכול לקבל את המצב שבו שרשור אחד משנה את הערך של משתנה, אבל שרשור שני לא רואה את השינוי, כי הוא עובד עם העותק השמור שלו של המשתנה. מטבע הדברים, ההשלכות יכולות להיות חמורות. נניח שזה לא סתם משתנה ישן אלא יתרת חשבון הבנק שלך, שפתאום מתחילה לקפוץ באקראי מעלה ומטה :) זה לא נשמע כיף, נכון? שנית, ב-Java, פעולות לקריאה וכתיבה של כל הסוגים הפרימיטיביים, למעטlong
ו- double
, הן אטומיות. ובכן, למשל, אם תשנה ערך של משתנה int
בשרשור אחד, ובשרשור אחר תקרא את הערך של המשתנה, תקבל את הערך הישן שלו או את הערך החדש, כלומר הערך שנבע מהשינוי בשרשור 1. אין 'ערכי ביניים'. עם זאת, זה לא עובד עם long
s ו- double
s. למה? בגלל תמיכה בין פלטפורמות. זוכרים ברמות ההתחלה שאמרנו שהעיקרון המנחה של Java הוא 'כתוב פעם אחת, רץ בכל מקום'? זה אומר תמיכה חוצת פלטפורמות. במילים אחרות, אפליקציית Java פועלת על כל מיני פלטפורמות שונות. לדוגמה, במערכות הפעלה Windows, גרסאות שונות של Linux או MacOS. זה יפעל ללא תקלות על כולם. שוקל 64 סיביות, long
והם double
הפרימיטיבים 'הכבדים' ביותר בג'אווה. ופלטפורמות מסוימות של 32 סיביות פשוט אינן מיישמות קריאה וכתיבה אטומית של משתני 64 סיביות. משתנים כאלה נקראים וכותבים בשתי פעולות. ראשית, 32 הסיביות הראשונות נכתבות למשתנה, ולאחר מכן נכתבות עוד 32 סיביות. כתוצאה מכך עלולה להיווצר בעיה. שרשור אחד כותב איזה ערך של 64 סיביות X
למשתנה ועושה זאת בשתי פעולות. במקביל, חוט שני מנסה לקרוא את הערך של המשתנה ועושה זאת בין שתי הפעולות הללו - כאשר 32 הסיביות הראשונות נכתבו, אך 32 הסיביות השניות לא. כתוצאה מכך, הוא קורא ערך ביניים, שגוי, ויש לנו באג. לדוגמה, אם בפלטפורמה כזו ננסה לכתוב את המספר ל -9223372036854775809 למשתנה, הוא יתפוס 64 סיביות. בצורה בינארית זה נראה כך: 100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001 השרשור הראשון מתחיל לכתוב את המספר למשתנה. בהתחלה, הוא כותב את 32 הסיביות הראשונות (1000000000000000000000000000000000000000000000000000000000000) ולאחר מכן את 32 הביטים השניים (000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001) והפתיל השני יכול להיתקע בין הפעולות האלה, קריאת ערך הביניים של המשתנה (1000000000000000000000000000000000000000000000000000000000), שהם 32 הביטים הראשונים שכבר נכתבו. בשיטה העשרונית, מספר זה הוא 2,147,483,648. במילים אחרות, רק רצינו לכתוב את המספר 9223372036854775809 למשתנה, אבל בשל העובדה שהפעולה הזו אינה אטומית בפלטפורמות מסוימות, יש לנו את המספר המרושע 2,147,483,648, שהגיע משום מקום ויהיה לו השפעה לא ידועה תכנית. השרשור השני פשוט קרא את הערך של המשתנה לפני שסיים להיכתב, כלומר השרשור ראה את 32 הסיביות הראשונות, אבל לא את 32 הסיביות השניות. כמובן שהבעיות האלה לא התעוררו אתמול. Java פותרת אותם עם מילת מפתח אחת: volatile
. אם נשתמש במילת volatile
המפתח כאשר אנו מצהירים על משתנה כלשהו בתוכנית שלנו...
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…זה אומר ש:
- זה תמיד ייקרא ויכתוב בצורה אטומית. גם אם זה 64 סיביות
double
אוlong
. - מכונת ה-Java לא תשמור אותה במטמון. אז לא יהיה לך מצב שבו 10 שרשורים עובדים עם עותקים מקומיים משלהם.
שיטת yield()
כבר סקרנו הרבה מהשיטותThread
של הכיתה, אבל יש אחת חשובה שתהיה חדשה עבורכם. זו yield()
השיטה . והוא עושה בדיוק את מה שהשם שלו מרמז! 
yield
על שרשור, היא למעשה מדברת עם השרשורים האחרים: 'היי, חבר'ה. אני לא ממהר במיוחד ללכת לשום מקום, אז אם חשוב למישהו מכם לקבל זמן מעבד, קחו אותו - אני יכול לחכות'. הנה דוגמה פשוטה לאיך זה עובד:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + " yields its place to others");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
אנו יוצרים ומתחילים שלושה שרשורים ברצף: Thread-0
, Thread-1
, ו Thread-2
. Thread-0
מתחיל קודם ומיד נכנע לאחרים. ואז Thread-1
הוא התחיל וגם מניב. ואז Thread-2
מתחיל, שגם מניב. אין לנו יותר שרשורים, ואחרי Thread-2
שנתן את מקומו האחרון, מתזמן השרשור אומר, 'הממ, אין יותר שרשורים חדשים. מי יש לנו בתור? מי נתן את מקומו לפני כן Thread-2
? נראה שכן Thread-1
. אוקיי, זה אומר שאנחנו ניתן לזה לרוץ'. Thread-1
משלים את עבודתו ואז מתזמן השרשור ממשיך בתיאום שלו: 'אוקיי, Thread-1
סיימתי. יש לנו עוד מישהו בתור?'. שרשור-0 נמצא בתור: הוא הניב את מקומו ממש לפני Thread-1
. עכשיו זה מגיע לתורו ורץ להשלמה. ואז המתזמן מסיים לתאם את השרשורים: 'אוקיי, Thread-2
, נכנעת לשרשורים אחרים, וכולם סיימו עכשיו. אתה היית האחרון שנכנע, אז עכשיו תורך'. ואז Thread-2
רץ לסיום. פלט הקונסולה ייראה כך: Thread-0 נותן את מקומו לאחרים Thread-1 נותן את מקומו לאחרים Thread-2 נותן את מקומו לאחרים Thread-1 סיים להפעיל. Thread-0 הסתיים להפעיל. Thread-2 הסתיים להפעיל. כמובן, מתזמן השרשורים עשוי להתחיל את השרשורים בסדר שונה (לדוגמה, 2-1-0 במקום 0-1-2), אך העיקרון נשאר זהה.
קורה-לפני חוקים
הדבר האחרון שניגע בו היום הוא המושג ' קורה לפני '. כפי שאתה כבר יודע, ב-Java מתזמן השרשורים מבצע את עיקר העבודה הכרוכה בהקצאת זמן ומשאבים לשרשורים לביצוע המשימות שלהם. ראית שוב ושוב כיצד שרשורים מבוצעים בסדר אקראי שבדרך כלל בלתי אפשרי לחזות אותו. ובאופן כללי, לאחר התכנות ה'רציף' שעשינו קודם, תכנות מרובה הליכי נראה כמו משהו אקראי. כבר הגעת להאמין שאתה יכול להשתמש במגוון שיטות כדי לשלוט בזרימה של תוכנית מרובה הליכי. אבל לריבוי השרשורים בג'אווה יש עוד נדבך אחד - 4 הכללים ' קורה-לפני '. הבנת הכללים הללו היא די פשוטה. תארו לעצמכם שיש לנו שני חוטים -A
ו B
. כל אחד מהשרשורים הללו יכול לבצע פעולות 1
ו 2
. בכל כלל, כאשר אנו אומרים ' A קורה-לפני B ', אנו מתכוונים לכך שכל השינויים שנעשו על ידי שרשור A
לפני הפעולה 1
והשינויים הנובעים מפעולה זו גלויים לשרשור B
בעת 2
ביצוע הפעולה ולאחריה. כל כלל מבטיח שכאשר אתה כותב תוכנית מרובה הליכי, אירועים מסוימים יתרחשו לפני אחרים ב-100% מהזמן, וכי בזמן ההפעלה 2
השרשור B
תמיד יהיה מודע לשינויים שהשרשור A
עשה במהלך הפעולה 1
. בואו נסקור אותם.
חוק מספר 1.
שחרור mutex מתרחש לפני שאותו צג נרכש על ידי שרשור אחר. אני חושב שאתה מבין הכל כאן. אם mutex של אובייקט או מחלקה נרכש על ידי thread אחד. לדוגמה, על ידי threadA
, thread אחר (thread B
) לא יכול לרכוש אותו בו-זמנית. זה חייב להמתין עד לשחרור המוטקס.
כלל 2.
השיטהThread.start()
מתרחשת לפני Thread.run()
. שוב, שום דבר לא קשה כאן. אתה כבר יודע שכדי להתחיל להריץ את הקוד בתוך run()
השיטה, עליך לקרוא למתודה start()
בשרשור. ספציפית, שיטת ההתחלה, לא run()
השיטה עצמה! כלל זה מבטיח שהערכים של כל המשתנים שהוגדרו לפני Thread.start()
הקריאה יהיו גלויים בתוך run()
השיטה ברגע שהיא מתחילה.
כלל 3.
סוף השיטהrun()
קורה לפני החזרה מהשיטה join()
. נחזור לשני השרשורים שלנו: A
ו B
. אנו קוראים join()
לשיטה כך שפתיל B
מובטח ימתין להשלמת השרשור A
לפני שהוא יעשה את עבודתו. המשמעות היא ששיטת האובייקט A run()
מובטחת לפעול עד הסוף. וכל השינויים בנתונים שקורים בשיטת run()
השרשור A
מובטחים במאה אחוז שיהיו גלויים בשרשור B
ברגע שהוא יסתיים בהמתנה שהשרשור A
יסיים את עבודתו כדי שיוכל להתחיל בעבודה משלו.
כלל 4.
volatile
כתיבה למשתנה מתרחשת לפני קריאה מאותו משתנה. כאשר אנו משתמשים במילת volatile
המפתח, אנו למעשה תמיד מקבלים את הערך הנוכחי. אפילו עם long
או double
(דיברנו קודם על בעיות שיכולות לקרות כאן). כפי שכבר הבנתם, שינויים שנעשו בשרשורים מסוימים לא תמיד גלויים לשרשורים אחרים. אבל, כמובן, יש מצבים תכופים מאוד שבהם התנהגות כזו לא מתאימה לנו. נניח שאנו מקצים ערך למשתנה ב-thread A
:
int z;
….
z = 555;
אם B
השרשור שלנו צריך להציג את הערך של z
המשתנה במסוף, הוא יכול בקלות להציג 0, כי הוא לא יודע על הערך שהוקצה. אבל כלל 4 מבטיח שאם נכריז על z
המשתנה כ- volatile
, אז שינויים בערכו בשרשור אחד תמיד יהיו גלויים בשרשור אחר. אם נוסיף למילה volatile
לקוד הקודם...
volatile int z;
….
z = 555;
...אז אנו מונעים את המצב שבו שרשור B
עשוי להציג 0. כתיבה volatile
למשתנים מתרחשת לפני הקריאה מהם.
GO TO FULL VERSION