Trên Unix, gần như mọi thứ đều là một file descriptor (fd): file đang mở, socket mạng, pipe, epoll, timerfd — tất cả đều tốn một fd. Nhưng số fd một tiến trình mở được là hữu hạn, và khi cạn, open/socket/accept bắt đầu thất bại — server ngừng nhận kết nối, chương trình ngừng đọc file, thường kèm dòng lỗi "Too many open files". Bài này đo giới hạn đó và điều gì xảy ra khi chạm — và phát hiện có hai giới hạn khác nhau với hai thông báo lỗi gần như y hệt, dẫn tôi tới một lần suýt vá nhầm chỗ.

Cạn file descriptor

Hai giới hạn file descriptor

Có hai trần khác nhau, và biết mình đang chạm cái nào là mấu chốt. Thứ nhất là RLIMIT_NOFILE — giới hạn số fd của mỗi tiến trình. Nó có hai mức: hạn mềm (soft, giá trị thực sự áp dụng) và hạn cứng (hard, trần tối đa mà hạn mềm được phép nâng tới). Đây là con số bạn chỉnh bằng ulimit -n. Thứ hai là fs.file-max — giới hạn toàn hệ thống, tổng số file mở của mọi tiến trình cộng lại, chỉnh bằng sysctl.

Khi chạm hạn mỗi tiến trình, lời gọi trả về lỗi EMFILE. Khi chạm hạn toàn hệ thống, lỗi là ENFILE. Điều nguy hiểm — và là trọng tâm bài này — là chuỗi thông báo của hai lỗi đó gần như giống hệt nhau. Tôi muốn đo cả hai và xem chúng khác nhau chỗ nào.

Đo: chạm hạn tiến trình, rồi chạm hạn hệ thống

Máy đo (Docker) có hạn mỗi tiến trình cao bất ngờ: mềm = cứng = 1.048.576, còn file-max toàn hệ thống = 811.758. Tôi đo hai tình huống. Tình huống A: cố tình hạ hạn mềm xuống 64, rồi mở /dev/null liên tục tới khi thất bại:

open thất bại ở fd 61 (= 64 - 3 cho stdin/stdout/stderr)
errno 24 = "Too many open files"   (EMFILE, per-process)

Đúng như dự đoán: mở được 61 fd (64 trừ 3 fd chuẩn đã chiếm 0/1/2), rồi EMFILE. Đây là chạm hạn mỗi tiến trình, và cách chữa là nâng hạn mềm — mà tôi làm được ngay trong chương trình bằng setrlimit, không cần quyền root, miễn là không vượt hạn cứng. Hạn mềm là một cái trần tự đặt, không phải bức tường cứng: nhiều hệ thống mặc định để hạn mềm thấp (kinh điển là 1024) trong khi hạn cứng cao gấp nghìn lần, nên một tiến trình gặp EMFILE sớm chỉ vì cái trần tự đặt đó, và tự nới ra là xong.

Tình huống B: nâng hạn mềm lên tối đa (1 triệu) rồi mở tiếp tới khi thất bại:

open thất bại ở fd 810.943
errno 23 = "Too many open files in system"   (ENFILE, toàn hệ thống)

Lần này khác hẳn: dù hạn mỗi tiến trình là 1 triệu, tôi chỉ mở được tới ~810 nghìn thì thất bại — vì chạm file-max toàn hệ thống (811.758). Và errno khác: ENFILE, với thông báo "Too many open files in system".

Một lần tôi đo hớ: ba chữ "in system" đổi cả cách chữa

Đây là chỗ suýt lừa tôi. Thực ra, phép đo đầu tiên của tôi không phải kịch bản gọn gàng ở trên. Tôi chỉ mở fd liên tục với hạn mặc định (1 triệu) và chạm ngay lỗi "Too many open files in system" ở fd thứ 810 nghìn. Phản xạ của tôi — và của gần như mọi lập trình viên khi thấy "Too many open files" — là "À, cạn fd rồi, tăng ulimit -n lên là xong". Tôi đã thử nâng hạn mềm... và nó chẳng giúp gì cả. Mở lại vẫn thất bại ở đúng chỗ đó.

