Java Internals & Concurrency/Thread Safety: shared mutable state và bốn chiến lược
6/75
Bài 6 / 75~15 phútConcurrency cơ bảnMiễn phí lượt xem

Thread Safety: shared mutable state và bốn chiến lược

Định nghĩa thread safety qua invariant và postcondition, vì sao đối tượng stateless luôn an toàn, và bản đồ bốn chiến lược xử lý shared mutable state.

TL;DR: Viết concurrent code đúng quy về một việc: quản lý truy cập vào shared mutable state. Thiếu một trong hai tính từ đó thì phần lớn rắc rối tan biến — dữ liệu không chia sẻ thì không ai tranh, dữ liệu không đổi thì đọc lúc nào cũng như nhau. Một class là thread-safe nếu nó giữ đúng invariant và postcondition dưới mọi cách xen kẽ của scheduler, mà phía gọi không cần đồng bộ thêm. Từ shared mutable state sinh ra hai vấn đề độc lập — atomicity và visibility — và có đúng bốn hướng gỡ: confinement, immutability, synchronization, delegation.

1. Vì sao viết concurrent code quy về quản lý shared mutable state?

Như bài Process và Thread đã chỉ ra, thread nhẹ và tiện vì các thread trong cùng một process dùng chung không gian địa chỉ — chung heap, chung object, chung field. Bài Thread API và vòng đời rồi trang bị công cụ tạo, dừng và phối hợp chúng. Chính sự dùng chung kia, nếu không phối hợp cẩn thận, sinh ra những lỗi khó đoán.

Đêm mở bán, TicketFlow có 500 ghế; sáng hôm sau hệ thống đã in ra 503 vé. Không một dòng code nào sai khi chạy đơn luồng. Học xong bài này, bạn giải thích được vì sao một class là thread-safe hay không, bằng đúng hai công cụ: invariant và postcondition.

Luận điểm trung tâm nói thẳng ngay từ đầu: viết chương trình concurrent đúng, về cốt lõi, là quản lý việc truy cập vào shared mutable state. Cần để ý cả hai tính từ. "Shared" nghĩa là nhiều thread cùng với tới. "Mutable" nghĩa là giá trị có thể đổi. Thiếu một trong hai thì phần lớn rắc rối tan biến: dữ liệu không chia sẻ thì không ai tranh, dữ liệu không đổi thì đọc lúc nào cũng cho cùng kết quả.

Cũng cần rõ sớm: yêu cầu thread safety thường không đến từ quyết định "dùng thread" của bạn. Bạn hiếm khi tự tay gọi new Thread(). Nhưng khi bạn viết một servlet, một @RestController hay một message listener, framework sẽ gọi code của bạn từ nhiều thread cùng lúc. Trách nhiệm làm cho component an toàn vẫn rơi vào tay bạn.

Ta làm việc với BookingService của capstone TicketFlow, phiên bản v0, viết để chạy đúng khi chỉ có một thread.

// TicketFlow v0 — single-thread baseline. CHUA thread-safe (co y).
public class BookingService {

    private final Map<String, Event> events = new HashMap<>();
    private final Map<String, Integer> sold = new HashMap<>();

    public void register(Event event) {
        Objects.requireNonNull(event);
        if (events.putIfAbsent(event.id(), event) != null) {
            throw new IllegalArgumentException("Su kien da ton tai: " + event.id());
        }
        sold.put(event.id(), 0);                        // invariant: da register thi co entry sold
    }

    public Booking book(String eventId, String userId) {
        Event event = events.get(eventId);
        if (event == null) {
            throw new IllegalArgumentException("Khong co su kien: " + eventId);
        }
        int current = sold.getOrDefault(eventId, 0);    // (1) doc so ve da ban
        if (current >= event.capacity()) {              // (2) con cho khong?
            throw new SoldOutException(eventId);
        }
        sold.put(eventId, current + 1);                 // (3) ghi lai so ve da ban
        return new Booking(eventId, userId, current + 1);
    }
}

Có một invariant nghiệp vụ không được phép vi phạm: số vé bán ra không bao giờ vượt capacity. Khi chỉ một thread gọi book, code này giữ invariant đó hoàn hảo, và mọi test đơn luồng sẽ pass. Nhưng khi hai thread cùng gọi book cho một sự kiện chỉ còn đúng một chỗ, điều gì xảy ra? Trước khi trả lời, ta cần một định nghĩa rõ ràng cho chữ "đúng".

