Mọi thao tác file trong Unix đi qua một mô tả tệp (file descriptor, fd) — cái số nhỏ như 0, 1, 2 mà read/write nhận. Ở bài rlimit ta thấy có giới hạn số fd; bài này nói về cơ chế: fd thật ra là gì, và vì sao hai fd tới cùng một file có thể cư xử hoàn toàn khác nhau. Tôi đo trong container, và câu trả lời phá vỡ một giả định tôi mang theo bao lâu nay.

Mô tả tệp (fd)

fd là chỉ mục, không phải bản thân file

Một fd không phải là file. Nó chỉ là một số nguyên nhỏ — một chỉ mục vào một bảng riêng của mỗi tiến trình: bảng file descriptor. Mỗi ô trong bảng đó trỏ tới một đối tượng của nhân gọi là open file description (mô tả tệp đang mở), và chính đối tượng này giữ những thứ quan trọng: offset (vị trí đọc/ghi hiện tại) và các cờ trạng thái. Nói cách khác có ba tầng: số fdô trong bảng fdopen file description (offset + cờ) → và cuối cùng là file thật trên đĩa.

Sự phân tầng này giải thích một hành vi mà nhiều người hiểu sai. Khi bạn có hai fd cùng trỏ tới một file, chúng có chia sẻ vị trí đọc không? Câu trả lời là: tùy — tùy hai fd đó cùng trỏ vào một open file description hay vào hai cái khác nhau. Tôi đo cả hai trường hợp, đọc offset thật từ /proc/self/fdinfo.

Đo: cùng hai fd, hai thế giới

Tôi tạo một file chứa ABCDEFGHIJ..., rồi thử ba cách có hai fd:

Cách A — open rồi DUP:
  fd1=3, fd2=dup(fd1)=4 | pos ban đầu: fd1=0, fd2=0
  đọc 5 byte qua fd1: 'ABCDE' | pos: fd1=5, fd2=5   (fd2 CŨNG dời!)
  đọc 5 byte qua fd2: 'FGHIJ'   <- fd2 đi tiếp từ chỗ fd1 dừng

Cách B — OPEN hai lần:
  fd1=open(...)=3, fd2=open(...)=4 | pos: fd1=0, fd2=0
  đọc 5 byte qua fd1: 'ABCDE' | pos: fd1=5, fd2=0   (fd2 không đổi)
  đọc 5 byte qua fd2: 'ABCDE'   <- fd2 đọc lại từ ĐẦU

Cách C — FORK:
  con đọc 3 byte: 'ABC' | cha đọc 3 byte: 'DEF'   <- cha đi tiếp sau con

Ba khối này là ba câu chuyện. Với dup (cách A), đọc qua fd1 làm pos của cả fd1 lẫn fd2 nhảy lên 5 — vì chúng trỏ vào cùng một open file description, chung một offset. Nên fd2 đọc tiếp 'FGHIJ', không phải 'ABCDE'. Với open hai lần (cách B), mỗi lần open tạo một open file description riêng, nên fd2 giữ offset 0 và đọc lại 'ABCDE' từ đầu — hai vị trí độc lập. Với fork (cách C), tiến trình con và cha thừa kế cùng open file description, nên sau khi con đọc 'ABC', cha đọc tiếp 'DEF' — offset được chia sẻ qua ranh giới tiến trình.

Và một chi tiết nhỏ nhưng quan trọng: khi tôi đóng fd số 1 (stdout) rồi open một file, nó được cấp fd = 1 — nhân luôn cấp số fd nhỏ nhất chưa dùng. (Đây chính là mẹo đứng sau chuyển hướng trong shell.)

Một lần tôi đo hớ: "hai fd = hai vị trí" là sai một nửa

Tôi vào bài với niềm tin đơn giản: "hai file descriptor tới cùng một file thì có hai vị trí đọc độc lập, đọc cái này không ảnh hưởng cái kia". Cách B khẳng định niềm tin đó. Nhưng cách A phá vỡ nó hoàn toàn: hai fd tạo bằng dup chia sẻ một offset, đọc qua fd1 dời luôn vị trí của fd2. Cùng một mô tả "hai fd, một file", hành vi ngược hẳn nhau — và biến ẩn quyết định là cách bạn có hai fd đó: dup/fork cho chung, open cho riêng.

Đây là một cái bẫy đua tranh khó chịu trong thực tế. Nếu bạn dup một fd (hoặc để hai luồng/tiến trình dùng chung một fd thừa kế qua fork) rồi cho hai bên cùng đọc/ghi, chúng sẽ giẫm lên offset của nhau: bên này ghi ở vị trí X, bên kia tưởng mình đang ở vị trí Y nhưng thực ra offset đã bị bên kia dời. Kết quả là dữ liệu chồng chéo hoặc bỏ sót, một lỗi cực khó lần vì code trông như hai "kênh" độc lập.

