JavaRush /Blog Java /Random-VI /Nguyên tắc cơ bản của Concurrency: Deadlocks và Object Mo...
Snusmum
Mức độ
Хабаровск

Nguyên tắc cơ bản của Concurrency: Deadlocks và Object Monitor (phần 1, 2) (bản dịch bài viết)

Xuất bản trong nhóm
Bài viết nguồn: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Được đăng bởi Martin Mois Bài viết này là một phần của khóa học Nguyên tắc cơ bản về đồng thời Java của chúng tôi . Trong khóa học này, bạn sẽ đi sâu vào sự kỳ diệu của sự song song. Bạn sẽ tìm hiểu những kiến ​​thức cơ bản về tính song song và mã song song, đồng thời làm quen với các khái niệm như tính nguyên tử, đồng bộ hóa và an toàn luồng. Lại đây xem cái này chút !

Nội dung

1. Liveness  1.1 Deadlock  1.2 Starvation 2. Giám sát đối tượng với wait() và notification()  2.1 Các khối được đồng bộ hóa lồng nhau với wait() và notification()  2.2 Các điều kiện trong các khối được đồng bộ hóa 3. Thiết kế cho đa luồng  3.1 Đối tượng bất biến  3.2 Thiết kế API  3.3 Lưu trữ chủ đề cục bộ
1. Sức sống
Khi phát triển các ứng dụng sử dụng tính song song để đạt được mục tiêu, bạn có thể gặp phải tình huống trong đó các luồng khác nhau có thể chặn lẫn nhau. Nếu ứng dụng chạy chậm hơn dự kiến ​​trong trường hợp này, chúng tôi sẽ nói rằng ứng dụng đó không chạy như mong đợi. Trong phần này, chúng ta sẽ xem xét kỹ hơn các vấn đề có thể đe dọa khả năng tồn tại của ứng dụng đa luồng.
1.1 Chặn lẫn nhau
Thuật ngữ bế tắc được các nhà phát triển phần mềm biết đến và thậm chí hầu hết người dùng bình thường thỉnh thoảng sử dụng nó, mặc dù không phải lúc nào cũng đúng nghĩa. Nói một cách chính xác, thuật ngữ này có nghĩa là mỗi luồng trong số hai (hoặc nhiều) luồng đang chờ luồng kia giải phóng một tài nguyên bị nó khóa, trong khi chính luồng đầu tiên đã khóa một tài nguyên mà luồng thứ hai đang chờ truy cập: Để hiểu rõ hơn vấn đề, hãy xem Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A đoạn mã sau: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Như bạn có thể thấy từ đoạn mã trên, hai luồng khởi động và cố gắng khóa hai tài nguyên tĩnh. Nhưng để khóa chết, chúng ta cần một trình tự khác nhau cho cả hai luồng, vì vậy chúng ta sử dụng một thể hiện của đối tượng Ngẫu nhiên để chọn tài nguyên nào mà luồng muốn khóa trước. Nếu biến boolean b là đúng thì trước tiên tài nguyên1 sẽ bị khóa, sau đó luồng sẽ cố gắng lấy khóa cho tài nguyên2. Nếu b sai thì luồng sẽ khóa tài nguyên2 và sau đó cố gắng lấy tài nguyên1. Chương trình này không cần chạy lâu để đạt được bế tắc đầu tiên, tức là. Chương trình sẽ bị treo vĩnh viễn nếu chúng ta không làm gián đoạn nó: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. Trong lần chạy này, Tread-1 đã có được khóa Resource2 và đang chờ khóa của Resource1, trong khi Tread-2 có khóa Resource1 và đang chờ Resource2. Nếu chúng ta đặt giá trị của biến boolean b trong đoạn mã trên thành true, chúng ta sẽ không thể quan sát bất kỳ bế tắc nào vì trình tự khóa yêu cầu thread-1 và thread-2 sẽ luôn giống nhau. Trong trường hợp này, một trong hai luồng sẽ lấy khóa trước rồi yêu cầu khóa thứ hai, luồng này vẫn khả dụng vì luồng kia đang đợi khóa đầu tiên. Nói chung, chúng ta có thể phân biệt các điều kiện cần thiết sau để xảy ra bế tắc: - Thực thi chia sẻ: Có một tài nguyên chỉ có thể được truy cập bởi một luồng tại một thời điểm. - Giữ tài nguyên: Trong khi lấy một tài nguyên, một luồng sẽ cố gắng lấy một khóa khác trên một số tài nguyên duy nhất. - No preemption: Không có cơ chế giải phóng tài nguyên nếu một thread giữ lock trong một khoảng thời gian nhất định. - Chờ tuần hoàn: Trong quá trình thực thi, một tập hợp các luồng xảy ra trong đó hai (hoặc nhiều) luồng chờ nhau giải phóng tài nguyên đã bị khóa. Mặc dù danh sách các điều kiện có vẻ dài nhưng không hiếm trường hợp các ứng dụng đa luồng chạy tốt gặp vấn đề bế tắc. Nhưng bạn có thể ngăn chặn chúng nếu loại bỏ được một trong các điều kiện trên: - Thực thi chung: điều kiện này thường không thể loại bỏ được khi tài nguyên chỉ được sử dụng bởi một người. Nhưng đây không nhất thiết phải là lý do. Khi sử dụng hệ thống DBMS, một giải pháp khả thi, thay vì sử dụng khóa bi quan trên một số hàng trong bảng cần được cập nhật, là sử dụng một kỹ thuật có tên là Khóa lạc quan . - Cách tránh giữ tài nguyên trong khi chờ tài nguyên độc quyền khác là khóa tất cả tài nguyên cần thiết khi bắt đầu thuật toán và giải phóng tất cả nếu không thể khóa tất cả cùng một lúc. Tất nhiên, điều này không phải lúc nào cũng có thể thực hiện được; có lẽ các tài nguyên cần khóa đều không được biết trước hoặc cách tiếp cận này sẽ đơn giản dẫn đến lãng phí tài nguyên. - Nếu không thể lấy được khóa ngay lập tức, cách để vượt qua tình trạng bế tắc có thể xảy ra là áp dụng thời gian chờ. Ví dụ: lớp ReentrantLocktừ SDK cung cấp khả năng đặt ngày hết hạn cho khóa. - Như chúng ta đã thấy từ ví dụ trên, bế tắc không xảy ra nếu chuỗi yêu cầu không khác nhau giữa các luồng khác nhau. Điều này rất dễ kiểm soát nếu bạn có thể đặt tất cả mã chặn vào một phương thức mà tất cả các luồng phải trải qua. Trong các ứng dụng nâng cao hơn, bạn thậm chí có thể xem xét triển khai hệ thống phát hiện bế tắc. Ở đây, bạn sẽ cần triển khai một số hình thức giám sát luồng, trong đó mỗi luồng báo cáo rằng nó đã lấy được khóa thành công và đang cố lấy khóa. Nếu các luồng và khóa được mô hình hóa dưới dạng biểu đồ có hướng, bạn có thể phát hiện khi hai luồng khác nhau đang giữ tài nguyên trong khi cố gắng truy cập các tài nguyên bị khóa khác cùng một lúc. Sau đó, nếu bạn có thể buộc các luồng chặn giải phóng các tài nguyên cần thiết thì bạn có thể tự động giải quyết tình huống bế tắc.
1.2 Nhịn ăn
Bộ lập lịch quyết định luồng nào ở trạng thái RUNNABLE sẽ được thực thi tiếp theo. Quyết định dựa trên mức độ ưu tiên của luồng; do đó, các luồng có mức độ ưu tiên thấp hơn sẽ nhận được ít thời gian CPU hơn so với các luồng có mức độ ưu tiên cao hơn. Những gì tưởng chừng như là một giải pháp hợp lý cũng có thể gây ra vấn đề nếu bị lạm dụng. Nếu các luồng có mức độ ưu tiên cao đang thực thi hầu hết thời gian thì các luồng có mức độ ưu tiên thấp dường như bị chết đói vì chúng không có đủ thời gian để thực hiện công việc của mình một cách chính xác. Do đó, bạn chỉ nên đặt mức độ ưu tiên của luồng khi có lý do thuyết phục để làm như vậy. Ví dụ, một ví dụ không rõ ràng về tình trạng thiếu luồng được đưa ra bằng phương thức Finalize(). Nó cung cấp một cách để ngôn ngữ Java thực thi mã trước khi một đối tượng được thu gom rác. Nhưng nếu bạn nhìn vào mức độ ưu tiên của luồng hoàn thiện, bạn sẽ nhận thấy rằng nó không chạy với mức độ ưu tiên cao nhất. Do đó, tình trạng thiếu luồng xảy ra khi các phương thức Finalize() của đối tượng của bạn dành quá nhiều thời gian so với phần còn lại của mã. Một vấn đề khác với thời gian thực hiện phát sinh từ thực tế là nó không được xác định thứ tự các luồng đi qua khối được đồng bộ hóa. Khi nhiều luồng song song đang duyệt qua một số mã được đóng khung trong một khối được đồng bộ hóa, có thể xảy ra trường hợp một số luồng phải đợi lâu hơn các luồng khác trước khi vào khối. Về lý thuyết, họ có thể không bao giờ đạt được điều đó. Giải pháp cho vấn đề này là cái gọi là chặn “công bằng”. Khóa công bằng sẽ tính đến thời gian chờ đợi của chuỗi khi xác định ai sẽ vượt qua tiếp theo. Một ví dụ triển khai khóa công bằng có sẵn trong Java SDK: java.util.concurrent.locks.ReentrantLock. Nếu một hàm tạo được sử dụng với cờ boolean được đặt thành true thì ReentrantLock sẽ cấp quyền truy cập vào luồng đã chờ lâu nhất. Điều này đảm bảo không có nạn đói nhưng đồng thời dẫn đến vấn đề bỏ qua các ưu tiên. Do đó, các quy trình có mức độ ưu tiên thấp hơn thường chờ ở rào cản này có thể chạy thường xuyên hơn. Cuối cùng nhưng không kém phần quan trọng, lớp ReentrantLock chỉ có thể xem xét các luồng đang chờ khóa, tức là. các luồng được khởi chạy đủ thường xuyên và chạm tới rào cản. Nếu mức độ ưu tiên của một luồng quá thấp thì điều này sẽ không xảy ra thường xuyên đối với nó và do đó các luồng có mức độ ưu tiên cao vẫn sẽ vượt qua khóa thường xuyên hơn.
2. Giám sát đối tượng bằng wait() và notification()
Trong điện toán đa luồng, một tình huống phổ biến là có một số luồng công việc đang chờ nhà sản xuất tạo ra một số công việc cho chúng. Tuy nhiên, như chúng ta đã học, việc chủ động chờ đợi trong một vòng lặp trong khi kiểm tra một giá trị nhất định không phải là một lựa chọn tốt về mặt thời gian của CPU. Sử dụng phương thức Thread.sleep() trong tình huống này cũng không đặc biệt phù hợp nếu chúng ta muốn bắt đầu công việc ngay sau khi đến. Với mục đích này, ngôn ngữ lập trình Java có một cấu trúc khác có thể được sử dụng trong sơ đồ này: wait() và notification(). Phương thức wait(), được kế thừa bởi tất cả các đối tượng từ lớp java.lang.Object, có thể được sử dụng để tạm dừng luồng hiện tại và đợi cho đến khi một luồng khác đánh thức chúng ta bằng phương thức notification(). Để hoạt động chính xác, luồng gọi phương thức wait() phải giữ một khóa mà nó có được trước đó bằng từ khóa được đồng bộ hóa. Khi wait() được gọi, khóa được giải phóng và luồng sẽ đợi cho đến khi một luồng khác hiện giữ lệnh gọi khóa notification() trên cùng một thể hiện đối tượng. Trong một ứng dụng đa luồng, đương nhiên có thể có nhiều hơn một luồng đang chờ thông báo trên một số đối tượng. Do đó, có hai phương pháp khác nhau để đánh thức các luồng: thông báo() và thông báoAll(). Trong khi phương thức đầu tiên đánh thức một trong các luồng đang chờ thì phương thức notificationAll() sẽ đánh thức tất cả chúng. Nhưng hãy lưu ý rằng, cũng như từ khóa được đồng bộ hóa, không có quy tắc nào xác định luồng nào sẽ được đánh thức tiếp theo khi hàm notification() được gọi. Trong một ví dụ đơn giản với nhà sản xuất và người tiêu dùng, điều này không thành vấn đề vì chúng tôi không quan tâm chuỗi nào được đánh thức. Đoạn mã sau đây cho thấy cách wait() và notification() có thể được sử dụng để khiến các luồng tiêu dùng chờ tác phẩm mới được xếp hàng bởi một luồng sản xuất: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } Phương thức main() bắt đầu năm luồng tiêu dùng và một luồng sản xuất, sau đó đợi chúng kết thúc. Sau đó, luồng sản xuất sẽ thêm giá trị mới vào hàng đợi và thông báo cho tất cả các luồng đang chờ rằng có điều gì đó đã xảy ra. Người tiêu dùng nhận được một khóa hàng đợi (tức là một người tiêu dùng ngẫu nhiên) và sau đó đi ngủ, sẽ được nâng lên sau khi hàng đợi đầy trở lại. Khi nhà sản xuất hoàn thành công việc, nó sẽ thông báo cho tất cả người tiêu dùng để đánh thức họ. Nếu chúng tôi không thực hiện bước cuối cùng, chuỗi khách hàng sẽ đợi thông báo tiếp theo mãi mãi vì chúng tôi không đặt thời gian chờ để chờ. Thay vào đó, chúng ta có thể sử dụng phương thức chờ (thời gian chờ lâu) để được đánh thức ít nhất sau một thời gian trôi qua.
2.1 Các khối được đồng bộ hóa lồng nhau với wait() và notification()
Như đã nêu ở phần trước, việc gọi wait() trên màn hình của một đối tượng chỉ giải phóng khóa trên màn hình đó. Các khóa khác được giữ bởi cùng một sợi không được mở ra. Thật dễ hiểu, trong công việc hàng ngày, có thể xảy ra trường hợp luồng gọi wait() giữ khóa hơn nữa. Nếu các luồng khác cũng đang đợi các khóa này thì tình trạng bế tắc có thể xảy ra. Hãy xem xét việc khóa trong ví dụ sau: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Như chúng ta đã tìm hiểu trước đó , việc thêm được đồng bộ hóa vào chữ ký phương thức tương đương với việc tạo khối được đồng bộ hóa (điều này){}. Trong ví dụ trên, chúng tôi đã vô tình thêm từ khóa được đồng bộ hóa vào phương thức, sau đó đồng bộ hóa hàng đợi với màn hình của đối tượng hàng đợi để đưa luồng này vào chế độ ngủ trong khi nó chờ giá trị tiếp theo từ hàng đợi. Sau đó, luồng hiện tại sẽ giải phóng khóa trên hàng đợi, nhưng không giải phóng khóa trên hàng đợi này. Phương thức putInt() thông báo cho thread đang ngủ rằng một giá trị mới đã được thêm vào. Nhưng tình cờ chúng tôi cũng đã thêm từ khóa được đồng bộ hóa vào phương pháp này. Bây giờ thread thứ hai đã ngủ quên, nó vẫn giữ khóa. Do đó, luồng đầu tiên không thể nhập phương thức putInt() trong khi khóa được giữ bởi luồng thứ hai. Kết quả là chúng ta rơi vào tình trạng bế tắc và chương trình bị đóng băng. Nếu bạn chạy đoạn mã trên, nó sẽ xảy ra ngay sau khi chương trình bắt đầu chạy. Trong cuộc sống hàng ngày, tình huống này có thể không quá rõ ràng. Các khóa được giữ bởi một luồng có thể phụ thuộc vào các tham số và điều kiện gặp phải trong thời gian chạy và khối được đồng bộ hóa gây ra sự cố có thể không giống với mã mà chúng tôi đã thực hiện lệnh gọi wait(). Điều này gây khó khăn cho việc tìm ra những vấn đề như vậy, đặc biệt vì chúng có thể xảy ra theo thời gian hoặc dưới tải trọng cao.
2.2 Điều kiện trong khối được đồng bộ hóa
Thông thường, bạn cần kiểm tra xem một số điều kiện có được đáp ứng hay không trước khi thực hiện bất kỳ hành động nào trên một đối tượng được đồng bộ hóa. Ví dụ: khi bạn có một hàng đợi, bạn muốn đợi cho đến khi nó đầy. Vì vậy, bạn có thể viết một phương thức để kiểm tra xem hàng đợi đã đầy hay chưa. Nếu nó vẫn trống thì bạn gửi luồng hiện tại vào chế độ ngủ cho đến khi nó thức dậy: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } Đoạn mã trên đồng bộ hóa với hàng đợi trước khi gọi wait() và sau đó đợi trong vòng lặp while cho đến khi có ít nhất một phần tử xuất hiện trong hàng đợi. Khối được đồng bộ hóa thứ hai lại sử dụng hàng đợi làm trình giám sát đối tượng. Nó gọi phương thức poll() của hàng đợi để lấy giá trị. Vì mục đích trình diễn, IllegalStateException sẽ được đưa ra khi cuộc thăm dò trả về giá trị rỗng. Điều này xảy ra khi hàng đợi không có phần tử nào để tìm nạp. Khi chạy ví dụ này, bạn sẽ thấy IllegalStateException được ném ra rất thường xuyên. Mặc dù chúng tôi đã đồng bộ hóa chính xác bằng cách sử dụng trình giám sát hàng đợi nhưng vẫn xảy ra một ngoại lệ. Lý do là chúng ta có hai khối đồng bộ khác nhau. Hãy tưởng tượng chúng ta có hai luồng đã đến khối được đồng bộ hóa đầu tiên. Chuỗi đầu tiên đi vào khối và chuyển sang chế độ ngủ vì hàng đợi trống. Điều tương tự cũng đúng với chủ đề thứ hai. Bây giờ cả hai luồng đều hoạt động (nhờ lệnh gọi notificationAll() được gọi bởi luồng khác cho màn hình), cả hai luồng đều nhìn thấy giá trị (mục) trong hàng đợi do nhà sản xuất thêm vào. Sau đó cả hai đến hàng rào thứ hai. Ở đây luồng đầu tiên đã nhập và lấy giá trị từ hàng đợi. Khi luồng thứ hai đi vào, hàng đợi đã trống. Do đó, nó nhận được giá trị rỗng là giá trị được trả về từ hàng đợi và đưa ra một ngoại lệ. Để ngăn chặn những tình huống như vậy, bạn cần thực hiện tất cả các thao tác phụ thuộc vào trạng thái của màn hình trong cùng một khối được đồng bộ hóa: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Ở đây chúng ta thực thi phương thức poll() trong cùng một khối được đồng bộ hóa với phương thức isEmpty(). Nhờ khối được đồng bộ hóa, chúng tôi chắc chắn rằng chỉ có một luồng đang thực thi một phương thức cho màn hình này tại một thời điểm nhất định. Do đó, không có luồng nào khác có thể xóa các phần tử khỏi hàng đợi giữa các lệnh gọi tới isEmpty() và poll(). Dịch tiếp đây .
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION