Khi cần làm nhiều việc song song, bạn có hai lựa chọn: nhiều luồng (thread) trong một tiến trình, hay nhiều tiến trình (process) riêng. Câu thường nghe là "luồng nhẹ hơn tiến trình". Tôi đo cả hai trong container để xem "nhẹ hơn" là bao nhiêu và vì sao — rồi phát hiện cái làm luồng nhẹ cũng chính là cái khiến nó nguy hiểm.
Cùng một syscall, khác vài cái cờ
Điều đầu tiên khiến tôi bất ngờ khi soi bằng strace: cả pthread_create (tạo luồng) lẫn fork (tạo tiến trình) đều gọi cùng một syscall là clone. Khác biệt nằm ở cờ truyền vào:
Tạo luồng: clone(flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...)
Tạo tiến trình (fork): clone(flags = SIGCHLD) # KHÔNG có CLONE_VM
Cờ then chốt là CLONE_VM. Khi có nó (luồng), cái mới dùng chung không gian địa chỉ với cái tạo ra nó — cùng một bảng trang, cùng một vùng nhớ. Khi không có nó (fork), nhân phải tạo một bản sao không gian địa chỉ cho tiến trình con (dùng copy-on-write như bài fork/COW đã đo, nhưng vẫn phải dựng bảng trang riêng). Việc dựng bản sao đó tốn thời gian, và đó là gốc rễ của "luồng nhẹ hơn".
Đo: nhẹ hơn ba lần
Tôi đo thời gian tạo rồi kết thúc một đơn vị, lặp 20.000 lần, ghim vào một lõi:
pthread_create + join : 15,25 micro giây mỗi lần
fork + wait : 46,45 micro giây mỗi lần
Tạo một luồng nhanh hơn tạo một tiến trình khoảng ba lần. Lý do đã rõ từ cờ clone: luồng không phải sao chép không gian địa chỉ, nó chỉ cần cấp một ngăn xếp mới và dựng chút trạng thái — nhẹ hơn hẳn việc dựng cả một không gian địa chỉ riêng. Và cái lợi của luồng còn vượt ra ngoài lúc tạo: chuyển ngữ cảnh giữa hai luồng cùng tiến trình cũng rẻ hơn giữa hai tiến trình, vì không phải đổi bảng trang, không phải xả (flush) TLB — chúng dùng chung một không gian địa chỉ nên bộ nhớ ánh xạ giữ nguyên.
Đến đây, kết luận "luồng rẻ hơn, cứ dùng luồng" nghe rất hợp lý. Và tôi suýt dừng ở đó.
Một lần tôi đo hớ: cái làm nó rẻ cũng làm nó mong manh
Con số "rẻ hơn 3 lần" đứng một mình đã bỏ quên một cái giá không hiện ra trong đồng hồ: sự cô lập lỗi. Chính thứ làm luồng rẻ — chia sẻ chung một không gian địa chỉ — cũng có nghĩa là các luồng không cách ly nhau chút nào. Tôi đo trực tiếp điều này bằng hai thí nghiệm segfault.
Thí nghiệm một: một tiến trình con (tạo bằng fork) truy cập con trỏ NULL và chết vì SIGSEGV:
== tiến trình con segfault ==
con chết vì tín hiệu 11 (SIGSEGV)
CHA VẪN SỐNG, chạy tiếp bình thường
Tiến trình con chết, nhưng tiến trình cha hoàn toàn không hề hấn — nó có không gian địa chỉ riêng, lỗi của con không lan sang được. Nhân báo cho cha biết con đã chết (qua waitpid), và cha xử lý tiếp.
Thí nghiệm hai: một luồng truy cập con trỏ NULL:
== một luồng segfault ==
CẢ tiến trình chết vì tín hiệu 11 -> một luồng crash giết HẾT
Chỉ một luồng segfault, nhưng toàn bộ tiến trình chết theo — mọi luồng khác, dù đang làm việc đứng đắn, cũng bị kéo xuống mồ cùng nó. Vì các luồng chia sẻ một không gian địa chỉ, một luồng ghi bậy vào bộ nhớ (hay chạm con trỏ hỏng) có thể phá hỏng dữ liệu của luồng khác, nên khi một luồng nhận tín hiệu chí mạng, nhân cho chết cả tiến trình — không có cách nào cứu riêng những luồng còn lành.
Bài học đo lường: "rẻ" vô nghĩa nếu chưa hỏi "bền chưa". Luồng thắng rõ ràng về chi phí tạo và chuyển ngữ cảnh, nhưng nếu tôi chỉ đo cái đó rồi chọn luồng cho mọi thứ, tôi đã đổi mất một thứ không nằm trên đồng hồ: khả năng một phần hỏng mà không kéo sập cả hệ thống. Cùng một sự chia sẻ vừa là ưu điểm (giao tiếp nhanh, tạo rẻ) vừa là nhược điểm (không cô lập lỗi) — chọn cái nào là chọn cả gói, không chỉ con số tốc độ.
Luồng không hề miễn phí về bộ nhớ
"Nhẹ hơn tiến trình" dễ khiến ta tưởng luồng gần như không tốn gì, nên cứ tạo vài nghìn cái. Nhưng mỗi luồng cần một ngăn xếp riêng — theo mặc định glibc dành sẵn 8MB không gian địa chỉ ảo cho mỗi ngăn xếp luồng. Không gian ảo đó chưa tốn RAM thật cho tới khi chạm tới (nhờ cấp phát lười, như bài bộ nhớ ảo đã đo), nên tạo nghìn luồng không ngốn 8GB RAM ngay. Nhưng nó ngốn không gian địa chỉ, và trên tiến trình 32-bit hay khi đặt giới hạn, con số đó là trần thật: 1000 luồng × 8MB = 8GB địa chỉ ảo. Đó là lý do các mô hình phục vụ hàng vạn kết nối không tạo một luồng cho mỗi kết nối, mà dùng một ít luồng cộng epoll — quay lại đúng bài toán ở các phần trước của sê-ri. Muốn nhiều luồng, hãy giảm cỡ ngăn xếp mặc định (pthread_attr_setstacksize) cho hợp với việc chúng thật sự làm.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chọn luồng hay tiến trình theo nhu cầu cô lập, không chỉ theo tốc độ. Dùng luồng khi các phần việc tin cậy lẫn nhau và cần chia sẻ dữ liệu nhiều, nhanh (một máy chủ xử lý request, một pipeline tính toán chia sẻ một cấu trúc lớn) — chi phí thấp và giao tiếp qua bộ nhớ chung là điểm mạnh. Dùng tiến trình khi cần cách ly lỗi hoặc bảo mật: một worker xử lý dữ liệu không tin cậy, một plugin của bên thứ ba, một tác vụ dễ crash. Cái giá 3× lúc tạo là nhỏ so với việc một lỗi trong một worker kéo sập cả server.
Hệ quả thứ hai: đây là lý do nhiều phần mềm lớn cố tình dùng tiến trình dù đắt hơn. Trình duyệt Chrome chạy mỗi tab (hay nhóm tab) trong một tiến trình riêng: một trang web làm crash renderer của nó thì chỉ tab đó chết, các tab khác sống. Máy chủ như nginx hay PostgreSQL dùng nhiều tiến trình worker để một worker hỏng không hạ cả dịch vụ. Họ trả cái giá tạo đắt hơn để mua lấy sự cô lập — đúng cái đánh đổi bài này đo.
Hệ quả thứ ba: luồng chia sẻ bộ nhớ nghĩa là phải khóa. Vì mọi luồng thấy chung một bộ nhớ, hai luồng cùng sửa một dữ liệu sẽ giẫm chân nhau (đua tranh dữ liệu) trừ khi bạn dùng khóa (như mutex/futex đã đo). Tiến trình thì mặc định không chia sẻ nên không có lớp lỗi này — muốn chia sẻ phải cố ý (bộ nhớ chia sẻ). Con số mang theo: tạo một luồng rẻ hơn tạo một tiến trình ~3 lần (15µs so với 46µs) vì luồng chia sẻ không gian địa chỉ thay vì sao chép; nhưng chính sự chia sẻ đó khiến một luồng segfault giết cả tiến trình, trong khi một tiến trình con crash không đụng tới cha. Rẻ đổi lấy mong manh; đắt đổi lấy cô lập. Chọn theo cái bạn cần, không theo cái đồng hồ khen.
Thử ba mươi giây
Xem một tiến trình đa luồng có bao nhiêu luồng: ls /proc/<pid>/task | wc -l — mỗi thư mục con trong task/ là một luồng, tất cả chia sẻ cùng một pid tiến trình nhưng có tid (thread id) riêng. So với ps --ppid <pid> liệt kê các tiến trình con riêng biệt. Rồi để thấy sự cô lập: mở hai tab trong trình duyệt, xem chúng trong trình quản lý tác vụ của trình duyệt (Chrome: Shift+Esc) — mỗi tab một tiến trình riêng với PID riêng. Đóng băng hay crash một tab, các tab khác vẫn chạy: đó chính là cái giá tạo đắt hơn mua lấy sự cô lập mà bài này đo, đang bảo vệ bạn mỗi ngày.