Bài học đo lường: hai thứ trông giống nhau về bề mặt (hai fd, cùng file) có thể mang ngữ nghĩa ngược nhau tùy một chi tiết ẩn. Khi hai đường code cùng chạm một file, câu hỏi đúng không phải "chúng có phải hai fd khác nhau không" mà là "chúng có chung một open file description không" — và câu trả lời nằm ở cách hai fd đó ra đời.

Bảng fd sống ở đâu, và ai chia sẻ nó

Có một tầng nữa đáng phân biệt: bảng fd (danh sách các số fd → open file description) thuộc về tiến trình, còn open file description thuộc về nhân và có thể được nhiều tiến trình chia sẻ. Khi fork, con nhận một bản sao của bảng fd cha, nhưng các ô sao chép vẫn trỏ vào cùng open file description — nên con và cha chung offset (như cách C đo được), nhưng nếu con close một fd thì chỉ ô trong bảng của con biến mất, cha vẫn giữ. Ngược lại, dup sao chép một ô trong cùng một bảng (cùng tiến trình) trỏ vào cùng description. Sự phân biệt "bảng fd riêng mỗi tiến trình, open file description chung được" chính là thứ làm cho fd vừa cô lập (số fd của tiến trình này không dính tới tiến trình kia) vừa chia sẻ được (hai tiến trình có thể cùng đọc/ghi một luồng, như hai đầu của một pipe).

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

Hệ quả đầu tiên: hiểu chia sẻ offset để tránh lỗi ghi đè. Khi truyền một fd cho nhiều nơi dùng — qua dup, qua fork, hay một fd toàn cục nhiều luồng dùng — hãy nhớ chúng chung một con trỏ vị trí. Muốn hai bên đọc/ghi độc lập cùng một file, phải open riêng (mỗi bên một offset), hoặc dùng pread/pwrite (đọc/ghi tại một offset chỉ định, không đụng tới offset chung). Đây là lý do các thư viện I/O đa luồng thường dùng pread/pwrite: chúng không phụ thuộc offset chung nên an toàn khi nhiều luồng chia sẻ fd.

Hệ quả thứ hai: cơ chế này chính là nền của chuyển hướng shell. Khi bạn gõ (cmd1; cmd2) > file, shell mở file một lần rồi để cả cmd1cmd2 thừa kế cùng fd (chung offset) qua fork — nên đầu ra của chúng ghi nối tiếp đúng thứ tự, không đè lên nhau. Và 2>&1 là một dup2 biến fd 2 (stderr) thành bản sao của fd 1 (stdout) — chung một open file description, nên stdout và stderr trộn vào cùng một chỗ tại một offset chung. Hiểu fd là hiểu tại sao những cấu trúc này hoạt động.

Hệ quả thứ ba là một bài học rộng: fd là một lớp gián tiếp, và lớp gián tiếp đó có trạng thái chia sẻ được. Con số mang theo: fd chỉ là chỉ mục vào bảng fd của tiến trình, trỏ tới một open file description giữ offset và cờ; dup/fork tạo fd mới trỏ CHUNG description đó (chung offset, đọc lẫn nhau), còn open hai lần tạo hai description RIÊNG (offset độc lập) — nên "hai fd cùng file" có thể chia sẻ hay không tùy cách tạo, và nhân luôn cấp số fd nhỏ nhất chưa dùng. Khi hai chỗ cùng chạm một file, đừng đoán — hỏi chúng có chung open file description không.

Thử ba mươi giây

Xem các fd của một tiến trình đang chạy: ls -l /proc/<pid>/fd liệt kê từng fd và nó trỏ tới file/socket/pipe nào. Rồi xem chi tiết offset: cat /proc/<pid>/fdinfo/<n> in ra pos (offset hiện tại) và flags của fd đó. Để tận mắt thấy chia sẻ offset, chạy trong bash: exec 3< /etc/hostname; head -c 3 <&3; head -c 3 <&3 — hai lần đọc qua cùng fd 3 (chung offset) sẽ cho hai đoạn khác nhau, đoạn sau đi tiếp sau đoạn trước; còn head -c 3 /etc/hostname; head -c 3 /etc/hostname (mỗi lần một open mới) cho hai đoạn giống nhau từ đầu. Khác biệt đó chính là chung-offset so với offset-độc-lập mà bài này đo.