Ở chung một căn hộ với bạn bè thì rẻ và tiện — chung bếp, chung tủ lạnh, nói với nhau một câu là xong. Nhưng nếu một người bất cẩn để cháy bếp, cả căn hộ cháy theo, không ai thoát riêng. Ở phòng riêng thì đắt hơn và muốn nhắn gì phải gọi điện, đổi lại phòng người này cháy thì người kia vẫn ngủ yên. Luồng và tiến trình đúng là hai kiểu ở đó, và tôi suýt chỉ nhìn giá thuê mà quên mất cái giá của hỏa hoạn. Khi cần làm nhiều việc song song, bạn có hai lựa chọn: nhiều luồng trong một tiến trình, hay nhiều tiến trình 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 ở đó.
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.
Điều rút ra: "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ề đến code hằng ngày
Điều đầ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.
Điều 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.
Và cái đánh đổi "chia sẻ để rẻ nhưng mất cô lập" này định hình cả các mô hình đồng thời cấp cao mà bạn dùng hằng ngày. Java đặt hết vào một bên: mọi thread trong một JVM chia sẻ heap, nên một OutOfMemoryError hay một segfault trong JNI hạ cả process — bù lại thread rẻ và giao tiếp qua heap chung; đây cũng là lý do một thread ném exception không bắt không giết cả app (JVM cô lập ở mức exception, không phải bộ nhớ), nhưng một lỗi bộ nhớ tự nhiên thì có. Erlang/BEAM chọn hẳn cực kia: "process" của nó cực nhẹ, không chia sẻ bộ nhớ và giao tiếp bằng message — nên một process sập không lan, và triết lý "let it crash" dựng trên đúng sự cô lập đó. Go nằm giữa: goroutine chia sẻ bộ nhớ như thread (một panic không recover giết cả chương trình), nhưng khuyến khích giao tiếp qua channel để giảm chia sẻ. Ngay cả CSDL: một backend PostgreSQL là một process riêng chính vì lý do bài này — một truy vấn làm crash backend đó không kéo sập cả server. Mẫu số chung: chia sẻ bộ nhớ mua tốc độ và giao tiếp dễ bằng cái giá mất cô lập lỗi; kiến trúc nào cũng phải chọn điểm trên trục đó, và chọn sai là hoặc chậm oan hoặc một lỗi nhỏ kéo sập tất cả. Gói lại bằng số: tạo một luồng rẻ hơn tạo một tiến trình ~3 lần (15,25µs so với 46,45µ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 (tín hiệu 11), trong khi một tiến trình con crash không đụng tới cha. Và luồng chia sẻ bộ nhớ nghĩa là hai luồng cùng sửa một dữ liệu phải khóa (như mutex/futex đã đo), một lớp lỗi mà tiến trình riêng không có. 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.
Muốn tự thấy thì 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.