Lý do lộ ra khi tôi đọc chính xác thông báo lỗi thay vì lướt qua. "Too many open files" (EMFILE) và "Too many open files in system" (ENFILE) khác nhau đúng ba chữ in system — nhưng ba chữ đó đổi hoàn toàn nguyên nhân và cách chữa. EMFILE là hạn mỗi tiến trình, chữa bằng ulimit -n/setrlimit. ENFILE là hạn toàn hệ thống, chữa bằng sysctl fs.file-max. Tôi chạm ENFILE nhưng lại đi vá EMFILE — nâng ulimit trong khi giới hạn đang bị chạm nằm ở tầng hệ thống, nên gõ cả buổi cũng vô ích.

Bài học đo lường: đọc chính xác thông báo lỗi trước khi vá — cùng một câu chữ có thể là hai nguyên nhân khác nhau. Cái bẫy ở đây tinh vi hơn các bài trước: không phải tôi đo nhầm đại lượng, mà tôi đọc thiếu thông tin đã có sẵn ngay trong lỗi, rồi vá theo phản xạ. Một thông báo lỗi là dữ liệu đo; đọc lướt nó rồi áp giải pháp quen thuộc là cách chắc chắn để sửa nhầm chỗ. Ba chữ "in system" ở đây đáng giá cả một buổi gỡ lỗi.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là rò rỉ fd là nguyên nhân số một khiến dịch vụ "chạy vài giờ rồi ngừng nhận kết nối". Mỗi socket, mỗi file, mỗi kết nối HTTP client bạn quên close là một fd không được trả lại; một server xử lý hàng nghìn request mà rò rỉ vài fd mỗi request sẽ cạn dần cho tới khi accept trả EMFILE và ngừng phục vụ — trong khi RAM và CPU vẫn nhàn. Luôn đóng fd (dùng try-with-resources/defer/with/context manager để đảm bảo đóng cả khi có ngoại lệ), và theo dõi số fd đang mở như một chỉ số sức khỏe.

Hệ quả thứ hai là khi thật sự cần nhiều fd, nâng đúng giới hạn đang chạm. Một server nhiều kết nối (proxy, database, message broker) hợp lệ cần hàng chục nghìn fd. Nếu chạm EMFILE, nâng hạn mềm (ulimit -n, hay LimitNOFILE= trong systemd, hay --ulimit nofile= trong Docker) — và bạn làm được tới tận hạn cứng mà không cần root. Nhưng nếu chạm ENFILE, ulimit vô dụng; phải nâng fs.file-max toàn hệ thống bằng sysctl. Chẩn đoán sai tầng là gõ nhầm lệnh cả buổi.

Hệ quả thứ ba, về đo lường và gỡ lỗi: thông báo lỗi là một phép đo — hãy đọc nó chính xác tới từng chữ. Con số mang theo: có hai giới hạn fd: mỗi tiến trình (RLIMIT_NOFILE, lỗi EMFILE "Too many open files", chữa bằng ulimit -n — nâng được tới hạn cứng không cần root) và toàn hệ thống (fs.file-max, lỗi ENFILE "Too many open files in system", chữa bằng sysctl); hai thông báo chỉ khác ba chữ "in system" nhưng cần hai cách chữa khác nhau. Trước khi áp một bản vá quen thuộc, đọc kỹ lỗi xem nó thật sự nói gì — sự khác biệt nhỏ nhất trong câu chữ có thể là toàn bộ vấn đề.

Thử ba mươi giây

Xem giới hạn fd của bạn: ulimit -Sn (hạn mềm) và ulimit -Hn (hạn cứng) cho mỗi tiến trình; cat /proc/sys/fs/file-max cho toàn hệ thống; và cat /proc/sys/fs/file-nr cho biết hiện đang mở bao nhiêu (cột đầu) trên tổng file-max (cột cuối). Đếm fd một tiến trình đang mở: ls /proc/<pid>/fd | wc -l — con số này tăng đều mà không giảm là dấu hiệu rò rỉ fd. Muốn nâng hạn mềm ngay trong phiên shell (tới tận hạn cứng, không cần root): ulimit -n <số>. Và lần tới gặp "Too many open files", hãy đọc kỹ: nếu có ba chữ "in system", đừng phí thời gian với ulimit — đó là ENFILE, phải sửa sysctl fs.file-max, đúng cái bẫy bài này đo.