01 Vì sao đồng thời?
Đồng thời (concurrency) là một chiến lược tách rời (decoupling): tách "cái gì" (what) khỏi "khi nào" (when). Trong code đơn luồng, what & when gắn chặt — nhìn ngăn xếp (call stack) là biết ngay trạng thái hệ thống. Tách hai thứ đó ra giúp cải thiện cả throughput lẫn cấu trúc: hệ thống trông như nhiều máy tính nhỏ cộng tác, thay vì một vòng lặp chính (main loop) khổng lồ.
Ví dụ thời gian chờ I/O: một bộ thu thập (aggregator) đơn luồng hit từng web tuần tự, cứ mỗi lần phải chờ I/O socket; thêm vài web nữa là tổng thời gian vượt 24 giờ. Bản đa luồng hit nhiều web cùng lúc, tận dụng đúng những khoảng chờ đó. Tương tự, hệ chỉ phục vụ một user mỗi lần sẽ chậm dần khi đông user; xử lý đồng thời nhiều user thì tốt hơn hẳn.
RAG Trong hệ RAG, khâu hỏi-đáp tài liệu cũng đầy chỗ phải chờ I/O — truy hồi vector store, gọi API mô hình, đọc nguồn. Phục vụ nhiều truy vấn đồng thời tận dụng các khoảng chờ này để tăng throughput, miễn là ta tách bạch "cái gì cần làm" khỏi "khi nào nó chạy".
02 Lầm tưởng & sự thật
Concurrency mang theo nhiều ngộ nhận tai hại. Ba lầm tưởng phổ biến nhất — và sự thật đi kèm:
"Luôn cải thiện hiệu năng"
Sự thật: chỉ đúng khi có nhiều thời gian chờ (wait time) chia sẻ được giữa nhiều luồng/CPU. Không có wait time để chia, concurrency chỉ thêm overhead.
"Thiết kế không đổi"
Sự thật: thiết kế thuật toán đồng thời khác rất nhiều so với đơn luồng — việc tách what/when ảnh hưởng sâu tới cấu trúc.
"Dùng container thì khỏi hiểu"
Sự thật: dù chạy trong Web/EJB container, vẫn phải biết container làm gì & phòng concurrent update và deadlock.
Vài điều cần khắc cốt: concurrency luôn tốn overhead (cả hiệu năng lẫn lượng code thêm); làm cho nó đúng-đắn thì phức tạp kể cả với vấn đề đơn giản; bug đồng thời thường không lặp lại nên hay bị bỏ qua như chuyện "một-lần" (one-off); và nó thường đòi đổi chiến lược thiết kế tận gốc.
03 Thử thách: ++ không nguyên tử
Một ví dụ nhỏ đủ cho thấy các luồng "giẫm chân" nhau ra sao. Giả sử lastIdUsed = 42 và hai luồng cùng gọi get_next_id() với phép ++ trần (không bảo vệ). Có ba kết cục khả dĩ: luồng A nhận 43 & B nhận 44 (biến thành 44); A nhận 44 & B nhận 43 (biến thành 44); hoặc cả hai cùng nhận 43 và biến chỉ thành 43 — mất một lần tăng.
++ không phải thao tác nguyên tử (atomic). Một dòng tăng biến biên dịch thành nhiều byte-code; hai luồng đan xen các byte-code đó tạo ra hàng nghìn đường thực thi khả dĩ. Đa số đường cho kết quả đúng — nhưng một số thì sai, và đúng những đường sai hiếm gặp đó mới là bug khó truy.
RAG Trong hệ RAG, một bộ đếm dùng chung — ví dụ cấp document id khi nạp tài liệu song song, hay đếm số truy vấn — chính là cái bẫy này. Bỏ ++ trần, dùng đồng bộ hóa hoặc kiểu atomic:
class IdSource:
def __init__(self):
self.last_id = 42
def next_id(self):
self.last_id += 1 # ✗ ++ không nguyên tử
return self.last_id # 2 luồng → có thể MẤT một lần tăng
class IdSource:
def __init__(self):
self.last_id = 42
self.lock = Lock()
def next_id(self):
with self.lock: # ✓ critical section được bảo vệ
self.last_id += 1
return self.last_id # (hoặc dùng counter atomic của thư viện)
Vì sao: bọc phần đọc-sửa-ghi vào một critical section (qua lock, tương đương synchronized trong ví dụ Java của sách) khiến chỉ một luồng chạy đoạn đó tại một thời điểm, nên không còn đan xen byte-code gây mất lần tăng. Một bộ đếm atomic của thư viện cũng đạt cùng mục đích.
04 Nguyên tắc phòng thủ
Martin đưa ra một bộ nguyên tắc & kỹ thuật để hạn chế rủi ro của code đồng thời. Bốn nguyên tắc nền tảng:
Single Responsibility
Code đồng thời có vòng đời phát triển, đổi & tinh chỉnh (tuning) riêng, với thử thách riêng và khó hơn code thường → giữ nó tách biệt, đừng nhúng chi tiết concurrency vào code production khác.
Limit the Scope of Data
Hai luồng sửa cùng một field của object chia sẻ sẽ can thiệp lẫn nhau. Dùng đồng bộ hóa bảo vệ critical section, và hạn chế tối đa số nơi truy cập dữ liệu chia sẻ (càng nhiều càng dễ quên bảo vệ, dễ trùng lặp — vi phạm DRY).
Use Copies of Data
Tránh chia sẻ bằng cách không chia sẻ ngay từ đầu: sao chép object để xử lý read-only, hoặc thu kết quả từ nhiều bản sao rồi gộp trong một luồng. Tránh được lock thường bù lại chi phí tạo bản sao & GC.
Threads độc lập nhất có thể
Cho mỗi luồng sống trong "thế giới riêng", không chia sẻ dữ liệu: mỗi luồng xử lý một request, lấy dữ liệu từ nguồn không-chia-sẻ và lưu vào biến cục bộ (như HttpServlet nhận tham số doGet/doPost) → khỏi lo đồng bộ.
RAG Áp vào RAG: thay vì để nhiều luồng cùng ghi vào một store/registry dùng chung trong lúc nạp, cho mỗi luồng xử lý độc lập trên bản sao rồi gộp kết quả ở một luồng duy nhất:
shared_index = {} # ✗ dữ liệu chia sẻ khắp nơi
def worker(docs):
for d in docs:
shared_index[d.id] = embed(d.text) # nhiều luồng cùng ghi
global_stats.count += 1 # ✗ và cùng tăng đếm chung
def worker(docs):
local = {} # ✓ mỗi luồng dùng bản sao cục bộ
for d in docs:
local[d.id] = embed(d.text)
return local # gộp các bản sao trong MỘT luồng
def merge(results): # phạm vi sửa dữ liệu chung thu về 1 nơi
index = {}
for part in results: index.update(part)
return index
Vì sao: bản bẩn để nhiều luồng cùng ghi shared_index & tăng global_stats không bảo vệ — vừa race vừa rải dữ liệu chia sẻ khắp nơi. Bản sạch cho mỗi luồng làm việc trên bản sao cục bộ độc lập, rồi chỉ một nơi (merge) gộp — không còn dữ liệu chia sẻ cần khóa.
05 Hiểu thư viện & mô hình thực thi
Know your library. Trước khi tự tay viết khóa, hãy tận dụng công cụ có sẵn:
Collection thread-safe
Dùng các collection an toàn luồng (vd ConcurrentHashMap — thường tốt hơn HashMap ở mọi tình huống, cho đọc-ghi đồng thời).
Executor framework
Quản lý pool luồng (resize, tạo lại); hỗ trợ Future/Callable để chạy tác vụ và lấy kết quả về.
Nonblocking (CAS)
AtomicInteger/AtomicReference dùng lệnh CAS phần cứng — incrementAndGet() thay ++, gần như luôn nhanh hơn mà không cần khóa.
Cẩn thận: vài lớp KHÔNG thread-safe — ví dụ SimpleDateFormat, các kết nối CSDL (Database Connections), nhiều container trong java.util, và Servlets. Đừng mặc định cứ là lớp thư viện thì an toàn luồng.
Know your execution models — ba mô hình kinh điển
Học ba mô hình thực thi này để nhận ra & giải các bài toán cạnh tranh tài nguyên thường gặp:
Producer–Consumer
Luồng sản xuất bỏ việc vào một queue/buffer có giới hạn; luồng tiêu thụ lấy ra xử lý. Hai bên phải báo hiệu cho nhau (queue hết rỗng / hết đầy) và đôi lúc phải chờ.
Readers–Writers
Tài nguyên chủ yếu để đọc, thỉnh thoảng writer cập nhật → phải cân bằng throughput vs tránh starvation (bỏ đói). Ưu tiên reader thì writer bị bỏ đói; ưu tiên writer thì throughput giảm.
Dining Philosophers
Các triết gia (=luồng) quanh bàn cần 2 fork (=tài nguyên) mới ăn được. Thiết kế thiếu cẩn thận → deadlock, livelock hoặc giảm hiệu suất. Mẫu hình cho nhiều app tranh giành tài nguyên dùng chung.
06 Section đồng bộ, shutdown & test code đa luồng
synchronized tạo ra một lock; mọi đoạn dùng cùng lock chỉ cho một luồng chạy tại một thời điểm. Lock đắt (gây delay + overhead), nên đừng rải synchronized khắp nơi; nhưng critical section thì bắt buộc phải bảo vệ.
Giữ section đồng bộ NHỎ nhất. Số critical section càng ít càng tốt; nhưng cũng đừng làm một section to ra quá mức tối thiểu — section phình to làm tăng contention (tranh chấp lock) và giảm hiệu năng.
Cẩn thận phụ thuộc giữa các method synchronized. synchronized chỉ bảo vệ một method; nếu có hơn một synchronized method trên cùng một shared object thì hệ vẫn có thể sai. Tránh tình huống này; nếu buộc phải, dùng Client-Based Locking / Server-Based Locking / Adapted Server.
Viết code shutdown đúng là khó
Viết một hệ chạy mãi khác hẳn viết một hệ chạy rồi tắt duyên dáng (graceful shutdown). Shutdown dễ deadlock: một luồng cha sinh nhiều luồng con rồi đợi tất cả xong — chỉ một con kẹt là cha đợi mãi; hoặc producer nhận tín hiệu tắt & dừng nhanh, để consumer còn đang chờ message bị kẹt, không nhận được tín hiệu tắt. Lời khuyên: nghĩ về shutdown sớm và làm cho nó chạy đúng sớm — việc này lâu hơn bạn tưởng.
Test code đa luồng
Không thể chứng minh code đa luồng đúng; test tốt chỉ giảm rủi ro. Vài lời khuyên cốt lõi:
Coi spurious failure là lỗi thật
Một lần fail "ngẫu nhiên" rất có thể là vấn đề luồng tiềm ẩn — đừng bỏ qua như chuyện one-off.
Làm code không-luồng chạy đúng TRƯỚC
Test logic POJO ngoài môi trường luồng — đừng đuổi cùng lúc cả bug luồng lẫn bug không-luồng.
Pluggable & tunable
Cho code chạy nhiều cấu hình: số luồng thay đổi, test double nhanh/chậm.
Nhiều luồng hơn số lõi
Chạy với số luồng nhiều hơn số CPU/lõi để thúc đẩy task-swapping, làm lộ lỗi; thử trên nhiều nền tảng.
Instrument để ép lỗi lộ
Chèn wait/sleep/yield (thủ công hoặc tự động bằng công cụ như ConTest của IBM) để xáo trộn thứ tự luồng, ép lỗi tiềm ẩn lộ sớm.
Tách concurrency là việc một-trách-nhiệm: ví dụ của Martin về một process() ôm bốn trách nhiệm — quản lý kết nối socket, xử lý client, chính sách luồng, chính sách shutdown — được refactor bằng cách dồn mọi thứ liên quan tới luồng vào một nơi (một bộ lập lịch luồng riêng), đúng tinh thần SRP.
07 Ghi nhớ nhanh
Concurrency = tách "cái gì" khỏi "khi nào". Lợi ích thật khi có nhiều wait time chia sẻ được — nhưng luôn kèm overhead & độ phức tạp.
Đừng tin ba lầm tưởng: không phải lúc nào cũng nhanh hơn · thiết kế đổi rất nhiều · dùng container vẫn phải hiểu concurrency.
++ không nguyên tử — bảo vệ mọi đọc-sửa-ghi trên dữ liệu chia sẻ bằng critical section hoặc kiểu atomic.
Phòng thủ: tách code concurrency (SRP) · giới hạn scope dữ liệu chia sẻ · dùng bản sao · giữ luồng độc lập nhất có thể.
Hiểu thư viện (collection thread-safe, Executor/Future, atomic CAS) và 3 mô hình: Producer-Consumer · Readers-Writers · Dining Philosophers.
Giữ section đồng bộ nhỏ, cẩn thận phụ thuộc giữa các method synchronized; nghĩ về shutdown sớm.
Test nghiêm: coi spurious failure là lỗi thật · chạy nhiều luồng hơn lõi · instrument (ConTest) ép lỗi lộ.