מלכודות נפוצות במקביל בג'אווה

1. הקדמה

במדריך זה אנו נראה כמה מהבעיות המקבילות הנפוצות ביותר בג'אווה. נלמד גם כיצד להימנע מהם ומהגורמים העיקריים שלהם.

2. שימוש בחפצים בטוחים בחוטים

2.1. שיתוף אובייקטים

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

הדרך העיקרית בה אנו יכולים להימנע מבעיות מקבילות כאלה ולבנות קוד אמין היא לעבוד עם אובייקטים בלתי ניתנים לשינוי. הסיבה לכך היא שלא ניתן לשנות את מצבם על ידי הפרעה של מספר אשכולות.

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

2.2. הפיכת אוספים לבטיחות הברגה

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

מפת מפה = Collections.synchronizedMap (HashMap חדש ()); רשימת רשימה = Collections.synchronizedList (ArrayList חדש ());

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

2.3. אוספים מרובי הליכי מומחים

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

מסיבה זו, Java מספקת אוספים בו זמנית כגון CopyOnWriteArrayList ו ConcurrentHashMap אליהם ניתן לגשת בו זמנית באמצעות מספר אשכולות:

רשימת CopyOnWriteArrayList = CopyOnWriteArrayList חדש (); מפת מפה = ConcurrentHashMap חדש ();

ה CopyOnWriteArrayList משיגה בטיחות חוטים על ידי יצירת עותק נפרד של המערך הבסיסי לפעולות מוטטיביות כמו הוספה או הסרה. למרות שיש לו ביצועים גרועים יותר עבור פעולות כתיבה מאשר Collections.synchronizedList, זה מספק לנו ביצועים טובים יותר כאשר אנו זקוקים לקריאות משמעותיות יותר מכתיבה.

ConcurrentHashMap הוא ביסודו חוט בטוח והוא ביצועים יותר מ- Collections.synchronizedMap עוטף סביב כיסוי שאינו בטוח לחוט מַפָּה. זו למעשה מפה בטוחה של חוטים של מפות בטיחות חוטים, המאפשרת פעילויות שונות להתרחש בו זמנית במפות הילדים שלה.

2.4. עבודה עם סוגים שאינם בטוחים בחוטים

לעתים קרובות אנו משתמשים בחפצים מובנים כמו SimpleDateFormat לנתח ולעצב אובייקטים של תאריכים. ה SimpleDateFormat מחלקה משנה את מצבה הפנימי תוך כדי פעולותיו.

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

אז איך נוכל להשתמש ב- SimpleDateFormat בצורה בטוחה? יש לנו מספר אפשרויות:

  • צור מופע חדש של SimpleDateFormat בכל פעם שהוא משמש
  • הגבל את מספר האובייקטים שנוצרו באמצעות ThreadLocal לְהִתְנַגֵד. זה מבטיח שלכל חוט יהיה מופע משלו SimpleDateFormat
  • סנכרן גישה בו זמנית על ידי מספר אשכולות עם ה- מסונכרן מילת מפתח או נעילה

SimpleDateFormat היא רק דוגמה אחת לכך. אנו יכולים להשתמש בטכניקות אלה בכל סוג שאינו בטוח בחוטים.

3. תנאי מרוץ

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

3.1. דוגמא למצב גזע

בואו ניקח בחשבון את הקוד הבא:

מונה בכיתה {מונה אינטימי פרטי = 0; תוספת חלל ציבורית () {counter ++; } public int getValue () {מונה החזרה; }}

ה דֶלְפֵּק class מתוכנן כך שכל הפעלה של שיטת התוספת תוסיף 1 ל- דֶלְפֵּק. עם זאת, אם א דֶלְפֵּק אובייקט מופנה ממספר אשכולות, ההפרעה בין האשכולות עשויה למנוע את התרחשותו כצפוי.

אנחנו יכולים לפרק את מונה ++ הצהרה לשלושה שלבים:

  • אחזר את הערך הנוכחי של דֶלְפֵּק
  • הגדל את הערך שאוחזר ב -1
  • אחסן את הערך המוגבר חזרה פנימה דֶלְפֵּק

בואו נניח ששני חוטים, חוט 1 ו חוט 2, להפעיל את שיטת התוספת בעת ובעונה אחת. פעולותיהם המשולבות עשויות לעקוב אחר רצף זה:

  • חוט 1 קורא את הערך הנוכחי של דֶלְפֵּק; 0
  • חוט 2 קורא את הערך הנוכחי של דֶלְפֵּק; 0
  • חוט 1 מגדיל את הערך שאוחזר; התוצאה היא 1
  • חוט 2 מגדיל את הערך שאוחזר; התוצאה היא 1
  • חוט 1 מאחסן את התוצאה ב דֶלְפֵּק; התוצאה היא כעת 1
  • חוט 2 מאחסן את התוצאה ב דֶלְפֵּק; התוצאה היא כעת 1

ציפינו לערך של דֶלְפֵּק להיות 2, אבל זה היה 1.

3.2. פתרון מבוסס סינכרון

אנו יכולים לתקן את חוסר העקביות על ידי סנכרון הקוד הקריטי:

מחלקה SynchronizedCounter {מונה int פרטי = 0; תוספת חלל מסונכרנת ציבורית () {counter ++; } ציבורי מסונכרן int getValue () {מונה החזרה; }}

מותר להשתמש רק בשרשור אחד מסונכרן שיטות של אובייקט בכל עת, ולכן זה כופה עקביות בקריאה ובכתיבה של דֶלְפֵּק.

3.3. פתרון מובנה

אנו יכולים להחליף את הקוד שלמעלה עם מובנה AtomicInteger לְהִתְנַגֵד. מחלקה זו מציעה, בין היתר, שיטות אטומיות לתוספת מספר שלם והיא פיתרון טוב יותר מאשר כתיבת הקוד שלנו. לכן, אנו יכולים לקרוא לשיטות שלה ישירות ללא צורך בסנכרון:

AtomicInteger atomicInteger = AtomicInteger חדש (3); atomicInteger.incrementAndGet ();

במקרה זה, ה- SDK פותר לנו את הבעיה. אחרת, היינו יכולים גם לכתוב קוד משלנו, ולכלול את החלקים הקריטיים במחלקה המותאמת אישית לבטיחות פתילים. גישה זו עוזרת לנו למזער את המורכבות ולמקסם את השימוש הרב ביותר בקוד שלנו.

4. תנאי מרוץ סביב אוספים

4.1. הבעיה

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

בואו נבדוק את הקוד שלהלן:

רשימת רשימה = Collections.synchronizedList (ArrayList חדש ()); אם (! list.contains ("foo")) {list.add ("foo"); }

כל פעולה ברשימה שלנו מסונכרנת, אך כל שילוב של קריאות שיטות מרובות אינו מסונכרן. באופן ספציפי יותר, בין שתי הפעולות, חוט אחר יכול לשנות את האוסף שלנו ולהביא לתוצאות לא רצויות.

לדוגמה, שני שרשורים יכולים להיכנס ל- אם לחסום באותו זמן ואז לעדכן את הרשימה, וכל שרשור מוסיף את foo ערך לרשימה.

4.2. פיתרון לרשימות

אנו יכולים להגן על הגישה מפני יותר משרשור אחד בכל פעם באמצעות סנכרון:

מסונכרן (list) {if (! list.contains ("foo")) {list.add ("foo"); }}

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

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

4.3. פתרון מובנה עבור ConcurrentHashMap

עכשיו, בואו נשקול להשתמש במפה מאותה סיבה, כלומר להוסיף ערך רק אם הוא לא קיים.

ה ConcurrentHashMap מציע פיתרון טוב יותר לבעיות מסוג זה. אנחנו יכולים להשתמש באטום שלו putIfAbsent שיטה:

מפת מפה = ConcurrentHashMap חדש (); map.putIfAbsent ("foo", "bar");

לחלופין, אם אנו רוצים לחשב את הערך, את האטום שלו computeIfAbsent שיטה:

map.computeIfAbsent ("foo", key -> key + "bar");

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

5. בעיות עקביות בזיכרון

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

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

5.1. הבעיה

בואו נזכור את שלנו דֶלְפֵּק דוגמא:

מונה בכיתה {מונה אינטימי פרטי = 0; תוספת חלל ציבורית () {counter ++; } public int getValue () {מונה החזרה; }}

