Hình dung một công ty giao hàng. Cách ngớ ngẩn: với mỗi kiện hàng, bạn tuyển một người giao mới — phỏng vấn, làm giấy tờ, phát đồng phục và xe (mất cả buổi), giao xong đúng một kiện thì cho nghỉ việc. Hai vạn kiện hàng là hai vạn lần tuyển-rồi-đuổi, và gần như toàn bộ thời gian đổ vào thủ tục nhân sự chứ không phải giao hàng. Cách hiển nhiên đúng: giữ một đội giao hàng cố định, tuyển một lần, rồi cả ngày họ cứ ra kệ lấy kiện tiếp theo mà giao. Chi phí "onboarding" trả một lần cho cả đội, không phải mỗi kiện. Thread pool là đúng cái đội giao hàng cố định đó — và con số cho thấy khoảng cách giữa hai cách lớn đến mức nào.
Ở bài đầu sê-ri ta đo tạo một luồng tốn ~47 µs — và tôi đã hứa sẽ quay lại giải thích vì sao con số đó dẫn tới thread pool. Đây là lúc trả lời. Thread pool là mẫu thiết kế mà mọi web server, mọi runtime song song đều dùng: thay vì tạo một luồng mới cho mỗi việc, ta tạo một số luồng một lần rồi tái dùng chúng cho hết việc này tới việc khác. Câu hỏi: nó nhanh hơn bao nhiêu, và dùng bao nhiêu luồng? Tôi đo, và con số biện minh cho cả mẫu thiết kế.
Hai cách chạy nhiều việc nhỏ
Có hai cách xử lý một loạt việc. Thread-per-task: mỗi việc, gọi pthread_create để tạo một luồng chạy nó, rồi join (tuyển một người cho một kiện). Đơn giản, nhưng mỗi việc phải trả chi phí tạo luồng. Thread pool: tạo N luồng worker một lần lúc khởi động; các worker lấy việc từ một hàng đợi việc (đúng mẫu producer-consumer ta vừa đo); khi hết việc trong hàng, worker ngủ chờ. Chi phí tạo luồng chỉ trả một lần cho N luồng, không phải mỗi việc.
Tôi đo trong container gcc:13 (10 lõi): chạy 20.000 việc nhỏ (mỗi việc chỉ vài µs tính toán) theo cả hai cách, và kiểm checksum để chắc mọi việc chạy đủ.
Đo: pool nhanh 125 lần, worker quanh số lõi
THREAD-PER-TASK (20.000 việc) : 989 ms -> 20 nghìn việc/giây
Chạy từng việc trên một luồng mới mất 989 ms cho 20.000 việc — tức khoảng 49 µs mỗi việc. Nhưng việc thật chỉ vài µs! Phần lớn 49 µs đó là chi phí tạo luồng ~47 µs (đo ở bài 1) — bạn đang trả tiền dựng và dẹp một luồng cho mỗi mẩu việc tí xíu (tuyển và đuổi một người cho mỗi kiện). Giờ thử thread pool, đổi số worker:
THREAD POOL:
2 worker : 17,7 ms | 1.131 nghìn việc/giây (nhanh 56× per-task)
8 worker : 8,6 ms | 2.321 nghìn việc/giây
16 worker : 7,9 ms | 2.519 nghìn việc/giây (tốt nhất)
32 worker : 14,7 ms | 1.358 nghìn việc/giây (oversubscribe)
Thread pool — ngay cả với chỉ 2 worker — nhanh hơn thread-per-task 56 lần, và với 16 worker là 125 lần (7,9 ms so với 989 ms). Vì nó tạo luồng đúng một lần rồi tái dùng; 20.000 việc chia nhau vài luồng thay vì sinh ra 20.000 luồng. Cái ~47 µs tạo luồng, nhân với 20.000, biến mất.
Nhưng chú ý cột số worker: nó không phải "càng nhiều càng nhanh". Từ 2 lên 16 worker tăng dần (2 luồng chưa dùng hết 10 lõi), đạt đỉnh quanh 16 (gần số lõi). Rồi 32 worker lại chậm đi — 14,7 ms, tệ hơn cả 8 worker. Đây là oversubscription: quá nhiều luồng tranh nhau khóa hàng đợi và gây thrashing lịch (hệ điều hành liên tục chuyển ngữ cảnh) — như tuyển nhiều người giao hơn cả số kiện, họ chen chúc ở kho, xếp hàng chờ cùng một bàn điều phối, đốt thời gian cho quản lý thay vì việc thật.
(Môi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, 20.000 việc nhỏ, kiểm checksum, số lấy sau warmup. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: thread-per-task bị chi phí tạo luồng lấn át, pool amortize nên nhanh gấp bội, và số worker tối ưu quanh số lõi rồi giảm khi oversubscribe.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin trái ngược mà cùng lệch. Thứ nhất, của người mới: "tạo một luồng cho mỗi việc là cách tự nhiên, và với việc nhỏ thì có sao đâu". Sai — với việc nhỏ, chi phí tạo luồng (~47 µs) lớn hơn việc thật (vài µs) hàng chục lần, nên bạn dành phần lớn thời gian dựng-dẹp luồng thay vì làm việc. Thứ hai, của người ngại phức tạp: "thread pool là kỹ thuật rườm rà thừa thãi". Cũng sai — nó nhanh hơn hàng trăm lần ở đúng workload này, và độ phức tạp của nó (một hàng đợi việc, vài worker) là vừa phải cho phần thưởng đó.
Hai niềm tin gặp nhau ở một sự thật: một chi phí "nhỏ" per-item nhân với số item lớn thành nút thắt khổng lồ. 47 µs nghe chẳng đáng gì cho một luồng; nhưng 47 µs × 20.000 việc = gần một giây chỉ cho khâu tạo luồng. Đây là cùng một mẫu với đồng bộ per-item ở bài producer-consumer: thứ rẻ khi làm một lần trở nên đắt khi làm hàng vạn lần, và cách chữa là amortize — trả chi phí đó một lần rồi tái dùng. Thread pool chính là amortize hóa việc tạo luồng.
Đo cho công bằng: chi phí tạo pool trả một lần
Có một tinh tế khi đo, đúng tinh thần đo lường vi mô đúng cách: thread pool cũng tạo N luồng lúc khởi động, nên nó không miễn phí hoàn toàn — chỉ là chi phí đó trả một lần. Ở phép đo trên, tôi cố ý không tính thời gian tạo N worker vào con số throughput của pool (chỉ tính thời gian chạy 20.000 việc), vì trong thực tế pool được dựng một lần lúc chương trình khởi động rồi phục vụ hàng triệu việc suốt vòng đời — chi phí tạo ban đầu (N × ~47 µs, với N nhỏ là chưa tới 1 ms) tan biến khi chia cho số việc khổng lồ nó xử lý. Đây là điểm mấu chốt của amortize: nếu bạn chỉ chạy một việc, thread-per-task và pool tương đương (cả hai tạo một luồng); pool chỉ thắng khi số việc nhiều, đủ để chia nhỏ chi phí tạo ban đầu xuống gần không. Nên khi đánh giá "pool có đáng không", hãy đo ở đúng quy mô việc thật của bạn, không phải một việc đơn lẻ.
Về đến code hằng ngày
Điều đầu tiên: với nhiều việc nhỏ, đừng tạo luồng mỗi việc — dùng thread pool. Đây không phải tối ưu sớm; đó là chênh lệch hàng trăm lần. Bất cứ khi nào bạn thấy pthread_create (hay new Thread) trong một vòng lặp xử lý nhiều việc ngắn, đó là dấu hiệu cần một pool.
Điều thứ hai: chọn số worker quanh số lõi, đừng quá nhiều. Với việc nặng CPU, số worker tối ưu thường xấp xỉ số lõi (ở đây ~10-16); ít hơn thì bỏ phí lõi, nhiều hơn thì oversubscription làm chậm vì thrashing. (Với việc nặng I/O — luồng thường chờ — số worker tối ưu có thể cao hơn số lõi, vì luồng chờ không chiếm lõi; nhưng vẫn phải đo, không đoán.)
Chỗ này thread pool là một trong những mẫu bạn chắc chắn đang dùng dù có tự viết hay không, nên biết cách các nền tảng gói nó lại giúp bạn dùng đúng. Java có ExecutorService — Executors.newFixedThreadPool(n) dựng đúng cái pool tôi đo, và anti-pattern kinh điển là new Thread(...).start() cho mỗi request (chính là thread-per-task 989 ms); mọi web server Java (Tomcat, Jetty) phục vụ request từ một pool luồng cố định vì lý do này. Cùng ý "amortize một tài nguyên đắt để tạo" cũng là connection pool của database (HikariCP): mở một kết nối DB tốn hàng chục ms, nên ta giữ sẵn một pool và tái dùng — thread pool và connection pool là cùng một mẫu cho hai tài nguyên khác nhau. Go thì lật ngược bài toán: goroutine rẻ (~vài KB, do runtime lập lịch chứ không phải luồng OS), nên go func() cho mỗi việc thường ổn — khác hẳn luồng OS đắt; nhưng người ta vẫn dựng worker pool trong Go, không phải để tiết kiệm chi phí tạo mà để giới hạn số việc đồng thời (kẻo hàng triệu goroutine cùng đập vào một database) — đúng cái semaphore giới hạn tài nguyên ta đã bàn. Và Java 21 virtual thread (Loom) đưa Java về gần mô hình Go cho việc I/O. Mẫu số chung: cái gì đắt để tạo và dùng nhiều lần thì tạo sẵn một pool và tái dùng — luồng, kết nối, buffer đều vậy.
Muốn tự soi thì viết một vòng lặp chạy vài nghìn việc nhỏ, mỗi việc tạo một luồng mới (pthread_create + join) — và bấm giờ. Rồi viết một phiên bản dùng một pool nhỏ (vài luồng worker lấy việc từ một hàng đợi) và chạy đúng số việc đó — bấm giờ lại. Bạn sẽ thấy phiên bản pool nhanh hàng chục tới hàng trăm lần, dù mỗi việc làm y hệt. Nếu muốn, thử đổi số worker từ 2 lên số lõi lên gấp đôi số lõi và quan sát: throughput tăng tới quanh số lõi rồi giảm. Ít phút đó cho bạn lý do đằng sau một mẫu thiết kế bạn gặp ở khắp nơi mà có thể chưa từng đo: thread pool không phải kỹ thuật hàn lâm, mà là câu trả lời trực tiếp cho một con số — ~47 µs mỗi lần tạo luồng, quá đắt để trả cho mỗi việc.