OS & Concurrency: Nền tảng Threads và Runtime
1. Các khái niệm cơ bản
| Khái niệm | Lớp | Ai quản lý | Mô tả ngắn |
|---|---|---|---|
| Physical Thread | Hardware | CPU | Đơn vị thực thi thật trên silicon |
| OS Thread | Kernel | Hệ điều hành | Đơn vị lập lịch của kernel |
| User-space Thread | Runtime | Language runtime | Thread nhẹ, kernel không biết |
| Goroutine | Runtime | Go runtime | User-space thread của Go, cực nhẹ |
2. Physical Thread — Phần cứng
Kiến trúc CPU
CPU Package (con chip vật lý)
├── Core 0
│ ├── Physical Thread 0 ← có bộ registers riêng
│ └── Physical Thread 1 ← Hyper-Threading
├── Core 1
│ ├── Physical Thread 2
│ └── Physical Thread 3
└── Core N...
Hyper-Threading (HT) là gì?
Mỗi core có 1 bộ ALU/FPU (đơn vị tính toán) duy nhất. Hyper-Threading tạo ra 2 bộ registers trên cùng 1 core — khi thread 0 đang chờ cache miss, ALU rảnh, thread 1 tranh thủ chạy. Kernel nhìn thấy 1 core HT như 2 logical CPUs.
Không phải 2x hiệu năng — thực tế chỉ tăng ~20-30%. Nếu cả hai threads đều cần ALU liên tục (CPU-bound), chúng vẫn phải tranh nhau.
Ví dụ: Intel i7 8 cores × 2 HT = 16 logical CPUs
Nhưng chỉ có 8 ALU thật sự
→ Tối đa 8 tác vụ tính toán nặng chạy song song thật sự
Giới hạn cứng
Physical Thread là giới hạn của phần cứng. Dù bạn tạo bao nhiêu OS Thread hay Goroutine, tại một thời điểm chỉ có đúng N physical threads đang thực thi thật sự (N = số logical CPUs).
3. OS Thread — Kernel quản lý
Định nghĩa
OS Thread (hay Kernel Thread) là đơn vị lập lịch do kernel tạo và quản lý. Kernel biết từng OS thread tồn tại và quyết định nó chạy trên core nào và khi nào.
Đặc điểm kỹ thuật
| Thuộc tính | Giá trị |
|---|---|
| RAM mỗi thread (stack) | ~1–8 MB (cố định từ đầu) |
| Tạo/hủy | ~1ms (phải syscall vào kernel) |
| Context switch | ~1–10 µs |
| Giới hạn thực tế | ~1,000–10,000 threads/máy |
4 trạng thái của OS Thread
RUNNING ── đang chiếm Physical Thread, thực thi lệnh
│
├── hết time slice ──────────────► READY
├── chờ I/O, sleep, mutex... ───► BLOCKED/WAITING
└── xong việc ──────────────────► TERMINATED
READY ── trong run queue, chờ được gán Physical Thread
BLOCKED ── không cần CPU, đang chờ sự kiện (I/O, timer...)
Context Switch chi tiết
Khi kernel lấy core từ Thread A để giao cho Thread B, nó phải lưu toàn bộ trạng thái của A và khôi phục trạng thái của B. Đây là overhead không tránh được.
Bước 1 — Lưu trạng thái Thread A vào PCB:
- General registers (~16 registers x86-64)
- Program Counter (đang chạy dòng lệnh nào)
- Stack Pointer
- FPU/SIMD registers
→ Tổng: ~512 bytes đến vài KB
Bước 2 — Load trạng thái Thread B từ PCB
Bước 3 — Flush TLB (nếu switch sang process khác)
→ Phần TỐN KÉM NHẤT — cache địa chỉ bộ nhớ bị xóa
→ CPU cache bị "làm lạnh" → miss rate tăng
Tổng chi phí: 1–10 µs
(CPU 3GHz = 3 tỷ lệnh/giây → 1µs = mất ~3,000 lệnh CPU)
Tại sao không tạo vô hạn OS Thread?
Vấn đề không chỉ ở RAM mà còn ở context switching overhead:
100,000 threads × 1MB stack = 100 GB RAM 💥
8 cores, 100,000 threads:
→ Mỗi thread chỉ chạy 0.008% thời gian
→ CPU dành phần lớn để switch, không làm việc thật
→ Gọi là "Thrashing"
Điểm tối ưu:
CPU-bound task → số threads ≈ số cores
I/O-bound task → số threads ≈ vài lần số cores
4. Goroutine — Go Runtime quản lý
Định nghĩa
Goroutine là user-space thread của Go. Kernel không biết goroutine tồn tại — Go runtime tự quản lý việc lập lịch goroutines lên OS threads. Đây là mô hình M:N scheduling.
Đặc điểm kỹ thuật
| Thuộc tính | Giá trị |
|---|---|
| RAM ban đầu | ~2 KB (dynamic, tự grow) |
| Tạo/hủy | ~300 ns (không cần syscall) |
| Context switch | ~100 ns |
| Giới hạn thực tế | Hàng triệu goroutines/máy |
Stack động — Khác biệt lớn
OS Thread có stack cố định — allocate ngay từ đầu dù dùng hay không. Goroutine bắt đầu với 2KB và tự grow khi cần.
OS Thread: [════════════ 8MB cố định ════════════]
Goroutine: [2KB] → [4KB] → [8KB] → ... → tự grow
Đây là lý do bạn có thể tạo hàng triệu goroutines mà không hết RAM.
Mô hình G-M-P — Trái tim của Go
M Goroutines : N OS Threads : P Physical Cores
Ví dụ: 1,000,000 goroutines : 8 OS threads : 8 CPU cores
3 thực thể:
G (Goroutine) — công việc cần thực hiện
M (Machine) — OS thread thực sự
P (Processor) — "bộ lập lịch ảo", số lượng = GOMAXPROCS (mặc định = số cores)
Quan hệ: G chạy trên P, P gắn vào M, M chạy trên Physical Core
Physical CPU: [Core 1] [Core 2] [Core 3] [Core 4]
│ │ │ │
OS Threads: [M1] [M2] [M3] [M4]
│ │ │ │
Processors: [P1] [P2] [P3] [P4]
/ │ \ / │ \ / │ \ / │ \
Goroutines: G1 G2 G3 G4 G5 G6 G7 G8 G9 G10 G11 G12
... hàng nghìn G khác trong run queue ...
Go Scheduler — Khi nào goroutine bị dừng?
Goroutine bị tạm dừng (yield) khi:
1. Gọi channel operation (block)
2. Gọi syscall (network, file I/O)
3. Gọi runtime.Gosched() — tự nguyện nhường
4. Hàm quá dài → Go 1.14+ tự preempt (không cần cooperative yield)
Non-blocking I/O — Điều làm goroutine mạnh
Điểm khác biệt lớn nhất: khi goroutine gọi I/O, nó không block OS thread.
resp, err := http.Get("https://api.example.com")
Bên dưới hood:
1. Goroutine A bị "park" — tạm ngưng, KHÔNG block OS thread M1
2. OS Thread M1 được giải phóng → chạy Goroutine B, C, D...
3. Khi network response về → Go runtime "wake up" Goroutine A
4. Goroutine A tiếp tục (có thể trên M khác)
Kết quả: 1 OS thread phục vụ hàng nghìn goroutines đang chờ I/O. Đây là lý do Go xử lý concurrent connections tốt hơn thread-per-connection model đáng kể.