בטיחות תהליכון בג'אווה

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

בטיחות התהליך

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

package com.journaldev.threads;

public class ThreadSafety {

    public static void main(String[] args) throws InterruptedException {
    
        ProcessingThread pt = new ProcessingThread();
        Thread t1 = new Thread(pt, "t1");
        t1.start();
        Thread t2 = new Thread(pt, "t2");
        t2.start();
        //ממתין לסיום תהליכים
        t1.join();
        t2.join();
        System.out.println("Processing count="+pt.getCount());
    }

}

class ProcessingThread implements Runnable{
    private int count;
    
    @Override
    public void run() {
        for(int i=1; i < 5; i++){
            processSomething(i);
        	count++;
        }
    }

    public int getCount() {
        return this.count;
    }

    private void processSomething(int i) {
        // מעבד עבודה כלשהי
        try {
            Thread.sleep(i*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

בתכנית לעיל, בלולאת ה-for, count מתועדף ב-1 ארבע פעמים ומאחר שיש לנו שני תהליכים, ערכו אמור להיות 8 לאחר ששני התהליכים יסיימו את הביצוע. אך כאשר תפעיל את התכנית לעיל מספר פעמים, תגלה שערך ה-count משתנה בין 6, 7, 8. הזהות זו קורה משום שגם אם count++ נראה כמבצע אטומי, זה לא וגורם לפגיעה בנתונים.

בטיחות תהליכים ב-Java

בטיחות תהליכים ב-Java היא התהליך שבו אנו מבטיחים שהתוכנית שלנו תהיה בטוחה לשימוש בסביבה מרובה תהליכים. ישנם שיטות שונות דרך כלשהי ניתן להבטיח את התוכנית שלנו כהילה תהליכים.

  • הסנכרון הוא הכלי הקל ביותר והנפוץ ביותר לבטיחות תהליכים ב-Java.
  • שימוש ב-Class-ים מחברת java.util.concurrent.atomic. לדוגמא, AtomicInteger
  • שימוש בנעילות מחברת java.util.concurrent.locks.
  • שימוש ב-Class-ים מאובטחים לתהליכים, ראה פוסט זה לשימוש ב-ConcurrentHashMap לבטיחות תהליכים.
  • שימוש במילת המפתח volatile עם משתנים כדי לוודא שכל תהליך יקרא לנתונים מהזיכרון ולא מהמטמון של התהליך.

Java synchronized

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

  • בגיוון הרצפי של Java עובד על נעילה ושחרור של משאב לפני שכל תהליך יכנס לקוד מסונכרן, יש לרכוש את הנעילה על האובייקט וכאשר הביצוע של הקוד מסתיים, המשאב שוחרר ויכול להיות נעול על ידי תהליכים אחרים. בינתיים, תהליכים אחרים נמצאים במצב המתנה לנעילת המשאב המסונכרן.
  • ניתן להשתמש במילת המפתח synchronized בשני אופנים, הראשון הוא להפוך שלמה למסונכרנת והדרך השנייה היא ליצור בלוק מסונכרן.
  • כאשר שיטה היא מסונכרנת, היא נועלת את האובייקט, ואם השיטה היא סטטית היא נועלת את המחלקה, לכן תמיד נהוג להשתמש ב-בלוק מסונכרן כדי לנעול רק את קטעי הקוד שצריכים להיות מסונכרנים.
  • בעת יצירת בלוק מסונכרן, יש לספק את המשאב עליו יתבצע הנעילה, זה יכול להיות XYZ.class או כל שדה אובייקט של המחלקה.
  • synchronized(this) ינעיל את האובייקט לפני כניסה לבלוק המסונכרן.
  • עליך להשתמש ב-רמת הנעילה הנמוכה ביותר, לדוג, אם ישנם מספר בלוקים מסונכרנים במחלקה ואחד מהם נועל את האובייקט, אז בלוקים אחרים גם לא יהיו זמינים לביצוע על ידי תהליכים אחרים. כאשר אנו נועילים באובייקט, הוא יקבל נעילה על כל השדות שלו.
  • הסינכרוניזציה ב-Java מספקת שלמות נתונים על חשבון הביצוע, על חשבון הביצוע, אז יש להשתמש בה רק כאשר זה מוכרח.
  • סינכרוניזציה ב-Java עובדת רק באותו JVM, כלומר אם יש צורך לנעול משאב בסביבה מרובה-JVM, זה לא יעבוד ועשוי להיות עליך לחפש מכניזם גלובלי לנעילה.
  • Java סינכרוניזציה עשויה להביא למצבי קישור מתים, בדוק את פוסט זה על deadlock ב-Java וכיצד למנוע אותם.
  • המילה synchronized ב-Java לא יכולה לשמש לבנאיים ולמשתנים.
  • מועדף ליצור אובייקט פריפרי דמוי כדי להשתמש בבלוק synchronized כך שההפניה שלו לא יכולה להשתנות על ידי קוד אחר. לדוגמה, אם יש לך שיטת setter עבור Object שעליו אתה מסנכרן, ההפניה שלו יכולה להשתנות על ידי קוד אחר מוביל לביצוע מקבילי של הבלוק המסונכרן.
  • אנו לא צריכים להשתמש באובייקט שנשמר בבריכת קבועים, לדוגמה לא ניתן להשתמש במחרוזת לסינכרוניזציה משום שאם קוד אחר נעול גם על אותה מחרוזת, הוא ינסה לרכוש נעילה על אותו אובייקט ההפניה מבריכת המחרוזות ואפילו אם שני הקודים אינם קשורים, הם ינעלו זה את זה.

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

    //משתנה אובייקט דמו לסינכרוניזציה
    private Object mutex=new Object();
    ...
    //שימוש בבלוק synchronized עבור קריאה, גידול ועדכון ערך המונטות באופן סנכרוני
    synchronized (mutex) {
            count++;
    }

בואו נראה כמה דוגמאות לסינכרוניזציה ומה אנו יכולים ללמוד מהן.

public class MyObject {
 
  // נעילות על מוניטור האובייקט
  public synchronized void doSomething() { 
    // ...
  }
}
 
// קוד האקרים
MyObject myObject = new MyObject();
synchronized (myObject) {
  while (true) {
    // דחיית ללא הגבלה של myObject
    Thread.sleep(Integer.MAX_VALUE); 
  }
}

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

public class MyObject {
  public Object lock = new Object();
 
  public void doSomething() {
    synchronized (lock) {
      // ...
    }
  }
}

//קוד לא מהוטמן

MyObject myObject = new MyObject();
//שנה את ההפניה של אובייקט הנעילה
myObject.lock = new Object();

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

public class MyObject {
  //נעילה על מוניטור אובייקט המחלקה
  public static synchronized void doSomething() { 
    // ...
  }
}
 
//קוד האקר
synchronized (MyObject.class) {
  while (true) {
    Thread.sleep(Integer.MAX_VALUE); // Indefinitely delay MyObject
  }
}

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

package com.journaldev.threads;

import java.util.Arrays;

public class SyncronizedMethod {

    public static void main(String[] args) throws InterruptedException {
        String[] arr = {"1","2","3","4","5","6"};
        HashMapProcessor hmp = new HashMapProcessor(arr);
        Thread t1=new Thread(hmp, "t1");
        Thread t2=new Thread(hmp, "t2");
        Thread t3=new Thread(hmp, "t3");
        long start = System.currentTimeMillis();
        //התחל את כל התהליכים
        t1.start();t2.start();t3.start();
        //המתן לתהליכים לסיים
        t1.join();t2.join();t3.join();
        System.out.println("Time taken= "+(System.currentTimeMillis()-start));
        //בדוק את ערך המשתנה המשותף כעת
        System.out.println(Arrays.asList(hmp.getMap()));
    }

}

class HashMapProcessor implements Runnable{
    
    private String[] strArr = null;
    
    public HashMapProcessor(String[] m){
        this.strArr=m;
    }
    
    public String[] getMap() {
        return strArr;
    }

    @Override
    public void run() {
        processArr(Thread.currentThread().getName());
    }

    private void processArr(String name) {
        for(int i=0; i < strArr.length; i++){
            //עיבוד נתונים והוספת שם התהליך
            processSomething(i);
            addThreadName(i, name);
        }
    }
    
    private void addThreadName(int i, String name) {
        strArr[i] = strArr[i] +":"+name;
    }

    private void processSomething(int index) {
        //עיבוד מסוים
        try {
            Thread.sleep(index*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

זהו הפלט כשאני מפעיל את התוכנית לעיל.

Time taken= 15005
[1:t2:t3, 2:t1, 3:t3, 4:t1:t3, 5:t2:t1, 6:t3]

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

    private Object lock = new Object();
    private void addThreadName(int i, String name) {
        synchronized(lock){
        strArr[i] = strArr[i] +":"+name;
        }
    }

לאחר השינוי הזה, התוכנית שלנו פועלת נכון וזהו הפלט התקין של התוכנית.

Time taken= 15004
[1:t1:t2:t3, 2:t2:t1:t3, 3:t2:t3:t1, 4:t3:t2:t1, 5:t2:t1:t3, 6:t2:t1:t3]

הכל כאן לגבי בטיחות התהליך ב-Java, אני מקווה שלמדת על תכנות מאובטח לתהליכים ושימוש במילת המפתח synchronized.

Source:
https://www.digitalocean.com/community/tutorials/thread-safety-in-java