Java Internals & Concurrency/Delegation: tái dùng lớp thread-safe và khi nào nó vỡ
14/47
Bài 14 / 47~19 phútConcurrency cơ bảnMiễn phí lượt xem

Delegation: tái dùng lớp thread-safe và khi nào nó vỡ

Đạt thread safety bằng delegation cho ConcurrentHashMap: khi nào đủ, khi nào vỡ trước invariant bắc cầu, và compound action nguyên tử compute/merge/putIfAbsent.

TL;DR: Cách đạt thread safety ít rủi ro nhất là delegation — giao việc đồng bộ cho component đã thread-safe (ConcurrentHashMap...) thay vì tự cầm khóa. Delegation đủ khi mỗi method chỉ là một thao tác đơn lên một component độc lập; nó vỡ khi xuất hiện invariant bắc cầu nhiều component, hoặc khi nghiệp vụ là compound action ghép từ nhiều lời gọi đơn. Các synchronized collection đời cũ (Collections.synchronizedMap, Vector) cho mọi truy cập nối đuôi qua một khóa duy nhất, và compound action tự ghép vẫn cần client-side locking mong manh. ConcurrentHashMap cung cấp sẵn compound action nguyên tử — putIfAbsent, compute, merge — chạy nguyên tử per-key. Pitfall lớn nhất: tưởng rằng collection thread-safe thì chuỗi thao tác bạn ghép trên nó cũng nguyên tử — không hề.

1. Delegation: mượn thread safety thay vì tự viết

Bài trước cho ta ReentrantLock, Condition, ReadWriteLock, StampedLock — mạnh hơn synchronized, nhưng cái giá là tự cầm lock, tự unlock trong finally, tự suy luận biến nào canh bởi khóa nào. Đúng được, nhưng dễ sai. Có lối bền hơn: đừng tự viết đồng bộ — giao trách nhiệm đồng bộ cho một component đã thread-safe, để class thừa hưởng sự an toàn đó. Ý tưởng này tên là delegation (ủy thác). Series đã chạm nó ở Atomicity: bộ đếm dùng AtomicLong thay long thì phép tăng nguyên tử mà không cần synchronized, vì việc đồng bộ đã giao cho một đối tượng đã kiểm chứng. Building block hay dùng nhất của hướng này nằm sẵn trong java.util.concurrent: các concurrent collection.

Nguyên tắc: nếu toàn bộ state mutable của một class nằm trong những đối tượng đã thread-safe, và class không áp thêm invariant nào lên cách chúng phối hợp, thì class tự động thread-safe mà không cần khóa riêng. Ví dụ trong TicketFlow — catalog sự kiện chỉ đăng ký và tra Event theo id, state vỏn vẹn một map:

public class EventRegistry {
    private final ConcurrentMap<String, Event> events = new ConcurrentHashMap<>();

    public void register(Event event) {
        Objects.requireNonNull(event, "event must not be null");
        if (events.putIfAbsent(event.id(), event) != null) {
            throw new IllegalArgumentException("Sự kiện đã tồn tại: " + event.id());
        }
    }

    public Event find(String eventId) {
        return events.get(eventId);
    }
}

Không một synchronized nào. EventRegistry thread-safe vì giao trọn việc đồng bộ cho ConcurrentHashMap: mỗi method chỉ là một lời gọi đơn xuống map, mà mỗi lời gọi đó tự nó đã nguyên tử; class không thêm state nào ngoài map nên không có gì để tự canh. So với Monitor Pattern ở volatile & synchronized — nơi BookingService quây mọi method bằng private lock — đây là một sự nhẹ nhõm. Hình dung delegation như nhà hàng thuê bảo vệ đứng cửa: chủ quán không cần tự học võ, chỉ giao việc canh cửa. Chừng nào trật tự gói trong "ai được vào cửa nào", bảo vệ lo trọn; vấn đề chỉ nảy sinh khi an ninh đòi phối hợp nhiều cửa cùng lúc — đúng chỗ delegation hụt hơi ở mục sau.

2. Khi delegation đủ, và khi nó vỡ

