JavaRush /בלוג Java /Random-HE /מבוי סתום בג'אווה ושיטות להילחם בו
articles
רָמָה

מבוי סתום בג'אווה ושיטות להילחם בו

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

סדר סנכרון נעילה הדדית

שקול את המשימה הבאה: עליך לכתוב שיטה שמבצעת עסקה להעברת סכום כסף מסוים מחשבון אחד לאחר. הפתרון עשוי להיראות כך:
public void transferMoney(Account fromAccount, Account toAccount, Amount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}
במבט ראשון, קוד זה מסונכרן באופן רגיל למדי; יש לנו פעולה אטומית של בדיקה ושינוי מצב חשבון המקור ושינוי חשבון היעד. עם זאת, עם אסטרטגיית סנכרון זו, עלול להתרחש מצב מבוי סתום. בואו נסתכל על דוגמה איך זה קורה. יש צורך לבצע שתי פעולות: העברת x כסף מחשבון א' לחשבון ב', והעברת y כסף מחשבון ב' לחשבון א'. לעתים קרובות מצב זה לא יגרום למבוי סתום, עם זאת, בסט מצער של נסיבות, עסקה 1 תכבוש את צג החשבון A, עסקה 2 תכבוש את צג החשבון ב'. התוצאה היא מבוי סתום: עסקה 1 ממתינה לעסקה 2 כדי לשחרר את מעקב החשבון ב, אבל עסקה 2 חייבת לגשת למוניטור A, שתפוס על ידי טרנזקציה 1. אחת הבעיות הגדולות עם מבוי סתום היא שלא קל למצוא אותם בבדיקה. גם במצב המתואר בדוגמה, פתילים עלולים לא לחסום, כלומר, מצב זה לא ישוחזר כל הזמן, מה שמסבך משמעותית את האבחון. באופן כללי, הבעיה המתוארת של אי-דטרמיניזם אופיינית לריבוי שרשורים (אם כי זה לא מקל על זה). לכן, סקירת קוד ממלאת תפקיד חשוב בשיפור האיכות של יישומים מרובי הליכי, מכיוון שהיא מאפשרת לזהות שגיאות שקשה לשחזר במהלך הבדיקה. זה, כמובן, לא אומר שאין צורך לבדוק את האפליקציה; רק אל לנו לשכוח סקירת קוד. מה עלי לעשות כדי למנוע מקוד זה לגרום למבוי סתום? חסימה זו נגרמת מהעובדה שסנכרון החשבון יכול להתרחש בסדר שונה. בהתאם לכך, אם תכניס קצת סדר בחשבונות (זהו כלל שמאפשר לומר שחשבון א' קטן מחשבון ב'), אזי הבעיה תבוטל. איך לעשות את זה? ראשית, אם לחשבונות יש איזשהו מזהה ייחודי (לדוגמה, מספר חשבון) מספרי, באותיות קטנות או משהו אחר עם מושג טבעי של סדר (ניתן להשוות מחרוזות בסדר לקסיקוגרפי, אז אנחנו יכולים לראות את עצמנו בני מזל, ואנו תמיד נוכל לכבוש תחילה את המוניטור של החשבון הקטן יותר, ולאחר מכן את הגדול יותר (או להיפך).
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromId= fromAcct.getId();
	int toId = fromAcct.getId();
	if (fromId < toId) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	} else  {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	}
}
האפשרות השנייה, אם אין לנו מזהה כזה, נצטרך להמציא אותו בעצמנו. אנו יכולים, לקירוב ראשון, להשוות אובייקטים לפי קוד hash. סביר להניח שהם יהיו שונים. אבל מה אם יתברר שהם אותו הדבר? לאחר מכן תצטרך להוסיף אובייקט נוסף לסנכרון. זה אולי נראה קצת מתוחכם, אבל מה אתה יכול לעשות? וחוץ מזה, האובייקט השלישי ישמש די נדיר. התוצאה תיראה כך:
private static final Object tieLock = new Object();
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromHash = System.identityHashCode(fromAcct);
	int toHash = System.identityHashCode(toAcct);
	if (fromHash < toHash) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else if (fromHash > toHash) {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else {
		synchronized (tieLock) {
			synchronized (fromAcct) {
				synchronized (toAcct) {
					doTransfer(fromAcct, toAcct, amount)
				}
			}
		}
	}
}

