CodeGym /בלוג Java /Random-HE /עדיף ביחד: Java וכיתה Thread. חלק שלישי - אינטראקציה
John Squirrels
רָמָה
San Francisco

עדיף ביחד: Java וכיתה Thread. חלק שלישי - אינטראקציה

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

מבוא

אז, אנחנו יודעים שלג'אווה יש חוטים. אתה יכול לקרוא על כך בסקירה שכותרתה Better together: Java and the Thread class. חלק א' - חוטי הוצאה להורג . וחקרנו את העובדה שרשורים יכולים להסתנכרן אחד עם השני בסקירה שכותרתה Better together: Java and the Thread class. חלק ב' - סנכרון . הגיע הזמן לדבר על איך שרשורים מתקשרים זה עם זה. איך הם חולקים משאבים משותפים? אילו בעיות עלולות להתעורר כאן? עדיף ביחד: Java וכיתה Thread.  חלק שלישי - אינטראקציה - 2

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

הבעיה המפחידה מכולן היא מבוי סתום. מבוי סתום הוא כאשר שני חוטים או יותר מחכים לנצח לשני. ניקח דוגמה מדף האינטרנט של אורקל שמתאר מבוי סתום :
public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
מבוי סתום אולי לא יתרחש כאן בפעם הראשונה, אבל אם התוכנית שלך אכן נתקעת, הגיע הזמן להפעיל jvisualvm: עדיף ביחד: Java וכיתה Thread.  חלק שלישי - אינטראקציה - 3כאשר תוסף JVisualVM מותקן (באמצעות כלים -> פלאגינים), נוכל לראות היכן התרחש הקיפאון:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
	at Deadlock$Friend.bowBack(Deadlock.java:16)
	- waiting to lock <33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
שרשור 1 מחכה לנעילה משרשור 0. למה זה קורה? Thread-1מתחיל לרוץ ומבצע את Friend#bowהשיטה. הוא מסומן במילת synchronizedהמפתח, מה שאומר שאנו רוכשים את המוניטור עבור this(האובייקט הנוכחי). הקלט של השיטה היה הפניה לאובייקט השני Friend. כעת, Thread-1רוצה לבצע את השיטה על הצד השני Friend, ועליו לרכוש את המנעול שלה כדי לעשות זאת. אבל אם השרשור השני (במקרה הזה Thread-0) הצליח להיכנס לשיטה bow(), אז המנעול כבר נרכש ומחכה Thread-1ל- Thread-0ולהיפך. המבוי הסתום הזה הוא בלתי פתיר, ואנחנו קוראים לזה מבוי סתום. כמו אחיזת מוות שאי אפשר לשחרר, מבוי סתום הוא חסימה הדדית שאי אפשר לשבור. להסבר נוסף על מבוי סתום, אתה יכול לצפות בסרטון זה: מבוי סתום ו-Livelock Explained .

Livelock

