Khi ai đó nói "chương trình chạy mất 10 giây", họ đang nói về thời gian nào? Nghe như một câu hỏi thừa, nhưng hệ điều hành đo tới hai loại thời gian rất khác nhau, và lẫn chúng dẫn tới tối ưu sai chỗ. Tôi đo cả hai trong container — và một con số nghe bất khả suýt làm tôi tưởng phép đo hỏng.

Thời gian CPU và tường

Hai loại thời gian

Thời gian tường (wall/real time) là số giây thật sự trôi qua, đúng như một cái đồng hồ treo tường: bạn bấm giờ lúc bắt đầu, bấm lúc kết thúc, hiệu là thời gian tường. Đây là thời gian người dùng cảm nhận.

Thời gian CPU là số giây mà CPU thật sự dành cho tiến trình của bạn, và nó tách thành hai phần: user time (thời gian chạy code của bạn) và system time (thời gian nhân chạy thay bạn, tức trong các lời gọi hệ thống). Đây là thời gian tính toán, không phải thời gian trôi qua.

Điểm mấu chốt là hai loại này không bằng nhau, và tỉ lệ giữa chúng cho biết chương trình đang làm gì. Tôi đo bằng getrusage (lấy user/sys time) và clock_gettime (lấy wall time) qua bốn tình huống, ghim vào các lõi.

Đo: bốn tình huống, bốn tỉ lệ

Tình huống (~1s)   | wall  | user | sys  | CPU=user+sys | %CPU
sleep 1 giây       | 1,01s | 0,00 | 0,00 | 0,00s        |   0%
busy 1 luồng       | 1,00s | 1,00 | 0,00 | 1,00s        | 100%
busy 4 luồng       | 1,00s | 4,00 | 0,00 | 4,00s        | 400%
I/O nặng (write)   | 1,00s | 0,36 | 0,64 | 1,00s        | 100%

Bốn dòng này là bốn kiểu chương trình. sleep trôi 1 giây tường nhưng dùng 0 giây CPU — khi một tiến trình chờ (ngủ, hay chờ I/O), nó nhường CPU và không tính toán gì; thời gian trôi mà CPU rảnh. Một luồng bận dùng đúng 1 giây CPU trong 1 giây tường: %CPU = 100%, một lõi chạy hết công suất. I/O nặng cũng ~100% CPU, nhưng để ý user chỉ 0,36 còn sys tới 0,64 — phần lớn thời gian CPU đổ vào nhân để thực hiện hàng nghìn lời gọi write, không phải vào code của tôi. Và dòng làm tôi khựng lại: bốn luồng bận báo %CPU = 400%.

Một lần tôi đo hớ: 400% CPU nghe bất khả, nhưng đúng

Thấy %CPU = 400% — tức CPU time 4 giây trong khi wall chỉ 1 giây — phản xạ đầu tiên của tôi là "phép đo hỏng rồi". Làm sao một chương trình dùng nhiều thời gian hơn thời gian có? Trong 1 giây tường thật, làm sao tiêu thụ 4 giây? Nghe như một con số bất khả tố cáo công cụ đang nói dối.

Nhưng lần này con số bất khả lại đúng, và cái sai nằm ở mô hình trong đầu tôi. Thời gian CPU được cộng dồn trên mọi lõi: bốn luồng chạy song song trên bốn lõi khác nhau, mỗi lõi góp một giây-CPU trong cùng một giây tường — tổng lại là bốn giây-CPU tiêu thụ trong một giây tường. %CPU > 100% không phải lỗi; nó là cách các lõi song song cộng lại, và trên máy nhiều lõi bạn sẽ thấy nó thường xuyên (một chương trình dùng cả 10 lõi báo ~1000% trong top).

Đây là một bài học đo lường tinh tế: một con số "bất khả" đôi khi không phải công cụ nói dối, mà là giả định của bạn về ý nghĩa con số bị sai. %CPU không phải "phần trăm thời gian", mà là "số giây-CPU trên mỗi giây tường, nhân 100" — và với song song nó vượt 100% một cách hoàn toàn hợp lệ. Khi một con số phá vỡ trực giác, hãy hỏi lại chính xác nó đo cái gì trước khi kết luận phép đo sai.

Đo loại nào bằng cái gì

Vì có hai loại thời gian, có hai họ công cụ để đo, và dùng nhầm cho ra số vô nghĩa. Đo thời gian tường dùng đồng hồ đơn điệu — clock_gettime(CLOCK_MONOTONIC) trong C, time.monotonic() trong Python, System.nanoTime() trong Java — như bài gettimeofday đã đo, nó không nhảy khi giờ hệ thống chỉnh. Đo thời gian CPU của tiến trình dùng getrusage (tách user/sys), times(), hay clock_gettime(CLOCK_PROCESS_CPUTIME_ID) — chính cái đồng hồ giờ-CPU đắt hơn mà bài vDSO đã đo (nó phải vào nhân, không đi qua vDSO như đồng hồ tường). Cái bẫy kinh điển: dùng clock() của C tưởng đo wall time, nhưng clock() đo CPU time — nên với một chương trình sleep, clock() trả gần 0 (đúng như dòng sleep trong phép đo), khiến người ta tưởng sleep "tức thì". Muốn đo thời gian người dùng chờ, phải dùng đồng hồ tường; muốn đo tải tính toán, phải dùng đồng hồ CPU — chọn nhầm là đo nhầm đại lượng ngay từ đầu.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: "mất bao lâu" phải hỏi "wall hay CPU". Đây là gốc của rất nhiều tối ưu sai chỗ. Nếu một tác vụ có wall time cao nhưng CPU time thấp (như sleep, hay chờ mạng/đĩa), nó nghẽn I/O — tối ưu thuật toán, làm code chạy nhanh hơn chẳng giúp gì, vì CPU vốn đã rảnh; phải sửa phần I/O (giảm số lần gọi, làm song song, dùng cache). Ngược lại, nếu CPU time ≈ wall time và cao, nó nghẽn CPU — lúc này tối ưu tính toán mới đáng. Nhìn nhầm loại thời gian là đổ công vào đúng chỗ không giúp được.

Hệ quả thứ hai: tách user với sys để biết thời gian đổ vào đâu. User time cao nghĩa là code bạn tính toán nhiều — tối ưu thuật toán. Sys time cao (như tình huống I/O trên) nghĩa là chương trình gọi syscall quá nhiều — tối ưu bằng cách gom lô, giảm số lời gọi (đúng như bài chi phí syscallpipe đã đo, mỗi syscall có phí cố định). Một chương trình "chậm" với sys time cao thường không cần thuật toán tốt hơn, mà cần ít lời gọi hệ thống hơn.

Hệ quả thứ ba là cách đọc %CPU cho đúng: %CPU là chỉ số song song, không phải "mức bận". Thấy một tiến trình ở 100% trong top không có nghĩa nó "chiếm cả máy" — trên máy 10 lõi, 100% là một lõi, còn chín lõi khác vẫn rảnh; muốn dùng hết máy nó phải lên gần 1000%. Con số mang theo: thời gian tường là giây thật trôi qua, thời gian CPU (user+sys) là giây-CPU tiêu thụ cộng dồn trên các lõi — chờ thì CPU~0 dù wall trôi, N luồng song song thì CPU = N×wall (%CPU vượt 100% là bình thường), và sys cao nghĩa là tốn thời gian trong nhân chứ không phải trong code bạn. Trước khi tối ưu "cho nhanh", hãy đo cả hai loại thời gian và xem chương trình thật sự nghẽn ở đâu.

Thử ba mươi giây

Dùng lệnh time để thấy cả ba con số cùng lúc: time <lệnh> in ra real (wall), user, và sys. Thử time sleep 2 — bạn sẽ thấy real 2.0s nhưng usersys gần 0 (chờ, không dùng CPU). Rồi time sh -c 'yes | head -c 200000000 > /dev/null' (một tải nặng I/O/CPU) và so ba con số. Trên máy nhiều lõi, chạy một chương trình đa luồng dưới time và bạn sẽ thấy user + sys lớn hơn real — chính là hiệu ứng CPU-cộng-dồn-qua-lõi mà bài này đo, hiện ra ngay trên dòng lệnh: một chương trình "tốn" nhiều giây CPU hơn số giây nó thật sự chạy.