Hệ điều hành & Tiến trình/Ready, running, blocked — và giá của context switch
17/28
Bài 17 / 28~13 phútThread & Lập lịch CPUMiễn phí lượt xem

Ready, running, blocked — và giá của context switch

Ba trạng thái của thread/tiến trình, chuyện gì xảy ra khi chờ I/O, và context switch tốn gì thật sự: lưu register, mất cache nóng, flush TLB.

TL;DR: Một thread không phải lúc nào cũng chạy. Nó luân phiên qua ba trạng thái: running (đang chiếm một CPU core), ready (sẵn sàng chạy nhưng đang chờ tới lượt vì core bận), và blocked (đang chờ một sự kiện — thường là I/O — nên tạm thời không cần CPU). Điểm quan trọng nhất: thread blocked không tốn CPU — kernel lấy nó ra khỏi core và cho thread khác chạy, đó là lý do một server có thể phục vụ hàng nghìn kết nối đang chờ mạng chỉ với vài core. Mỗi lần đổi thread trên một core là một context switch: kernel lưu register của thread cũ, nạp register của thread mới. Chi phí trực tiếp (lưu/khôi phục register) chỉ vài µs, nhưng chi phí gián tiếp thường lớn hơn: thread mới bắt đầu với cache lạnh và TLB có thể vừa bị flush, nên vài chục nghìn lần truy cập bộ nhớ đầu tiên chậm hơn bình thường.

Bạn chạy một service, và khi soi bằng vmstat 1, cột cs (context switches per second) báo 80.000. Bạn nghĩ "app mình có mấy chục thread thôi mà, sao đổi tới 80.000 lần mỗi giây?". Rồi bạn nhận ra: mỗi context switch tốn vài µs trực tiếp, cộng thêm phần cache và TLB nguội đi sau đó. 80.000 lần một giây, mỗi lần vài µs, là hàng trăm mili-giây CPU mỗi giây chỉ để đổi việc — chưa làm được việc gì có ích. Đây là chi phí ẩn mà người ta hay quên khi thấy app chậm và phản xạ "thêm thread đi".

Bài này trả lời hai câu: một thread đi qua những trạng thái nào và vì sao chờ I/O không đốt CPU; và mỗi lần đổi thread trên một core thật sự tốn những gì — không chỉ lưu register, mà cả cache nóng và TLB bạn đã học ở khoá Bộ nhớ. Học xong, bạn Trace được vòng đời một thread qua ready/running/blocked và định giá được cái mất thật của mỗi lần chuyển context.

1. Analogy — bếp ăn một lò

Hình dung một bếp ăn chỉ có một lò (một CPU core) và nhiều đầu bếp (thread) cùng muốn nấu.

  • Đầu bếp đang đứng ở lò xàorunning — chiếm lò, đang làm việc thật.
  • Đầu bếp đã sẵn nguyên liệu, đứng xếp hàng chờ lò trốngready — làm được ngay nếu tới lượt, chỉ thiếu chỗ.
  • Đầu bếp đã đặt ship nguyên liệu và đang chờ shipper giaoblocked — chưa làm gì được cho tới khi hàng tới, nên nhường lò cho người khác thay vì đứng ì chiếm chỗ.

Điểm hay của bếp thông minh: khi một đầu bếp phải chờ shipper (blocked), quản lý bếp không để lò trống — kéo ngay một đầu bếp đang xếp hàng (ready) vào lò. Nhờ vậy một lò phục vụ được nhiều đầu bếp, miễn là ai cũng có lúc phải chờ ship. Đó chính là vì sao vài core chạy được hàng trăm thread I/O-bound.

Nhưng mỗi lần đổi người ở lò tốn công: người cũ phải cất dụng cụ, ghi lại đang nấu tới bước nào; người mới bày dụng cụ ra, đọc lại công thức. Đó là context switch — và cái tốn nhất không phải động tác cất/bày (register), mà là người mới phải "làm nóng" lại: nêm nếm lại, tìm lại đồ trong bếp (cache và TLB nguội).