Hãy neo bài toán vào một hình ảnh quen. Một quầy vé có một tấm bảng chung ghi số ghế đã bán, và nhiều nhân viên cùng phục vụ khách. Tấm bảng ấy vừa dùng chung vừa sửa được — đó là toàn bộ nguồn cơn.

Trong ví vonTrong máy
Tấm bảng chung ở quầyShared mutable state (sold)
Nhiều nhân viên cùng phục vụNhiều thread gọi cùng một object
"Số trên bảng không vượt sức chứa"Invariant của class
"Bán xong thì số phải tăng đúng một"Postcondition của method
Thứ tự ngẫu nhiên ai chạm bảng trướcInterleaving do scheduler quyết định

2. Thread safety là gì?

Cốt lõi của mọi định nghĩa tử tế nằm ở correctness — tính đúng đắn. Một class đúng nếu nó tuân thủ đặc tả của nó: các invariant ràng buộc trạng thái, và các postcondition mô tả hệ quả của từng thao tác. Với BookingService, invariant là "sold của mỗi sự kiện luôn nằm trong khoảng từ 0 đến capacity", còn postcondition của book là "sau mỗi lần đặt thành công, sold tăng đúng một và seatNumber trả về là duy nhất".

Từ đó:

Một class là thread-safe nếu nó tiếp tục hành xử đúng khi được truy cập từ nhiều thread, bất kể runtime lập lịch hay xen kẽ các thread đó như thế nào, và phía code gọi không cần thêm bất kỳ cơ chế đồng bộ hóa nào.

Ba mệnh đề cần đọc kỹ. "Hành xử đúng" nghĩa là invariant và postcondition luôn được giữ. "Bất kể lập lịch hay xen kẽ thế nào" là phần khắc nghiệt nhất: tính đúng phải đứng vững trước mọi khả năng xen kẽ, kể cả xui xẻo nhất — chứ không phải "thường thì đúng". "Không cần đồng bộ hóa ở phía gọi" nghĩa là class đóng gói trọn phần đồng bộ hóa nó cần.

Nhưng phải nói ngay một bẫy lớn: thread-safe không có nghĩa là mọi tổ hợp nhiều lời gọi lên object đó cũng tự động atomic. Một Vector thread-safe, nhưng if (!v.contains(x)) v.add(x) thì không — giữa containsadd vẫn có cửa sổ để thread khác chen vào. Cái bẫy này tên là check-then-act, và bài Atomicity mổ kỹ nó; cách vá mong manh của nó — client-side locking — thuộc bài Delegation & concurrent collections.

Vì mọi chương trình đơn luồng cũng là một chương trình đa luồng hợp lệ, một class không thể thread-safe nếu nó còn chưa đúng khi chạy một mình. Và đây là viên gạch đầu tiên: một đối tượng thật sự không có trạng thái (stateless) thì luôn luôn thread-safe.

public final class SeatPriceCalculator {
    public long priceFor(long basePrice, int seatNumber) {
        return basePrice + (seatNumber <= 10 ? 50_000 : 0);   // 10 ghe dau phu thu VIP
    }
}

Class này không có field nào. Mọi dữ liệu nó cần đều sống trên stack của thread đang gọi, mà stack thì riêng cho từng thread — không chia sẻ gì thì không có gì để tranh.

Nhưng chữ "stateless" cần một điều kiện kèm theo, và điều kiện đó dễ trượt hơn ta tưởng.

Thử đoán

Class dưới đây cũng không có field nào. Theo lập luận vừa rồi, nó có thread-safe không? Trả lời trước khi đọc tiếp.

class StatelessLooking {
    public void f(List<String> xs) {
        if (!xs.contains("a")) xs.add("a");
    }
}

Không. Class này không có field, nhưng nếu nhiều thread truyền vào cùng một List, thao tác kiểm-tra-rồi-thêm chạy trên state của caller và vẫn dính race. Nói cách khác, "stateless" chỉ miễn nhiễm khi object vừa không giữ state mutable, vừa không chạm tới shared mutable state bên ngoài — một static mutable, một singleton không thread-safe, hay một object mutable được truyền vào rồi bị sửa.

3. Atomicity và visibility khác nhau thế nào?

Rắc rối thật sự bắt đầu khi đối tượng cần nhớ một thứ gì đó giữa các lần gọi. Khi đó shared mutable state sinh ra hai vấn đề, và điều quan trọng nhất cần nắm ngay từ đầu là chúng độc lập với nhau: vá được cái này không có nghĩa đã vá cái kia.

Atomicity — một cụm thao tác có bị xen ngang giữa chừng hay không. Một cụm nhỏ như "đọc sold, kiểm tra còn chỗ, ghi sold + 1" trông là một hành động, thực ra là ba. Nếu thread khác chen vào giữa chừng, invariant vỡ. Đây là vấn đề của BookingService.book ở trên. Bài Atomicity mổ xẻ nó.

Visibility — thread này có thấy thay đổi của thread kia không. Phản trực giác hơn nhiều: ngay cả khi không ai chen ngang, một thread ghi giá trị mới vẫn có thể không bao giờ hiển thị với thread khác, vì compiler, JIT và CPU được phép cache và sắp xếp lại thao tác bộ nhớ. Bài Visibility mổ xẻ nó.

Nhầm lẫn hai vấn đề này là nguồn gốc của phần lớn code concurrency sai một cách tinh vi — điển hình là tin rằng volatile (vốn chỉ lo visibility) sẽ làm cho count++ trở nên nguyên tử.

Điều an ủi là dù hai vấn đề có khác nhau, cả hai đều mọc từ một gốc, nên cũng chỉ có bấy nhiêu hướng để gỡ.

flowchart TB
    SMS["Shared mutable state<br/>gốc của mọi bug concurrency"]
    SMS --> AT["Vấn đề 1: Atomicity<br/>compound action bị xen ngang"]
    SMS --> VI["Vấn đề 2: Visibility<br/>không thấy thay đổi của thread khác"]
    AT --> ST["Bốn chiến lược<br/>gỡ shared mutable state"]
    VI --> ST
    ST --> C1["Confinement"]
    ST --> C2["Immutability"]
    ST --> C3["Synchronization"]
    ST --> C4["Delegation"]

4. Bốn chiến lược xử lý shared mutable state

Mỗi hướng nhắm vào một chữ trong cụm "shared mutable state".

Confinement — đừng chia sẻ. Dữ liệu chỉ một thread chạm tới thì không có gì để tranh, và tự động an toàn ngay cả khi bản thân không thread-safe. SeatPriceCalculator an toàn chính nhờ điều này. Bài Confinement đi từ stack tới ThreadLocal tới ScopedValue.

Immutability — đừng cho thay đổi. Nếu trạng thái không bao giờ đổi sau khi khởi tạo và mọi field đều final, mọi hiểm họa atomicity lẫn visibility đều tan biến: đọc lúc nào cũng ra cùng một giá trị. (Thiếu final, object chỉ effectively immutable và vẫn cần được công bố an toàn — bài 07 mổ kỹ.) EventBooking của TicketFlow là record immutable chính vì vậy. Xem bài Immutability.

Synchronization — canh gác truy cập. Khi dữ liệu vừa buộc phải chia sẻ vừa buộc phải đổi, như số vé đã bán, không còn cách né: phải canh mọi lần đọc và ghi. Java có cả một phổ công cụ, từ volatile nhẹ nhất, qua atomic và CAS, tới synchronized và explicit lock. Đây cũng là nơi BookingService v0 được vá thành v1, ở bài volatile & synchronized.

Delegation — giao cho thứ đã an toàn. Thay vì tự dựng giao thức khóa, ta ủy thác phần khó cho component đã thiết kế thread-safe sẵn: ConcurrentHashMap, BlockingQueue, các synchronizer trong java.util.concurrent. Cái khéo nằm ở chỗ biết khi nào ủy thác là đủ, và khi nào nó vỡ — chủ đề bài Delegation & concurrent collections.

Bốn hướng này không loại trừ nhau. Một class tốt thường phối hợp cả bốn: giam những gì giam được, đóng băng những gì đóng băng được, ủy thác phần ủy thác được, và chỉ tự tay khóa đúng phần lõi mutable còn lại.

