เมื่อพัฒนาแอปพลิเคชันแบบมัลติเธรด มักเกิดปัญหาขึ้น: สิ่งที่สำคัญกว่าคือความน่าเชื่อถือหรือประสิทธิภาพของแอปพลิเคชัน ตัวอย่างเช่น เราใช้การซิงโครไนซ์เพื่อความปลอดภัยของเธรด และในกรณีที่ลำดับการซิงโครไนซ์ไม่ถูกต้อง เราอาจทำให้เกิดการหยุดชะงักได้ นอกจากนี้เรายังใช้กลุ่มเธรดและเซมาฟอร์เพื่อจำกัดการใช้ทรัพยากร และข้อผิดพลาดในการออกแบบนี้อาจนำไปสู่การหยุดชะงักเนื่องจากขาดทรัพยากร ในบทความนี้เราจะพูดถึงวิธีหลีกเลี่ยงการหยุดชะงักรวมถึงปัญหาอื่น ๆ ในประสิทธิภาพของแอปพลิเคชัน นอกจากนี้เรายังจะดูว่าแอปพลิเคชันสามารถเขียนในลักษณะที่สามารถกู้คืนในกรณีที่เกิดการหยุดชะงักได้อย่างไร การหยุดชะงักเป็นสถานการณ์ที่กระบวนการตั้งแต่สองกระบวนการขึ้นไปครอบครองทรัพยากรบางส่วนกำลังพยายามรับทรัพยากรอื่น ๆ ที่ถูกครอบครองโดยกระบวนการอื่น และไม่มีกระบวนการใดที่สามารถครอบครองทรัพยากรที่พวกเขาต้องการได้ และด้วยเหตุนี้ จึงปล่อยกระบวนการที่ถูกครอบครองออกไป คำจำกัดความนี้กว้างเกินไปจึงเข้าใจยาก เพื่อความเข้าใจที่ดีขึ้น เราจะดูประเภทของการหยุดชะงักโดยใช้ตัวอย่าง
การซิงโครไนซ์คำสั่งการล็อคร่วมกัน
พิจารณางานต่อไปนี้: คุณต้องเขียนวิธีการดำเนินธุรกรรมเพื่อโอนเงินจำนวนหนึ่งจากบัญชีหนึ่งไปยังอีกบัญชีหนึ่ง วิธีแก้ปัญหาอาจมีลักษณะดังนี้: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 เงินจากบัญชี A ไปยังบัญชี B และโอนเงิน y จากบัญชี B ไปยังบัญชี A บ่อยครั้งที่สถานการณ์นี้จะไม่ทำให้เกิดการหยุดชะงัก อย่างไรก็ตาม ในสถานการณ์ที่โชคร้าย ธุรกรรม 1 จะครอบครองการตรวจสอบบัญชี A ธุรกรรม 2 จะครอบครองการตรวจสอบบัญชี B ผลลัพธ์คือการหยุดชะงัก: ธุรกรรม 1 รอธุรกรรม 2 เพื่อปล่อยการตรวจสอบบัญชี B แต่ธุรกรรมที่ 2 ต้องเข้าถึงมอนิเตอร์ A ซึ่งถูกครอบครองโดยธุรกรรมที่ 1 ปัญหาใหญ่ประการหนึ่งเกี่ยวกับการหยุดชะงักคือไม่พบได้ง่ายในการทดสอบ แม้ในสถานการณ์ที่อธิบายไว้ในตัวอย่าง เธรดอาจไม่บล็อก นั่นคือ สถานการณ์นี้จะไม่ถูกทำซ้ำอย่างต่อเนื่อง ซึ่งทำให้การวินิจฉัยมีความซับซ้อนอย่างมาก โดยทั่วไป ปัญหาที่อธิบายไว้ของการไม่กำหนดระดับเป็นเรื่องปกติสำหรับมัลติเธรด (แม้ว่าจะไม่ได้ทำให้ง่ายขึ้นก็ตาม) ดังนั้นการตรวจสอบโค้ดจึงมีบทบาทสำคัญในการปรับปรุงคุณภาพของแอปพลิเคชันแบบมัลติเธรด เนื่องจากช่วยให้คุณระบุข้อผิดพลาดที่ยากต่อการทำซ้ำในระหว่างการทดสอบ แน่นอนว่านี่ไม่ได้หมายความว่าไม่จำเป็นต้องทดสอบแอปพลิเคชัน แต่เราไม่ควรลืมเกี่ยวกับการตรวจสอบโค้ด ฉันควรทำอย่างไรเพื่อป้องกันไม่ให้รหัสนี้ทำให้เกิดการหยุดชะงัก การบล็อกนี้เกิดจากการที่การซิงโครไนซ์บัญชีสามารถเกิดขึ้นได้ในลำดับอื่น ดังนั้น หากคุณแนะนำคำสั่งซื้อบางอย่างในบัญชี (นี่คือกฎบางอย่างที่อนุญาตให้คุณบอกว่าบัญชี A น้อยกว่าบัญชี B) ปัญหาก็จะหมดไป ทำอย่างไร? ประการแรก หากบัญชีมีตัวระบุที่ไม่ซ้ำกัน (เช่น หมายเลขบัญชี) ที่เป็นตัวเลข ตัวพิมพ์เล็ก หรืออย่างอื่นที่มีแนวคิดในการเรียงลำดับตามธรรมชาติ (สามารถเปรียบเทียบสตริงตามลำดับพจนานุกรมได้ เราก็ถือว่าเราโชคดี และเราจะ เสมอ เราสามารถครอบครองมอนิเตอร์ของบัญชีที่เล็กกว่าได้ก่อน แล้วตามด้วยบัญชีที่ใหญ่กว่า (หรือกลับกัน)
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)}
}
}
}
}
ตัวเลือกที่สอง ถ้าเราไม่มีตัวระบุดังกล่าว เราจะต้องคิดขึ้นมาเอง ในการประมาณครั้งแรก เราสามารถเปรียบเทียบวัตถุด้วยรหัสแฮชได้ ส่วนใหญ่แล้วพวกเขาจะแตกต่างกัน แต่จะเกิดอะไรขึ้นถ้าพวกเขากลายเป็นคนเดียวกัน? จากนั้นคุณจะต้องเพิ่มวัตถุอื่นเพื่อการซิงโครไนซ์ อาจดูซับซ้อนเล็กน้อย แต่คุณจะทำอย่างไร? และอีกอย่างวัตถุชิ้นที่สามนั้นจะถูกใช้ค่อนข้างน้อย ผลลัพธ์จะมีลักษณะดังนี้:
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
และ เมธอด getMap
คลาสนั้นDispatcher
ซิงโครไนซ์และเรียกเมธอดซิงโครไนซ์ของคลาสอื่นภายในตัวมันเอง นี่เป็นแนวทางปฏิบัติที่ไม่ดีโดยทั่วไป วิธีแก้ไขนี้จะกล่าวถึงในหัวข้อถัดไป ผลก็คือ หากเครื่องบินมาถึงสถานที่นั้น ทันทีที่มีคนตัดสินใจรับบัตร ก็อาจเกิดการหยุดชะงักได้ นั่นคือ วิธีการ getMap
และ จะถูกเรียก setLocation
ซึ่งจะครอบครองการตรวจสอบอินสแตนซ์Dispatcher
และPlane
ตามลำดับ จากนั้นเมธอดจะgetMap
เรียก (โดยเฉพาะ บนplane.getLocation
อินสแตนซ์Plane
ที่กำลังยุ่งอยู่) ซึ่งจะรอให้จอภาพว่างสำหรับแต่ละอินสแตนซ์ Plane
ในเวลาเดียวกัน วิธีการนี้setLocation
จะถูกเรียกdispatcher.requestLanding
ในขณะที่ตัวตรวจสอบอินสแตนซ์Dispatcher
ยังคงยุ่งอยู่กับการวาดแผนที่ ผลลัพธ์ที่ได้คือการหยุดชะงัก
เปิดสาย
เพื่อหลีกเลี่ยงสถานการณ์เช่นเดียวกับที่อธิบายไว้ในส่วนก่อนหน้านี้ ขอแนะนำให้ใช้การเรียกสาธารณะไปยังเมธอดของอ็อบเจ็กต์อื่น นั่นคือการเรียกเมธอดของอ็อบเจ็กต์อื่นที่อยู่นอกบล็อกที่ซิงโครไนซ์ หากวิธีการถูกเขียนใหม่โดยใช้หลักการของการโทรแบบเปิด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
(คืนค่าจริงหากเป็นไปได้ที่จะครอบครองจอภาพ) สมมติว่าเรามีอ็อบเจ็กต์คู่หนึ่งที่ใช้อินเทอร์เฟซ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 อนุญาตให้คุณวินิจฉัยการชะงักงันโดยการแสดงไว้ในเธรดดัมพ์ การดัมพ์ดังกล่าวรวมถึงข้อมูลเกี่ยวกับสถานะของเธรด หากถูกบล็อก ดัมพ์ประกอบด้วยข้อมูลเกี่ยวกับมอนิเตอร์ที่เธรดกำลังรอการปล่อย ก่อนที่จะดัมพ์เธรด JVM จะดูกราฟของการมอนิเตอร์ที่รอ (ไม่ว่าง) และหากพบรอบ 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)
ดัมพ์ด้านบนแสดงให้เห็นอย่างชัดเจนว่าสองเธรดที่ทำงานกับฐานข้อมูลได้บล็อกซึ่งกันและกัน เพื่อวินิจฉัยการหยุดชะงักโดยใช้คุณลักษณะ JVM นี้ จำเป็นต้องทำการเรียกการดำเนินการดัมพ์เธรดในตำแหน่งต่างๆ ในโปรแกรมและทดสอบแอปพลิเคชัน ถัดไป คุณควรวิเคราะห์บันทึกผลลัพธ์ หากบ่งชี้ว่าเกิดการชะงักงันเกิดขึ้น ข้อมูลจากดัมพ์จะช่วยตรวจสอบเงื่อนไขที่เกิดขึ้น โดยทั่วไป คุณควรหลีกเลี่ยงสถานการณ์เช่นเดียวกับในตัวอย่างการหยุดชะงัก ในกรณีเช่นนี้ แอปพลิเคชันจะทำงานได้อย่างเสถียร แต่อย่าลืมเกี่ยวกับการทดสอบและการตรวจทานโค้ด ซึ่งจะช่วยระบุปัญหาหากเกิดขึ้น ในกรณีที่คุณกำลังพัฒนาระบบที่การกู้คืนฟิลด์การชะงักงันเป็นสิ่งสำคัญ คุณสามารถใช้วิธีที่อธิบายไว้ในส่วน “วิธีการหลีกเลี่ยงการชะงักงัน?” ในกรณีนี้ วิธีการ lockInterruptibly
อินเทอร์เฟซLock
จากไฟล์java.util.concurrent.locks
. ช่วยให้คุณสามารถขัดจังหวะเธรดที่ครอบครองมอนิเตอร์โดยใช้วิธีนี้ (และทำให้มอนิเตอร์ว่าง)
GO TO FULL VERSION