Đời thườngKhái niệm
Đầu bếp đang ở lòThread running
Đầu bếp xếp hàng chờ lò trốngThread ready
Đầu bếp chờ shipper giao nguyên liệuThread blocked (chờ I/O)
Quản lý kéo người xếp hàng vào lò khi có người phải chờ shipScheduler cho thread khác chạy khi một thread block
Đổi người ở lò: cất/bày dụng cụ, làm nóng lạiContext switch (trực tiếp + gián tiếp)
💡 Cách nhớ

Running = đang trên core. Ready = muốn chạy, thiếu core. Blocked = không muốn core (đang chờ sự kiện). Chỉ running mới tiêu CPU; blocked thì nhường CPU chứ không phí nó.

2. Ba trạng thái và các chuyển tiếp

Một thread (và một tiến trình nói chung) luôn ở đúng một trong ba trạng thái, và di chuyển giữa chúng theo các chuyển tiếp có tên:

stateDiagram-v2
    [*] --> Ready: tao thread
    Ready --> Running: scheduler chon (dispatch)
    Running --> Ready: het time slice (preempt)
    Running --> Blocked: goi I/O / cho su kien
    Blocked --> Ready: I/O xong / su kien den
    Running --> [*]: ket thuc (exit)

Đọc từng chuyển tiếp:

  • Ready → Running (dispatch): scheduler chọn một thread ready và trao nó một core. Đây là lúc một context switch xảy ra để nạp trạng thái thread được chọn.
  • Running → Ready (preempt): thread đang chạy hết phần thời gian được cấp (time slice — bài 03), timer interrupt cướp CPU, thread bị đẩy về hàng ready dù nó vẫn muốn chạy tiếp. Nó không làm gì sai; chỉ là tới lượt người khác.
  • Running → Blocked: thread tự nguyện rời CPU vì gọi một thao tác phải chờ — đọc file, chờ gói tin mạng, chờ một lock. Nó không thể tiến thêm cho tới khi sự kiện xảy ra, nên chờ trên core là vô nghĩa.
  • Blocked → Ready: sự kiện thread chờ đã tới (dữ liệu I/O sẵn sàng, lock được nhả). Thread chưa chạy ngay — nó quay lại hàng ready và chờ scheduler chọn. Đây là chỗ nhiều người nhầm: I/O xong không có nghĩa là thread chạy tức thì.
  • Running → Exit: thread hoàn thành, được thu dọn.

Chú ý: không có chuyển tiếp trực tiếp Blocked → Running. Một thread vừa được đánh thức luôn phải đi qua Ready trước — vì có thể mọi core đang bận với thread khác.

3. Vì sao blocked không tốn CPU?

Đây là ý quan trọng nhất của bài, và là nền cho bài 04 (chọn số thread).

Khi thread gọi một thao tác chờ, ví dụ đọc từ socket chưa có dữ liệu:

int n = socket.getInputStream().read(buffer);   // chua co du lieu -> BLOCK

Kernel không để thread "quay vòng chờ" (busy-wait) đốt CPU. Thay vào đó:

  1. Kernel chuyển thread sang trạng thái blockedlấy nó ra khỏi core.
  2. Kernel ghi nhận: "khi dữ liệu tới trên socket này, đánh thức thread đó".
  3. Scheduler chọn một thread ready khác và trao core cho nó. Core không hề rảnh — nó làm việc có ích cho thread khác.
  4. Khi card mạng nhận dữ liệu, phần cứng phát một interrupt; kernel xử lý và chuyển thread từ blocked về ready. Tới lượt, nó chạy tiếp từ đúng dòng read().

Hệ quả trực tiếp: thời gian một thread nằm blocked không tính vào thời gian CPU của nó. Một server có 1.000 kết nối, phần lớn đang chờ mạng, chỉ cần vài core — vì tại mỗi thời điểm chỉ một nhúm thread thật sự cần CPU, số còn lại đang blocked, không tranh core. Đây chính là lý do workload I/O-bound hưởng lợi từ việc có nhiều thread hơn số core (bài 04 sẽ định lượng).

Blocking khác busy-waiting

"Chờ I/O không tốn CPU" chỉ đúng với blocking thật (thread rời core, kernel đánh thức sau). Nếu bạn tự viết vòng lặp while (!ready) {} để chờ, thread vẫn runningđốt 100% một core để không làm gì — đó là busy-wait, ngược hẳn với blocking. Luôn dùng cơ chế chờ của thư viện/OS (blocking read, wait(), queue) để thread thật sự nhường core, đừng quay vòng bằng tay.