בואו ניקח בחשבון את התרחיש איפה חוט 1 מגדיל את דֶלְפֵּק ואז חוט 2 קורא את ערכו. רצף האירועים הבא עשוי לקרות:

  • חוט 1 קורא את ערך הנגד מהמטמון שלו; הדלפק הוא 0
  • t1 מגדיל את הדלפק וכותב אותו בחזרה למטמון שלו; הדלפק הוא 1
  • חוט 2 קורא את ערך הנגד מהמטמון שלו; הדלפק הוא 0

כמובן, רצף האירועים הצפוי יכול לקרות גם כן thread2 יקרא את הערך הנכון (1), אך אין כל ערובה ששינויים שבוצעו על ידי פתיל אחד יהיו גלויים לשרשורים אחרים בכל פעם.

5.2. הפתרון

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

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

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

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

בוא נשכתב את שלנו דֶלְפֵּק דוגמה לשימוש נָדִיף:

מחלקה SyncronizedCounter {מונה פרטי נדיף פרטי = 0; תוספת חלל מסונכרנת ציבורית () {counter ++; } public int getValue () {מונה החזרה; }}

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

5.3. לא אטומי ארוך ו לְהַכפִּיל ערכים

לכן, אם אנו קוראים משתנה ללא סנכרון נכון, אנו עשויים לראות ערך מעופש. Fאוֹ ארוך ו לְהַכפִּיל באופן די מפתיע, ניתן אפילו לראות ערכים אקראיים לחלוטין בנוסף לערכים מעופשים.

על פי JLS-17, JVM עשויה להתייחס לפעולות של 64 סיביות כאל שתי פעולות נפרדות של 32 סיביות. לכן, כשקוראים א ארוך אוֹ לְהַכפִּיל ערך, אפשר לקרוא 32 סיביות מעודכן יחד עם 32 סיביות מעופש. כתוצאה מכך, אנו עשויים להתבונן במראה אקראי ארוך אוֹ לְהַכפִּיל ערכים בהקשרים מקבילים.

מצד שני, כותב וקורא על תנודתי ארוך ו לְהַכפִּיל ערכים הם תמיד אטומיים.

6. שימוש לרעה בסינכרון

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

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

6.1. סנכרון מופעל זֶה התייחסות

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

אנו יכולים לראות בדוגמאות הבאות כיצד ניתן לתרגם סינכרון ברמת השיטה לסינכרון ברמת הבלוק עם ה- זֶה התייחסות כמנעול.

שיטות אלה שוות ערך:

פועל מסונכרן בטל () {// ...}
חלל ציבורי foo () {מסונכרן (זה) {// ...}}

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

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

6.2. מָבוֹי סָתוּם

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

בואו ניקח בחשבון את הדוגמה:

class class DeadlockExample {public static Object lock1 = Object חדש (); נעילת אובייקטים סטטית ציבורית = אובייקט חדש (); סטטי ציבורי ריק ריק (String args []) {thread threadA = thread thread חדש (() -> {synchronized (lock1) {System.out.println ("ThreadA: Holding lock 1 ..."); sleep (); System .out.println ("ThreadA: מחכה לנעילה 2 ..."); מסונכרן (lock2) {System.out.println ("ThreadA: מנעול החזקה 1 & 2 ...");}}}); חוט הברגה B = שרשור חדש (() -> {מסונכרן (נעילה 2) {System.out.println ("ThreadB: החזקת נעילה 2 ..."); שינה (); System.out.println ("ThreadB: מחכה לנעילה 1 ... "); מסונכרן (lock1) {System.out.println (" ThreadB: נעילת החזקה 1 & 2 ... ");}}}); threadA.start (); threadB.start (); }}

בקוד לעיל אנו יכולים לראות זאת בבירור חוט A. רוכש נעילה 1 ו חוט B. רוכש נעילה 2. לאחר מכן, פתיל A. מנסה להשיג את נעילה 2 שכבר נרכש על ידי חוט B. ו חוט B. מנסה להשיג את נעילה 1 שכבר נרכש על ידי חוט A.. אז, אף אחד מהם לא ימשיך, כלומר, הוא נתון במבוי סתום.

אנו יכולים לתקן בעיה זו בקלות על ידי שינוי סדר הנעילות באחד החוטים.

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

7. מסקנה

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

ראשית, למדנו שעלינו לבחור בחפצים או פעולות שאינן ניתנות לשינוי או לבטיחות הברגה.

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

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

כרגיל, כל הדוגמאות המשמשות במאמר זה זמינות באתר GitHub.


$config[zx-auto] not found$config[zx-overlay] not found