Điều kiện để delegation đủ: class không được áp thêm invariant nào lên các component thread-safe của nó. Mỗi component phải độc lập, và mỗi method công khai phải hoàn thành công việc chỉ bằng một thao tác đơn lên một component. EventRegistry thỏa: state là đúng một map độc lập, không ràng buộc nào bắt giá trị trong map khớp với biến nào khác.

Thử thêm một tính năng tưởng vô hại: giữ thêm biến count đếm số sự kiện đang mở bán để dashboard đọc cho nhanh.

public class CountingRegistry {                     // @NotThreadSafe — dung lam the nay
    private final ConcurrentMap<String, Event> events = new ConcurrentHashMap<>();
    private final AtomicInteger count = new AtomicInteger(0);
    // INVARIANT: count == events.size() (luon luon)

    public void register(Event event) {
        if (events.putIfAbsent(event.id(), event) == null) {
            count.incrementAndGet();                // cap nhat bien thu hai, o mot buoc rieng
        }
    }
}

Mỗi component vẫn thread-safe riêng: eventsConcurrentHashMap, countAtomicInteger. Vậy mà class không còn thread-safe. Ta vừa áp một invariant bắc cầu hai component: count phải luôn bằng events.size(). Giữa putIfAbsentincrementAndGet tồn tại một cửa sổ — dù vài nano giây — trong đó map đã có entry mới còn count chưa tăng; thread khác đọc đúng vào cửa sổ đó sẽ thấy invariant bị vi phạm. Đây chính là bài học "atomic của một biến không phải atomic của invariant" từ Atomicity, chỉ thay AtomicInteger đơn lẻ bằng các component thread-safe đơn lẻ. Khi delegation hụt hơi thế này, ta phải tự bổ sung đồng bộ cho riêng cụm thao tác đó, thường bằng một khóa của chính class — vẫn là tư duy composition: lắp thêm lớp đồng bộ quanh các mảnh có sẵn, quay về Monitor Pattern cho đúng phần lõi liên đới.

flowchart TB
    subgraph OK["Delegation DU: EventRegistry"]
        M1["method register / find"] --> CM1["mot loi goi don xuong ConcurrentHashMap"]
        CM1 --> S1["class tu dong thread-safe, khong can khoa rieng"]
    end
    subgraph BROKEN["Delegation VO: CountingRegistry"]
        M2["method register"] --> P1["putIfAbsent tren events"]
        P1 -.->|"cua so: count chua tang"| INV["INVARIANT count == size() bi vi pham"]
        P1 --> P2["incrementAndGet tren count (buoc rieng)"]
    end
    style S1 fill:#6EE7B7
    style INV fill:#FCA5A5

Điều kiện phân định nằm ở nhánh trái so với nhánh phải: bên trái mỗi method là một lời gọi đơn xuống một component độc lập; bên phải, hai component bị buộc bởi một invariant nên có một cửa sổ mà trạng thái nhìn từ ngoài không nhất quán.

Một dạng hụt hơi tinh vi hơn nằm ở chỗ một thao tác nghiệp vụ vốn dĩ là compound action trên cùng một component — trông y hệt delegation hợp lệ.

// check-then-act tren ConcurrentHashMap — van dinh race du map thread-safe
Integer current = sold.get(eventId);                 // (1) doc
if (current == null || current < event.capacity()) { // (2) kiem tra
    sold.put(eventId, (current == null ? 0 : current) + 1);  // (3) ghi
}

Mỗi getput đều nguyên tử và an toàn xét riêng lẻ, nhưng ba bước ghép lại không tự thành một khối nguyên tử. Hai thread cùng đọc current = 0, cùng thấy còn chỗ, cùng put giá trị 1: lại đúng kịch bản check-then-act của BookingService.book, lost update tái diễn dù collection đã thread-safe. Một collection thread-safe bảo đảm mỗi thao tác đơn của nó nguyên tử; nó không bảo đảm một chuỗi nhiều thao tác do bạn ghép cũng nguyên tử. May thay, với ConcurrentHashMap ta không cần khóa ngoài để vá — bản thân nó cung cấp sẵn compound action nguyên tử, và đó là lý do nó hơn hẳn các synchronized collection đời cũ.

3. synchronized collection vì sao không đủ?