5. Liên hệ các bài khác

Bài này là điểm neo khái niệm của cả module — hầu hết các bài sau đều quay về đây:

  • Atomicity — vấn đề thứ nhất, và là nơi bug oversell của BookingService v0 được mổ xẻ tới tận bytecode.
  • Visibility — vấn đề thứ hai: thread không thấy giá trị thread khác vừa ghi. Độc lập hoàn toàn với atomicity — vá cái này không vá cái kia.
  • Confinement — chiến lược thứ nhất: triệt tính "shared". Đọc ngay sau hai bài trên để thấy cách né vấn đề trước khi học cách giải nó.
  • Immutability — chiến lược thứ hai: triệt tính "mutable", và dựng nền safe publication.
  • volatile & synchronized — cơ chế quây compound action thành khối nguyên tử, và bản đồ happens-before chi tiết của JMM.

6. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

  • JLS §17.4 — Memory Model — nơi Java định nghĩa hình thức thế nào là một chương trình đa luồng đúng.
  • Java Concurrency in Practice (Goetz et al.), chương 2 — nguồn gốc của định nghĩa thread safety dùng trong bài, và ví dụ stateless servlet.

Ghi chú: Định nghĩa thread safety trong bài cố tình tránh mọi hình thức hóa. Điều đáng nhớ không phải câu chữ, mà là hai câu hỏi nó buộc bạn đặt ra trước mỗi mảnh state: "cái này có buộc phải shared không?" và "có buộc phải mutable không?".

7. Tóm tắt

  • Thread safety = giữ invariant và postcondition đúng dưới mọi cách xen kẽ, không cần caller đồng bộ thêm.
  • Gốc của mọi bug concurrency là shared mutable state — thiếu một trong hai tính từ thì rắc rối phần lớn biến mất.
  • Yêu cầu thread safety thường do framework áp lên bạn, không phải do bạn tự tạo thread.
  • Đối tượng stateless luôn thread-safe, nhưng "stateless" phải bao gồm cả việc không chạm shared mutable state bên ngoài.
  • Object thread-safe không làm cho tổ hợp nhiều lời gọi lên nó tự động nguyên tử — bẫy Vector put-if-absent.
  • Shared mutable state sinh ra hai vấn đề độc lập: atomicity (bị xen ngang) và visibility (không thấy thay đổi).
  • Bốn chiến lược gỡ: confinement, immutability, synchronization, delegation — thường phối hợp cả bốn trong một class.

Khi đứng trước một mảnh state, câu hỏi đầu tiên không phải "khóa thế nào" mà là "mảnh này có buộc phải shared không, có buộc phải mutable không". Trả lời được hai câu đó là đã chọn được trong bốn chiến lược. Nhưng trước khi chọn, phải hiểu rõ hai vấn đề mà chúng gỡ. Bài kế tiếp mổ vấn đề thứ nhất: atomicity.

8. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao định nghĩa thread safety lại nhấn mạnh cụm 'bất kể runtime lập lịch hay xen kẽ thế nào'? Bỏ cụm đó đi thì định nghĩa hỏng ở chỗ nào?
Vì nếu bỏ cụm đó, ta chỉ còn 'chạy nhiều thread thấy đúng' — mà một chương trình concurrent sai vẫn cho kết quả đúng ở đại đa số lần chạy. Race condition chỉ lộ khi cửa sổ xen kẽ rơi đúng vào khoảng thời gian xui xẻo, thường là dưới tải cao. Cụm đó biến thread safety từ một quan sát thực nghiệm (test thấy pass) thành một tính chất phổ quát (đúng với mọi interleaving mà scheduler có quyền tạo ra). Đó cũng là lý do không thể chứng minh thread safety bằng cách chạy test nhiều lần.
Q2
Vì sao cả hai tính từ trong 'shared mutable state' đều cần thiết? Nêu ví dụ dữ liệu thiếu một trong hai và giải thích vì sao nó an toàn.
Nếu dữ liệu không shared thì chỉ một thread với tới, không ai tranh — ví dụ biến cục bộ trong priceFor sống trên stack riêng của từng thread. Nếu dữ liệu không mutable thì mọi lần đọc đều trả cùng một giá trị bất kể thời điểm, nên không có atomicity lẫn visibility để lo — ví dụ record Event immutable. Chỉ khi dữ liệu vừa bị nhiều thread chạm vừa thay đổi được thì mới sinh ra tranh chấp. Đó chính là lý do hai chiến lược đầu tiên (confinement và immutability) mỗi cái triệt một tính từ.
Q3
SeatPriceCalculator không có field nào và thread-safe, còn StatelessLooking cũng không có field nào nhưng lại không thread-safe. Sự khác nhau nằm ở đâu?
SeatPriceCalculator chỉ đọc tham số và biến trung gian, tất cả đều sống trên stack riêng của thread đang gọi, nên không có gì được chia sẻ. StatelessLooking tuy không giữ state của riêng nó, nhưng lại thao tác lên state của caller: nếu nhiều thread cùng truyền vào một List, đoạn kiểm-tra-rồi-thêm chạy trên cùng một object dùng chung và dính race. Bài học: 'stateless' phải hiểu là không giữ state mutable VÀ không chạm shared mutable state bên ngoài — kể cả static mutable, singleton, hay object mutable nhận qua tham số.
Q4
Viết ra invariant và postcondition của BookingService.book. Với mỗi cái, chỉ ra một cách xen kẽ hai thread làm nó vỡ.
Invariant là ràng buộc trên trạng thái, phải đúng ở mọi thời điểm quan sát được: với mỗi sự kiện, sold luôn nằm trong khoảng từ 0 đến capacity. Postcondition là ràng buộc trên hệ quả của thao tác: sau mỗi lần đặt thành công, sold tăng đúng một và seatNumber trả về là duy nhất. Invariant vỡ khi sự kiện còn đúng một chỗ, hai thread cùng đọc sold = capacity - 1, cùng kết luận còn chỗ rồi cùng ghi — sold vượt capacity. Postcondition có thể vỡ ngay cả khi invariant còn nguyên: hai thread cùng đọc sold = 5 rồi cùng ghi 6, nên sold chỉ tăng một dù có hai lần đặt thành công, và hai khách nhận cùng một seatNumber. Đó là lý do định nghĩa thread safety phải nhắc cả hai, chứ không chỉ 'chương trình không crash'.
Q5
Vì sao atomicity và visibility được gọi là hai vấn đề độc lập? Hệ quả thực tế của việc nhầm lẫn chúng là gì?
Độc lập vì chúng hỏng theo hai cơ chế khác nhau: atomicity hỏng khi một cụm thao tác bị thread khác xen ngang giữa chừng; visibility hỏng khi một giá trị đã ghi xong nhưng không hiển thị với thread khác do caching và reordering. Vá cái này không vá cái kia. Hệ quả thực tế phổ biến nhất của việc nhầm lẫn là tin rằng volatile — vốn chỉ giải quyết visibility — sẽ làm count++ trở nên nguyên tử; nó không, vì count++ vẫn là ba thao tác đọc-cộng-ghi. Code kiểu đó trông đã 'được đồng bộ' nhưng vẫn mất update.
Q6
Vì sao một class chưa đúng khi chạy đơn luồng thì không thể nào thread-safe? Lập luận này dựa trên điều gì?
Vì mọi chương trình đơn luồng cũng là một chương trình đa luồng hợp lệ — trường hợp đặc biệt khi số thread bằng một. Định nghĩa thread safety đòi hỏi class giữ đúng invariant và postcondition dưới MỌI cách xen kẽ mà scheduler có thể tạo ra, và tập hợp đó bao gồm cả cách xen kẽ tầm thường nhất: chỉ một thread chạy từ đầu đến cuối. Nếu class đã sai ngay ở trường hợp đó, thì tồn tại một interleaving làm nó sai, nên nó không thỏa định nghĩa. Hệ quả thực dụng: correctness đơn luồng là điều kiện cần, phải đạt trước khi bàn tới đồng bộ hóa — thêm khóa vào một class vốn đã sai logic chỉ làm nó sai một cách ổn định hơn.

Bài tiếp theo: Atomicity — compound action và race condition

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Atomicity: compound action và race condition trong Java