🖥️ OS✍️ Khoa📅 19/04/2026☕ 6 phút đọc

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ể.