מהי בטיחות חוטים וכיצד להשיג זאת?

1. סקירה כללית

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

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

במדריך זה נבחן גישות שונות להשגתו.

2. יישומים חסרי מדינה

ברוב המקרים, שגיאות ביישומים מרובי הברגה הן תוצאה של שיתוף שגוי של מצב בין מספר שרשורים.

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

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

מחלקה ציבורית MathUtils {ציבורי BigInteger סטטי ציבורי (מספר int) {BigInteger f = BigInteger חדש ("1"); עבור (int i = 2; i <= number; i ++) {f = f.multiply (BigInteger.valueOf (i)); } להחזיר f; }} 

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

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

כל הנושאים יכולים לקרוא בבטחה ל- מפעל () השיטה ותקבל את התוצאה הצפויה מבלי להפריע זו לזו ומבלי לשנות את הפלט שהשיטה מייצרת לשרשורים אחרים.

לָכֵן, יישומים חסרי מדינה הם הדרך הפשוטה ביותר להשיג בטיחות חוטים.

3. יישומים בלתי ניתנים לשינוי

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

אי-משתנות הוא מושג רב-עוצמה, שפת-אגנוסטי, ודי קל להשיג אותו בג'אווה.

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

הדרך הקלה ביותר ליצור מחלקה בלתי ניתנת לשינוי בג'אווה היא הכרזה על כל השדות פְּרָטִי ו סופי ולא מספק קובעים:

מחלקה ציבורית MessageService {הודעה מחרוזת פרטית סופית; MessageService ציבורי (הודעת מחרוזת) {this.message = message; } // גטר סטנדרטי}

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

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

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

4. שדות חוט מקומיים

בתכנות מונחה עצמים (OOP), עצמים צריכים למעשה לשמור על מצב דרך שדות ולהטמיע התנהגות באמצעות שיטה אחת או יותר.

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

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

נוכל להגדיר, למשל, א פְּתִיל כיתה המאחסנת מַעֲרָך שֶׁל מספרים שלמים:

class class ThreadA מרחיב את Thread {private final List numbers = Arrays.asList (1, 2, 3, 4, 5, 6); @ עקוף ריצת חלל ציבורית () {numbers.forEach (System.out :: println); }}

בעוד אחד אחר יכול להחזיק מַעֲרָך שֶׁל מיתרים:

class public ThreadB מרחיב את Thread {private final List letters = Arrays.asList ("a", "b", "c", "d", "e", "f"); @ Override public void run () {letters.forEach (System.out :: println); }}

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

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

בואו ניקח בחשבון, למשל, את הדברים הבאים מחזיק המדינה מעמד:

מעמד ציבורי StateHolder {גמר פרטי מחרוזת; // קונסטרוקטורים סטנדרטיים / גטר}

אנו יכולים להפוך אותו בקלות למשתנה מקומי-חוט כדלקמן:

class class ThreadState {public static final ThreadLocal statePerThread = new ThreadLocal () {@Override מוגן StateHolder initialValue () {להחזיר StateHolder חדש ("פעיל"); }}; סטטי ציבורי StateHolder getState () {return statePerThread.get (); }}

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

5. אוספים מסונכרנים

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

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

אוסף syncCollection = Collections.synchronizedCollection (ArrayList חדש ()); שרשור הברגה 1 = שרשור חדש (() -> syncCollection.addAll (Arrays.asList (1, 2, 3, 4, 5, 6))); שרשור הברגה 2 = שרשור חדש (() -> syncCollection.addAll (Arrays.asList (7, 8, 9, 10, 11, 12)); thread1.start (); thread2.start (); 

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

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

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

6. אוספים במקביל

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

Java מספקת את java.util.concurrent חבילה, המכילה כמה אוספים במקביל, כגון ConcurrentHashMap:

מפה concurrentMap = ConcurrentHashMap חדש (); concurrentMap.put ("1", "one"); concurrentMap.put ("2", "שניים"); concurrentMap.put ("3", "שלוש"); 

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

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

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

7. אובייקטים אטומיים

אפשר גם להשיג בטיחות חוטים באמצעות קבוצת הכיתות האטומיות שג'אווה מספקת, כולל AtomicInteger, AtomicLong, אטומי בוליאני, ו AtomicReference.

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

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

מונה כיתת ציבורי {מונה אינטר פרטי <0; incrementCounter ריק () {counter + = 1; } ציבורי int getCounter () {דלפק החזרה; }}

נניח שבתנאי גזע, שני חוטים ניגשים ל- incrementCounter () שיטה במקביל.

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

בואו ליצור יישום בטוח לשרשור דֶלְפֵּק בשיעור באמצעות AtomicInteger לְהִתְנַגֵד:

מעמד ציבורי AtomicCounter {מונה פרטי AtomicInteger מונה = AtomicInteger חדש (); public void incrementCounter () {counter.incrementAndGet (); } public int getCounter () {return counter.get (); }}

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

8. שיטות מסונכרנות

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

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

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

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

ריק מסונכרן בטל incrementCounter () {counter + = 1; }

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

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

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

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

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

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

9. הצהרות מסונכרנות

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

כדי להדגים מקרה שימוש זה, בואו לשקף מחדש את ה- incrementCounter () שיטה:

public void incrementCounter () {// פעולות נוספות לא מסונכרנות מסונכרנות (זה) {counter + = 1; }}

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

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

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

9.1. חפצים אחרים כמנעול

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

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

מחלקה ציבורית ObjectLockCounter {counter counter counter = 0; נעילת אובייקטים סופית פרטית = אובייקט חדש (); incrementCounter חלל ציבורי () {מסונכרן (נעילה) {counter + = 1; }} // גטר סטנדרטי}

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

כשמשתמש זֶה לנעילה פנימית, תוקף עלול לגרום למבוי סתום על ידי רכישת המנעול הפנימי והפעלת מצב של מניעת שירות (DoS).

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

9.2. אזהרות

למרות שאנחנו יכולים להשתמש בכל אובייקט Java כנעילה פנימית, עלינו להימנע משימוש מיתרים למטרות נעילה:

מחלקה ציבורית Class1 {גמר סטטי פרטי פרטי מחרוזת LOCK = "נעילה"; // משתמש בנעילה כמנעול מהותי} מחלקה ציבורית Class2 {פרטית סופית סופית מחרוזת נעילה = "נעילה"; // משתמש בנעילה כמנעול מהותי}

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

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

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

10. שדות נדיפים

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

כדי למנוע מצב זה, אנו יכולים להשתמש נָדִיף שדות כיתה:

מעמד בכיתה ציבורית {מונה פרטי נדיף פרטי; // קונסטרוקטורים סטנדרטיים / גטר}

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

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

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

מחלקה ציבורית משתמש {שם פרטי מחרוזת; גיל תנודתי פרטי; // קונסטרוקטורים / גטרים סטנדרטיים}

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

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

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

11. מנעולים חוזרים

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

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

אין מנגנון בסיסי הבודק את השרשור בתור ומעניק עדיפות גישה לשרשור ההמתנה הארוך ביותר.

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

מעמד ציבורי ReentrantLockCounter {מונה אינטליגנטי פרטי; גמר פרטי ReentrantLock reLock = ReentrantLock חדש (נכון); incrementCounter חלל ציבורי () {reLock.lock (); נסה {counter + = 1; } סוף סוף {reLock.unlock (); }} // בונים סטנדרטיים / גטר}

ה ReentrantLock בונה לוקח אופציונלי הֲגִינוּתבוליאני פָּרָמֶטֶר. כאשר מוגדר ל נָכוֹן, ומספר אשכולות מנסים לרכוש נעילה, ה- JVM ייתן עדיפות לשרשור ההמתנה הארוך ביותר ויעניק גישה למנעול.

12. קרא / כתוב מנעולים

מנגנון עוצמתי נוסף בו נוכל להשתמש להשגת בטיחות חוטים הוא השימוש ReadWriteLock יישומים.

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

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

אנחנו יכולים להשתמש ב- ReadWriteLock ננעל כדלקמן:

מעמד ציבורי ReentrantReadWriteLockCounter {מונה אינטליגנטי פרטי; גמר פרטי ReentrantReadWriteLock rwLock = ReentrantReadWriteLock חדש (); סופי פרטי נעילה readLock = rwLock.readLock (); סופי נעילה פרטית writeLock = rwLock.writeLock (); incrementCounter חלל ציבורי () {writeLock.lock (); נסה {counter + = 1; } סוף סוף {writeLock.unlock (); }} ציבורי int getCounter () {readLock.lock (); נסה {דלפק החזרה; } סוף סוף {readLock.unlock (); }} // קונסטרוקציות סטנדרטיות} 

13. מסקנה

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

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


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