4. Context switch — phần trực tiếp

Mỗi lần một core chuyển từ thread này sang thread khác là một context switch. Phần "hiển nhiên" của nó là lưu và khôi phục trạng thái CPU:

  1. Lưu context thread cũ: kernel chép toàn bộ register hiện tại (program counter, stack pointer, các register dữ liệu) vào cấu trúc mô tả thread cũ (trong kernel). Đây là ảnh chụp "thread cũ đang ở đâu" để sau này chạy tiếp đúng chỗ.
  2. Chọn thread mới: scheduler quyết ai chạy tiếp (bài 03).
  3. Khôi phục context thread mới: nạp register đã lưu của thread mới vào CPU. Nếu là process khác, còn phải đổi con trỏ trỏ tới bảng ánh xạ trang (page table) sang address space của process mới.
  4. CPU tiếp tục thực thi thread mới từ đúng lệnh nó dừng lần trước.

Phần này — thuần lưu/nạp register và chuyển vào kernel rồi ra — rẻ về mặt trực tiếp: cỡ vài µs trên CPU hiện đại (biên độ, tuỳ kiến trúc và kernel; đo được bằng lmbench hay microbenchmark). Nếu context switch chỉ có phần này, 80.000 lần/giây cũng không quá đáng ngại.

Nhưng câu chuyện chưa hết. Chi phí thật nằm ở phần không nhìn thấy trong danh sách trên.

5. Chi phí gián tiếp — cache lạnh và TLB flush

Đây là phần nối thẳng về khoá Bộ nhớ, và là lý do context switch đắt hơn con số "vài µs" gợi ý.

Khi thread cũ chạy, nó đã "làm nóng" cache: dữ liệu và lệnh nó hay dùng nằm sẵn trong L1/L2/L3 (cache và độ trễ). Nó cũng đã nạp các ánh xạ địa chỉ ảo → vật lý vào TLB (MMU và TLB). Khi context switch sang thread mới:

  • Cache trở nên "lạnh" với thread mới. Thread mới có bộ dữ liệu riêng, chưa nằm trong cache. Những lần truy cập đầu của nó là cache miss — mỗi miss xuống RAM tốn cả trăm ns thay vì vài ns. Và tệ hơn: thread mới chạy dần dần đẩy dữ liệu của thread cũ ra khỏi cache, nên khi thread cũ được chạy lại, cũng phải làm nóng cache từ đầu.
  • TLB có thể bị flush. Nếu đổi sang một process khác (address space khác), các ánh xạ TLB của process cũ không còn đúng nữa. Nhiều kiến trúc phải xoá (flush) TLB, và thread mới phải nạp lại từng ánh xạ qua page walk (kernel/MMU dò bảng trang nhiều tầng để dịch địa chỉ) — mỗi lần vài chục ns cho tới khi TLB nóng lại.

Chi phí gián tiếp này không cố định — nó phụ thuộc working set (lượng dữ liệu nóng) của các thread. Với thread có working set lớn, việc làm nóng lại cache có thể tốn hàng chục µs, lớn hơn phần trực tiếp nhiều lần. Đây là lý do OSTEP nhấn mạnh rằng chi phí context switch không chỉ là lưu/khôi phục register, mà gồm cả "chi phí làm nguội cache và TLB" — và chính chi phí này khiến time slice không nên quá ngắn (bài 03).

flowchart LR
  A["Thread A chay<br/>cache + TLB nong cho A"] -->|context switch| B["Thread B chay<br/>cache lanh, TLB co the flush"]
  B -->|"B lam viec"| C["Cache dan nong cho B<br/>day du lieu cua A ra"]
  C -->|context switch| D["Thread A chay lai<br/>cache lai lanh cho A"]

Nói cách khác: mỗi context switch không chỉ "mất vài µs", mà còn để lại một cái đuôi — thread vừa vào chạy chậm hơn bình thường trong một quãng ngắn vì phải làm nóng lại cache/TLB. Càng switch nhiều, càng ít thời gian chạy ở tốc độ đầy đủ.

6. Vì sao đổi thread cùng process rẻ hơn đổi process?

Nhớ lại bài 01: thread cùng process chia sẻ address space. Điều này biến thành lợi thế cụ thể ở context switch.

Loại chuyển đổiĐổi address space?TLB flush?Chi phí tương đối
Giữa hai thread cùng processKhông (chung page table)KhôngThấp hơn
Giữa hai processCó (đổi page table)Thường cóCao hơn

Khi đổi giữa hai thread cùng process, page table không đổi, nên các ánh xạ trong TLB vẫn đúng — không cần flush. Cache dữ liệu cũng có thể còn dùng lại được phần nào vì hai thread chia sẻ heap. Khi đổi giữa hai process khác nhau, phải trỏ CPU sang page table mới, và (trên nhiều kiến trúc) flush TLB — thread mới nạp lại ánh xạ từ đầu.

CPU hiện đại giảm nhẹ đòn TLB flush

Các CPU x86-64 và ARM hiện đại gắn tag định danh address space vào mỗi mục TLB (Intel gọi là PCID, ARM gọi là ASID). Nhờ đó khi đổi process, kernel không cần flush sạch TLB — mục TLB của process cũ được giữ lại, chỉ tạm không khớp với tag address space đang active (không phải bị xoá), nên khi quay lại nó vẫn còn nóng. Điều này giảm chi phí, nhưng không xoá bỏ: cache vẫn bị làm nguội, và áp lực TLB vẫn cao hơn so với đổi thread cùng process. Kết luận thực dụng vẫn đúng: đổi thread cùng process nhẹ hơn đổi process.

7. Pitfall của riêng concept này

Nhầm 1 — tưởng thread blocked vẫn đốt CPU:

✅ Thread blocked bị kernel lấy khỏi core; nó không tiêu CPU trong lúc chờ. Bằng chứng: mở top, chạy một chương trình đang sleep hay chờ mạng — cột CPU của nó gần 0. Chỉ khi bạn busy-wait (while (!ready) {}) thread mới vừa "chờ" vừa đốt 100% core. Blocking đúng cách nhường CPU; busy-wait thì không.

Nhầm 2 — tưởng I/O xong là thread chạy lại ngay lập tức:

✅ Blocked → Running không có đường trực tiếp. Khi I/O xong, thread về ready và chờ scheduler chọn. Nếu các core đang bận, nó xếp hàng — độ trễ đánh thức (wakeup latency) là có thật và tăng khi hệ thống tải nặng. Đây là một nguồn latency đuôi (tail latency) mà người ta hay bỏ sót.

Nhầm 3 — tưởng "thêm thật nhiều thread" luôn tăng thông lượng:

CPU-bound task, may 4 core:
  4 thread  -> 4 core chay full, it context switch
  400 thread -> van chi 4 core chay, nhung context switch tang vot
             -> phan lon thoi gian dot vao switch + lam nong lai cache

✅ Với workload CPU-bound, thêm thread quá số core chỉ làm tăng context switch — mỗi switch tốn trực tiếp + làm nguội cache — mà không thêm sức tính toán (vẫn 4 core). Thông lượng giảm. Bài 04 định lượng vì sao, và khi nào thêm thread mới thật sự giúp (I/O-bound).

8. 📚 Deep Dive

📚 Deep Dive (tuỳ chọn)

Sách / man page chính thức:

  • OSTEP — Scheduling: Introduction — chương giải thích trạng thái, preemption và vì sao chi phí context switch (gồm làm nguội cache/TLB) ảnh hưởng tới việc chọn độ dài time slice. Nền cho bài 03.
  • OSTEP — The Abstraction: The Process — hình process state machine (ready/running/blocked) và bảng trạng thái đầy đủ.
  • sched(7) — tổng quan cách Linux điều phối CPU (dùng lại ở bài 03).

Ghi chú: con số "vài µs trực tiếp" và "hàng chục µs gián tiếp" là biên độ để định hướng, không phải hằng số cho mọi máy — chi phí gián tiếp phụ thuộc mạnh vào working set của thread. Muốn đo trên máy mình: perf stat -e context-switches,cache-misses <chương trình>, hoặc benchmark hai thread ping-pong qua pipe (cách lmbench đo lat_ctx).

9. Liên hệ các bài khác

10. Tóm tắt

  • Thread luôn ở một trong ba trạng thái: running (trên core), ready (chờ tới lượt), blocked (chờ sự kiện).
  • Chuyển tiếp có tên: dispatch (ready→running), preempt (running→ready), block (running→blocked), wakeup (blocked→ready). Không có Blocked → Running trực tiếp.
  • Blocked không tốn CPU: kernel lấy thread khỏi core và chạy thread khác; nền để hiểu I/O-bound scaling.
  • Busy-wait (while quay vòng) thì vẫn running và đốt core — ngược với blocking đúng cách.
  • Context switch trực tiếp (lưu/nạp register) cỡ vài µs; gián tiếp (cache lạnh, TLB flush) thường lớn hơn, có thể hàng chục µs tuỳ working set.
  • Đổi thread cùng process rẻ hơn đổi process vì không đổi address space, không flush TLB (CPU tag PCID/ASID giảm nhẹ nhưng không xoá đòn này).
  • Switch quá nhiều (nhiều thread hơn cần thiết) đốt CPU vào việc đổi việc thay vì làm việc.

11. Tự kiểm tra

Tự kiểm tra
Q1
Một thread đang blocked chờ đọc disk — vì sao nó không tốn CPU? Ai đánh thức nó, và qua cơ chế nào?
Khi thread gọi thao tác đọc disk chưa sẵn dữ liệu, kernel chuyển nó sang trạng thái blockedlấy nó ra khỏi core — thread rời hàng đợi chạy (runqueue), nên scheduler không bao giờ trao core cho nó trong lúc chờ. Vì chỉ thread running mới tiêu CPU, thread blocked không tốn chu kỳ nào; core được scheduler giao cho một thread ready khác làm việc có ích. Trước khi rời đi, kernel ghi nhận: "khi I/O trên thiết bị này xong thì đánh thức thread đó". Cơ chế đánh thức: khi disk hoàn tất, bộ điều khiển đĩa phát một interrupt; kernel xử lý interrupt, tìm thread đang chờ sự kiện đó và chuyển nó từ blocked về ready. Nó chưa chạy ngay — phải chờ scheduler chọn (không có đường Blocked→Running trực tiếp, vì có thể mọi core đang bận) — nhưng giờ đã đủ điều kiện để được cấp core và chạy tiếp từ đúng dòng read(). Đây là lý do "I/O xong" không đồng nghĩa "chạy ngay".
Q2
Một server phục vụ 1.000 kết nối, phần lớn đang chờ dữ liệu mạng, chỉ chạy trên 4 core. Vì sao khả thi? Cơ chế nào khiến các thread chờ không làm nghẽn CPU?
Khả thi vì thread blocked không tốn CPU. Khi một thread gọi read() trên socket chưa có dữ liệu, kernel chuyển nó sang blocked và lấy nó ra khỏi core, ghi nhận "khi dữ liệu tới thì đánh thức", rồi cho một thread ready khác chạy — core không rảnh mà làm việc có ích. Tại mỗi thời điểm chỉ một nhúm nhỏ thread thật sự cần CPU (số vừa có dữ liệu để xử lý); phần lớn đang blocked, không tranh core. Nên 4 core đủ phục vụ 1.000 kết nối miễn là mỗi kết nối phần lớn thời gian đang chờ chứ không tính toán. Điều kiện then chốt: phải dùng blocking thật (kernel đánh thức), không busy-wait — nếu mỗi thread quay vòng while chờ thì 1.000 thread sẽ đòi 1.000 core.
Q3
Đồng nghiệp viết while (!dataReady) {} để chờ dữ liệu thay vì dùng blocking read. Trên top, thread này chiếm 100% một core dù 'không làm gì'. Giải thích cơ chế.
Vòng lặp while (!dataReady) {}busy-wait: thread liên tục kiểm tra điều kiện, nên nó luôn ở trạng thái running — CPU thực thi hàng triệu lần lặp rỗng mỗi giây. Với scheduler, đây là một thread đang tính toán bình thường, không có tín hiệu gì để lấy nó khỏi core, nên nó chiếm trọn một core làm việc vô ích. Ngược lại, blocking read báo cho kernel "tôi cần chờ sự kiện này" → kernel chuyển thread sang blocked, lấy khỏi core, và chỉ đánh thức khi dữ liệu tới. Khác biệt: busy-wait hỏi liên tục và đốt CPU; blocking nhường CPU rồi được đánh thức. Fix: thay bằng blocking read, wait()/notify(), hay một hàng đợi chặn — để thread thật sự rời core khi chờ.
Q4
Người ta nói 'context switch chỉ tốn vài µs để lưu và khôi phục register'. Câu này thiếu gì? Chi phí thật đến từ đâu?
Câu đó chỉ tính chi phí trực tiếp — lưu register của thread cũ, nạp register của thread mới, ra/vào kernel — quả thật cỡ vài µs. Nó bỏ sót chi phí gián tiếp, thường lớn hơn: thread mới bắt đầu với cache lạnh (dữ liệu của nó chưa trong L1/L2/L3), nên các truy cập đầu là cache miss xuống RAM (~trăm ns mỗi lần); và nếu đổi sang process khác, TLB có thể bị flush, phải nạp lại ánh xạ địa chỉ qua page walk. Tệ hơn, thread mới chạy sẽ đẩy dữ liệu của thread cũ khỏi cache, nên khi thread cũ quay lại cũng phải làm nóng cache từ đầu. Chi phí gián tiếp này phụ thuộc working set — với thread dữ liệu lớn có thể tốn hàng chục µs. Đó là lý do OSTEP xếp "làm nguội cache/TLB" vào chi phí context switch, và vì sao time slice quá ngắn thì phản tác dụng (bài 03).
Q5
Vì sao context switch giữa hai thread cùng process rẻ hơn giữa hai process khác nhau?
Thread cùng process chia sẻ address space (chung page table — bài 01). Khi đổi giữa chúng, kernel không phải đổi bảng ánh xạ trang, nên các mục trong TLB vẫn đúng — không cần flush; và vì hai thread chia sẻ heap, một phần cache dữ liệu còn dùng lại được. Đổi giữa hai process phải trỏ CPU sang page table mới (đổi address space), và trên nhiều kiến trúc phải flush TLB, buộc thread mới nạp lại toàn bộ ánh xạ từ đầu — cộng với cache bị làm nguội mạnh hơn. CPU hiện đại gắn tag PCID/ASID vào mục TLB để tránh flush sạch khi đổi process, giảm bớt chi phí, nhưng không xoá hẳn: cache vẫn nguội và áp lực TLB vẫn cao hơn. Kết luận thực dụng: đổi thread cùng process nhẹ hơn đổi process.
Q6
Máy 4 core chạy 400 thread CPU-bound. Mô tả điều gì xảy ra ở mỗi lần timer interrupt, và vì sao tổng thời gian CPU hữu ích giảm.
Mỗi timer interrupt kéo kernel vào; scheduler thấy nhiều thread ready đang xếp hàng nên thường preempt thread đang chạy và context switch sang thread khác — với 400 thread tranh 4 core, việc này xảy ra rất dày. Mỗi context switch tốn chi phí trực tiếp (lưu/nạp register, ra vào kernel) cộng gián tiếp: thread vừa được nạp gặp cache lạnh (dữ liệu của nó chưa nằm trong L1/L2/L3, các truy cập đầu là cache miss xuống RAM), có thể phải nạp lại TLB, đồng thời nó đẩy dữ liệu của thread bị hoán ra khỏi cache — nên khi thread cũ quay lại, nó cũng phải làm nóng cache từ đầu. Vì CPU-bound gần như luôn tính, không có thời gian chờ I/O để "giấu" các switch này, nên mỗi mili-giây dành cho đổi việc và làm nóng lại cache là một mili-giây không dành cho tính toán. Sức tính vẫn chỉ là 4 core dù có 400 thread; phần chênh chỉ là overhead. Kết quả: tổng thời gian CPU hữu ích giảm, thông lượng tụt.

Bài tiếp theo: Scheduler — ai được chạy tiếp theo

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên