Cấu trúc dữ liệu và Giải thuật Bài 93: Mô hình đồng thời (Concurrency Models) - So sánh Thread Pool, Event Loop và Goroutines.
Khi xây dựng các hệ thống backend tại Hasaki hoặc hệ thống vé AFC, bạn sẽ thường xuyên đối mặt với bài toán C10K: Làm sao để một máy chủ duy nhất có thể xử lý đồng thời 10,000 (thậm chí 1 triệu) kết nối mạng cùng lúc?
Phần lớn thời gian của một HTTP Request không dành cho việc tính toán trên CPU, mà là để chờ đợi (I/O Wait): chờ truy vấn cơ sở dữ liệu, chờ gọi API bên thứ 3, hoặc chờ đọc file từ ổ cứng. Cách mà hệ điều hành và ngôn ngữ lập trình xử lý khoảng thời gian "chờ đợi" này chính là cốt lõi phân định sự khác biệt giữa 3 mô hình đồng thời thống trị thế giới backend hiện nay: Thread Pool (PHP/Java), Event Loop (Node.js) và CSP/Goroutines (Golang).
1. Mô hình Thread-per-Request (Java Spring Boot, PHP-FPM)
Đây là mô hình cổ điển và trực quan nhất. Mỗi khi có một Request gửi đến, hệ thống sẽ cấp phát (hoặc lấy từ một Pool) một Hệ điều hành Thread (OS Thread) duy nhất để phục vụ riêng cho Request đó từ đầu đến cuối.
-
Cơ chế chặn (Blocking I/O): Khi mã nguồn gọi hàm
DB.query(), Thread hiện tại sẽ bị Block (Đóng băng) hoàn toàn. Hệ điều hành sẽ cất Thread này đi (Context Switch) và chạy Thread khác. Khi DB trả về kết quả, hệ điều hành mới đánh thức Thread này dậy để chạy tiếp. -
Điểm yếu chí mạng: Mỗi OS Thread ngốn khoảng 1MB - 2MB RAM. Nếu có 10,000 người dùng kết nối cùng lúc, hệ thống sẽ mất đứt 10GB - 20GB RAM chỉ để duy trì các Thread... đang ngủ (chờ I/O). Đồng thời, việc Hệ điều hành liên tục bốc Thread ra vào CPU (Context Switching) tiêu tốn một lượng chu kỳ CPU khổng lồ (Overhead).
-
Phù hợp với: Các bài toán cần tính toán nặng trên CPU (CPU-bound) vì không luồng nào chặn luồng nào về mặt CPU.
2. Mô hình Event Loop (Node.js, Nginx)
Để phá vỡ giới hạn RAM của Thread Pool, Ryan Dahl đã tạo ra Node.js với triết lý: Tại sao phải dùng 10,000 Threads để chờ, trong khi 1 Thread có thể vừa chạy vừa "lắng nghe" hàng ngàn kết nối?
-
Cơ chế Không chặn (Non-blocking I/O & Asynchronous): Node.js chạy trên đúng 1 Thread duy nhất (Main Thread). Khi gặp lệnh
DB.query(), nó KHÔNG đứng chờ. Nó ném lệnh đó cho hệ điều hành xử lý ngầm (hoặc một Worker Pool nhỏ phía sau), đăng ký một hàm Gọi lại (Callback/Promise), rồi lập tức chạy đi phục vụ Request của người dùng khác. -
Event Loop: Khi DB xử lý xong, nó ném kết quả vào một hàng đợi (Event Queue). Vòng lặp sự kiện (Event Loop) liên tục quay tròn, bốc kết quả từ hàng đợi lên và thực thi tiếp đoạn code Callback tương ứng.
-
Điểm mạnh: Cực kỳ tiết kiệm RAM. Một server Node.js có thể xử lý hàng chục ngàn kết nối WebSocket đồng thời mà chỉ tốn vài chục MB RAM.
-
Điểm yếu chí mạng: Nếu vô tình nhét một đoạn code tính toán nặng (VD: Xử lý mảng 1 triệu phần tử, mã hóa chuỗi) vào Main Thread, toàn bộ Event Loop sẽ bị nghẽn (Blocked). Mọi Request của tất cả người dùng khác sẽ bị treo cứng lại cho đến khi đoạn code đó chạy xong.
3. Mô hình CSP & Goroutines (Golang, Erlang)
Golang mang đến một kiến trúc dung hòa hoàn hảo sức mạnh của cả hai mô hình trên bằng khái niệm Goroutines (Luồng nhẹ - Lightweight Threads) và mô hình giao tiếp CSP (Communicating Sequential Processes) qua Channels.
-
Bản chất của Goroutine: Một Goroutine không phải là OS Thread. Nó là một luồng logic do Golang Runtime (Go Scheduler) tự quản lý. Mỗi Goroutine chỉ tốn đúng 2KB RAM (nhỏ hơn OS Thread 1000 lần!). Bạn có thể tạo 1 triệu Goroutines dễ dàng.
-
M:N Scheduler: Golang tự động tạo ra một vài OS Thread (thường bằng số nhân CPU). Lõi Go Scheduler sẽ phân bổ hàng triệu Goroutines chạy chèn lên số ít OS Thread này.
-
Cơ chế "Giả" Blocking: Khi bạn viết code Go, bạn cứ viết theo kiểu Blocking tuyến tính cực kỳ dễ đọc (như PHP). Nhưng khi gọi
db.Query(), Go Scheduler sẽ lập tức "bốc" Goroutine đó ra khỏi OS Thread, nhường chỗ cho Goroutine khác chạy. Khi có kết quả I/O, Scheduler lại ném nó trở lại OS Thread. Tất cả diễn ra ở tầng User-Space, hệ điều hành hoàn toàn không biết, do đó không có độ trễ Context Switch của OS.
5. B+ Tree vs. LSM-Tree: Chọn Vũ Khí Nào Cho Hệ Thống?
| Tiêu chí | B+ Tree (MySQL, PostgreSQL) | LSM-Tree (Cassandra, RocksDB, ScyllaDB) |
|---|---|---|
| Tốc độ Ghi (Write) | Chậm (Random I/O, Page Split) | Cực nhanh (Sequential Append, MemTable) |
| Tốc độ Đọc (Read) | Nhanh, độ trễ ổn định | Chậm hơn (Phải quét MemTable và nhiều SSTable) |
| Không gian (Space) | Dễ bị phân mảnh, tốn dung lượng | Dữ liệu được nén rất tốt trong SSTable |
| Bảo trì hệ thống | Ít tiến trình nền | Compaction tốn CPU và Disk I/O ngầm |
LSM-Tree đánh đổi tốc độ Đọc để đạt được tốc độ Ghi vô song. Để khắc phục nhược điểm Đọc chậm (phải mò mẫm qua quá nhiều file SSTable để tìm một Key không tồn tại), các hệ thống LSM-Tree buộc phải tích hợp thêm một cấu trúc dữ liệu xác suất để chặn đứng các truy vấn vô ích ngay từ vòng gửi xe.
5. Triển khai Đồng thời (Concurrency) bằng Golang
Dưới đây là một ví dụ kinh điển minh họa triết lý CSP của Golang: "Đừng giao tiếp bằng cách chia sẻ bộ nhớ; hãy chia sẻ bộ nhớ bằng cách giao tiếp" (Do not communicate by sharing memory; instead, share memory by communicating).
Thay vì dùng Mutex Lock (như Java/C++) để tránh xung đột khi 10,000 Goroutines cùng truy cập một biến, chúng ta dùng Channels để bơm dữ liệu qua lại như một đường ống.
Go
package main
import (
"fmt"
"sync"
"time"
)
// Worker function: Xử lý công việc độc lập
// Nhận jobs từ kênh jobs, trả kết quả vào kênh results
func worker(id int, jobs <-chan int, results chan<- string) {
for j := range jobs {
// Giả lập I/O wait hoặc xử lý nghiệp vụ tốn thời gian
time.Sleep(100 * time.Millisecond)
// Trả kết quả vào pipeline
results <- fmt.Sprintf("Worker %d đã hoàn thành Job %d", id, j)
}
}
func main() {
numJobs := 10
numWorkers := 3 // Khởi tạo Pool gồm 3 Worker (Goroutines)
// Tạo các Buffered Channels
jobs := make(chan int, numJobs)
results := make(chan string, numJobs)
// 1. Khởi động các Goroutines (Chạy ngầm không block Main Thread)
for w := 1; w <= numWorkers; w++ {
go worker(w, jobs, results)
}
// 2. Bơm 10 Request vào kênh jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // Đóng kênh để báo cho worker biết đã hết việc
// 3. Gom kết quả từ các worker trả về một cách an toàn
// Không cần dùng Lock (Mutex) vì dữ liệu chảy tuần tự qua Channel
for a := 1; a <= numJobs; a++ {
fmt.Println(<-results)
}
}
Trong ví dụ này, dù worker có gọi lệnh time.Sleep (đại diện cho I/O blocking), Go Runtime sẽ tự động tháo Goroutine đó ra khỏi CPU và cho phép các đoạn code khác chạy, đảm bảo hiệu suất khai thác CPU luôn ở mức 100%.
Kiến trúc phần mềm đã đi đến hồi kết cấu. Khi bạn đã nắm trong tay cách lưu trữ dữ liệu khổng lồ (B-Tree, LSM-Tree), cách tìm đường (Graph), và cách mở rộng hệ thống (Consistent Hashing, Raft, Goroutines), bạn đã sở hữu bộ não của một Systems Engineer đích thực.
All rights reserved