Dùng Web Workers Xử Lý 20,000+ OHLC Ticks Mỗi Giây Mà Không Đơ UI
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Hồi còn ở Bolt Technologies, mình phụ trách màn hình trading chính của nền tảng phái sinh hàng hoá MXV. Dữ liệu OHLC đổ về qua WebSocket, hơn 20,000 ticks/giây vào giờ cao điểm. UI vẫn phải mượt.
Main thread không chịu nổi
Ban đầu pipeline rất “ngây thơ”: socket nhận tick, parse JSON, aggregate thành candle theo khung 1m, 5m, 1h, tính indicator EMA, RSI, MACD, rồi gọi setState để re-render chart. Tất cả trên main thread. Gọn gàng trên giấy.
flowchart LR
subgraph MT["Main thread"]
A["Socket nhận tick"] --> B["Parse JSON"]
B --> C["Aggregate thành candle<br/>1m, 5m, 1h"]
C --> D["Tính indicator<br/>EMA, RSI, MACD"]
D --> E["setState"]
E --> F["Re-render chart"]
end
Traffic tăng là lộ ngay. Aggregate với tính indicator cho hàng chục symbol cùng lúc tốn vài chục mili-giây mỗi lần. Đủ để main thread nghẽn. Kéo chart trễ, click order trễ. Xấu nhất là drop frame liên tục. Trading real-time mà giật thì không ai dùng.
Tách phần tính toán sang Web Worker
Tụi mình tách hết phần tính toán nặng — aggregate, indicator — sang một Web Worker riêng. Main thread chỉ còn render: đẩy data đã tính sẵn vào canvas/WebGL của TradingView.
// worker.ts
self.onmessage = (event: MessageEvent<TickBatch>) => {
const { ticks, symbol } = event.data;
const candles = aggregateToCandles(ticks); // gộp tick thành OHLC theo khung thời gian
const indicators = computeIndicators(candles); // EMA, RSI, MACD...
// Chuyển ArrayBuffer bằng transfer list để tránh copy toàn bộ buffer
const buffer = encodeToBuffer(candles, indicators);
(self as unknown as Worker).postMessage({ symbol, buffer }, [buffer]);
};
Thứ quyết định tốc độ ở đây là transferable objects. postMessage thường thì trình duyệt phải clone toàn bộ structured data giữa hai thread. Hàng nghìn candle mỗi giây thì chi phí copy còn tốn hơn cả phép tính. Encode dữ liệu vào một ArrayBuffer rồi truyền qua transfer list, tham số thứ hai của postMessage, là quyền sở hữu buffer chuyển thẳng sang thread kia. Không copy. Chỉ chuyển con trỏ.
Double-buffering để không xé hình
Có một vấn đề tinh vi hơn. Main thread đang đọc buffer để render, đúng lúc worker ghi đè dữ liệu mới vào, thì chart bị xé hình: nửa trên data cũ, nửa dưới data mới.
Double buffering giải quyết chuyện đó. Hai buffer: main thread luôn đọc từ buffer stable, worker ghi vào buffer staging. Worker ghi xong một batch hoàn chỉnh thì gửi tín hiệu, main thread hoán đổi vai trò hai buffer — swap con trỏ, không copy dữ liệu:
let stableBuffer: Float64Array;
let stagingBuffer: Float64Array;
function onWorkerMessage(newBuffer: ArrayBuffer) {
// newBuffer đã được worker tính xong hoàn chỉnh
stagingBuffer = new Float64Array(newBuffer);
// Swap: staging trở thành stable, chart sẽ render từ đây ở frame tiếp theo
[stableBuffer, stagingBuffer] = [stagingBuffer, stableBuffer];
requestAnimationFrame(() => renderChart(stableBuffer));
}
Main thread không bao giờ đọc buffer đang bị ghi dở. Render luôn nhất quán theo từng snapshot hoàn chỉnh.
Vai trò hai buffer đổi nhau mỗi batch:
flowchart LR ST["Buffer staging<br/>worker đang ghi"] --> SIG["Batch xong: gửi tín hiệu"] SIG --> SW["Swap con trỏ"] SW --> RD["Main thread render<br/>từ buffer stable"]
Batch hoá: 16ms một lần thay vì từng tick
Gửi message cho từng tick một thì overhead postMessage không nhỏ chút nào khi gọi hàng nghìn lần mỗi giây. Tụi mình gom tick vào buffer trong khoảng 16ms, tức một frame, rồi gửi theo batch. Số message giảm đáng kể. Độ trễ cảm nhận gần như không đổi, vì 16ms là ngưỡng mắt người khó nhận ra.
- Không batch: 20,000 message/giây → overhead message-passing áp đảo phần tính toán thực sự.
- Batch theo frame (~60 lần/giây): mỗi batch gộp ~300 tick, worker xử lý một lần, overhead giảm hơn 300 lần.
Nhiều symbol: một worker hay một worker mỗi symbol?
Hàng chục symbol theo dõi cùng lúc. Dùng một worker xử lý tất cả, hay mỗi symbol một worker riêng?
Tụi mình chọn worker pool cố định: 4-6 worker tuỳ số core CPU khả dụng, lấy từ navigator.hardwareConcurrency, phân phối symbol vào các worker theo round-robin. Lý do:
- Mở quá nhiều worker (một worker/symbol) gây overhead khởi tạo và context-switching giữa các thread OS.
- Một worker duy nhất xử lý tất cả lại biến nó thành bottleneck mới — không tận dụng được đa nhân.
Điểm cân bằng nằm ở đó: tận dụng đa nhân mà không đẻ ra quá nhiều thread.
Đo lại sau khi đổi
Sau khi áp dụng worker pool + transferable objects + double buffering:
- Main thread gần như rảnh hoàn toàn cho input và render — frame rate chart giữ ổn định 60fps kể cả giờ cao điểm.
- Độ trễ từ lúc tick đến lúc hiển thị trên chart giảm từ hơn 200ms (có lúc giật, drop frame) xuống dưới 30ms ổn định.
- Không còn hiện tượng xé hình dù symbol có volume giao dịch cao nhất.
Vài chỗ rút ra
Web Worker không phải cây đũa thần. Gửi message không batch, clone data thay vì transfer — overhead message-passing còn tệ hơn cả xử lý trên main thread. Còn lại hai nguyên tắc: giảm tần suất giao tiếp giữa các thread, và khi giao tiếp thì chuyển quyền sở hữu dữ liệu thay vì copy. Không chỉ trading đâu, bài toán real-time nào cũng vậy.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.