Trước java.util.concurrent (Java 5) chỉ có hai lựa chọn sẵn: VectorHashtable từ JDK 1.0, hoặc các wrapper Collections.synchronizedMap, synchronizedList, synchronizedSet từ JDK 1.2. (Cách thứ ba luôn tồn tại: tự bọc collection trong khối synchronized của mình — Monitor Pattern.) Những wrapper này đặt mỗi method công khai vào một khối synchronized khóa trên cùng một đối tượng — thread-safe theo nghĩa hẹp, nhưng vướng hai giới hạn nghiêm trọng đến mức trong code mới gần như không còn lý do để chọn.

Giới hạn thứ nhất là compound action. Thời JCiP viết (Java 5), synchronizedMap không có thao tác "kiểm tra rồi đặt nếu vắng" nguyên tử nào. Từ Java 8 thì khác: Map có các default method putIfAbsent, compute, merge, và Collections.SynchronizedMap override lại toàn bộ chúng, mỗi cái bọc trong synchronized (mutex) — nên Collections.synchronizedMap(m).putIfAbsent(k, v) nguyên tử. Cái vẫn không cứu được là compound action do chính bạn ghép từ nhiều lời gọi — và List thì không hề có putIfAbsent nào để override, nên Vector vẫn là ví dụ sống. Muốn một check-then-act đúng trên chúng, ta phải tự khóa từ phía client.

3.1 Client-side locking — và vì sao nó mong manh

Từng method của Vector đều đồng bộ bên trong nên mỗi lời gọi lẻ là nguyên tử, nhưng cụm "kiểm tra vắng rồi mới thêm" là một check-then-act bắc cầu hai lời gọi, muốn nguyên tử phải quây vào cùng một khóa. Khóa nào? Thử cách trông tự nhiên nhất — đồng bộ method của chính helper:

// SAI: lock cua helper khac lock noi bo cua Vector
public class ListHelper<E> {
    public final Vector<E> list = new Vector<>();      // luu y: list la PUBLIC

    public synchronized boolean putIfAbsent(E x) {     // lock tren ListHelper instance
        boolean absent = !list.contains(x);            // Vector tu lock tren CHINH NO
        if (absent) list.add(x);
        return absent;
    }
}

Chỗ này dễ tóm tắt sai. Hai thread cùng gọi helper.putIfAbsent(x) thì loại trừ nhau: cả hai đều phải giành khóa trên ListHelper. Cửa sổ chỉ mở ra với một thread thao tác thẳng lên list — mà list khai public, nên bất kỳ ai cũng gọi được list.add(x) mà không bị khóa helper chặn. Nói chính xác như JCiP: putIfAbsent không nguyên tử so với các thao tác khác trên chính List đó. Đây là lỗi "khóa nhầm khóa", nguy hiểm vì đọc lên rất giống code đúng.

Phiên bản đúng phải khóa trên đúng đối tượng mà Vector dùng để tự bảo vệ — tức chính list:

// DUNG: client-side locking -- lock tren chinh collection
public boolean putIfAbsent(E x) {
    synchronized (list) {                 // cung lock voi lock noi bo cua Vector
        boolean absent = !list.contains(x);
        if (absent) list.add(x);
        return absent;
    }
}

Nó đúng vì Vector cam kết mọi method đồng bộ trên this. Nhưng nhìn cái nền phiên bản "đúng" này đứng lên: nó phụ thuộc chính sách khóa nội bộ của một lớp khác — chi tiết cài đặt, không phải interface. Ba chỗ mong manh: lớp kia đổi cách khóa thì code ta sai ngay mà không cảnh báo compile nào; kỹ thuật không khái quát được (ConcurrentHashMap không có khóa duy nhất nào để client cầm — xem mục 4); và sự đúng đắn bị phân mảnh ra mọi nơi từng dùng collection. Vì vậy client-side locking là phương án cuối — ưu tiên vẫn là dùng collection có sẵn compound action nguyên tử.

Giới hạn thứ hai, đau hơn trong thực tế, là iteration. Khi duyệt synchronizedMap bằng iterator, từng next được đồng bộ nhưng khoảng giữa hai lời gọi thì không: thread khác sửa map giữa chừng thì iterator ném ConcurrentModificationException theo cơ chế fail-fast. Để duyệt an toàn, phải khóa toàn bộ map suốt cả vòng lặp — và trong khoảng đó mọi thread khác chạm map đều bị chặn cứng. Với map nóng phục vụ hàng nghìn request, khóa toàn cục suốt một vòng duyệt là thảm họa throughput: nó biến mọi truy cập, dù đọc hay ghi, dù vào key nào, thành một hàng đợi nối đuôi qua đúng một khóa. java.util.concurrent từ Java 5 mang đến dòng collection thiết kế lại từ gốc cho concurrency, đổi ngữ nghĩa lấy throughput thật.

4. ConcurrentHashMap: khóa per-bin và compound action nguyên tử

ConcurrentHashMap là concurrent collection dùng nhiều nhất. Thay vì một khóa duy nhất canh toàn bộ map như synchronizedMap, nó khóa mịn hơn: map chia thành nhiều bin (bucket), phần lớn thao tác chỉ đồng bộ trên đúng bin chứa key liên quan (chi tiết cơ chế ở bài kế tiếp Nội thất ConcurrentHashMap). Hệ quả: hai thread thao tác trên hai key ở hai bin khác nhau gần như không chặn nhau; nhiều reader luôn chạy song song với nhau và với writer. Trên map nóng, chênh lệch throughput với synchronizedMap là rất lớn.

Cái giá của khóa mịn là ngữ nghĩa vài thao tác toàn cục yếu đi. size()isEmpty() trả về con số gần đúng, có thể đã cũ ngay khi bạn nhận, vì map không khóa toàn cục để đếm. Quan trọng hơn, iterator của nó là weakly consistent chứ không fail-fast: duyệt được mà không bao giờ bị ném ConcurrentModificationException, phản ánh trạng thái map tại một thời điểm nào đó kể từ khi tạo và có thể — nhưng không bắt buộc — thấy các thay đổi sau đó. Với duyệt báo cáo hay quét định kỳ, đánh đổi này hời.

Phần khiến nó thay thế được cả synchronizedMap lẫn nhu cầu tự khóa là tập compound action nguyên tử có sẵn — lời giải cho check-then-act ở mục 2. putIfAbsent gói "nếu vắng thì đặt" thành một bước nguyên tử; compute, computeIfAbsent, computeIfPresent, merge cho phép đọc-sửa-ghi một entry trong một thao tác duy nhất, lambda chạy nguyên tử per-key.

💡 Cách nhớ

Component thread-safe bảo đảm từng câu nguyên tử, không bảo đảm cả đoạn văn bạn ghép từ nhiều câu. Muốn cả đoạn nguyên tử: hoặc dùng compound action có sẵn của component (putIfAbsent, compute, merge), hoặc tự khóa quanh cả đoạn.

Với đếm thuần túy — tăng counter per-key, không invariant nào phải kiểm — merge gọn hơn cả compute:

// Dem luot xem trang chi tiet cua moi event — atomic per-key, khong can khoa ngoai
viewCount.merge(eventId, 1L, Long::sum);

Nếu key chưa có, merge đặt 1L; nếu đã có, áp Long::sum lên giá trị cũ và 1L — tất cả trong một thao tác nguyên tử trên đúng bin của key. Phân vai: merge cho đếm vô điều kiện; khi phải kiểm tra rồi mới ghi — như chặn bán vượt capacity — thì dùng compute để nhét cả bước kiểm tra vào trong thao tác nguyên tử:

public Booking book(String eventId, String userId) {
    Event event = registry.find(eventId);       // throw neu null (bo qua o day)
    int seatNumber = sold.compute(eventId, (k, current) -> {
        int cur = (current == null) ? 0 : current;
        if (cur >= event.capacity()) throw new SoldOutException(eventId);  // check ...
        return cur + 1;                          // ... va act, cung trong mot thao tac nguyen tu
    });
    return new Booking(eventId, userId, seatNumber);
}

Lambda truyền vào compute chạy dưới khóa của đúng bin chứa eventId. Trong suốt thời gian đó, không thread nào chen vào giữa bước kiểm tra cur >= capacity và bước ghi cur + 1 cho cùng key: hai thread cùng đọc 0 rồi cùng ghi 1 không còn xảy ra, thread thứ hai chỉ chạy lambda sau khi thread thứ nhất ghi xong nên đọc đúng giá trị mới và ném SoldOutException khi hết chỗ. Invariant "không vượt capacity" được giữ mà không một dòng synchronized. So với Monitor Pattern ở v1 — nơi hai khách đặt vé cho hai sự kiện khác nhau vẫn xếp hàng chờ nhau — compute còn cho hai sự kiện ở hai bin khác nhau không chặn nhau: vừa đúng vừa song song.

Đổi lại có một kỷ luật với lambda của compute và họ hàng. Nó chạy trong khóa của bin nên phải ngắn, thuần, và tuyệt đối không làm hai việc nguy hiểm: không tái vào chính map đó với một key khác (javadoc nói thẳng là không được phép — tùy thao tác JVM ném IllegalStateException hoặc rơi vào retry không bao giờ dừng), và không giành thêm khóa nào khác (mở đường deadlock). Lambda lý tưởng chỉ tính giá trị mới từ giá trị cũ rồi trả về: không I/O, không tính toán dài, không side-effect.

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

  • Atomicity — delegation là câu trả lời "đừng tự viết" cho các race check-then-act mà bài đó mổ xẻ; đây cũng là nơi ranh giới "atomic một biến ≠ atomic invariant" được dựng lên.
  • Volatile & synchronized — Monitor Pattern với private lock là phương án ta quay về khi delegation vỡ, hoặc khi phải bọc client-side locking quanh một synchronized collection.
  • ReadWriteLock, StampedLock & AQS — khi tự cầm khóa là lựa chọn cuối, các khóa nâng cao ở bài đó là công cụ; delegation là hướng tránh phải tự cầm khóa ngay từ đầu.
  • Nội thất ConcurrentHashMap: per-bin lock, CAS và snapshot — vì sao compute/merge/putIfAbsent chạy được nguyên tử per-key, và cách chọn concurrent collection khác (CopyOnWriteArrayList, ConcurrentSkipListMap, ConcurrentLinkedQueue) theo workload.

6. Tóm tắt

  • Nếu toàn bộ state mutable của một class nằm trong các component đã thread-safe và class không áp invariant nào lên cách chúng phối hợp, thì class tự động thread-safe mà không cần khóa riêng — đó là delegation (EventRegistry ủy thác trọn cho ConcurrentHashMap).
  • Delegation vỡ khi có invariant bắc cầu nhiều component, hoặc khi nghiệp vụ là compound action ghép từ nhiều lời gọi đơn. Component thread-safe bảo đảm mỗi thao tác đơn nguyên tử, không bảo đảm chuỗi bạn ghép cũng nguyên tử; khi đó phải tự bổ sung đồng bộ cho cụm liên đới.
  • Collections.synchronizedXxxVector/Hashtable cho mọi truy cập nối đuôi qua một khóa duy nhất — lý do thật để bỏ chúng. Từ Java 8 wrapper đã có putIfAbsent/compute/merge nguyên tử, nhưng compound action tự ghép vẫn cần client-side locking, và iteration buộc khóa cả map suốt vòng lặp nếu không sẽ dính ConcurrentModificationException fail-fast.
  • Client-side locking đúng phải khóa trên đúng đối tượng collection tự bảo vệ (synchronized (list)), không phải khóa của helper — nhưng nó mong manh vì phụ thuộc chính sách khóa nội bộ của lớp khác, không khái quát, và làm sự đúng đắn phân mảnh. Vì thế nó là phương án cuối.
  • ConcurrentHashMap là lối thoát: nó cung cấp compound action nguyên tử sẵn — putIfAbsent, compute, merge — lambda chạy nguyên tử per-key nên vá được check-then-act mà không cần khóa ngoài; đổi lại lambda phải ngắn, thuần, không tái vào map, không giành thêm khóa.

Vì sao compute/merge/putIfAbsent làm được điều đó — reader và writer trên key khác nhau không chặn nhau, get() không khóa mà vẫn đúng — là chuyện của nội thất ConcurrentHashMap: khóa per-bin, CAS cho bin rỗng, và đếm phân tán. Bài kế tiếp mở nắp xem cơ chế đó, cùng cách chọn giữa các concurrent collection còn lại theo workload.

7. Tự kiểm tra

Tự kiểm tra
Q1
Hai field của một class đều là component thread-safe (ConcurrentHashMap và AtomicInteger). Vì sao class chứa chúng vẫn có thể không thread-safe?
Vì delegation chỉ đứng vững khi class không áp thêm invariant nào bắc cầu giữa các component. Mỗi component bảo đảm từng thao tác đơn của chính nó nguyên tử, nhưng nếu class đòi hỏi count == events.size() luôn đúng, thì giữa hai lần cập nhật hai component tồn tại một cửa sổ mà invariant bị vi phạm — thread khác đọc vào đúng cửa sổ đó sẽ thấy trạng thái không nhất quán. Đây là phiên bản nhiều-component của bài học "atomic của một biến không phải atomic của invariant". Khi đó phải tự bọc cụm thao tác liên đới bằng một khóa của chính class.
Q2
Điều kiện để delegation là đủ cho thread safety là gì? Cho một ví dụ delegation ĐỦ và một ví dụ delegation VỠ.
Delegation đủ khi toàn bộ state mutable của class nằm trong các component đã thread-safe, mỗi component độc lập, và mỗi method công khai hoàn thành việc chỉ bằng một thao tác đơn lên một component — class không áp thêm invariant nào lên cách chúng phối hợp. Ví dụ đủ: EventRegistry chỉ có một ConcurrentHashMap, mỗi method là một lời gọi đơn xuống map nên tự thread-safe, không một synchronized nào. Ví dụ vỡ: thêm một AtomicInteger count với invariant count == events.size() — dù cả hai field đều thread-safe, invariant bắc cầu hai component tạo cửa sổ giữa hai lần cập nhật, phá delegation.
Q3
Một helper bọc Vector làm putIfAbsent bằng cách khai public synchronized boolean putIfAbsent(...). Vì sao đây là "khóa nhầm khóa", và bản vá đúng khóa trên cái gì?
Method synchronized của helper khóa trên instance helper, còn mọi method của Vector lại tự đồng bộ trên chính nó — hai khóa khác nhau. Vì field list khai public, bất kỳ ai cũng gọi thẳng list.add(x) mà không bị khóa helper chặn, nên cụm check-then-act của helper không nguyên tử so với các thao tác khác trên chính List đó. Bản vá đúng là client-side locking khóa trên chính list (synchronized (list) {...}), trùng đúng đối tượng mà Vector dùng để tự bảo vệ. Nhưng nó mong manh vì phụ thuộc chính sách khóa nội bộ của một lớp khác — không có cảnh báo compile nào nếu lớp đó đổi cách khóa.
Q4
Từ Java 8, Collections.synchronizedMap(m).putIfAbsent(k, v) đã nguyên tử. Vậy tại sao vẫn nói synchronized collection "không đủ" cho compound action?
Vì Java 8 chỉ override các default method có sẵn tên (putIfAbsent, compute, merge) và bọc mỗi cái trong synchronized (mutex) — cái đó nguyên tử. Nhưng compound action do chính bạn ghép từ nhiều lời gọi rời (ví dụ get rồi kiểm tra rồi put) thì không method đơn nào bảo vệ được — vẫn cần khóa client. Và List không hề có putIfAbsent để override, nên Vector vẫn là ví dụ sống của việc phải tự khóa. Ngoài ra iteration vẫn buộc khóa cả map suốt vòng lặp, nếu không sẽ dính ConcurrentModificationException fail-fast.
Q5
compute() của ConcurrentHashMap cho ta guarantee gì mà cặp get() rồi put() không có? Lambda truyền vào nó phải tuân kỷ luật nào?
compute() chạy lambda dưới khóa của đúng cái bin chứa key, nên toàn bộ chuỗi đọc-kiểm tra-ghi cho key đó là một khối nguyên tử: không thread nào chen vào giữa, kịch bản hai thread cùng đọc giá trị cũ rồi cùng ghi đè (lost update) không xảy ra. Cặp get/put rời rạc thì mỗi lời gọi nguyên tử riêng lẻ nhưng khoảng giữa chúng hở. Đổi lại, lambda chạy bên trong khóa bin nên phải ngắn và thuần: không I/O, không tái vào chính map đó (hành vi không được phép — có thể ném IllegalStateException hoặc treo), không giành thêm khóa khác kẻo mở đường deadlock.

Bài tiếp theo: Nội thất ConcurrentHashMap: per-bin lock, CAS và snapshot

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