Máy của bạn chạy hàng trăm tiến trình cùng lúc, nhưng mỗi lõi CPU chỉ chạy được đúng một luồng tại một thời điểm. Ảo giác "chạy song song" đến từ việc kernel liên tục chuyển ngữ cảnh (context switch): dừng tiến trình này, lưu trạng thái của nó, nạp trạng thái tiến trình khác, cho chạy tiếp. Việc đó không miễn phí. Bài này đo chi phí một lần chuyển ngữ cảnh trong container Linux — và lần đo đầu tiên của tôi sai gấp hai mươi hai lần, vì một biến ẩn tôi quên kiểm soát.
Chuyển ngữ cảnh là gì
Khi kernel quyết định cho một tiến trình khác chạy trên một lõi đang bận, nó phải lưu toàn bộ trạng thái của tiến trình hiện tại — các thanh ghi, con trỏ lệnh, con trỏ ngăn xếp — vào bộ nhớ, rồi nạp trạng thái của tiến trình kế tiếp và nhảy vào đúng chỗ nó dừng lần trước. Nếu tiến trình kế thuộc một không gian địa chỉ khác (một chương trình khác, không phải cùng chương trình), kernel còn phải đổi bảng trang và làm mất hiệu lực bộ đệm dịch địa chỉ (TLB), khiến những lần truy cập bộ nhớ ngay sau đó chậm đi. Chi phí trực tiếp (lưu/nạp thanh ghi) cộng chi phí gián tiếp (cache và TLB nguội) là cái tôi muốn đo.
Cách đo kinh điển là pipe ping-pong: hai tiến trình chuyền qua lại một byte qua ống (pipe). Tiến trình A ghi một byte rồi đọc chờ; hành động đọc-chờ khi ống rỗng buộc kernel chuyển sang tiến trình B; B đọc byte, ghi trả lại, rồi đọc-chờ, lại buộc kernel chuyển về A. Mỗi vòng như vậy là hai lần chuyển ngữ cảnh.
Đo: 691 nano giây một lần chuyển
Tôi cho hai tiến trình ping-pong hai trăm nghìn vòng, ghim cả hai vào cùng lõi 0 (taskset -c 0) để chúng buộc phải tranh một lõi, và chia thời gian cho số lần chuyển:
Cùng lõi 0: 691 ns mỗi lần chuyển ngữ cảnh
Để biết bao nhiêu trong số đó là chi phí chuyển thật sự chứ không phải chi phí các syscall read/write, tôi đo thêm một tiến trình một mình làm write rồi read trên chính ống của nó (không có chuyển ngữ cảnh nào): 303 ns cho một cặp. Vậy một phần đáng kể của 691 ns là chi phí syscall nền (đúng như bài trước đo ~120 ns mỗi syscall), phần còn lại mới là cái giá riêng của việc chuyển ngữ cảnh và cache nguội. Con số "chi phí chuyển ngữ cảnh" vì thế không phải một số sạch — nó bó cả syscall lẫn hiệu ứng cache.
Và để chắc mình đang đo đúng chuyện chuyển ngữ cảnh chứ không phải tưởng tượng, tôi đọc bằng chứng từ getrusage: với một trăm nghìn vòng, tiến trình cha ghi nhận nvcsw (chuyển tự nguyện) = 51.923 và nivcsw (chuyển ép buộc) = 48.223 — tổng xấp xỉ đúng một trăm nghìn. Số lần chuyển khớp số vòng, xác nhận mỗi lần đọc-chờ thật sự tạo một lần chuyển ngữ cảnh.
Một lần tôi đo hớ: quên ghim lõi, đo nhầm đại lượng
Đây là chỗ tôi vấp, và nó là một bài học kinh điển của đo lường hệ thống. Lần chạy đầu tiên, tôi không ghim lõi — cứ để kernel tự đặt hai tiến trình đâu tùy ý. Kết quả: ~15.000 ns mỗi "lần chuyển", và tôi suýt viết thẳng vào bài "một lần chuyển ngữ cảnh tốn 15 micro giây".
May là con số đó vô lý đến mức phải dừng lại — nó lớn gấp hơn hai mươi lần con số ~700 ns mà tài liệu hệ điều hành hay nói. Máy này có mười lõi. Khi tôi không ghim, kernel thảnh thơi đặt hai tiến trình lên hai lõi khác nhau — và khi đó không có lần chuyển ngữ cảnh nào cả. Cả hai chạy song song thật trên hai lõi; mỗi lần A ghi vào ống, kernel phải đánh thức B đang ngủ trên một lõi khác (một tín hiệu liên-lõi), và dòng cache chứa dữ liệu ống phải bật qua lại giữa hai lõi qua hệ thống nhất quán cache. Cái tôi đo được không phải chuyển ngữ cảnh, mà là độ trễ đánh thức xuyên lõi cộng lưu lượng cache coherency — một đại lượng hoàn toàn khác, và đắt hơn nhiều.
Ghim cả hai vào một lõi (ép chúng tranh nhau đúng một CPU) mới đo ra chuyển ngữ cảnh thật: 691 ns, ít hơn hai mươi hai lần. Ghim vào hai lõi khác nhau tường minh cũng cho ~14.000 ns, xác nhận: khoảng cách khổng lồ đó đến từ lõi nào chạy, không phải từ bản thân việc chuyển.
Bài học đo lường: biến ẩn "lõi nào chạy" không chỉ đổi con số, nó đổi chính đại lượng mình đang đo. Cùng một đoạn mã ping-pong, cùng một phép chia, mà ra hai kết quả chênh nhau hai mươi hai lần — đó là dấu hiệu kinh điển của việc đo nhầm thứ. Muốn đo chuyển ngữ cảnh, tôi phải ép hai tiến trình tranh cùng một lõi; nếu không, tôi đo một thứ khác mang tên khác. Kiểm soát biến ẩn (taskset) và kiểm chứng bằng bằng chứng (getrusage) là cách duy nhất để chắc con số nói về điều mình nghĩ.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chuyển ngữ cảnh quá nhiều là một chi phí ẩn giết hiệu năng. Một hệ thống tạo quá nhiều luồng so với số lõi, hoặc có nhiều luồng liên tục chặn-rồi-thức (chờ khóa, chờ I/O nhỏ), sẽ tiêu tốn hàng trăm nano giây mỗi lần chuyển, nhân với hàng trăm nghìn lần mỗi giây — thành gánh nặng CPU thuần túy không làm việc hữu ích nào. Khi thấy %sys cao trong top hay số context switch lớn trong vmstat/pidstat, đó là dấu hiệu cần giảm số luồng hoặc gom công việc lại.
Hệ quả thứ hai: luồng rẻ hơn tiến trình khi chuyển, vì chung không gian địa chỉ. Chuyển giữa hai luồng của cùng một tiến trình không cần đổi bảng trang hay xả TLB — chúng dùng chung ánh xạ bộ nhớ — nên rẻ hơn chuyển giữa hai tiến trình khác nhau. Đây là một lý do (ngoài việc chia sẻ dữ liệu) khiến các máy chủ hiệu năng cao thường dùng nhiều luồng trong một tiến trình thay vì nhiều tiến trình. Và ghim luồng vào lõi (affinity) giúp giữ cache nóng, giảm cả chi phí chuyển lẫn chi phí cache nguội.
Hệ quả thứ ba là bài học đo lường mang theo: khi đo một cơ chế hệ điều hành, phải kiểm soát nơi nó chạy, nếu không sẽ đo nhầm thứ. Con số mang theo: một lần chuyển ngữ cảnh giữa hai tiến trình trên cùng một lõi tốn khoảng 691 ns (đã gồm cả syscall pipe ~300 ns), xác nhận bằng getrusage; nhưng nếu không ghim lõi, hai tiến trình chạy trên hai lõi khác nhau và phép đo biến thành ~15.000 ns của đánh thức xuyên lõi cộng cache — một đại lượng hoàn toàn khác. Cùng một thí nghiệm, hai kết quả chênh hai mươi hai lần: biến ẩn không nằm ở con số, mà ở chính câu hỏi bạn tưởng mình đang trả lời.
Thử ba mươi giây
Chạy vmstat 1 trên máy của bạn và nhìn cột cs (context switches per second) — trên một máy nhàn rỗi nó có thể là vài nghìn, dưới tải nặng có thể lên hàng trăm nghìn hay hàng triệu. Rồi thử pidstat -w 1 để xem tiến trình nào đang chuyển ngữ cảnh nhiều nhất, tách cswch/s (tự nguyện, thường do chờ I/O hay khóa) khỏi nvcswch/s (ép buộc, do hết lượt CPU). Nếu một tiến trình có cswch/s cao ngất, nó đang chặn-rồi-thức liên tục — thường là dấu hiệu của khóa quá mịn hay I/O quá nhỏ lẻ, và mỗi lần chuyển đó là vài trăm nano giây CPU trôi đi. Đó là chi phí ẩn mà bài này đo, hiện ra ngay trong một lệnh.