מבוי סתום בין חפצים

תנאי החסימה המתוארים מייצגים את המקרה הקל ביותר של מבוי סתום לאבחון. לעתים קרובות ביישומים מרובים, אובייקטים שונים מנסים לגשת לאותם בלוקים מסונכרנים. זה עלול לגרום למבוי סתום. שקול את הדוגמה הבאה: אפליקציה לשגר טיסה. מטוסים מודיעים לבקר כשהם הגיעו ליעדם ומבקשים רשות לנחות. הבקר מאחסן את כל המידע על מטוסים שטסים לכיוונו ויכול לשרטט את מיקומם על המפה.
class Plane {
	private Point location, destination;
	private final Dispatcher dispatcher;

	public Plane(Dispatcher dispatcher) {
		this.dispatcher = dispatcher;
	}
	public synchronized Point getLocation() {
		return location;
	}
	public synchronized void setLocation(Point location) {
		this.location = location;
		if (location.equals(destination))
		dispatcher.requestLanding(this);
	}
}

class Dispatcher {
	private final Set<Plane> planes;
	private final Set<Plane> planesPendingLanding;

	public Dispatcher() {
		planes = new HashSet<Plane>();
		planesPendingLanding = new HashSet<Plane>();
	}
	public synchronized void requestLanding(Plane plane) {
		planesPendingLanding.add(plane);
	}
	public synchronized Image getMap() {
		Image image = new Image();
		for (Plane plane : planes)
			image.drawMarker(plane.getLocation());
		return image;
	}
}
ההבנה שיש באג בקוד הזה שיכול להוביל למבוי סתום קשה יותר מאשר בקודם. במבט ראשון אין לו סנכרון מחדש, אבל זה לא המקרה. בטח כבר שמתם לב ששיטות מחלקות setLocationומחלקות Planeמסונכרנות וקוראות לשיטות מסונכרנות של מחלקות אחרות בתוך עצמן. זה בדרך כלל תרגול גרוע. כיצד ניתן לתקן זאת נדון בחלק הבא. כתוצאה מכך, אם המטוס יגיע למקום, ברגע שמישהו מחליט לקבל את הכרטיס, יכול להתרחש מבוי סתום. כלומר, ייקרא ה- and , אשר יתפוס את צגי המופע ובהתאמה . לאחר מכן, השיטה תתקשר (במיוחד למופע שנמצא כעת תפוס) אשר ימתין עד שהמוניטור יהפוך פנוי עבור כל אחד מהמופעים . במקביל, השיטה תיקרא , בעוד מוניטור המופעים נשאר עסוק בציור המפה. התוצאה היא מבוי סתום. getMapDispatchergetMapsetLocationDispatcherPlanegetMapplane.getLocationPlanePlanesetLocationdispatcher.requestLandingDispatcher

שיחות פתוחות

על מנת להימנע ממצבים כמו זה שתואר בסעיף הקודם, מומלץ להשתמש בקריאות ציבוריות לשיטות של אובייקטים אחרים. כלומר, קריאה לשיטות של אובייקטים אחרים מחוץ לבלוק המסונכרן. אם שיטות ייכתבו מחדש באמצעות עקרון השיחות הפתוחות setLocation, getMapתבוטל האפשרות למבוי סתום. זה ייראה, למשל, כך:
public void setLocation(Point location) {
	boolean reachedDestination;
	synchronized(this){
		this.location = location;
		reachedDestination = location.equals(destination);
	}
	if (reachedDestination)
		dispatcher.requestLanding(this);
}
………………………………………………………………………………
public Image getMap() {
	Set<Plane> copy;
	synchronized(this){
		copy = new HashSet<Plane>( planes);
	}
	Image image = new Image();
	for (Plane plane : copy)
		image.drawMarker(plane.getLocation());
	return image;
}

מבוי סתום של משאבים

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

איך להימנע ממבוי סתום?

