שלום! אנו ממשיכים ללמוד ריבוי שרשורים, והיום נכיר מילת מפתח חדשה - נדיפה ושיטת yield() . בוא נבין מה זה :)
מילת מפתח נדיפה
בעת יצירת יישומים מרובים, אנו יכולים להתמודד עם שתי בעיות חמורות. ראשית, במהלך ההפעלה של אפליקציה מרובה הליכי שרשורים שונים יכולים לאחסן את ערכי המשתנים במטמון (על כך נדבר יותר בהרצאה "שימוש בנדיף" ). יתכן שרשור אחד שינה את הערך של משתנה, אבל השני לא ראה את השינוי הזה מכיוון שהוא עבד עם עותק מטמון משלו של המשתנה. מטבע הדברים, ההשלכות יכולות להיות חמורות. תארו לעצמכם שזה לא סתם איזה "משתנה", אלא למשל היתרה של כרטיס הבנק שלכם, שפתאום התחילה לקפוץ באקראי הלוך ושוב :) לא נעים במיוחד, נכון? שנית, בג'אווה, פעולות קריאה וכתיבה על שדות מכל הסוגים למעטlong
והם double
אטומיים. מהי אטומיות? ובכן, למשל, אם תשנה את הערך של משתנה בשרשור אחד int
, ובשרשור אחר תקרא את הערך של המשתנה הזה, תקבל או את הערך הישן שלו או את הערך החדש - זה שהתברר לאחר השינוי ב- שרשור 1. לא יופיעו שם "אפשרויות ביניים" אולי. עם זאת, זה לא עובד עם long
ו . double
למה? כי זה חוצה פלטפורמות. אתה זוכר איך אמרנו ברמות הראשונות שעיקרון הג'אווה "נכתב פעם אחת, עובד בכל מקום"? זה חוצה פלטפורמות. כלומר, אפליקציית Java פועלת על פלטפורמות שונות לחלוטין. לדוגמה, במערכות הפעלה של Windows, גרסאות שונות של לינוקס או MacOS, ובכל מקום יישום זה יעבוד ביציבות. long
וכן double
- הפרימיטיבים ה"כבדים" ביותר בג'אווה: הם שוקלים 64 סיביות. וכמה פלטפורמות של 32 סיביות פשוט לא מיישמות את האטומיות של קריאה וכתיבה של משתנים של 64 סיביות. משתנים כאלה נקראים וכותבים בשתי פעולות. ראשית, 32 הסיביות הראשונות נכתבות למשתנה, ואז עוד 32. בהתאם לכך, במקרים אלו עלולה להיווצר בעיה. שרשור אחד כותב איזה ערך של 64 סיביות למשתנהХ
, והוא עושה את זה "בשני שלבים". במקביל, השרשור השני מנסה לקרוא את הערך של המשתנה הזה, ועושה זאת בדיוק באמצע, כאשר 32 הסיביות הראשונות כבר נכתבו, אך השניים עדיין לא נכתבו. כתוצאה מכך, הוא קורא ערך ביניים, שגוי, ומתרחשת שגיאה. לדוגמה, אם בפלטפורמה כזו ננסה לכתוב מספר למשתנה - 9223372036854775809 - הוא יתפוס 64 סיביות. בצורה בינארית זה יראה כך: 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 הביט תחילה, ותחילה יכתוב את ה-00000 סיביות ראשונות למשתנה, ויכתוב תחילה את ה-00000 סיביות ראשונות: 00000 000000 00000 ואז השני 32: 0000000000000000000000000000000001 וחוט שני יכול להתקע לתוך הפער הזה קרא את ערך הביניים של המשתנה - 1000000000000000000000000000000000, 32 הסיביות הראשונות שכבר נכתבו. במערכת העשרונית, המספר הזה שווה ל-2147483648. כלומר, רק רצינו לכתוב את המספר 9223372036854775809 למשתנה, אבל בשל העובדה שהפעולה הזו בפלטפורמות מסוימות אינה אטומית, קיבלנו את המספר "שמאלי" 2147483648 , שאנחנו לא צריכים, משום מקום.ולא ידוע איך זה ישפיע על פעולת התוכנית. השרשור השני פשוט קרא את הערך של המשתנה לפני שהוא נכתב לבסוף, כלומר, הוא ראה את 32 הסיביות הראשונות, אבל לא את 32 הסיביות השניות. הבעיות הללו, כמובן, לא התעוררו אתמול, וב-Java הן נפתרות באמצעות מילת מפתח אחת בלבד - volatile . אם נכריז על משתנה כלשהו בתוכנית שלנו עם המילה נדיף...
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…זה אומר ש:
- זה תמיד ייקרא וכתוב בצורה אטומית. גם אם זה 64 סיביות
double
אוlong
. - מכונת ה-Java לא תשמור אותה במטמון. אז המצב שבו 10 שרשורים עובדים עם העותקים המקומיים שלהם אינו נכלל.
שיטת yield().
כבר בדקנו שיטות רבות של הכיתהThread
, אבל יש אחת חשובה שתהיה חדשה עבורך. זוהי שיטת yield() . תורגם מאנגלית כ"תמסר". וזה בדיוק מה שהשיטה עושה! כשאנחנו קוראים לשיטת התשואה על שרשור, זה בעצם אומר לשרשורים אחרים: "אוקיי, חבר"ה, אני לא ממהר במיוחד, אז אם זה חשוב למישהו מכם לקבל זמן מעבד, קחו את זה, אני לא דחוף." הנה דוגמה פשוטה לאיך זה עובד:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + "give way 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 הושלם. יש לנו עוד מישהו בתור?" יש Thread-0 בתור: הוא ויתר על מקומו מיד לפני Thread-1. כעת הגיע אליו העניין, והוא מבוצע עד הסוף. לאחר מכן מסיים המתזמן לתאם את השרשורים: "בסדר, שרשור-2, פינית את מקומו לשרשורים אחרים, כולם כבר עבדו. אתה היית האחרון שנכנע, אז עכשיו תורך". לאחר מכן, Thread-2 פועל עד לסיומו. פלט הקונסולה ייראה כך: Thread-0 מפנה את מקומו לאחרים Thread-1 מפנה את מקומו לאחרים Thread-2 מפנה את מקומו לאחרים Thread-1 סיים להפעיל. Thread-0 הסתיים להפעיל. Thread-2 הסתיים להפעיל. מתזמן השרשורים, כמובן, יכול להריץ שרשורים בסדר אחר (לדוגמה, 2-1-0 במקום 0-1-2), אבל העיקרון זהה.
קורה-לפני חוקים
הדבר האחרון שניגע בו היום הוא עקרונות ה" קורה לפני ". כפי שאתה כבר יודע, ב-Java, רוב העבודה של הקצאת זמן ומשאבים לשרשורים כדי להשלים את המשימות שלהם נעשית על ידי מתזמן השרשור. ראית גם יותר מפעם אחת איך שרשורים מבוצעים בסדר שרירותי, ולרוב אי אפשר לחזות זאת. ובכלל, אחרי התכנות ה"רציף" שעשינו קודם, ריבוי השרשורים נראה כמו דבר אקראי. כפי שכבר ראית, ניתן לשלוט בהתקדמות של תוכנית מרובה הליכי באמצעות מערכת שלמה של שיטות. אבל בנוסף לזה, ב-Java multithreading יש עוד "אי של יציבות" - 4 כללים הנקראים " קורה-לפני ". מילולית מאנגלית זה מתורגם כ"קורה לפני", או "קורה לפני". המשמעות של כללים אלה היא די פשוטה להבנה. תאר לעצמך שיש לנו שני חוטים -A
ו B
. כל אחד מהשרשורים הללו יכול לבצע פעולות 1
ו 2
. וכאשר בכל אחד מהכללים אנו אומרים " A קורה-לפני B ", זה אומר שכל השינויים שעשה השרשור A
לפני הפעולה 1
והשינויים שהפעולה הזו כרוכה גלויים לשרשור B
בזמן ביצוע הפעולה 2
. לאחר ביצוע הפעולה. כל אחד מהכללים הללו מבטיח שכאשר כותבים תוכנית מרובת חוטים, אירועים מסוימים יקרו לפני אחרים ב-100% מהזמן, ושהשרשור B
בזמן הפעולה 2
תמיד יהיה מודע לשינויים שהשרשור А
עשה במהלך הפעולה 1
. בואו נסתכל עליהם.
חוק מספר 1.
שחרור mutex מתרחש לפני מתרחש לפני שרשור אחר רוכש את אותו צג. ובכן, הכל נראה ברור כאן. אם המוטקס של אובייקט או מחלקה נרכש על ידי חוט אחד, למשל, חוטА
, חוט אחר (חוט B
) לא יכול לרכוש אותו בו-זמנית. אתה צריך לחכות עד שה-mutex ישוחרר.
כלל 2.
השיטהThread.start()
קורה לפני Thread.run()
. גם שום דבר לא מסובך. אתה כבר יודע: כדי שהקוד בתוך השיטה יתחיל לבצע run()
, אתה צריך לקרוא למתודה בשרשור start()
. זה שלו, ולא השיטה עצמה run()
! כלל זה מבטיח שהערכים Thread.start()
של כל המשתנים שהוגדרו לפני הביצוע יהיו גלויים בתוך השיטה שהתחילה בביצוע run()
.
כלל 3.
השלמת השיטהrun()
מתרחשת לפני יציאת השיטה join()
. נחזור לשני הזרמים שלנו - А
ו B
. אנו קוראים לשיטה join()
בצורה כזו שהשרשור B
חייב להמתין עד לסיומו A
לפני ביצוע עבודתו. המשמעות היא ששיטת run()
האובייקט A בהחלט תפעל עד הסוף. וכל השינויים בנתונים המתרחשים בשיטת run()
השרשור A
יהיו גלויים לחלוטין בשרשור B
כאשר הוא ממתין להשלמה A
ומתחיל לעבוד בעצמו.
כלל 4.
כתיבה למשתנה נדיף מתרחשת לפני קריאה מאותו משתנה. על ידי שימוש במילת המפתח הפכפכה, נקבל, למעשה, תמיד את הערך הנוכחי. גם במקרה שלlong
ו double
, הבעיות שבהן נדונו קודם לכן. כפי שכבר הבנתם, שינויים שנעשו בשרשורים מסוימים לא תמיד גלויים לשרשורים אחרים. אבל, כמובן, לעתים קרובות מאוד יש מצבים שבהם התנהגות תוכנית כזו לא מתאימה לנו. נניח שהקצנו ערך למשתנה בשרשור A
:
int z;
….
z= 555;
אם השרשור שלנו B
היה מדפיס את הערך של משתנה z
לקונסולה, הוא יכול בקלות להדפיס 0 כי הוא לא יודע על הערך שהוקצה לו. אז, כלל 4 מבטיח לנו: אם אתה מכריז על משתנה z
כנדיף, שינויים בערכים שלו בשרשור אחד תמיד יהיו גלויים בשרשור אחר. אם נוסיף את המילה נדיף לקוד הקודם...
volatile int z;
….
z= 555;
...לא נכלל המצב בו הזרם B
יוציא 0 לקונסולה. כתיבה למשתנים נדיפים מתרחשת לפני הקריאה מהם.
GO TO FULL VERSION