Đồng bộ hóa (Synchronization)

Xem thử

Tình huống mở đầu

Quán Cà Phê Gió có hai quầy bán hàng cùng chạy trên hai luồng, và mỗi quầy cộng 100.000 lần vào một bộ đếm số ly đã bán dùng chung. Tổng đúng phải là 200.000. Có những ngày cuối ngày màn hình báo một con số thấp hơn 200.000, chạy lại thì ra một con số khác, và cũng có ngày lại đúng 200.000. Không có lỗi nào hiện ra, không có dòng nào trông sai, chỉ có doanh thu thỉnh thoảng bị báo thiếu.

Bạn thử thêm Thread.sleep cho chắc thì con số vẫn lệch. Vì sao hai luồng cùng làm một phép cộng đơn giản mà lại làm mất lần cộng, vì sao con số mất khác nhau mỗi lần, và vì sao có lần không mất gì? Hãy giữ câu hỏi này, vì ở phần Ví dụ đầu tiên bạn sẽ ép lỗi xảy ra theo một thứ tự cố định để nhìn rõ nó, rồi chặn nó.

Ôn nhanh bài trước: muốn đọc kết quả mà một luồng khác đã ghi, bạn nên gọi lệnh nào trước khi đọc?

Đáp án: join() của luồng đó. Sau join(), mọi thứ luồng kia đã ghi đều nhìn thấy được.

Mục tiêu bài học

Sau bài này, bạn có thể:

  • Giải thích được vì sao count++ không an toàn khi hai luồng cùng chạy, vì nó là ba bước đọc, cộng, ghi có thể bị xen kẽ.
  • Dùng synchronized trên đúng một đối tượng khoá chung để mỗi lần chỉ một luồng vào khối, và chỉ ra khi nào khoá không có tác dụng.
  • Phân biệt volatile (thấy giá trị mới nhất) với synchronized (loại trừ lẫn nhau), và nêu vì sao volatile int không cứu được count++.
  • Nhận ra deadlock khi hai luồng chờ khoá của nhau, và sửa bằng cách lấy khoá theo cùng một thứ tự.

Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.

Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.

Bức tranh lớn (Big Picture)

Trước khi vào chi tiết, hãy xem ba rắc rối khi nhiều luồng dùng chung một biến và công cụ nào trả lời rắc rối nào.

Cách đọc hình: đi từ trên xuống. Ô trắng trên cùng là biến dùng chung. Hàng giữa gồm ba ô đỏ nhạt là ba rắc rối: tranh nhau ghi (race condition), nhìn thấy giá trị cũ (stale value), và chờ nhau mãi (deadlock). Hàng dưới là ba ô lục là ba công cụ: synchronized, volatile, và thứ tự khoá thống nhất. Mỗi mũi tên "fixed by" nối một rắc rối với công cụ của nó. Màu chỉ là tín hiệu phụ, mỗi ô đều có nhãn chữ.

Điểm đáng chú ý là ba công cụ không thay thế được nhau. volatile không chặn tranh nhau ghi, còn synchronized không cứu được deadlock nếu bạn lấy khoá lung tung.

Xuyên suốt bài, mình dùng hình ảnh một chiếc chìa khoá duy nhất cho một cánh cửa: chỉ ai cầm chìa mới được vào căn phòng chứa bộ đếm. Dự án xuyên suốt là bộ đếm số ly bán của quán Cà Phê Gió. Hình ảnh này chưa đúng ở một chỗ: chìa khoá của Java là một đối tượng, và hai luồng chỉ loại trừ nhau khi cùng dùng đúng một đối tượng đó. Mình sẽ chỉ rõ khi tới khái niệm 2.

Khái niệm cốt lõi

Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình.

Khái niệm 1: race condition và vì sao count++ làm mất lần cộng

Race condition (tranh chấp dữ liệu) là tình huống kết quả phụ thuộc vào thứ tự xen kẽ của các luồng. Nguyên tử (atomic) nghĩa là một thao tác không thể bị chia nhỏ giữa chừng. count++ trông như một bước nhưng không nguyên tử: máy làm ba bước tương đương như sau.

java
int copy = count;
copy = copy + 1;
// Luồng khác có thể đã ghi count ở giữa, nên bước này ghi đè lên
count = copy;

Cách đọc hình: đi từ trái sang phải, bốn ô nối nhau bằng "then". Luồng A đọc 5, luồng B cũng đọc 5, luồng A ghi 6, rồi luồng B ghi 6 (ô đỏ) đè lên. Ô cuối cho thấy count bằng 6 trong khi đúng ra phải là 7. Dòng chú thích nhắc rằng đây chỉ là một trong các thứ tự có thể xảy ra.

Đây là khái niệm quan trọng nhất của bài, vì mọi công cụ còn lại đều sinh ra để trả lời nó. Thứ tự xen kẽ do hệ điều hành quyết định, nên con số mất khác nhau mỗi lần chạy [Có thể khác nhau]. Cái được bảo đảm duy nhất là tổng cuối cùng không bao giờ lớn hơn tổng kỳ vọng, vì lần cộng chỉ có thể bị mất chứ không tự sinh thêm.

Concept Check 1. count đang bằng 5. Hai luồng, mỗi luồng chạy count++ đúng một lần, không có khoá. Kết quả cuối cùng có thể là gì? Hãy chọn trước khi đọc tiếp.

  • A. Chắc chắn 7, vì mỗi luồng cộng một lần.
  • B. Chắc chắn 6, vì luồng đến sau luôn ghi đè luồng đến trước.
  • C. 6 hoặc 7, tuỳ thứ tự xen kẽ đọc và ghi của hai luồng.
  • D. 5, vì hai phép cộng triệt tiêu nhau.

Đáp án: C. Nếu hai luồng chạy nối đuôi nhau thì được 7, nếu cả hai cùng đọc 5 trước khi ai ghi thì được 6 [Đã chạy: LostUpdate.java ép đúng thứ tự thứ hai và luôn in 6]. A là hiểu lầm hay gặp nhất: nghĩ rằng cộng một lần thì chắc được cộng. B sai vì không có luồng nào "luôn" đến sau, thứ tự không cố định. D sai vì không có phép nào làm count giảm.

Khái niệm 2: synchronized và khoá chung

synchronized là từ khoá bảo đảm mỗi lần chỉ một luồng chạy khối code được bảo vệ. Khoá (monitor lock) là cái "chìa" gắn với một đối tượng bất kỳ: luồng nào giữ khoá của đối tượng đó thì được vào, luồng khác phải chờ ở trạng thái BLOCKED. Có hai cách viết: gắn lên cả phương thức (synchronized void increment()), hoặc bao quanh một khối với đối tượng khoá nêu rõ.

Cách đọc hình: hàng trên: Thread A (ô lục) giữ chìa khoá của Lock object (ô vàng), nên được chạy Guarded block. Thread B (ô đỏ nhạt) ở hàng dưới xin khoá bằng mũi tên nét đứt và phải chờ, trạng thái BLOCKED. Ô xám Wrong lock ở góc phải cho biết khi khoá khác nhau thì bảo vệ vô tác dụng.

Đoạn dưới đây là khối được bảo vệ chuẩn. Khoá là một trường final dùng chung cho mọi luồng:

java
private final Object lock = new Object();
void increment() {
    // Mọi luồng phải dùng cùng một đối tượng lock này
    synchronized (lock) {
        count++;
    }
}
Dòng codeMáy làm gìVì sao viết như vậy
private final Object lock = new Object();Tạo một đối tượng khoá, tạo đúng một lầnfinal bảo đảm mọi luồng thấy đúng một khoá, không ai đổi nó đi
synchronized (lock) {Luồng xin khoá của lock, nếu có luồng khác đang giữ thì chờ ở trạng thái BLOCKEDĐây là "cánh cửa": chỉ luồng nào giữ khoá mới đi tiếp
count++;Đọc, cộng, ghi, trong lúc không luồng nào khác vào khốiBa bước không còn bị xen kẽ giữa chừng
} kết thúc khốiLuồng nhả khoá, một luồng đang chờ được vàoKhoá luôn được nhả kể cả khi có lỗi bên trong khối

Nếu viết synchronized (new Object()), mỗi lần gọi tạo một khoá mới và không có hai luồng nào cùng một khoá, nên không có gì được bảo vệ. Còn viết synchronized trên phương thức thì khoá là chính đối tượng this: hai phương thức synchronized của cùng một đối tượng chia nhau một khoá.

Chỗ hình ảnh không còn đúng: chiếc chìa khoá không gắn với cánh cửa là khối code, mà gắn với đối tượng. Hai khối code khác nhau dùng cùng một đối tượng khoá thì loại trừ nhau, dù chúng nằm ở hai nơi.

Khái niệm 3: volatile và nhìn thấy giá trị mới

Tính nhìn thấy (visibility) là việc một luồng đọc được giá trị mà luồng khác vừa ghi. Mỗi luồng có thể giữ bản sao của biến để chạy nhanh, nên không có gì bảo đảm luồng đọc thấy giá trị mới. volatile bảo đảm mọi lần đọc biến đều lấy giá trị mới nhất, và mọi lần ghi đều được các luồng khác nhìn thấy.

Cách đọc hình: hàng trên là biến volatile: main đặt stop = true, giá trị được công bố, và vòng lặp đọc thấy true rồi thoát (ô lục). Hàng dưới là biến thường: vòng lặp đọc giữ bản sao cũ và có thể lặp mãi, không được bảo đảm (ô đỏ nhạt). Dòng chú thích nhắc rằng volatile chỉ giải quyết việc nhìn thấy.

Đoạn dưới đây là cờ dừng dùng volatile:

java
// volatile: mọi lần đọc đều lấy giá trị mới nhất
static volatile boolean stop;
while (!stop) {
    spins++;
}

Kết quả của Visibility.java [Đã chạy, JDK 17.0.15; dòng thứ hai là [Có thể khác nhau], máy hoặc phiên bản Java khác có thể cho true]:

text
volatile flag: reader stopped true
plain flag: reader stopped true
Dòng codeMáy làm gìVì sao viết như vậy
static volatile boolean stop;Khai báo cờ dùng chung, mọi lần đọc ghi đi thẳng tới giá trị mới nhấtCờ do một luồng ghi và luồng khác đọc cần volatile để lời nhắn tới nơi
while (!stop) { spins++; }Vòng lặp đọc lại stop ở mỗi vòngNhờ volatile, khi main đặt true thì vòng lặp thoát
Cùng vòng lặp với stop là biến thườngLuồng đọc có thể giữ bản sao cũ và không thoátĐây là kết quả false ở dòng thứ hai, và nó không được bảo đảm

volatile rất hợp với một cờ đơn. Nhưng nó không biến count++ thành nguyên tử: volatile int count với bốn luồng vẫn mất lần cộng, và hình "mô hình sai và mô hình đúng" ở phần Lỗi thường gặp sẽ nói rõ.

Khái niệm 4: deadlock và thứ tự khoá

Deadlock là tình huống hai luồng (hoặc hơn) mỗi bên giữ một khoá và chờ khoá của bên kia, nên không ai đi tiếp được. Nó xảy ra khi có từ hai khoá trở lên và các luồng lấy chúng theo thứ tự khác nhau.

Cách đọc hình: hai ô vàng là hai khoá: Lock A (ở trên) và Lock B (ở dưới). Hai ô đỏ nhạt là hai luồng. Mũi tên nét liền "holds" nối luồng với khoá nó đang giữ: t1 giữ A, t2 giữ B. Mũi tên nét đứt "waits for" nối luồng với khoá nó đang chờ: t1 chờ B, t2 chờ A. Hai mũi tên chéo nhau tạo thành một vòng, và dòng chú thích cho cách sửa.

Đoạn dưới đây là cách hai luồng lấy khoá. Cả hai gọi cùng một hàm, nhưng truyền khoá theo thứ tự ngược nhau:

java
static void grab(Object first, Object second, CountDownLatch gate) {
    synchronized (first) {
        try {
            gate.countDown();
            // Chờ cả hai luồng đã giữ khoá đầu để deadlock chắc chắn xảy ra
            gate.await();
        } catch (InterruptedException e) {
            return;
        }
        synchronized (second) { }
    }
}

Hai luồng chạy grab(A, B, ...) và grab(B, A, ...). Kết quả [Đã chạy, JDK 17.0.15; chương trình có cổng chờ nên deadlock xảy ra chắc chắn, và JVM tự phát hiện bằng findDeadlockedThreads]:

text
deadlocked threads 2, states BLOCKED and BLOCKED
Dòng codeMáy làm gìVì sao viết như vậy
synchronized (first) {Luồng lấy khoá thứ nhấtt1 lấy A, t2 lấy B vì chúng nhận tham số ngược nhau
gate.countDown(); gate.await();Báo "tôi đã giữ khoá đầu" rồi chờ luồng kia cũng báoCountDownLatch (bài sau của chương) chỉ dùng ở đây để cả hai chắc chắn cùng giữ khoá đầu, nếu không deadlock có thể không xảy ra
synchronized (second) { }Luồng xin khoá còn lạiKhoá này đang do luồng kia giữ, nên cả hai chờ nhau mãi

Cách sửa là cho mọi luồng lấy khoá theo cùng một thứ tự: cả hai luồng cùng synchronized (A) rồi mới synchronized (B). Khi đó luồng đến sau bị chặn ngay ở khoá đầu và chưa giữ khoá nào, nên không có vòng chờ. Hãy xem từng bước, cả lúc deadlock lẫn lúc đã sửa [Suy luận, khớp với kết quả Deadlock.java và OrderFix.java]:

Đang tải Animation…

Bảng trace (bản chữ tương đương của animation):

Bướct1 giữt2 giữTrạng thái
1không cókhông cóCả hai chạy, khoá A và B rảnh
2Akhông cót1 lấy A
3ABt2 lấy B
4ABt1 xin B, BLOCKED
5ABt2 xin A, BLOCKED: deadlock
6ABJVM báo hai luồng bị deadlock
7 (đã sửa)Akhông cóCùng lấy A trước, t2 chờ ở A
8 (đã sửa)A rồi B, sau đó nhả hếtchờ ở A, sau đó A rồi Bt1 xong rồi t2 chạy tiếp, không deadlock

Ví dụ đầu tiên (Worked Example) và "Xem từng bước"

Chạy thật hai luồng cùng cộng thì thứ tự xen kẽ do hệ điều hành quyết định, nên kết quả đổi theo từng lần. Để nhìn rõ lỗi, mình ép hai luồng đi theo đúng một thứ tự: cả hai cùng đọc count bằng 5, rồi mới ghi. Chương trình LostUpdate làm việc đó, và nhờ đó kết quả cố định. Đoạn đầu gồm hai cách cộng một lần vào count, cách không khoá và cách có khoá:

LostUpdate.java

java
    static void addWithoutLock(CyclicBarrier bothRead) {
        try {
            int copy = count;
            // Ép hai luồng cùng đọc xong rồi mới được ghi: thứ tự xen kẽ cố định
            bothRead.await();
            count = copy + 1;
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    static void addWithLock() {
        synchronized (LOCK) {
            int copy = count;
            count = copy + 1;
        }
    }

Đoạn sau chạy mỗi cách bằng hai luồng và in kết quả:

LostUpdate.java

java
    static void runTwice(Runnable task) throws InterruptedException {
        Thread a = new Thread(task);
        Thread b = new Thread(task);
        a.start();
        b.start();
        a.join();
        b.join();
    }

    public static void main(String[] args) throws InterruptedException {
        CyclicBarrier bothRead = new CyclicBarrier(2);
        runTwice(() -> addWithoutLock(bothRead));
        System.out.println("no lock, both read 5 first: count is " + count);
        count = 5;
        runTwice(LostUpdate::addWithLock);
        System.out.println("with lock: count is " + count);
    }

Predict: hãy dừng lại, lấy giấy nháp và đoán: count bắt đầu bằng 5 và có hai lần cộng một. Dòng đầu (không khoá, cả hai cùng đọc 5 trước khi ai ghi) in ra số nào? Dòng thứ hai (có khoá) in ra số nào? Đoán xong rồi hãy đọc tiếp.

Kết quả mong đợi [Đã chạy sáu lần liên tiếp, JDK 17.0.15, lần nào cũng giống hệt: thứ tự xen kẽ được ép bằng CyclicBarrier nên kết quả cố định, không phụ thuộc vào máy]:

text
no lock, both read 5 first: count is 6
with lock: count is 7

Đọc code từng dòng:

Dòng codeMáy làm gìVì sao viết như vậy
int copy = count;Mỗi luồng giữ một bản sao của count, lúc này là 5Đây là bước đọc trong ba bước đọc, cộng, ghi
bothRead.await();Luồng dừng lại tới khi luồng kia cũng tới dòng nàyCyclicBarrier chỉ dùng ở đây để cả hai luồng chắc chắn đã đọc xong 5 trước khi ai ghi; bài sau của chương sẽ nói kỹ hơn
count = copy + 1;Ghi bản sao cộng một vào countHai luồng cùng ghi 6, nên một lần cộng bị mất
synchronized (LOCK) { ... }Mỗi lần chỉ một luồng được vào để đọc rồi ghiLuồng sau chờ ở trạng thái BLOCKED, đọc được 6 và ghi 7
runTwice(task)Chạy cùng một việc trên hai luồng rồi join cả haiPhải join trước khi in, nếu không có thể in giữa chừng

Bây giờ xem từng bước. Hình dưới đi đúng thứ tự mà LostUpdate ép, với count bằng 5:

Đang tải Animation…

Bảng trace (bản chữ tương đương của animation, khớp kết quả chạy thật của LostUpdate: không khoá ra 6, có khoá ra 7):

BướcLuồng làm gìA giữ bản saoB giữ bản saocount trong bộ nhớ
0Bắt đầukhông cókhông có5
1A đọc5không có5
2B đọc555
3A cộng một rồi ghi656
4B cộng một rồi ghi (không khoá)666, thiếu một lần cộng
5Có khoá: A ghi 6, B đang BLOCKED6chưa đọc6
6Có khoá: B đọc 6 rồi ghi 7677, đủ hai lần cộng

Bạn vừa làm được: đọc một thứ tự xen kẽ của hai luồng và chỉ ra chính xác bước nào làm mất lần cộng, và khoá chặn nó ở bước nào.

Ví dụ thứ hai: không ép thứ tự, chạy thật 100.000 lần. Đây là tình huống của quán Cà Phê Gió: hai quầy, mỗi quầy cộng 100.000 lần, không ai ép thứ tự gì cả.

SalesCounter.java

java
public class SalesCounter {
    static int unsafe;
    static int safe;
    static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable shift = () -> {
            for (int i = 0; i < 100_000; i++) {
                // Đọc, cộng, ghi là ba bước nên có thể bị xen kẽ
                unsafe++;
                // Mỗi lần chỉ một luồng vào khối này
                synchronized (LOCK) {
                    safe++;
                }
            }
        };
        Thread a = new Thread(shift);
        Thread b = new Thread(shift);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("safe is exactly 200000: " + (safe == 200_000));
        System.out.println("unsafe never above 200000: " + (unsafe <= 200_000));
        // Số này có thể bằng 200000 hoặc nhỏ hơn, khác nhau giữa các lần chạy
        System.out.println("unsafe total in this run: " + unsafe);
    }
}

Kết quả [Đã chạy, JDK 17.0.15]. Hai dòng đầu luôn true theo ngữ nghĩa Java; dòng cuối là [Có thể khác nhau] giữa các lần chạy:

text
safe is exactly 200000: true
unsafe never above 200000: true
unsafe total in this run: 199438

Mười lăm lần chạy liên tiếp trên máy này cho unsafe là 168049, 200000, 199983, 188359, 200000, 200000, 199990, 200000, 200000, 199970, 143059, 199999, 200000, 200000, 177832 [Đã chạy 15 lần với cùng vòng lặp, khi dòng cuối in thẳng số; Có thể khác nhau ở máy khác]. Bảy trong mười lăm lần không mất lần cộng nào. Điều này không mâu thuẫn với bài: lỗi chỉ xảy ra khi hai luồng thật sự xen kẽ đúng lúc, và hai luồng ngắn như thế này đôi khi chạy gần như nối đuôi nhau [Suy luận]. Chương trình chạy đúng không có nghĩa là code đúng, đó là lý do LostUpdate ép thứ tự cho bạn nhìn thấy lỗi chắc chắn.

Dòng codeMáy làm gìVì sao viết như vậy
unsafe++;Đọc, cộng, ghi mà không có gì chặn xen kẽHai luồng có thể cùng đọc một giá trị cũ rồi cùng ghi đè
synchronized (LOCK) { safe++; }Mỗi lần chỉ một luồng được cộngBa bước nối đuôi nhau, nên safe luôn đúng 200000
safe == 200_000In ra câu đúng hoặc sai thay cho một con sốCâu này ổn định ở mọi lần chạy
unsafe <= 200_000So sánh với tổng kỳ vọngChỉ có thể mất lần cộng chứ không tự sinh thêm, nên luôn true

Concept Check xen kẽ

Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.

Concept Check 2. Hai luồng cùng chạy synchronized (new Object()) { total += x; } quanh một biến total dùng chung. Điều nào đúng?

  • A. Không an toàn, vì mỗi lần là một khoá mới nên hai luồng không dùng chung khoá.
  • B. An toàn, vì đã có từ khoá synchronized bao quanh.
  • C. An toàn nhưng chậm, vì phải tạo rất nhiều đối tượng khoá.
  • D. Lỗi biên dịch, vì không được tạo đối tượng ngay trong ngoặc.

Đáp án: A. Khoá là cái chìa gắn với đối tượng, mà mỗi lần gọi lại là một đối tượng mới, nên không có hai luồng nào tranh cùng một chìa. B là hiểu lầm hay gặp nhất: tưởng có chữ synchronized là đủ. C sai vì chậm không phải vấn đề, mà là không bảo vệ gì cả. D sai vì cú pháp hợp lệ, trình biên dịch không phàn nàn. [Đã chạy, JDK 17.0.15: Compare.java ở phần Đào sâu cho thấy kiểu khoá này làm mất lần cộng ít nhất một lần trong 20 lần thử; số lần mất cụ thể là Có thể khác nhau.]

Concept Check 3. private volatile int count; và bốn luồng cùng gọi count++. Kết quả cuối cùng như thế nào?

  • A. Luôn đúng, vì volatile bắt mọi luồng thấy giá trị mới nhất.
  • B. Ném lỗi lúc chạy, vì volatile không dùng được cho kiểu int.
  • C. Luôn thiếu đúng một lần cộng, vì chỉ có một chỗ xen kẽ.
  • D. Có thể thiếu, vì volatile không gộp đọc, cộng, ghi thành một bước.

Đáp án: D. volatile giải quyết việc nhìn thấy giá trị mới, còn count++ vẫn là ba bước có thể bị xen kẽ. A nhầm giữa hai bảo đảm khác nhau: nhìn thấy và nguyên tử. B sai vì volatile int khai báo hợp lệ và chạy được. C sai vì số lần mất không cố định, tuỳ lúc xen kẽ [Có thể khác nhau]. Sửa bằng synchronized hoặc kiểu AtomicInteger của bài sau.

Concept Check 4. Luồng t1 khoá A rồi B, luồng t2 khoá B rồi A, và đôi khi chương trình đứng mãi. Cách sửa gọn nhất là gì?

  • A. Thêm Thread.sleep vào giữa hai lần khoá.
  • B. Cho cả hai luồng lấy khoá theo cùng một thứ tự, ví dụ A rồi B.
  • C. Đổi hai khoá thành biến volatile.
  • D. Gọi interrupt() cho cả hai luồng mỗi khi thấy treo.

Đáp án: B. Khi mọi luồng lấy khoá theo cùng một thứ tự thì không thể có vòng chờ. A chỉ làm lỗi xảy ra ít hơn chứ không hết, vì thứ tự vẫn ngược. C sai vì khoá và volatile là hai thứ khác nhau. D sai vì interrupt() không làm luồng đang chờ synchronized nhả ra: luồng chờ khoá vẫn ở BLOCKED.

Làm cùng mình (Guided Practice)

Bạn sẽ dựng dần class ShiftBoard qua bốn bước nhỏ, mỗi bước thêm vào chương trình của bước trước. Hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 4 chỉ còn đề. Gõ code vào file ShiftBoard.java rồi chạy bằng java ShiftBoard.java.

Bước 1: hai quầy cộng vào bộ đếm không khoá.

java
public class ShiftBoard {
    static int sold;

    public static void main(String[] args) throws InterruptedException {
        Runnable shift = () -> {
            for (int i = 0; i < 50_000; i++) {
                sold++;
            }
        };
        Thread a = new Thread(shift);
        Thread b = new Thread(shift);
        // TODO 1: chạy hai luồng a, b rồi chờ cả hai xong
        System.out.println("step 1: at most 100000 " + (sold <= 100_000));
    }
}

Chạy xong bạn sẽ thấy: step 1: at most 100000 true. Con số sold thật sự là [Có thể khác nhau], vì vậy ta chỉ in điều chắc chắn.

Bước 2: thêm bộ đếm có khoá. Thêm hai khai báo static int soldLocked; và static final Object LOCK = new Object(); ngay dưới sold, rồi thêm vào thân vòng for, ngay dưới sold++;:

java
                // TODO 2: tăng soldLocked bên trong khối synchronized (LOCK)

Và thêm dòng in dưới dòng step 1: System.out.println("step 2: " + soldLocked);. Chạy xong bạn sẽ thấy: step 2: 100000.

Vẽ ra giấy: vẽ hai luồng A, B và một cánh cửa có tên LOCK. Đánh dấu ai đang trong khối và ai đang BLOCKED ở ba thời điểm khác nhau. Đối chiếu: ở mỗi thời điểm chỉ có nhiều nhất một luồng ở trong khối, nên ba bước đọc, cộng, ghi không bị xen kẽ. Cùng ý với hình khoá ở khái niệm 2.

Bước 3: cùng việc đó bằng phương thức synchronized.

java
    // TODO 3: viết static synchronized void addMethod() tăng soldMethod, rồi gọi nó trong vòng for

Bạn cần khai báo static int soldMethod; và dòng in System.out.println("step 3: " + soldMethod);. Chạy xong bạn sẽ thấy: step 3: 100000. Gợi ý: phương thức static synchronized dùng khoá là chính lớp ShiftBoard.

Bước 4: cờ đóng cửa dùng volatile. Bạn tự quyết cách viết.

java
        // TODO 4: khai báo cờ closed dùng volatile; luồng watcher lặp tới khi closed là true; main đặt closed = true rồi chờ watcher
        System.out.println("step 4: watcher stopped " + !watcher.isAlive());

Chạy xong bạn sẽ thấy: step 4: watcher stopped true. Gợi ý: static volatile boolean closed;, vòng while (!closed) { Thread.onSpinWait(); }. Lời giải đầy đủ nằm ở cuối bài.

Bạn vừa làm được: tự dựng một chương trình dùng đủ các công cụ: khối synchronized, phương thức synchronized, và cờ volatile. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 15-da-luong/02-dong-bo của kho GitHub, nhưng bạn không cần mở nó để học.

Thử phá nó đi (Lỗi thường gặp)

Bốn lỗi dưới đây là những lỗi người mới gặp nhiều nhất khi đồng bộ hoá. Mỗi dòng đã được chạy thật bằng Compare.java, Visibility.java và Handoff.java [Đã chạy, JDK 17.0.15; chỗ ghi Có thể khác nhau thì máy bạn có thể khác, kể cả có lần không mất gì].

Code lỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
synchronized (new Object()) { count++; }Không báo lỗi, tổng có thể thiếu nhưng không phải lần nào cũng thiếu [Có thể khác nhau]Mỗi lần một khoá mới nên hai luồng không chung khoáDùng một trường private final Object lock chung
private volatile int count; rồi count++ ở nhiều luồngKhông báo lỗi, tổng có thể thiếu nhưng không phải lần nào cũng thiếu [Có thể khác nhau]volatile chỉ cho nhìn thấy, count++ vẫn là ba bướcDùng synchronized, hoặc AtomicInteger ở bài sau
new Object().wait(); không giữ khoáIllegalMonitorStateException: current thread is not ownerwait chỉ gọi được khi đang giữ khoá của chính đối tượng đóĐặt wait trong khối synchronized của cùng đối tượng
Hai luồng lấy khoá A, B theo thứ tự ngược nhauKhông có lỗi, chương trình đứng, hai luồng ở BLOCKEDMỗi luồng chờ khoá của luồng kia nên thành vòngLấy khoá theo cùng một thứ tự ở mọi nơi

Lỗi thứ hai là mô hình sai khó nhận ra nhất vì chữ volatile nghe có vẻ "an toàn". Hình dưới đây gom lại.

Hình: mô hình sai và mô hình đúng về một bộ đếm volatile int

Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: tưởng volatile làm count++ an toàn nên tổng luôn đúng. Hàng dưới màu lục là mô hình đúng: volatile chỉ chia sẻ giá trị, count++ vẫn là ba bước, nên lần cộng vẫn có thể bị mất và phải dùng synchronized.

Hai bảo đảm khác nhau: nhìn thấy giá trị mới (volatile) và không bị xen kẽ giữa ba bước (synchronized). Hiểu rõ hai thứ này là điều cần nhất của bài.

Bạn vừa làm được: đọc bốn thông báo hoặc hiện tượng lỗi của đồng bộ hoá và nói được nguyên nhân bằng lời thường.

Tự tay làm (Bài tập)

Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.

Mức Cơ bản

Bài 1. Vẽ trạng thái xen kẽ. Dữ liệu vào: count ban đầu là 7, hai luồng A và B mỗi luồng làm count++ hai lần, không có khoá. Mỗi count++ gồm đọc rồi ghi. Thứ tự chạy cho sẵn như sau:

  1. A đọc count.
  2. B đọc count.
  3. A ghi (đọc được cộng một).
  4. B ghi (đọc được cộng một).
  5. B đọc count.
  6. B ghi.
  7. A đọc count.
  8. A ghi.

Điền bảng gồm các cột: bước, bản sao A giữ, bản sao B giữ, giá trị count trong bộ nhớ sau bước đó. Kết quả mong đợi: bảng tám dòng và count cuối cùng. Tiêu chí nghiệm thu: bạn nói được tổng kỳ vọng là bao nhiêu, cuối cùng mất mấy lần cộng và mất ở bước nào. Gợi ý: mỗi lần ghi là giá trị đã đọc cộng một, không phải giá trị trong bộ nhớ lúc ghi.

Bài 2. Sửa khoá sai. Dữ liệu vào: hai luồng, mỗi luồng gọi add(1) 100000 lần, nhưng total vẫn bị thiếu.

java
void add(int cups) {
    synchronized (new Object()) {
        total += cups;
    }
}

Sửa để total cuối cùng đúng 200000. Kết quả mong đợi: in ra 200000. Tiêu chí nghiệm thu: giải thích trong một câu vì sao đoạn gốc không có tác dụng. Gợi ý: nhìn lại hình khoá ở khái niệm 2: khoá phải là cùng một đối tượng.

Bài 3. Chọn công cụ. Với mỗi tình huống, chọn volatile hay synchronized và nói lý do: (a) cờ closed do main đặt, luồng watcher đọc; (b) bộ đếm total cộng từ bốn luồng; (c) chuyển 10 ly từ kho A sang kho B, hai biến cùng đổi trong một lần. Kết quả mong đợi: ba lựa chọn kèm một câu lý do. Tiêu chí nghiệm thu: lý do nêu đúng "nhìn thấy" hay "nguyên tử". Gợi ý: (c) cần hai biến đổi cùng lúc trong một khối.

Mức Vận dụng

Bài 4. Class ShopTotals an toàn luồng. Viết class ShopTotals giữ tổng số ly đã bán, với các thành phần sau:

Phương thứcViệc phải làm
void addSale(int cups)Cộng cups vào tổng
int total()Trả về tổng hiện tại
int reset()Trả về tổng cũ và đặt tổng về 0 trong cùng một bước

Dữ liệu vào: hàm main tạo một ShopTotals, chạy 4 luồng, mỗi luồng gọi shop.addSale(1) 50000 lần, join cả bốn, rồi in shop.total() và in shop.reset() + " " + shop.total().

Kết quả mong đợi:

text
200000
200000 0

Tiêu chí nghiệm thu: hai dòng khớp từng ký tự, cả ba phương thức dùng cùng một khoá, và reset đọc rồi đặt về 0 trong một lần giữ khoá. Gợi ý: thêm synchronized vào cả ba phương thức; nếu reset chỉ đọc rồi mới ghi ở hai khối riêng, một lần cộng có thể lọt vào giữa.

Mức Thử thách (tuỳ chọn)

Bài 5. Chuyển tiền không deadlock. Có hai tài khoản a (id 1, số dư 1000) và b (id 2, số dư 1000). Viết transfer(from, to, amount) khoá cả hai tài khoản rồi trừ và cộng. Hai luồng chạy ngược chiều: luồng một gọi transfer(a, b, 1) 10000 lần, luồng hai gọi transfer(b, a, 1) 10000 lần. Kết quả mong đợi: 1000 1000 2000 (số dư của a, của b, và tổng). Tiêu chí nghiệm thu: chương trình luôn kết thúc, tổng luôn là 2000, và bạn giải thích vì sao khoá theo tham số from, to sẽ có thể deadlock [Có thể khác nhau]. Gợi ý: luôn khoá tài khoản có id nhỏ hơn trước, bất kể nó là from hay to.

Tự kiểm tra (Retrieval Practice)

Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.

Câu 1 (nhớ lại, mục tiêu 1). count++ gồm ba bước nào, và vì sao hai luồng làm việc đó có thể làm mất một lần cộng?

Câu 2 (dự đoán kết quả code, mục tiêu 2). Đoạn sau in ra gì, và có thể in ra số nhỏ hơn không?

java
Runnable r = () -> {
    for (int i = 0; i < 1000; i++) {
        synchronized (L) { n++; }
    }
};

Trong đó n là biến static int và L là một static final Object. Hai luồng cùng chạy r, start rồi join cả hai, rồi in n.

Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 1). count bằng 20, không có khoá. Luồng A và B xen kẽ theo thứ tự: A đọc, B đọc, B ghi, A ghi. Viết giá trị count sau mỗi bước, và cho biết mất mấy lần cộng.

Câu 4 (tìm lỗi, mục tiêu 3). Bốn luồng cùng gọi add(x) dưới đây, tổng cuối cùng bị thiếu. Chỉ ra lỗi và sửa.

java
private volatile int total;
void add(int x) { total += x; }

Câu 5 (giải thích cho bạn chưa học, mục tiêu 4). Bạn của bạn hỏi: "Hai người cùng cần cả chìa tủ A và chìa tủ B. Người một cầm chìa A, người hai cầm chìa B, rồi mỗi người đứng chờ chìa còn lại. Chuyện gì xảy ra, và làm sao tránh?" Hãy giải thích bằng khái niệm deadlock và thứ tự khoá.

Điều cần nhớ

  • count++ là ba bước đọc, cộng, ghi, nên hai luồng có thể xen kẽ làm mất lần cộng; có lần mất, có lần không, và con số mất khác nhau mỗi lần chạy (mục tiêu 1).
  • synchronized chỉ có tác dụng khi mọi luồng cùng dùng đúng một đối tượng khoá, và chỉ một luồng vào khối mỗi lần (mục tiêu 2).
  • volatile bảo đảm nhìn thấy giá trị mới nhất nhưng không làm count++ nguyên tử, nên cờ đơn dùng volatile, giá trị bị cộng dùng synchronized (mục tiêu 3).
  • Deadlock là vòng chờ khoá của nhau, và cách sửa gọn nhất là lấy khoá theo cùng một thứ tự ở mọi nơi (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmCú phápGhi chú
Bảo vệ một khốisynchronized (lock) { ... }lock là private final Object dùng chung
Bảo vệ cả phương thứcsynchronized void m()Khoá là this, hoặc lớp nếu là static
Cờ do một luồng ghivolatile boolean closed;Chỉ cho nhìn thấy, không nguyên tử
Đợi điều kiệnwhile (!ok) { wait(); }Trong synchronized, đổi điều kiện xong thì notifyAll()
Phát hiện deadlockThreadMXBean.findDeadlockedThreads()Trả về các luồng bị vòng chờ
Tránh deadlockKhoá luôn theo cùng thứ tựVí dụ khoá theo id tăng dần
Hình: tóm tắt công cụ đồng bộ nào cho việc nào

Cách đọc hình: mỗi hàng là một câu hỏi bên trái và công cụ nên chọn bên phải. Đọc theo hàng ngang: nhiều luồng cùng đổi một giá trị thì synchronized, một luồng ghi cờ còn luồng khác đọc thì volatile, cần hai khoá thì lấy theo thứ tự cố định.

Câu hỏi để dành cho ngày mai: synchronized chậm vì luồng phải chờ nhau. Java có công cụ nào cộng một bộ đếm an toàn mà không cần khoá không, và nhóm luồng có sẵn giúp bạn khỏi tự tạo từng Thread không? Hãy nghĩ trước khi sang bài sau.

Đào sâu

Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.

Mất lần cộng trên máy này. Compare.java chạy bốn luồng, mỗi luồng cộng 200000 lần, lặp lại 20 lần cho từng cách. Chương trình in hai nhóm dòng. Nhóm đầu là những câu đúng hoặc sai ổn định: synchronized luôn đúng 800000, không cách nào vượt quá 800000, và cả ba cách sai đều mất lần cộng ít nhất một lần trong 20 lần thử. Nhóm sau, bên dưới dòng gạch ngang, là những con số đổi theo từng lần chạy [Đã chạy, JDK 17.0.15; nhóm đầu ra giống nhau ở sáu lần chạy liên tiếp, nhóm sau là Có thể khác nhau]:

text
synchronized method is exactly 800000: true
no variant ever went above 800000: true
plain int lost updates at least once: true
volatile int lost updates at least once: true
new Object() as lock lost updates at least once: true
--- numbers below change on every run ---
plain int lost updates in 10 of 20 runs, last total 773767
volatile int lost updates in 14 of 20 runs, last total 767066
new Object() as lock lost updates in 10 of 20 runs, last total 601413

Con số khác nhau vì hệ điều hành xếp lịch cho bốn luồng khác nhau ở mỗi lần chạy, nên số lần các luồng thật sự đọc cùng một giá trị cũng khác nhau [Suy luận]; có lần cả bốn luồng chạy gần như nối đuôi nhau và không mất gì (bạn có thể thấy last total bằng 800000 ở cả cách sai). Đây là lý do không nên tin vào việc chạy thử vài lần thấy đúng: lỗi này là xác suất chứ không phải chắc chắn, và nhận xét ổn định duy nhất bạn có thể tin là synchronized luôn đúng.

Khoá vào lại. Một luồng đang giữ khoá của một đối tượng có thể gọi tiếp phương thức synchronized khác của cùng đối tượng mà không bị tự chặn. Reentrant.java cho thấy điều này [Đã chạy, JDK 17.0.15]:

text
outer then inner on the same lock

Nếu khoá không cho vào lại, outer sẽ tự chờ chính mình mãi.

wait và notifyAll. Khi một luồng phải chờ một điều kiện (ví dụ kệ đầy hoặc rỗng), nó gọi wait() bên trong khối synchronized, rồi luồng khác đổi điều kiện và gọi notifyAll(). Bắt buộc đặt wait trong vòng while kiểm tra điều kiện, vì luồng có thể thức dậy mà điều kiện chưa đúng. Handoff.java là một kệ chứa tối đa 2 món, một luồng đặt 10 món và một luồng lấy 10 món [Đã chạy, JDK 17.0.15; thứ tự nhận ổn định vì kệ giữ đúng thứ tự vào ra]:

text
received [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], shelf never held more than 2 items
IllegalMonitorStateException: current thread is not owner

Dòng thứ hai là lỗi nếu gọi wait() mà không giữ khoá, đúng như hàng thứ ba của bảng lỗi.

Giới hạn của bài. Bài này chưa nói về mô hình bộ nhớ Java chi tiết (quan hệ happens-before), về ReentrantLock và tryLock cho phép bỏ cuộc thay vì chờ mãi, và về AtomicInteger. Những công cụ đó nằm ở bài sau. Bài cũng không bàn về hiệu năng của khoá.

Đọc thêm (tài liệu chính thống, Java SE 17):

Bài tiếp theo

Bài sau là Concurrency Utilities: các công cụ có sẵn của Java để khỏi tự viết khoá, gồm bộ đếm nguyên tử, nhóm luồng ExecutorService, và các bộ điều phối như CountDownLatch mà bạn đã gặp ở ví dụ deadlock.

Mini-dự án nối tiếp: ở bài sau, quán Cà Phê Gió sẽ giao việc pha cho một nhóm luồng thay vì tự new Thread từng cái, và đếm số ly bằng một bộ đếm nguyên tử. Hãy giữ lại class ShopTotals làm điểm xuất phát.

Lời giải và đáp án

Hãy thử xong rồi mới đọc phần này.

Lời giải Làm cùng mình. Chỉ cần thay các dòng // TODO bằng những dòng dưới đây. Ghép lại chạy ra đúng bốn dòng step ở trên [Đã chạy, JDK 17.0.15]:

java
a.start();
b.start();
a.join();
b.join();
synchronized (LOCK) {
    soldLocked++;
}
static synchronized void addMethod() {
    soldMethod++;
}
static volatile boolean closed;
Dòng codeMáy làm gìLý do chọn cách này
a.start(); b.start(); a.join(); b.join();Chạy hai quầy song song rồi chờ cả haijoin trước khi in để số liệu đã xong
synchronized (LOCK) { soldLocked++; }Mỗi lần chỉ một quầy cộngCùng một LOCK cho cả hai luồng nên loại trừ lẫn nhau, ra đúng 100000
static synchronized void addMethod()Phương thức dùng khoá của lớp ShiftBoardViết ngắn hơn khối, và mọi nơi gọi nó cùng chia khoá của lớp
static volatile boolean closed;Cờ đóng cửa dùng chungvolatile để vòng lặp của watcher chắc chắn thấy main đặt true
Trong main: watcher.start(); closed = true; watcher.join();Chạy watcher, rồi đóng cửa, rồi chờ nó dừngThứ tự start, ghi cờ, join là bước 4 cần

Lời giải Bài 1. [Suy luận, đi từng bước theo đề]

BướcBản sao ABản sao Bcount trong bộ nhớ
0không cókhông có7
17không có7
2777
3878
4888
588 (đọc lại)8
6899
79 (đọc lại)99
810910

Tổng kỳ vọng là 7 cộng 4 lần, tức 11. Kết quả cuối là 10, mất một lần cộng ở bước 4: B ghi 8 đè lên 8 của A, vì B đã đọc 7 ở bước 2 trước khi A ghi.

Lời giải Bài 2. [Đã chạy, in 200000]

java
private final Object lock = new Object();

void add(int cups) {
    synchronized (lock) {
        total += cups;
    }
}

Đoạn gốc tạo khoá mới ở mỗi lần gọi nên hai luồng không bao giờ tranh cùng một khoá. Khoá là một trường final dùng chung nên hai luồng loại trừ nhau.

Lời giải Bài 3. (a) volatile: main chỉ ghi một cờ, watcher chỉ đọc, và cái cần là nhìn thấy giá trị mới. (b) synchronized: mỗi lần cộng là đọc, cộng, ghi, cần nguyên tử, volatile không đủ. (c) synchronized: hai biến phải đổi cùng nhau trong một khối, volatile không gom được hai bước [Suy luận].

Lời giải Bài 4. [Đã chạy, hai dòng khớp với kết quả mong đợi]

java
public synchronized void addSale(int cups) {
    total += cups;
}

public synchronized int reset() {
    int before = total;
    total = 0;
    return before;
}
Dòng codeMáy làm gìLý do chọn cách này
public synchronized void addSaleCộng dưới khoá của thisMọi lần cộng nối đuôi nhau nên tổng đúng
public synchronized int total()Đọc dưới cùng khoá thisCùng khoá để đọc thấy giá trị mới nhất
int before = total; total = 0; trong resetĐọc rồi đặt về 0 trong cùng một lần giữ khoáKhông có lần cộng nào lọt vào giữa hai dòng này

Lời giải Bài 5. [Đã chạy, in 1000 1000 2000]

java
Account first = from.id < to.id ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
    synchronized (second) {
        from.balance -= amount;
        to.balance += amount;
    }
}

Hai dòng đầu chọn tài khoản có id nhỏ hơn làm first. Dù luồng một chuyển từ a sang b hay luồng hai chuyển từ b sang a thì cả hai luôn khoá tài khoản id 1 trước rồi mới tới id 2, nên không có vòng chờ. Nếu khoá theo from rồi to, luồng một khoá a rồi chờ b còn luồng hai khoá b rồi chờ a, và có thể deadlock [Có thể khác nhau].

Đáp án Tự kiểm tra.

  1. count++ gồm đọc count, cộng một vào bản sao, rồi ghi lại. Nếu hai luồng cùng đọc trước khi ai ghi thì cả hai ghi cùng một giá trị, và một lần cộng bị mất. Kiểm tra mục tiêu 1.
  2. In ra 2000 [Đã chạy]. Không thể nhỏ hơn vì mọi n++ nằm trong khối synchronized (L) với cùng một khoá L. Kiểm tra mục tiêu 2.
  3. Sau bước A đọc: 20. Sau B đọc: 20. Sau B ghi: 21. Sau A ghi: 21 (A đã đọc 20 nên ghi 21 đè lên). Hai lần cộng nhưng chỉ tăng một: mất một lần cộng [Suy luận]. Kiểm tra mục tiêu 1.
  4. volatile chỉ cho nhìn thấy, còn total += x là đọc, cộng, ghi nên vẫn bị xen kẽ. Sửa bằng private int total; và synchronized void add(int x), hoặc dùng khối synchronized với một khoá chung [Suy luận]. Kiểm tra mục tiêu 3.
  5. Hai người cùng giữ một chìa và chờ chìa của người kia, nên không ai làm được gì: đó là deadlock. Cách tránh là thoả thuận mọi người luôn lấy chìa A trước rồi mới tới chìa B, nên không thể có hai người giữ hai chìa khác nhau và chờ nhau. Kiểm tra mục tiêu 4.