LMAX Disruptor & kiến trúc lock-free
Tung Bui Thanh (TungDaDev)9 mins read

Muốn hiệu năng cực cao thì chỉ giỏi thuật toán chưa đủ, phải hiểu phần cứng bên dưới chạy thế nào. LMAX Disruptor là ví dụ điển hình của Mechanical Sympathy trong thế giới Java.
# nút thắt của BlockingQueue truyền thống
Trong kiến trúc xử lý bất đồng bộ kinh điển giữa Producer và Consumer, người ta thường dùng ArrayBlockingQueue hoặc LinkedBlockingQueue trong package java.util.concurrent.
Nhưng khi tải tăng lên hàng triệu message mỗi giây và cần độ trễ ổn định ở mức micro giây, BlockingQueue bắt đầu đuối vì 3 nguyên nhân:
- Lock Contention & Context Switch: Tranh chấp
ReentrantLock(ArrayBlockingQueuedùng một lock chung cho cả put lẫn take) buộc thread phải park, hệ điều hành phải context switch. Một context switch tốn tới vài ngàn chu kỳ CPU, chưa kể cache bị làm nguội. - Dynamic Memory Allocation & GC: Mỗi phần tử đưa vào
LinkedBlockingQueuephải tạo ra một Node wrapper object mới trên Heap, khiến Garbage Collector phải dọn liên tục. - Hiện tượng False Sharing (Chia sẻ sai): Đầu đọc (head) và đầu ghi (tail) của queue thường nằm chung trong một Cache Line của CPU (64 bytes), làm các core CPU liên tục invalidate cache của nhau qua giao thức MESI.
Để giải quyết bài toán sàn giao dịch ngoại hối cần throughput cao và độ trễ cực thấp, đội kỹ sư ở LMAX đã làm ra Disruptor. Theo bài viết của Martin Fowler, Business Logic Processor của LMAX xử lý khoảng 6 triệu lệnh mỗi giây trên một thread duy nhất.
# kiến trúc của LMAX Disruptor
Disruptor bỏ hẳn hàng đợi truyền thống và dùng một cấu trúc dữ liệu vòng tròn có kích thước cố định: RingBuffer.
# cấp phát bộ nhớ trước 100%
- Kích thước RingBuffer luôn là lũy thừa của 2 (
2^N), ví dụ: 1024, 65536,... - Toàn bộ các Event Object trong RingBuffer được khởi tạo sẵn ngay khi hệ thống startup.
- Khi Producer đẩy dữ liệu, nó không tạo object mới mà chỉ lấy object có sẵn tại slot tương ứng và gán lại các giá trị trường. Nhờ vậy hệ thống gần như không cấp phát thêm gì (Zero GC Allocation) trong lúc chạy.
# phép toán bitwise AND
Để tìm vị trí slot từ sequence number tăng dần, thay vì dùng phép chia lấy dư % (khá tốn chu kỳ CPU), Disruptor dùng bitmask:
index = sequence & (bufferSize - 1)Bitwise AND chỉ tốn khoảng 1 chu kỳ CPU, trong khi phép chia số nguyên 64-bit (thứ nằm sau %) tốn cỡ vài chục chu kỳ tuỳ đời CPU. Với bufferSize là biến chứ không phải hằng số, JIT cũng không tự đổi % thành phép AND được.
TIP
Điều kiện: Phép toán bitwise AND sequence & (bufferSize - 1) chỉ tương đương với phép modulo khi bufferSize là lũy thừa của 2 (ví dụ: 1024, 2048, 65536). Vì vậy Disruptor luôn kiểm tra và ném ngoại lệ nếu cấu hình buffer size không phải power of 2.
# chặn false sharing bằng cache line padding
CPU hiện đại không đọc từng byte từ RAM mà nạp theo từng khối Cache Line, thường là 64 bytes trên x86 (một số CPU như Apple M-series dùng 128 bytes).
Nếu biến sequence của Producer và Consumer nằm cạnh nhau trong cùng một Cache Line:
- Mỗi khi Producer tăng sequence, Core CPU 1 sẽ đánh dấu Cache Line là Invalid trên toàn bộ hệ thống bus.
- Core CPU 2 (nơi Consumer đang chạy) buộc phải nạp lại cả cache line đó (từ cache của core kia hoặc L3), dù biến của Consumer không đổi.
- Đó là False Sharing. Hai thread không hề dùng chung dữ liệu mà vẫn tranh nhau một cache line, nên code đa luồng có thể chạy chậm đi nhiều lần so với khi mỗi biến nằm trên một line riêng.
# cách Disruptor xử lý trong java
Disruptor chèn thêm vài biến dummy (long p1, p2, p3, p4, p5, p6, p7) để độn (pad) object vượt qua 64 bytes. Code dưới theo kiểu của bản 3.x; bản 4.0 thay bằng 56 field byte nhưng ý tưởng y hệt. Padding được chia qua các lớp cha/con vì JVM được tự do sắp xếp lại field trong cùng một class, nhưng field của lớp cha luôn nằm trước field của lớp con:
class LhsPadding {
protected long p1, p2, p3, p4, p5, p6, p7; // 56 bytes
}
class Value extends LhsPadding {
protected volatile long value; // 8 bytes (Core data)
}
class RhsPadding extends Value {
protected long p9, p10, p11, p12, p13, p14, p15; // 56 bytes
}
public class Sequence extends RhsPadding {
// Đảm bảo biến `value` đứng độc lập trên một Cache Line riêng biệt!
}(Java 8 có annotation @sun.misc.Contended để JVM tự pad cache line, từ Java 9 chuyển thành @jdk.internal.vm.annotation.Contended. Đây là API nội bộ: dùng cho class của mình phải chạy với -XX:-RestrictContended, và từ Java 9 còn phải --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED lúc biên dịch. Vì vậy các thư viện như Disruptor vẫn tự pad bằng tay.)
# mô hình xử lý lock-free
Thay vì synchronized hay Lock, Disruptor dùng Atomic Sequence và Memory Barriers (Unsafe / VarHandle):
Các Consumer có thể khai báo phụ thuộc nhau qua DAG (Directed Acyclic Graph):
- Consumer 1 (ghi log đĩa) và Consumer 2 (network replication) chạy song song.
- Consumer 3 (core banking logic) chỉ kích hoạt khi Consumer 1 & 2 đã xử lý xong slot đó, mà không cần Lock nào giữa các thread.
Phía Producer cũng vậy: chỉ có một producer (ProducerType.SINGLE) thì việc lấy sequence tiếp theo chỉ là tăng một biến thường, không cần CAS. Nhiều producer (ProducerType.MULTI) thì mới phải dùng CAS để giành sequence, chậm hơn một chút. Đó là lý do LMAX chuộng mô hình Single Writer: mỗi dữ liệu chỉ có một thread được ghi.
# dùng Disruptor 4.x
# khởi tạo event và factory
// Event tái sử dụng, không new mới tại runtime
public class OrderEvent {
private long orderId;
private long amountMinor; // Tiền lưu bằng long theo đơn vị nhỏ nhất, không dùng double
public void set(long orderId, long amountMinor) {
this.orderId = orderId;
this.amountMinor = amountMinor;
}
// getters...
}# cấu hình disruptor engine
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType;
import com.lmax.disruptor.YieldingWaitStrategy;
import com.lmax.disruptor.util.DaemonThreadFactory;
public class TradingEngine {
public static void main(String[] args) {
int bufferSize = 1024 * 64; // Phải là lũy thừa của 2
Disruptor<OrderEvent> disruptor = new Disruptor<>(
OrderEvent::new,
bufferSize,
DaemonThreadFactory.INSTANCE,
ProducerType.MULTI, // Hỗ trợ nhiều Producer đồng thời
new YieldingWaitStrategy() // Chiến lược chờ độ trễ thấp
);
// Đăng ký Consumer xử lý
disruptor.handleEventsWith((event, sequence, endOfBatch) -> {
// Xử lý khớp lệnh siêu tốc tại đây
System.out.println("Processing order: " + event.getOrderId());
});
disruptor.start();
// Producer đẩy lệnh
var ringBuffer = disruptor.getRingBuffer();
long seq = ringBuffer.next();
try {
OrderEvent event = ringBuffer.get(seq);
event.set(888123L, 999_950L);
} finally {
ringBuffer.publish(seq); // Luôn publish trong finally!
}
// Cách gọn hơn: publishEvent + translator, Disruptor tự lo next()/publish()
ringBuffer.publishEvent((event, sequence, id, amount) -> event.set(id, amount), 888124L, 1_000L);
// Chờ consumer xử lý hết rồi mới dừng. Thiếu dòng này thì main() kết thúc,
// JVM tắt luôn các daemon thread và có thể không event nào kịp được xử lý
disruptor.shutdown();
}
}Đoạn next() / publish() phải luôn đi cặp: đã claim một sequence mà không publish thì consumer đứng chờ ở slot đó mãi. Vì thế tài liệu Disruptor khuyên dùng publishEvent() với translator như dòng cuối. Còn YieldingWaitStrategy cho độ trễ thấp nhưng consumer gần như luôn giữ 100% một core kể cả lúc rảnh; máy ít core hoặc không cần độ trễ cỡ micro giây thì BlockingWaitStrategy (mặc định) hợp lý hơn.
# so sánh hiệu năng
Số liệu dưới lấy từ tài liệu kỹ thuật của Disruptor, đo năm 2011 trên Intel Nehalem 2.8GHz (throughput) và Core i7-2720QM 2.2GHz (độ trễ, pipeline 3 bước). Phần cứng mới cho con số cao hơn nhiều, nhưng tỉ lệ chênh lệch vẫn tương tự.
| Tiêu chí | ArrayBlockingQueue | LMAX Disruptor |
|---|---|---|
| Throughput (1P - 1C) | ~5,300,000 ops/sec | ~26,000,000 ops/sec |
| Throughput (3P - 1C) | ~5,500,000 ops/sec | ~13,400,000 ops/sec |
| Độ trễ trung bình (Mean Latency) | ~32.8 microseconds | ~0.05 microseconds (52ns) |
| P99 Latency | ~2,097 microseconds | ~0.13 microseconds (128ns) |
| GC Overhead | ArrayBlockingQueue không tạo node, nhưng LinkedBlockingQueue tạo node mỗi lần put | Không cấp phát trên hot path |
# tổng kết
Bạn có thể không bao giờ phải viết sàn ngoại hối như LMAX, nhưng hiểu nguyên lý của Disruptor sẽ đổi cách bạn nghĩ về hiệu năng:
- Mechanical Sympathy: Viết phần mềm ăn khớp với phần cứng (CPU cache, memory alignment, branch prediction).
- Lock-Free over Locks: Tránh lock nặng khi có thể thay bằng CAS hoặc kiến trúc Single-Writer.
- Zero Allocation: Tái sử dụng bộ nhớ cho các luồng xử lý hot-path để Garbage Collector đỡ phải làm việc.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.