"Too many open files" là một trong những lỗi làm người ta bối rối nhất: chương trình đang chạy tốt bỗng không mở nổi thêm một file, một socket, một kết nối nào nữa. Câu hỏi tự nhiên là: giới hạn đó nằm ở đâu, chạm vào nó thì nhận được gì, và ai có quyền nới nó ra? Là phần cuối của loạt bài về hệ điều hành, đây là chủ đề khép lại đúng tinh thần cả sê-ri — vì giới hạn tài nguyên chính là cái trần vô hình mà môi trường dựng sẵn, và ta sẽ thấy nó không phải một con số mà là nhiều tầng trần lồng nhau, mỗi tầng nói một loại lỗi khác nhau.
Giới hạn mềm và giới hạn cứng
Mỗi tiến trình mang theo một bộ giới hạn tài nguyên (rlimit): số file mở tối đa (RLIMIT_NOFILE), số tiến trình một người dùng được tạo (RLIMIT_NPROC), dung lượng không gian địa chỉ (RLIMIT_AS), kích thước stack, thời gian CPU... Mỗi giới hạn có hai mức: mềm (soft) là mức đang thực sự bị siết, và cứng (hard) là trần mà mức mềm được phép nâng tới. Điểm mấu chốt — mà ta sẽ đo — là: một tiến trình tự nâng được mức mềm của mình lên tới mức cứng mà không cần quyền root; chỉ việc nâng mức cứng mới cần đặc quyền.
Khi chạm giới hạn, hệ thống không trả về một lỗi chung chung mà một mã lỗi riêng cho từng loại: hết fd thì EMFILE, fork hết chỗ thì EAGAIN, hết bộ nhớ thì malloc trả NULL (ENOMEM). Chính sự riêng biệt đó — như ta sẽ thấy ở phần đo hớ — là thứ cứu tôi khỏi một kết luận sai.
Đo: chạm ba giới hạn, và con số luôn lệch
Tôi hạ từng giới hạn xuống mức nhỏ trong một container rồi đâm thẳng vào nó, không nuốt lỗi, đọc kỹ errno:
| Giới hạn | Đặt | Thực chạm | Lỗi (errno) |
|---|---|---|---|
RLIMIT_NOFILE (fd) |
200 | mở được 197 | EMFILE (24) |
RLIMIT_AS (bộ nhớ) |
64 MB | cấp được ~56 MB | ENOMEM (12) |
RLIMIT_NPROC (tiến trình) |
40 | tạo được 38 con | EAGAIN (11) |
Cả ba con số đều lệch so với mức đặt, và lệch theo cùng một quy luật: mỗi giới hạn đếm mọi thứ đã tiêu tốn từ trước, không chỉ phần tôi vừa xin thêm. Đặt trần fd là 200 nhưng chỉ mở thêm được 197, vì fd 0, 1, 2 (đầu vào/ra/lỗi chuẩn) đã chiếm sẵn 3 chỗ — và quả nhiên fd cao nhất mở được là 63 khi tôi đặt trần 64. Đặt trần bộ nhớ 64 MB nhưng malloc chỉ cấp thêm ~56 MB, vì bản thân mã lệnh, stack và thư viện của tiến trình đã ăn ~8 MB không gian địa chỉ. Đặt trần 40 tiến trình nhưng chỉ fork được 38 con, vì tiến trình cha và cái vỏ (shell) đã chiếm 2 suất. "Trần 200" không có nghĩa "được làm 200 lần" — nó là trần tính cả những gì đã có mặt.
(Một chi tiết trung thực: RLIMIT_NPROC bị bỏ qua với root. Chạy thử dưới uid 0, fork không bao giờ thất bại; phải chạy dưới một người dùng thường mới thấy EAGAIN ở con thứ 38. Bản thân "ai đang chạy" cũng là một biến ẩn của phép đo.)
Một lần tôi đo hớ: tôi nâng trần của mình rồi đụng một cái trần khác
Đến đây tôi mắc đúng cái sai mà tôi tưởng mình đã đề phòng. Thấy giới hạn fd chặn ở 197, phản xạ đầu tiên là nghĩ "hết fd rồi, phải nhờ quản trị viên nâng trần". Nhưng mức mềm chỉ là một cái chốt tự đặt, không phải trần thật. Tôi tự gọi setrlimit nâng mức mềm lên bằng mức cứng (1.048.576) — lời gọi trả về thành công, không cần root. Vậy là "hết fd" thường tự sửa được ngay trong code; niềm tin "phải nhờ admin" đã sai.
Nhưng cái sai thứ hai mới đáng nhớ. Sau khi nâng mức mềm lên 1.048.576, tôi đinh ninh mình sẽ mở được ngần ấy file. Tôi mở tiếp trong một vòng lặp — và nó dừng ở khoảng 810.000, không phải 1.048.576. Hai con số vênh nhau hẳn. Nếu tôi nuốt lỗi bằng /dev/null và chỉ đếm, tôi đã ghi vào bài "chạm rlimit của mình ở ~810k" — một kết luận sai hoàn toàn. May là tôi đọc errno: nó không phải EMFILE mà là ENFILE — "Too many open files in system". Chữ "in system" là toàn bộ câu chuyện: tôi không còn chạm trần của tiến trình mình nữa, mà đã đụng trần toàn hệ thống — fs.file-max, đo được đúng 811.758, khớp khít con số dừng.
Đây là bài học chốt của cả sê-ri, gói trong một phép đo: hai con số không khớp nghĩa là đang đo nhầm đại lượng. Tôi tưởng đang đo trần fd của mình, thực ra sau khi nâng nó thì đại lượng bị chặn đã đổi thành trần fd của cả máy — một cái trần khác, một mã lỗi khác, và là cái tôi không tự nới được (nó cần sysctl với quyền root). Và thứ duy nhất phân biệt được hai cái trần lồng nhau đó chính là errno — công cụ đang nói thật rằng tôi chạm phải bức tường nào. Bịt nó bằng /dev/null là tự làm mù mình đúng lúc cần nhìn rõ nhất.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là đọc đúng mã lỗi trước khi kết luận "hết tài nguyên". EMFILE (trần của tiến trình bạn) và ENFILE (trần của cả máy) trông na ná trong thông báo nhưng đòi hai cách xử lý khác hẳn: cái đầu bạn tự nâng mức mềm trong code là xong; cái sau phải chỉnh sysctl ở tầng hệ thống. EAGAIN khi fork không phải "hệ thống lỗi" mà là "chạm trần số tiến trình". ENOMEM từ malloc có thể là RLIMIT_AS chứ chưa chắc máy hết RAM. Mã lỗi cụ thể là tấm biển chỉ đường; đọc lướt nó là đi sai hướng sửa.
Hệ quả thứ hai là biết mức mềm là một chốt điều chỉnh, không phải bức tường vật lý. Rất nhiều sự cố "hết fd" trong thực tế chỉ là mức mềm mặc định quá thấp (nhiều bản phân phối để 1024), và ứng dụng hoàn toàn có thể tự nâng nó lúc khởi động (hoặc chỉnh qua ulimit -n, LimitNOFILE trong systemd) mà không cần đụng tới hệ thống. Nhưng cũng phải nhớ có một trần thật nằm phía trên — mức cứng, rồi trần toàn hệ thống — nên nâng mức mềm không phải phép màu vô hạn; nó chỉ mở tới cái trần kế tiếp.
Hệ quả thứ ba khép lại cả loạt bài về hệ điều hành: mọi con số hiệu năng và dung lượng đều nằm trong một cái khung mà môi trường dựng sẵn, và cái khung đó thường vô hình cho tới khi bạn chạm vào nó. Suốt sê-ri này ta đã gặp đi gặp lại điều đó — nhân CPU, THP, page cache, fuseblk, bộ đếm PMU không có trong máy ảo, và giờ là rlimit với các tầng trần lồng nhau. Con số mang theo: chạm giới hạn nào thì có mã lỗi riêng của giới hạn đó; mức mềm tự nâng được không cần root, nhưng phía trên còn mức cứng và trần toàn hệ thống mà bạn không tự nới; và con số thực chạm luôn lệch bởi phần đã tiêu tốn sẵn. Cách duy nhất để biết mình đang đâm vào bức tường nào là làm đúng điều cả sê-ri lặp lại: đừng nuốt lỗi, đọc tín hiệu cụ thể, và khi hai số không khớp thì tin rằng mình đang đo nhầm đại lượng — chứ đừng ép chúng khớp.
Thử ba mươi giây
Gõ ulimit -n trong terminal để xem mức mềm số fd hiện tại của bạn, rồi ulimit -Hn để xem mức cứng — thường mức cứng lớn hơn mức mềm cả nghìn lần, đúng cái khoảng bạn tự nâng được mà không cần quyền gì. Thử ulimit -n 4096 (nâng mức mềm ngay trong phiên shell, không cần sudo) rồi ulimit -n lại để thấy nó đã đổi. Cuối cùng, cat /proc/sys/fs/file-max cho bạn thấy cái trần toàn hệ thống nằm phía trên tất cả — con số mà một tiến trình đơn lẻ, dù nâng mức mềm kịch trần cứng, vẫn không được phép vượt qua. Ba lệnh, ba tầng trần: của phiên, của tiến trình, và của cả máy.