Hình dung một event loop như một người phục vụ chạy giữa cả một dãy bàn: nhanh đến kinh ngạc chừng nào không bao giờ đứng lại. Ghi món bàn này, bưng nước bàn kia, dọn bàn nọ — mỗi việc một tí rồi đi tiếp. Nhưng cái ngày anh ta dừng lại ở một bàn để tự đứng đợi bếp làm xong món, thì cả dãy bàn đứng hình — không phải chỉ bàn đó. Phần 1 kết bằng một câu: bốn mươi chín luồng phục vụ năm nghìn kết nối chỉ đúng khi không handler nào chặn. Bài này đo xem "chặn" tốn bao nhiêu — và con số lớn hơn tôi tưởng.
Luật
// ĐƯỢC — trả quyền điều khiển lại cho event loop ngay
vertx.setTimer(50, id -> req.response().end("ok"));
// KHÔNG ĐƯỢC — giữ luôn event loop 50 mili giây
Thread.sleep(50);
req.response().end("ok");
Hai đoạn trên cho người dùng cùng một trải nghiệm: trả lời sau 50 ms. Khác biệt là đoạn thứ hai giữ một trong mười sáu event loop của máy chủ suốt thời gian đó, còn đoạn đầu không giữ gì cả.
Đo
Máy chủ có hai endpoint: /nhanh trả lời ngay, /chan gọi Thread.sleep(50). Tôi đo thông lượng của /nhanh ở 500 kết nối, trong khi một dòng nhỏ khách khác gõ vào /chan.
Với 16 bản sao (16 event loop):
Số kết nối gọi /chan |
Thông lượng /nhanh |
p50 |
|---|---|---|
| 0 | 95 424 req/s | 4,6 ms |
| 16 | 9 979 req/s | 54,0 ms |
| 64 | 2 388 req/s | 162,3 ms |
Với 1 bản sao (1 event loop):
Số kết nối gọi /chan |
Thông lượng /nhanh |
p50 |
|---|---|---|
| 0 | 113 485 req/s | 4,1 ms |
| 4 | 1 937 req/s | 217,7 ms |
| 16 | 339 req/s | 1 660,2 ms |
Bốn kết nối. Không phải bốn nghìn — bốn. Chúng làm thông lượng của mọi người khác sập 59 lần.
Phép tính giải thích chính xác con số đó
Không cần đoán, chỉ cần nhân:
16 kết nối × (1 request ÷ 0,05 giây) = 320 request/giây vào /chan
320 request/giây × 50 ms = 16 giây thời-gian-event-loop mỗi giây
16 giây ÷ 16 event loop = 100% bão hoà
Đúng bằng lúc /nhanh tụt từ 95 nghìn xuống 10 nghìn req/s. Với một event loop và bốn kết nối:
4 × 20 = 80 request/giây × 50 ms = 4 giây mỗi giây trên MỘT loop = quá tải 400%
Bán kính sát thương chia theo số bản sao
So hai bảng: cùng 16 kết nối chặn, một bản sao cho 339 req/s còn mười sáu bản sao cho 9 979 req/s — gấp 29 lần. Tăng số bản sao chia nhỏ thiệt hại, nhưng không xoá nó.
Và phần 2 đã đo rằng với handler không chặn, tăng bản sao gần như không mua thêm được gì (1 bản sao đạt 86% thông lượng của 16). Nên setInstances không phải cách chữa — nó chỉ làm vết thương nông hơn.
Cái gì tính là "chặn"
Danh sách này dài hơn người ta tưởng, và phần lớn không có chữ sleep nào:
| Việc | Vì sao chặn |
|---|---|
| JDBC, JPA, Hibernate | Mọi driver JDBC đều đồng bộ |
File.readAllBytes, InputStream.read |
I/O tệp của thư viện chuẩn |
HttpURLConnection, RestTemplate, OkHttp đồng bộ |
Chờ mạng trên luồng gọi |
future.get(), latch.await() |
Chờ trên chính event loop |
| Băm mật khẩu (BCrypt), nén, phân tích ảnh | Tốn CPU hàng chục mili giây |
| Log đồng bộ ra tệp chậm | Ghi đĩa nằm trong lời gọi |
Dòng cuối là dòng hay bị bỏ sót nhất: một appender ghi thẳng ra tệp trên đĩa chậm biến mọi lời gọi log.info thành một lần chặn nhỏ, nhân với mọi request.
Cách tự bắt cũng nhanh: bọc đoạn nghi ngờ trong một phép đo thời gian, và kêu lên nếu nó giữ event loop quá lâu:
long t0 = System.nanoTime();
// ... doan ma ban nghi ngo ...
long ms = (System.nanoTime() - t0) / 1_000_000;
if (ms > 5 && Context.isOnEventLoopThread())
log.warn("Chặn event loop {} ms tại {}", ms, "tên-chỗ-này");
Năm mili giây là ngưỡng đáng nghi trong một handler.
Ba bài tới nói về cách chạy đúng những việc này: worker verticle và executeBlocking ở bài 5, và các client không chặn ở chặng sau.
Mẫu số chung
Một mô hình dùng chung một người làm cho rất nhiều việc đánh đổi tính cô lập để lấy đồng thời giá rẻ: trên event loop, bán kính sát thương của một lời gọi chặn là mọi kết nối cùng chia sẻ cái loop đó, chứ không phải riêng kẻ gọi — ngược hẳn với một-luồng-mỗi-request, nơi một request kẹt chỉ tốn 1 trong 200 luồng. Đó là giao kèo của lập lịch hợp tác (cooperative scheduling): ít luồng, không tốn ngăn xếp cho mỗi request, tốc độ chóng mặt — đổi lại một việc không chịu nhường là đóng băng mọi hàng xóm của nó. Đúng luật ấy trong event loop của Node, trong một luồng duy nhất của Redis (một lệnh KEYS chạy trên production là đủ), trong luồng giao diện của một app làm I/O, trong một chương trình Go ghim vào một nhân. "Đừng chặn" không phải một lời khuyên về phong cách — nó là bất biến mà cả mô hình đứng trên đó.
Điều thứ hai: "chặn" là một phạm trù rộng hơn sleep rất nhiều, và bạn không đọc nó ra được từ cú pháp. JDBC, đọc tệp, future.get(), BCrypt, thậm chí một dòng log ghi đồng bộ — tất cả đều chặn, và những cái nguy hiểm nhất nấp trong các lời gọi thư viện trông vô hại, không một chữ sleep. Câu hỏi đáng tin duy nhất là "lời gọi này nhường quyền, hay giữ luôn cái luồng cho tới khi xong?" — và cái giá vô hình cho tới khi tải ập tới, lúc đó một phép nhân một dòng (số kết nối × thời gian phục vụ so với công suất loop) tiên đoán cú sập tới từng chữ số. Biết mỗi lời gọi làm gì với cái luồng, đừng nhìn nó trông như thế nào.
Bài sau đo xem công cụ có sẵn của Vert.x phát hiện chuyện này từ mốc nào — câu trả lời là muộn hơn bạn cần rất nhiều.