אם יש מבוי סתום, האם יש גם מבוי סתום? כן, יש :) Livelock קורה כאשר חוטים כלפי חוץ נראים חיים, אבל הם לא מסוגלים לעשות כלום, כי לא ניתן למלא את התנאים הנדרשים להם כדי להמשיך בעבודתם. בעיקרון, לייבלוק דומה למבוי סתום, אבל השרשורים לא "נתלים" בהמתנה למוניטור. במקום זאת, הם תמיד עושים משהו. לדוגמה:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";

    public static void log(String text) {
        String name = Thread.currentThread().getName(); // Like "Thread-1" or "Thread-0"
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
הצלחת קוד זה תלויה בסדר שבו מתזמן השרשורים של Java מתחיל את השרשורים. אם Thead-1מתחיל קודם, אז נקבל livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
כפי שניתן לראות מהדוגמה, שני החוטים מנסים לרכוש את שני המנעולים בתורם, אך הם נכשלים. אבל, הם לא במבוי סתום. כלפי חוץ הכל בסדר והם עושים את העבודה שלהם. עדיף ביחד: Java וכיתה Thread.  חלק שלישי - אינטראקציה - 4לפי JVisualVM, אנו רואים תקופות שינה ותקופת חנייה (זהו כאשר חוט מנסה לרכוש נעילה - הוא נכנס למצב הפארק, כפי שדיברנו קודם לכן כשדיברנו על סנכרון שרשור ) . אתה יכול לראות דוגמה ל-livelock כאן: Java - Thread Livelock .

רָעָב

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

https://www.logicbig.com/

אתה יכול לראות דוגמה על כאן: Java - Thread Starvation and Fairness . דוגמה זו מראה מה קורה עם חוטים בזמן הרעבה וכיצד שינוי קטן אחד מ- Thread.sleep()to Thread.wait()מאפשר לך לפזר את העומס באופן שווה. עדיף ביחד: Java וכיתה Thread.  חלק שלישי - אינטראקציה - 6

תנאי המירוץ

ב-multithreading, יש דבר כזה "תנאי גזע". תופעה זו מתרחשת כאשר שרשורים חולקים משאב, אך הקוד נכתב באופן שאינו מבטיח שיתוף נכון. תסתכל על דוגמה:
public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
ייתכן שקוד זה לא ייצור שגיאה בפעם הראשונה. כשזה קורה, זה עשוי להיראות כך:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
	at App.lambda$main$0(App.java:13)
	at java.lang.Thread.run(Thread.java:745)
כפי שאתה יכול לראות, משהו השתבש בזמן newValueשהוקצה לו ערך. newValueגדול מדי. בגלל מצב המירוץ, אחד השרשורים הצליח לשנות את המשתנה valueבין שני ההצהרות. מסתבר שיש מירוץ בין החוטים. עכשיו תחשוב כמה חשוב לא לעשות טעויות דומות בעסקאות כספיות... דוגמאות ודיאגרמות ניתן לראות גם כאן: קוד לסימולציה של מצב מירוץ בשרשור Java .

נָדִיף

אם כבר מדברים על האינטראקציה של שרשורים, volatileמילת המפתח ראויה להזכיר. בואו נסתכל על דוגמה פשוטה:
public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
המעניין ביותר, סביר להניח שזה לא יעבוד. השרשור החדש לא יראה את השינוי בשדה flag. כדי לתקן זאת עבור flagהשדה, עלינו להשתמש במילת volatileהמפתח. איך ולמה? המעבד מבצע את כל הפעולות. אבל תוצאות החישובים חייבות להיות מאוחסנות איפשהו. בשביל זה יש זיכרון ראשי ויש את המטמון של המעבד. המטמונים של המעבד הם כמו גוש קטן של זיכרון המשמש לגישה לנתונים מהר יותר מאשר בעת גישה לזיכרון הראשי. אבל לכל דבר יש חיסרון: ייתכן שהנתונים במטמון אינם מעודכנים (כמו בדוגמה למעלה, כאשר הערך של שדה הדגל לא עודכן). אז volatileמילת המפתח אומרת ל-JVM שאנחנו לא רוצים לשמור את המשתנה שלנו במטמון. זה מאפשר לראות את התוצאה המעודכנת בכל השרשורים. זהו הסבר מאוד פשוט. לגבי volatileמילת המפתח, אני ממליץ בחום לקרוא את המאמר הזה . למידע נוסף, אני גם ממליץ לך לקרוא את מודל הזיכרון של Java ו- Java Volatile Keyword . בנוסף, חשוב לזכור שזה volatileעל הנראות, ולא על האטומיות של שינויים. בהסתכלות על הקוד בסעיף "תנאי המירוץ", נראה הסבר כלים ב-IntelliJ IDEA: עדיף ביחד: Java וכיתה Thread.  חלק שלישי - אינטראקציה - 7בדיקה זו נוספה ל-IntelliJ IDEA כחלק מהגיליון IDEA-61117 , שהיה רשום בהערות השחרור עוד ב-2010.

אָטוֹמִיוּת

פעולות אטומיות הן פעולות שלא ניתן לחלק. לדוגמה, הפעולה של הקצאת ערך למשתנה חייבת להיות אטומית. למרבה הצער, פעולת ההגדלה אינה אטומית, כי ההגדלה דורשת עד שלוש פעולות CPU: קבל את הערך הישן, הוסף לו אחת ואז שמור את הערך. מדוע האטומיות חשובה? עם פעולת ההגדלה, אם יש מצב מרוץ, אז המשאב המשותף (כלומר הערך המשותף) עשוי להשתנות לפתע בכל עת. בנוסף, פעולות המערבות מבנים של 64 סיביות, למשל longו- double, אינן אטומיות. פרטים נוספים ניתן לקרוא כאן: ודא אטומיות בעת קריאה וכתיבה של ערכי 64 סיביות . ניתן לראות בעיות הקשורות לאטומיות בדוגמה זו:
public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
המעמד המיוחד AtomicIntegerתמיד ייתן לנו 30,000, אבל השיעור valueישתנה מעת לעת. יש סקירה קצרה של נושא זה: מבוא למשתנים אטומיים ב-Java . אלגוריתם ה"השוואה והחלפה" נמצא בלב המעמדות האטומיים. אתה יכול לקרוא עוד על זה כאן בהשוואה של אלגוריתמים ללא נעילה - CAS ו-FAA בדוגמה של JDK 7 ו-8 או במאמר השווה והחלפה בוויקיפדיה. עדיף ביחד: Java וכיתה Thread.  חלק שלישי - אינטראקציה - 9

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

קורה-לפני

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

סיכום

בסקירה זו, חקרנו כמה מהפרטים הספציפיים של האופן שבו שרשורים מתקשרים. דנו בבעיות שעלולות להתעורר, כמו גם בדרכים לזהות ולחסל אותן. רשימת חומרים נוספים בנושא: עדיף ביחד: Java וכיתה Thread. חלק א' - חוטי ביצוע טוב יותר ביחד: ג'אווה ומחלקת ה-Thread. חלק ב' - סנכרון טוב יותר ביחד: Java ומחלקת Thread. חלק IV — ניתן להתקשרות, עתיד וחברים טובים יותר ביחד: Java וכיתה Thread. חלק V - Executor, ThreadPool, Fork/Join Better יחד: Java ומחלקת Thread. חלק ו' - אש!
הערות
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION