Đồng bộ hóa
Tới đây ta chạm vào phần khó nhất của lập trình đa luồng. Khi nhiều luồng cùng đọc và ghi một dữ liệu chia sẻ, nếu không cẩn thận, kết quả sẽ sai một cách khó lường và khó tái hiện. Bài này giải thích vì sao tranh chấp dữ liệu xảy ra và bốn công cụ để xử lý: synchronized, khoá tường minh, volatile, cùng cách phòng tránh khoá chết.
Hãy hình dung một hệ thống bán vé còn đúng một chỗ trống. Hai người dùng bấm đặt vé gần như cùng lúc, máy chủ xử lý mỗi yêu cầu bằng một luồng riêng. Cả hai luồng cùng đọc số vé còn lại, cùng thấy giá trị 1, cùng kết luận là còn vé, cùng trừ đi một. Hệ thống bán hai vé cho một chỗ, dù không dòng lệnh nào sai về logic tuần tự.
Điều làm loại lỗi này đáng sợ hơn mọi loại khác là nó không tái hiện theo yêu cầu: bạn chạy thử một trăm lần trên máy mình đều đúng, lên máy chủ thật thì lỗi xuất hiện vào những lúc không đoán trước, và thêm lệnh in để gỡ lỗi thì lỗi lại biến mất vì lệnh in làm chậm luồng vừa đủ để cửa sổ tranh chấp khép lại. Bài học rất thẳng thắn: không thể dùng phép thử để chứng minh mã đa luồng đúng, chỉ có lập luận trên mã mới cho ta niềm tin thật. Cũng dẹp luôn một quan niệm sai hay gặp: máy một lõi vẫn có tranh chấp, vì hệ điều hành có thể ngắt một luồng ở giữa chừng để nhường CPU cho luồng khác.
Cả bài xoay quanh năm thuật ngữ dưới đây. Hãy lật từng thẻ để có nghĩa vắn tắt trước, rồi các mục sau sẽ mổ xẻ từng cái một.
Tranh chấp dữ liệu
Tranh chấp dữ liệu (race condition) là tình huống tính đúng đắn của chương trình phụ thuộc vào thứ tự xen kẽ ngẫu nhiên giữa các luồng khi chúng cùng truy cập một dữ liệu chia sẻ, mà ít nhất một luồng thực hiện thao tác ghi.
Nguồn gốc nằm ở chỗ những thao tác trông như một bước trong mã nguồn thực ra gồm nhiều bước máy. Lệnh count++ thực chất là đọc giá trị, cộng một, rồi ghi lại. Nếu hai luồng cùng đọc giá trị cũ trước khi luồng nào kịp ghi, cả hai sẽ ghi đè lên nhau và một lần tăng bị mất. Hình dung hai luồng A và B cùng đọc count bằng 5, cùng tính ra 6, rồi cùng ghi 6: kết quả là 6 trong khi lẽ ra phải là 7, một lần cập nhật đã bị đánh mất.
public class DemKhongAnToan {
private int count = 0;
public void tang() { count++; } // not atomic: read, add, write
public int getCount() { return count; }
public static void main(String[] args) throws InterruptedException {
DemKhongAnToan d = new DemKhongAnToan();
Runnable job = () -> { for (int i = 0; i < 100_000; i++) d.tang(); };
Thread t1 = new Thread(job), t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join(); // wait both threads to finish
System.out.println(d.getCount()); // often less than 200000
}
}
Ta nên nhìn xuống dưới một tầng để hiểu vì sao count++ không nguyên tử. Trong mã nguồn nó trông liền một mạch, nhưng máy ảo biên dịch nó thành một chuỗi lệnh: nạp giá trị của trường lên ngăn xếp toán hạng, nạp hằng số 1, cộng, rồi ghi kết quả trở lại. Ba pha đọc, cộng, ghi là ba thời điểm riêng biệt, và giữa hai thời điểm bất kỳ, bộ lập lịch có toàn quyền tạm dừng luồng này để chạy luồng khác. Hiện tượng mất một lần tăng như vậy gọi là cập nhật bị mất (lost update), và nó là hình mẫu gốc của gần như mọi lỗi đa luồng bạn sẽ gặp, kể cả x = x * 2 hay danhSach.add(...) trên một ArrayList thường.
Dấu hiệu nhận biết là kết quả đổi sau mỗi lần chạy dù mã nguồn không đổi. Ngoài kiểu đọc rồi ghi như count++, còn dạng hay gặp gọi là kiểm tra rồi hành động (check then act): luồng kiểm tra một điều kiện, thấy đúng, rồi hành động, nhưng giữa hai bước đó luồng khác đã kịp làm điều kiện trở nên sai. Đây chính là câu chuyện bán vé ở đầu bài.
public class BanVe {
private int veConLai = 1;
// BUG: check-then-act is not atomic
public boolean datVe() {
if (veConLai > 0) { // step 1: check
veConLai--; // step 2: act, but state may have changed
return true;
}
return false;
}
}
Đơn vị cần bảo vệ vì thế là cả nhóm thao tác, không phải từng dòng lệnh.
Thử ngay: tự xếp lịch hai luồng
Lý thuyết ở trên nói mất cập nhật sinh ra từ thứ tự xen kẽ, nhưng cách chắc nhất để tin là tự tay xếp lịch rồi xem máy tính lại. Hai luồng T1 và T2 cùng chạy count++ (khởi tạo count bằng 0), mỗi lần tăng gồm ba thao tác ĐỌC, CỘNG, GHI. Bạn quyết định luồng nào chạy thao tác kế; engine mô phỏng đúng lịch đó và cho ra count cuối. Hãy thử một lịch tuần tự (cho kết quả 2), rồi thử một lịch xen kẽ để thấy một lần tăng bị đánh mất.
| Bước | Luồng | Thao tác | r(T1) | r(T2) | count |
|---|---|---|---|---|---|
| Chưa có bước nào. Bấm “T1 chạy lệnh kế” hoặc “T2 chạy lệnh kế”, hoặc chọn một lịch mẫu ở trên. | |||||
Từ khoá synchronized
Cách sửa cổ điển là biến đoạn mã đụng tới dữ liệu chung thành một vùng tới hạn (critical section) mà tại mỗi thời điểm chỉ một luồng được vào. Java làm điều này qua từ khoá synchronized, dựa trên một khoá nội tại (intrinsic lock) gắn với mỗi đối tượng. Luồng muốn vào phải giành được khoá; luồng khác phải đợi cho tới khi khoá được nhả.
public class DemAnToan {
private int count = 0;
public synchronized void tang() { count++; } // only one thread at a time
public synchronized int getCount() { return count; }
}
// Run it like the previous example: the result is always exactly 200000.
Ta có thể đồng bộ cả phương thức như trên, hoặc chỉ đồng bộ một khối lệnh trên một đối tượng khoá cụ thể bằng cú pháp synchronized (khoa) { ... }. Đồng bộ khối hẹp hơn nên thường nhanh hơn vì giảm thời gian giữ khoá.
Cần nắm rõ khoá nào đang dùng: phương thức thực thể khoá trên this, phương thức tĩnh khoá trên TenLop.class, hai khoá khác nhau nên không loại trừ lẫn nhau. Kiểu viết được ưa chuộng là khối đồng bộ trên một đối tượng khoá riêng tư, khai báo private final Object khoa = new Object(); rồi bọc synchronized (khoa) { ... } quanh đúng đoạn đụng tới dữ liệu chung. Đừng làm việc chậm như đọc ghi tệp khi đang giữ khoá, vì mọi luồng khác phải xếp hàng chờ.
Điểm dễ hiểu sai nhất về synchronized là tưởng nó khoá đoạn mã. Không phải. Khoá thuộc về đối tượng, không thuộc về mã. Mỗi đối tượng Java mang sẵn một khoá nội tại, và synchronized chỉ nói rằng hãy giành khoá của đối tượng này trước khi vào rồi nhả khi ra. Hệ quả rất thực tế: hai luồng gọi cùng một phương thức synchronized nhưng trên hai đối tượng khác nhau thì không loại trừ nhau, vì chúng giữ hai khoá khác nhau; ngược lại, hai luồng gọi hai phương thức synchronized khác nhau trên cùng một đối tượng thì lại loại trừ nhau, dù hai phương thức chẳng liên quan gì tới nhau. Từ đó cũng suy ra vì sao phương thức tĩnh không bảo vệ được trường thực thể: nó khoá trên TenLop.class còn phương thức thực thể khoá trên this, và trình biên dịch sẽ không hề cảnh báo bạn.
Vì sao vùng tới hạn phải nhỏ
Có một cách bảo đảm chương trình không bao giờ sai là khoá toàn bộ bằng một khoá duy nhất; nó đúng tuyệt đối và cũng vô dụng tuyệt đối, vì bạn vừa biến chương trình đa luồng thành chương trình đơn luồng. Vùng tới hạn dài gây hại theo hai đường. Thứ nhất là mất tính song song, vì khi một luồng giữ khoá thì mọi luồng khác nằm chờ, nên có bao nhiêu lõi CPU cũng vô nghĩa. Thứ hai nguy hiểm hơn nhiều: nếu bạn làm việc chậm khi đang giữ khoá, ví dụ đọc ghi tệp hay gọi qua mạng, thì thời gian giữ khoá không còn do bạn quyết định mà do thế giới bên ngoài quyết định, và một lời gọi mạng treo ba mươi giây sẽ khoá cứng cả hệ thống ba mươi giây. Nguyên tắc rút ra: tính toán ở ngoài, chỉ khoá đúng đoạn chạm vào dữ liệu chung. Đi kèm là một cấm kỵ: đừng gọi mã lạ khi đang giữ khoá, vì nếu nó giành thêm khoá khác thì bạn vừa mở đường cho khoá chết.
Đồng bộ trên trường không phải final: nếu đối tượng khoá bị gán lại giữa chừng, các luồng khoá trên hai đối tượng khác nhau và bảo vệ biến mất.
Chỉ đồng bộ phương thức ghi mà quên phương thức đọc: luồng đọc không qua khoá thì không có bảo đảm nhìn thấy giá trị mới nhất.
Khoá tường minh và volatile
Gói java.util.concurrent.locks cung cấp ReentrantLock, một khoá tường minh (explicit lock) linh hoạt hơn synchronized: cho phép thử giành khoá có thời hạn, giành khoá ngắt được, và tách rõ điểm khoá với điểm mở khoá. Đổi lại, ta phải tự nhả khoá, và nguyên tắc bắt buộc là đặt lệnh mở khoá trong khối finally để chắc chắn khoá luôn được nhả dù có ngoại lệ.
import java.util.concurrent.locks.ReentrantLock;
public class DemBangKhoa {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void tang() {
lock.lock(); // acquire the lock
try { count++; } // critical section
finally { lock.unlock(); } // always release, even on exception
}
}
Khi nào chọn ReentrantLock? Đa số trường hợp synchronized gọn và an toàn hơn vì máy ảo tự nhả khoá. Chỉ chuyển sang khoá tường minh khi cần thứ mà synchronized không có: tryLock để bỏ cuộc nếu bận thay vì treo vô hạn, lockInterruptibly để có thể bị ngắt khi đang chờ, newCondition để tách nhiều hàng chờ. Gói java.util.concurrent còn có AtomicInteger, ConcurrentHashMap, ExecutorService; nên dùng chúng trước khi tự viết đồng bộ.
Từ khoá volatile giải một vấn đề khác, hẹp hơn. Vì lý do hiệu năng, mỗi luồng có thể giữ một bản sao biến trong vùng đệm riêng và không thấy thay đổi của luồng khác. Đánh dấu một trường là volatile buộc mọi lần đọc và ghi đi thẳng vào bộ nhớ chính, nhờ vậy các luồng luôn thấy giá trị mới nhất. Tuy nhiên volatile chỉ bảo đảm tính nhìn thấy (visibility), không bảo đảm tính nguyên tử (atomicity); nó hợp với cờ dừng kiểu boolean, không thay được synchronized cho phép count++.
volatile không phải là phiên bản nhẹ của synchronized. Một trường volatile vẫn bị tranh chấp nếu nhiều luồng cùng thực hiện thao tác đọc rồi ghi như x++. Dùng volatile cho cờ trạng thái, dùng synchronized hoặc khoá cho thao tác gồm nhiều bước.
Đây là đúng trường hợp volatile sinh ra để phục vụ: một cờ dừng do luồng này bật lên và luồng kia phải nhìn thấy. Nếu bỏ volatile, trình biên dịch có quyền coi dangChay là không đổi và biến vòng lặp thành vô tận.
public class NhiemVuNen implements Runnable {
private volatile boolean dangChay = true; // visibility across threads
public void run() {
while (dangChay) { /* work */ } // re-reads from main memory
}
public void dung() { dangChay = false; } // seen by the running thread
}
Mô hình bộ nhớ Java, gốc rễ của volatile
Muốn hiểu volatile cho tới nơi thì phải nói tới mô hình bộ nhớ Java. Trực giác của ta là có một vùng bộ nhớ duy nhất, ai ghi vào thì người khác đọc ra ngay, nhưng phần cứng hiện đại không như vậy: mỗi lõi CPU có bộ nhớ đệm riêng, nên giá trị vừa ghi có thể nằm trong đệm của lõi này rất lâu trước khi lõi khác nhìn thấy, và cả trình biên dịch lẫn CPU đều được phép sắp xếp lại thứ tự lệnh miễn là kết quả nhìn từ một luồng đơn lẻ không đổi. Java khai báo thẳng: nếu không có cơ chế đồng bộ, không có bảo đảm nào về việc luồng này bao giờ nhìn thấy thay đổi của luồng kia. synchronized và volatile chính là cách nói rõ chỗ nào cần bảo đảm; nhưng volatile chỉ cấm bộ nhớ đệm giữ bản sao cũ, nó không biến thao tác nhiều bước thành một bước, và đó là toàn bộ lý do nó cứu được cờ dừng mà không cứu được count++.
Các lớp nguyên tử
Với riêng bài toán bộ đếm, có một lời giải gọn hơn cả synchronized lẫn volatile. Gói java.util.concurrent.atomic cho ta AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference, những lớp gói một giá trị và cung cấp thao tác đọc rồi ghi ở dạng nguyên tử.
import java.util.concurrent.atomic.AtomicInteger;
public class DemNguyenTu {
private final AtomicInteger count = new AtomicInteger();
public void tang() { count.incrementAndGet(); } // atomic read-modify-write
public int getCount() { return count.get(); }
public static void main(String[] args) throws InterruptedException {
DemNguyenTu d = new DemNguyenTu();
Runnable job = () -> { for (int i = 0; i < 100_000; i++) d.tang(); };
Thread t1 = new Thread(job), t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(d.getCount()); // always exactly 200000
}
}
Cơ chế bên dưới không phải khoá mà là một lệnh phần cứng gọi là so sánh rồi hoán đổi: luồng đọc giá trị hiện tại, tính giá trị mới, rồi yêu cầu CPU ghi chỉ khi ô nhớ vẫn còn đúng giá trị cũ mà nó đã đọc; nếu có luồng khác chen vào đổi mất thì lệnh ghi thất bại và luồng thử lại. Không ai bị chặn, nên đây gọi là đồng bộ không khoá, nhanh hơn khoá khi tranh chấp nhẹ. Quan trọng hơn, lớp nguyên tử chỉ bảo vệ được một biến; nếu bất biến của bạn ràng buộc hai biến với nhau thì phải quay về dùng khoá.
Hãy dùng thư viện trước khi tự viết
Phần lớn nhu cầu đồng bộ hoá của bạn đã được người khác giải xong và giải tốt hơn bạn có thể tự làm. Cần một map dùng chung thì lấy ConcurrentHashMap chứ đừng bọc HashMap trong khoá tự chế; cần một hàng đợi nối luồng sản xuất với luồng tiêu thụ thì lấy BlockingQueue, nó đã lo hết phần wait và notify; cần chạy nhiều tác vụ song song thì lấy ExecutorService. Lý do là mã đồng bộ tự viết rất khó kiểm chứng: nó có thể chạy đúng suốt một năm rồi sai đúng vào lúc hệ thống chịu tải cao nhất. Vẫn phải học tầng dưới, vì bạn không thể dùng đúng thư viện nếu không hiểu vấn đề mà nó giải.
Chờ và đánh thức: wait và notify
Đôi khi luồng chỉ phải chờ một điều kiện do luồng khác tạo ra, chẳng hạn chờ hàng đợi có phần tử. Vòng lặp bận while (hang.isEmpty()) { } đốt sạch một lõi CPU để hỏi đi hỏi lại. Java có ba phương thức của Object: wait nhả khoá và ngủ, notify đánh thức một luồng đang chờ, notifyAll đánh thức tất cả.
import java.util.ArrayDeque;
import java.util.Queue;
public class HangDoi {
private final Queue<Integer> hang = new ArrayDeque<>();
public synchronized int lay() throws InterruptedException {
while (hang.isEmpty()) wait(); // while, not if
int x = hang.remove();
notifyAll(); // wake producers waiting for space
return x;
}
public synchronized void dat(int x) {
hang.add(x);
notifyAll(); // wake consumers waiting for data
}
}
Ba điều bắt buộc nhớ. Một, chỉ gọi wait và notify khi đang giữ khoá của chính đối tượng đó, nếu không sẽ nhận IllegalMonitorStateException. Hai, luôn kiểm tra điều kiện trong while chứ không phải if, vì luồng có thể tỉnh dậy khi điều kiện chưa thỏa. Ba, wait nhả khoá lúc ngủ để luồng khác vào đổi được điều kiện.
Khoá chết
Đồng bộ hóa giải bài toán tranh chấp nhưng sinh ra một nguy cơ mới. Khoá chết (deadlock) xảy ra khi hai luồng giữ mỗi luồng một khoá rồi cùng chờ khoá mà luồng kia đang giữ; không luồng nào nhả, cả hai đứng im mãi mãi. Ví dụ kinh điển: luồng A giữ khoá 1 và chờ khoá 2, trong khi luồng B giữ khoá 2 và chờ khoá 1.
Một cách phòng tránh khoá chết đơn giản và hiệu quả là quy định mọi luồng giành nhiều khoá đều theo cùng một thứ tự cố định. Nếu ai cũng lấy khoá 1 trước rồi mới tới khoá 2, vòng chờ vòng tròn không thể hình thành.
Bài toán chuyển khoản là ví dụ chuẩn mực: muốn chuyển tiền an toàn phải khoá cả tài khoản gửi lẫn tài khoản nhận, nên hai lời gọi ngược chiều nhau sẽ khoá chết. Cách chữa là áp một thứ tự toàn phần lên tập khoá, ở đây theo id.
public class NganHang {
static class TaiKhoan {
final long id;
long soDu;
TaiKhoan(long id, long soDu) { this.id = id; this.soDu = soDu; }
}
// DEADLOCK: synchronized (tu) { synchronized (den) { ... } }
// SAFE: lock the smaller id first, so no wait cycle can form
static void chuyen(TaiKhoan tu, TaiKhoan den, long tien) {
TaiKhoan dau = tu.id < den.id ? tu : den;
TaiKhoan sau = tu.id < den.id ? den : tu;
synchronized (dau) {
synchronized (sau) { tu.soDu -= tien; den.soDu += tien; }
}
}
}
Vì sao mẹo sắp thứ tự theo id lại hiệu nghiệm? Khoá chết chỉ hình thành khi có một chu trình chờ vòng tròn, và chỉ cần phá điều kiện đó là nó không thể xảy ra. Nếu mọi luồng đều giành khoá theo id tăng dần, thì luồng đang chờ khoá id bằng 7 chắc chắn chỉ đang giữ các khoá có id nhỏ hơn 7; muốn có chu trình thì phải tồn tại một luồng vừa chờ khoá nhỏ vừa giữ khoá lớn, điều mà quy ước đã cấm. Đây là một chứng minh, không phải một mẹo giảm rủi ro.
Khoá chết cần một cửa sổ thời gian rất hẹp để hai luồng giành khoá theo thứ tự ngược nhau. Môi trường phát triển tải nhẹ nên hầu như không bao giờ trúng cửa sổ đó; hệ thống thật với hàng trăm luồng thì trúng, và nó chọn đúng lúc đông khách nhất để xảy ra. Vì thế đừng tìm khoá chết bằng cách chạy thử, mà hãy đọc mã và hỏi: có chỗ nào giành hai khoá cùng lúc không, và thứ tự giành có nhất quán ở mọi nơi không. Nếu chương trình đã treo, gõ jstack để in ngăn xếp mọi luồng, máy ảo thường chỉ thẳng ra chu trình chờ.
Ngoài quy ước thứ tự, cách gốc rễ nhất là tránh chia sẻ trạng thái ngay từ thiết kế, vì dữ liệu bất biến và dữ liệu chỉ một luồng đụng tới thì không cần khoá. Khoá chết là cái giá của việc chia sẻ trạng thái thay đổi được; chia sẻ càng ít, giá càng rẻ.
Bài tập thực hành
Bài 1. Cho biết volatile bảo đảm điều gì và không bảo đảm điều gì. Vì sao không thể thay synchronized bằng volatile cho thao tác count++?
Gợi ý
volatile bảo đảm tính nhìn thấy: mọi luồng luôn đọc giá trị mới nhất từ bộ nhớ chính. Nó không bảo đảm tính nguyên tử. Lệnh count++ gồm ba bước đọc, cộng, ghi; hai luồng vẫn có thể xen kẽ và làm mất một lần tăng dù trường được đánh dấu volatile. Thao tác nhiều bước cần synchronized hoặc khoá.
Bài 2. Mô tả một tình huống khoá chết với hai luồng và hai khoá, rồi chỉ ra cách quy ước thứ tự giành khoá giúp loại bỏ tình huống đó.
Gợi ý
Luồng A giành khoá 1 rồi chờ khoá 2; luồng B giành khoá 2 rồi chờ khoá 1; cả hai chờ nhau mãi. Nếu quy ước mọi luồng đều phải lấy khoá 1 trước rồi mới tới khoá 2, thì luồng nào lấy được khoá 1 sẽ chắc chắn lấy được khoá 2 sau đó, vòng chờ vòng tròn không thể hình thành.
Bài 3. Viết lớp TaiKhoan có số dư và hai phương thức napTien và rutTien. Cho mười luồng cùng nạp và rút ngẫu nhiên trên một tài khoản. Chạy thử bản không đồng bộ để thấy số dư sai, rồi sửa bằng synchronized để số dư luôn đúng.
Gợi ý
Khởi đầu để napTien và rutTien không đồng bộ; vì cùng đọc rồi ghi soDu nên tổng cuối sẽ lệch khi chạy nhiều lần. Thêm từ khoá synchronized vào cả hai phương thức (và phương thức đọc số dư) để mỗi lần cập nhật là một vùng tới hạn. Dùng join để chờ cả mười luồng xong trước khi in số dư cuối.
Bài 4. Lớp Vi có hai phương thức đồng bộ getSoDu và tru. Phương thức rut(tien) không đồng bộ, chỉ viết if (getSoDu() >= tien) tru(tien);. Vì sao số dư vẫn có thể xuống âm, và sửa thế nào?
Lời giải
Đây là kiểu kiểm tra rồi hành động: khoá được nhả ra ở khoảng giữa hai lời gọi. Hai luồng cùng gọi rut(100) có thể cùng đọc số dư 100, cùng thấy đủ tiền, rồi cùng trừ, làm số dư còn âm 100. Cách sửa là gộp kiểm tra và hành động vào một vùng tới hạn duy nhất:
public class Vi {
private long soDu;
public synchronized long getSoDu() { return soDu; }
// check and act now happen inside one single critical section
public synchronized boolean rut(long tien) {
if (soDu >= tien) {
soDu -= tien;
return true;
}
return false; // not enough money
}
}
Bài 5. Một lập trình viên đọc rằng volatile bảo đảm tính nhìn thấy, nên anh ta viết private volatile int soLuong; rồi cho mười luồng cùng chạy soLuong++, và khẳng định mã đúng vì mọi luồng luôn đọc giá trị mới nhất. Hãy chỉ ra chỗ sai trong lập luận, rồi đưa ra hai cách sửa kèm đánh đổi.
Lời giải
Chỗ sai là đánh đồng nhìn thấy với nguyên tử. volatile đúng là bảo đảm mỗi lần đọc đều lấy giá trị mới nhất từ bộ nhớ chính, nhưng soLuong++ gồm ba pha đọc, cộng, ghi, và volatile không gắn ba pha đó thành một khối không chia cắt được. Luồng A đọc được 5, hoàn toàn hợp lệ; luồng B cũng đọc được 5, cũng hợp lệ vì chưa ai ghi gì; rồi cả hai cùng ghi 6. Bảo đảm nhìn thấy vẫn được tôn trọng ở từng bước mà kết quả cuối vẫn sai. Nói gọn: volatile chống được bộ nhớ đệm cũ, nhưng không chống được sự xen kẽ.
Cách sửa thứ nhất là dùng khoá, và nhớ rằng phương thức đọc cũng phải đi qua đúng khoá đó.
public class KhoAnToan {
private int soLuong = 0;
private final Object khoa = new Object();
public void tang() {
synchronized (khoa) { soLuong++; } // read, add, write become one unit
}
public int get() {
synchronized (khoa) { return soLuong; } // read under the same lock
}
}
Cách sửa thứ hai là dùng lớp nguyên tử, gọn hơn hẳn.
import java.util.concurrent.atomic.AtomicInteger;
public class KhoNguyenTu {
private final AtomicInteger soLuong = new AtomicInteger();
public void tang() { soLuong.incrementAndGet(); }
public int get() { return soLuong.get(); }
}
Đánh đổi: bản dùng khoá tổng quát hơn vì gộp được nhiều thao tác và nhiều biến vào một vùng tới hạn; bản nguyên tử nhanh hơn khi tranh chấp nhẹ nhưng chỉ bảo vệ được đúng một biến, nên nếu nghiệp vụ đòi cập nhật đồng thời soLuong và tongTien cho khớp nhau thì bạn buộc phải quay lại khoá.
Bài 6. Đoạn mã if (!map.containsKey(k)) map.put(k, tinhGiaTriTonKem(k)); chạy trên một ConcurrentHashMap, vốn là cấu trúc an toàn luồng, nhưng vẫn có tranh chấp. Vì sao, và sửa thế nào?
Lời giải
An toàn luồng của một cấu trúc dữ liệu chỉ bảo đảm từng lời gọi riêng lẻ là nguyên tử, chứ không bảo đảm một chuỗi lời gọi là nguyên tử. Ở đây containsKey nguyên tử, put nguyên tử, nhưng khoảng trống giữa hai lời gọi thì bỏ ngỏ, nên hai luồng có thể cùng thấy khoá chưa có, cùng chạy hàm tốn kém, rồi cùng put đè lên nhau. Đây chính là mẫu kiểm tra rồi hành động, núp sau một cái tên nghe rất an toàn. Cách sửa là dùng một thao tác gộp có sẵn, ở đây là map.computeIfAbsent(k, this::tinhGiaTriTonKem), để giá trị được tính nhiều nhất một lần cho mỗi khoá và cả bước kiểm tra lẫn bước ghi nằm gọn trong một thao tác nguyên tử.
Tự kiểm tra
Vì sao một bộ đếm dùng count++ từ nhiều luồng lại cho kết quả nhỏ hơn kỳ vọng?
Vì count++ không nguyên tử: nó gồm đọc giá trị, cộng một, rồi ghi lại. Khi hai luồng cùng đọc một giá trị cũ trước khi luồng nào kịp ghi, cả hai tính ra cùng một số và ghi đè lên nhau, làm mất một lần tăng. Càng nhiều luồng và càng nhiều lần lặp thì càng nhiều cập nhật bị đánh mất, nên tổng cuối nhỏ hơn kỳ vọng.
Câu hỏi tự kiểm
- 1volatile bảo đảm điều gì?
- 2Khoá nội tại của synchronized thuộc về đâu?
- 3Cách phòng tránh khoá chết khi một luồng cần giành nhiều khoá là gì?
- 4Vì sao điều kiện chờ với wait phải kiểm tra trong while chứ không phải if?
Tóm tắt và ghi nhớ
- Dữ liệu chia sẻ mà có ít nhất một luồng ghi thì phải được bảo vệ.
- Tranh chấp sinh ra vì thao tác trông như một bước thực chất gồm nhiều bước: đọc rồi ghi, kiểm tra rồi hành động.
synchronizedbiến cả nhóm thao tác thành vùng tới hạn; ưu tiên khối đồng bộ trên khoáprivate final.volatilechỉ bảo đảm tính nhìn thấy, không bảo đảm tính nguyên tử; dùng cho cờ trạng thái.ReentrantLocklinh hoạt hơn nhờtryLock, đổi lại phải tự nhả khoá trongfinally.- Điều kiện chờ với
waitluôn kiểm tra trongwhile. - Khoá chết phòng tránh bằng một thứ tự giành khoá cố định cho mọi luồng.
- Khoá thuộc về đối tượng chứ không thuộc về đoạn mã: cùng phương thức trên hai đối tượng thì không loại trừ nhau, khác phương thức trên cùng một đối tượng thì có.
- Mô hình bộ nhớ Java không hứa gì về tính nhìn thấy nếu không đồng bộ, vì bộ nhớ đệm và việc sắp xếp lại lệnh là có thật.
- Lớp nguyên tử giải bài toán một biến bằng so sánh rồi hoán đổi, nhưng không bao được bất biến trải trên nhiều biến.
- Giữ vùng tới hạn thật nhỏ, tính toán ở ngoài khoá, và không gọi mã lạ khi đang giữ khoá.
- Ưu tiên
java.util.concurrenthơn mã đồng bộ tự viết; học tầng dưới là để dùng đúng tầng trên và để sửa được khi hỏng. - Không thể chứng minh mã đa luồng đúng bằng cách chạy thử.