File descriptor: hai handle có thể dùng chung vị trí đọc
So sánh descriptor được nhân bản hoặc kế thừa với hai lần open độc lập, rồi kiểm tra vị trí đọc, close-on-exec và thời điểm pipe trả EOF.

Hai file descriptor có thể trỏ tới cùng một open file description, tức đối tượng trong kernel lưu trạng thái của một lần mở tệp, và vì vậy dùng chung vị trí đọc. Nếu viết mã Unix, bạn sẽ gặp điều này khi lần đọc trong tiến trình con làm thay đổi vị trí đọc tiếp theo của tiến trình cha. Descriptor là handle cục bộ của một tiến trình; open file description là đối tượng bên dưới mà handle tham chiếu tới.
Tài liệu open của Linux nêu rõ sự phân biệt này. Một lần open tạo open file description mới. Nhân bản descriptor hoặc kế thừa nó qua fork có thể tạo thêm handle tới đối tượng đã tồn tại. Vì vậy, các thao tác này không nhất thiết tạo cùng hành vi về vị trí đọc.
Bắt đầu bằng tệp sáu byte
Giả sử một tệp thường chứa đúng ABCDEF. Tiến trình mở tệp để đọc, nhận descriptor số ba với offset bằng không. Sau đó nó nhân bản descriptor thành số bốn. Cả hai cùng trỏ tới một open file description.
Đọc hai byte qua descriptor ba trả AB và đưa offset chung tới hai. Đọc tiếp hai byte qua descriptor bốn trả CD, rồi đưa cùng offset đó tới bốn. Đổi descriptor không đưa vị trí đọc về đầu vì offset nằm ở đối tượng dùng chung.
| Thao tác | Byte trả về | Offset chung sau thao tác |
|---|---|---|
| Mở rồi nhân bản descriptor | Chưa đọc | 0 |
| Đọc 2 byte qua fd 3 | AB | 2 |
| Đọc 2 byte qua fd 4 | CD | 4 |
| Đọc 2 byte qua fd 3 | EF | 6 |
Đối chiếu bằng cách mở cùng đường dẫn hai lần. Mỗi open thành công tạo open file description riêng. Nếu cả hai bắt đầu ở offset không, lần đọc hai byte đầu tiên trên mỗi descriptor đều trả AB. Nội dung tệp là một, nhưng hai lần mở giữ vị trí riêng.
Các mũi tên trong hình cho thấy hai handle đi tới một offset chung ở trường hợp đầu, còn hai lần mở độc lập đi tới hai offset khác nhau. Nếu chỉ ghi số descriptor, bạn sẽ bỏ qua trạng thái quyết định lần đọc kế tiếp.
Fork giữ lại quan hệ tham chiếu
Sau fork, tiến trình con có bảng descriptor của riêng nó. Các mục kế thừa trỏ tới cùng open file description với mục tương ứng của cha. Tài liệu fork ghi rõ việc chia sẻ offset và cờ trạng thái tệp.
Mở tệp trước fork, cho cha đọc AB, rồi mới cho con đọc. Nếu các thao tác diễn ra theo đúng thứ tự đó và mỗi lần đọc thành công hai byte, con nhận CD. Biến cục bộ chứa số ba được sao chép vào bộ nhớ riêng, nhưng descriptor ba trong hai tiến trình vẫn đi tới trạng thái kernel dùng chung.
Giả định thứ tự là cần thiết. Nếu không đồng bộ, lịch chạy quyết định bên nào đọc trước. Việc chương trình minh họa trên một máy luôn in dòng của cha trước không chứng minh có bảo đảm như vậy. Có thể dùng pipe để phối hợp hoặc đợi một tiến trình hoàn tất thao tác trước khi quan sát bên kia.
Cũng cần phân biệt cập nhật offset nguyên tử với ranh giới bản ghi của ứng dụng. Ngay cả khi hệ điều hành cập nhật offset chung một cách nguyên tử cho lần đọc tệp thường, bộ phân tích của bạn có thể dùng nhiều lời gọi để đọc một bản ghi. Bên đọc khác có thể chạy giữa các lời gọi đó. Nếu một parser cần sở hữu cả chuỗi byte, chia sẻ descriptor với parser khác có thể là thiết kế không phù hợp.
Đóng một handle không đóng mọi handle
close descriptor ba xóa mục đó khỏi bảng của tiến trình. Descriptor bốn vẫn có thể dùng được vì nó còn tham chiếu tới open file description. Kernel giải phóng đối tượng khi không còn tham chiếu liên quan, theo quy tắc của loại đối tượng cụ thể.
Sau close, số descriptor có thể được tái sử dụng. Một biến vẫn giữ số ba có thể vô tình chỉ tới socket hoặc tệp không liên quan vừa được mở sau đó. Mã bất đồng bộ lưu số descriptor phải quản lý quyền sở hữu rõ ràng. Đóng rồi mở tài nguyên khác có thể biến thao tác lặp nhầm thành thao tác trên sai đối tượng.
Pipe làm việc quản lý tham chiếu dễ quan sát. Bên đọc nhận EOF khi tất cả đầu ghi đã đóng và dữ liệu đệm đã được đọc hết. Một tiến trình con giữ đầu ghi không dùng có thể khiến bên đọc tiếp tục đợi dù tiến trình thực sự tạo dữ liệu đã kết thúc.
Cờ descriptor khác cờ trạng thái tệp
Close-on-exec là cờ của descriptor. Nó quyết định descriptor có tồn tại sau khi thay chương trình thành công hay không. Cờ trạng thái tệp, chẳng hạn chế độ nonblocking với đối tượng phù hợp, gắn với open file description và có thể được chia sẻ giữa các descriptor nhân bản. Nhầm hai nhóm cờ có thể gây thay đổi bất ngờ ở mã dùng chung handle.
Trong ứng dụng nhiều luồng, yêu cầu close-on-exec ngay khi tạo descriptor tránh khoảng trống giữa lúc tạo và lúc đặt cờ. Trong khoảng đó, luồng khác có thể tạo tiến trình con rồi làm rò descriptor sang con. Tài liệu open giải thích mục đích của O_CLOEXEC đối với cuộc đua này.
Nếu cần vị trí độc lập, bạn có thể mở riêng. Một lựa chọn khác là I/O có vị trí tường minh như pread: lời gọi nhận offset riêng và không dùng offset hiện tại của open file description theo cách đọc thông thường. Tuy nhiên, vẫn cần quy tắc cho việc nội dung tệp bị thay đổi đồng thời. Offset độc lập không tạo bản chụp dữ liệu bất biến.
Kiểm tra EOF bằng một đầu ghi được nhân bản
Ví dụ Python dưới tạo pipe rồi nhân bản đầu ghi. Sau khi chỉ đóng đầu ghi gốc, lần đọc không chặn trên pipe rỗng báo rằng nó chưa thể hoàn tất, bởi bản sao đầu ghi vẫn tồn tại. Đóng nốt bản sao rồi đọc lại trả chuỗi byte rỗng, tức EOF.
import os
read_end, write_end = os.pipe()
extra_writer = os.dup(write_end)
try:
os.set_blocking(read_end, False)
os.close(write_end)
try:
os.read(read_end, 1)
except BlockingIOError:
pass
else:
raise AssertionError("an open writer should prevent empty-pipe EOF")
os.close(extra_writer)
extra_writer = -1
assert os.read(read_end, 1) == b""
finally:
os.close(read_end)
if extra_writer != -1:
os.close(extra_writer)
Ví dụ hướng tới Unix và cố ý đặt bên đọc ở chế độ không chặn để quan sát đầu tiên không làm bài tập treo. Không có dữ liệu được ghi nên byte còn đệm không che khuất quy tắc về thời gian sống. Trong ứng dụng còn dữ liệu đệm, bên đọc nhận phần dữ liệu đó trước lần đọc rỗng cuối cùng.
Quan hệ tham chiếu này có thể trải qua nhiều tiến trình sau fork. Cha có thể ngừng tạo đầu ra và đóng đầu ghi, trong khi một tiến trình con không liên quan vẫn giữ bản kế thừa. Bên đọc chưa thể kết luận sẽ không có lần ghi nào nữa cho đến khi mọi tham chiếu ghi liên quan biến mất. Đóng đầu không dùng là một phần của giao thức, kể cả khi tiến trình giữ chúng không bao giờ gọi write.
Vẽ quyền sở hữu trước khi thêm xử lý bất đồng bộ
Với mỗi descriptor, xác định phần mã chịu trách nhiệm đóng nó và lúc quyền sở hữu được chuyển giao. Hàm mượn descriptor không nên đóng trừ khi giao diện có quy định. Hàm nhận quyền sở hữu phải dọn dẹp cả khi thành công và thất bại. Giữ một số nguyên trong callback không giữ cho descriptor còn sống.
Để luyện tập, vẽ tiến trình cha, tiến trình con tạo dữ liệu và tiến trình con đọc. Gán cho mỗi bên các bản descriptor mà nó nhận sau hai lần fork, rồi gạch đi đầu không cần dùng. Con đọc cần đầu đọc; con tạo dữ liệu cần đầu ghi; cha có thể không cần đầu nào sau khi thiết lập. Kiểm tra lại nếu khởi chạy thất bại trước exec. Một nhánh lỗi còn giữ đầu ghi có thể khiến bên đọc vốn chạy thành công đợi mãi, dù lỗi ban đầu nằm ở tiến trình khác.
Tài liệu pipe của Linux quy định EOF sau khi mọi descriptor ghi đã đóng và hành vi của pipe rỗng ở chế độ không chặn.