כמובן שאם הקוד נכתב ללא שגיאות (דוגמאות שלהן ראינו בסעיפים הקודמים), אז לא יהיו בו מבוי סתום. אבל מי יכול להבטיח שהקוד שלו נכתב ללא שגיאות? כמובן שבדיקה עוזרת לזהות חלק נכבד מהשגיאות, אך כפי שראינו קודם לכן, שגיאות בקוד מרובה הליכי אינן קלות לאבחון וגם לאחר בדיקה לא ניתן להיות בטוחים שאין מצבי מבוי סתום. האם נוכל איכשהו להגן על עצמנו מחסימה? התשובה היא כן. טכניקות דומות משמשות במנועי מסד נתונים, שלעיתים קרובות צריכים להתאושש ממבוי סתום (המשויך למנגנון העסקאות במסד הנתונים). הממשק Lockוההטמעות שלו הזמינות בחבילה java.util.concurrent.locksמאפשרים לך לנסות לכבוש את המוניטור המשויך למופע של מחלקה זו באמצעות השיטה tryLock(מחזירה true אם ניתן היה לכבוש את המוניטור). נניח שיש לנו זוג אובייקטים שמיישמים ממשק Lockואנחנו צריכים לתפוס את המסכים שלהם בצורה כזו כדי למנוע חסימה הדדית. אתה יכול ליישם את זה כך:
public void twoLocks(Lock A,  Lock B){
	while(true){
		if(A.tryLock()){
			if(B.tryLock())
			{
				try{
					//do something
				} finally{
					B.unlock();
					A.unlock();
				}
			} else{
				A.unlock();
			}
		}
	}
}
כפי שניתן לראות בתוכנית זו, אנו תופסים שני צגים, תוך ביטול האפשרות של חסימה הדדית. שימו לב שהחסימה try- finallyנחוצה מכיוון שהמחלקות בחבילה java.util.concurrent.locksלא משחררות אוטומטית את המוניטור, ואם מתרחש חריגה כלשהי במהלך ביצוע המשימה שלכם, המוניטור ייתקע במצב נעול. איך מאבחנים מבוי סתום? ה-JVM מאפשר לך לאבחן מבוי סתום על ידי הצגתם ב-Threads. מטלות כאלה כוללות מידע לגבי המצב שבו נמצא השרשור. אם הוא חסום, ה-dump מכיל מידע על המוניטור שהשרשור ממתין לשחרור. לפני השלכת שרשורים, ה-JVM מסתכל על הגרף של צגים ממתינים (עסוקים), ואם הוא מוצא מחזורים, הוא מוסיף מידע מבוי סתום, המציין את הצגים והשרשורים המשתתפים. מזבלה של שרשורים מבוי סתום נראית כך:
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x0f0d80cc (a MyDBConnection),
which is held by "ApplicationServerThread"
"ApplicationServerThread":
waiting to lock monitor 0x0f0d8fed (a MyDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MyDBConnection.remove_statement
- waiting to lock <0x6f50f730> (a MyDBConnection)
at MyDBStatement.close
- locked <0x604ffbb0> (a MyDBCallableStatement)
...
"ApplicationServerThread":
at MyDBCallableStatement.sendBatch
- waiting to lock <0x604ffbb0> (a MyDBCallableStatement)
at MyDBConnection.commit
- locked <0x6f50f730> (a MyDBConnection)
ה-dump שלמעלה מראה בבירור ששני שרשורים העובדים עם מסד הנתונים חסמו זה את זה. על מנת לאבחן מבוי סתום באמצעות תכונת ה-JVM הזו, יש צורך לבצע קריאות לפעולת ה-Thread dump במקומות שונים בתוכנית ולבדוק את האפליקציה. לאחר מכן, עליך לנתח את היומנים המתקבלים. אם הם מציינים שהתרחשה מבוי סתום, המידע מהמזבלה יעזור לזהות את התנאים שבהם היא התרחשה. באופן כללי, עליך להימנע ממצבים כמו אלה שבדוגמאות המבוי הסתום. במקרים כאלה, סביר להניח שהאפליקציה תעבוד ביציבות. אבל אל תשכח את הבדיקות ואת סקירת הקוד. זה יעזור לזהות בעיות אם הן מתרחשות. במקרים בהם אתם מפתחים מערכת שעבורה שחזור שדה הקיפאון הוא קריטי, תוכלו להשתמש בשיטה המתוארת בסעיף "כיצד להימנע ממבוי סתום?". lockInterruptiblyבמקרה זה, שיטת הממשק Lockמהחבילה עשויה להיות שימושית גם כן java.util.concurrent.locks. זה מאפשר לך לקטוע את השרשור שהעסיק את הצג בשיטה זו (ובכך לשחרר את הצג).
הערות
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION