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

Thread pool

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. Đơ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. 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), đốt CPU cho quản lý thay vì việc thật.

Một lần tôi đo hớ: việc nhỏ mà tốn tạo luồng lớn

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 đó.

Sự thật, và là bài học đo lường: 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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, go func không giới hạn) 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. Mọi web server (xử lý mỗi request) và mọi thư viện song song đều dựng trên nguyên tắc này.

Hệ quả 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.)

Hệ quả thứ ba là tinh thần đo lường: một chi phí nhỏ nhân lên là một chi phí lớn — đo tổng, không đo đơn lẻ. Con số mang theo: chạy 20.000 việc nhỏ kiểu thread-per-task mất 989 ms (chỉ 20 nghìn việc/s) vì mỗi việc trả ~47µs tạo luồng, lấn át việc thật vài µs; thread pool tạo luồng MỘT LẦN rồi tái dùng -> 7,9 ms với 16 worker (2519 nghìn việc/s), nhanh 125 lần; số worker tối ưu quanh số lõi, quá nhiều (32) thì oversubscribe chậm hơn. Đây là vì sao mọi runtime dùng thread pool — và vì sao "chi phí nhỏ" luôn phải nhân với số lần trước khi bỏ qua.

Thử ba mươi giây

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. Ba mươi giây (well, 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.