檔案共享、重導向與標準 I/O (File Sharing, Redirection & Standard I/O)
Overview Table
| 小節 | 主題 | 核心結論 |
|---|---|---|
| 10.8 (p.942) | Sharing Files | kernel 用三張表表示 open files:descriptor table(每 process 一張)、open file table(全系統共享,含 file position 與 refcnt)、v-node table(全系統共享,含 stat 資訊) |
| 10.8 (p.943) | 同檔多開 | 對同一檔案 open 兩次 → 兩個 descriptor 指向不同 file table entry → 各自獨立的 file position |
| 10.8 (p.944) | fork 繼承 | 子行程複製父行程的 descriptor table → 父子指向同一 file table entry → 共享 file position、refcnt 加倍 |
| 10.9 (p.945) | I/O Redirection | dup2(oldfd, newfd) 把 descriptor table entry oldfd 複製到 newfd;shell 的 ls > foo.txt 即靠此實作 |
| 10.10 (p.947) | Standard I/O | libc 把 open file 抽象為 stream(FILE *)= descriptor + stream buffer,目的與 Rio buffer 相同:減少系統呼叫 |
| 10.11 (p.947) | 該用哪套 I/O | G1 儘量用 standard I/O;G2 別用 scanf/rio_readlineb 讀 binary;G3 socket 一律用 Rio |
| 10.12 (p.949) | Summary | descriptor table → file table → v-node table 的三層結構,同時解釋 file sharing 與 redirection |
10.8 Sharing Files:kernel 的三張表 (p.942)
kernel 用三個相互關聯的資料結構表示 open files,理解它們是理解 file sharing 的前提:
- Descriptor table:每個 process 各有一張,以該 process 的 open file descriptor 為索引;每個 entry 指向 file table 的一個 entry。
- Open file table:所有 process 共享。每個 entry 包含目前 file position、reference count(refcnt,記錄有幾個 descriptor entry 指向它)、以及指向 v-node table entry 的指標。
close一個 descriptor 會使 refcnt 減 1;refcnt 歸零 kernel 才會刪除該 entry。 - v-node table:所有 process 共享。每個 entry 含
stat結構的大部分資訊(如st_mode、st_size,見 10-System-IO/02-Rio-Package-and-File-Metadata)。
典型情況:無共享(Figure 10.12, p.943)
descriptor 1 與 4 透過不同 file table entry 指向兩個不同檔案:
Descriptor table Open file table v-node table
(每 process 一張) (全 process 共享) (全 process 共享)
fd 0 File A
fd 1 ──────────────► ┌──────────┐ ┌────────────┐
fd 2 │ File pos │ ───────► │ File access│
fd 3 │ refcnt=1 │ │ File size │
fd 4 ────────┐ └──────────┘ │ File type │
│ └────────────┘
│ File B
└──────► ┌──────────┐ ┌────────────┐
│ File pos │ ───────► │ File access│
│ refcnt=1 │ │ File size │
└──────────┘ │ File type │
└────────────┘
情況二:同一檔案開兩次(Figure 10.13, p.943)
對同一 filename 呼叫 open 兩次 → 兩個 descriptor 各有自己的 file table entry(各自的 file position),但都指向同一個 v-node entry:
fd 3 ──► [File pos | refcnt=1] ──┐
├──► [同一個 v-node: File A]
fd 4 ──► [File pos | refcnt=1] ──┘
- 關鍵:每個 descriptor 有獨立的 file position,在不同 descriptor 上 read 會從檔案的不同位置取資料,互不影響。
情況三:fork 之後的繼承(Figure 10.14, p.944)
fork 後子行程取得父行程 descriptor table 的複本;父子指向同一組 open file table entry:
Parent's table Open file table
fd 3 ──────────┐
├────────► [File pos | refcnt=2] ──► v-node
Child's table │
fd 3 ──────────┘
- 父子共享同一個 file position:一方 read 移動了 position,另一方接著 read 會拿到「下一段」資料。
- refcnt 變為 2 → 父與子都必須 close 各自的 descriptor,kernel 才會刪除 file table entry。
判斷「共不共享 file position」只看一件事:兩個 descriptor 指向的是同一個 file table entry(fork 繼承、dup2 複製 → 共享),還是不同 entry(各自 open → 獨立)。這是本章考題(Problems 10.2、10.3、10.5)的唯一判準。
「每個 descriptor 對應一個獨立檔案位置」只是典型情況的一般化敘述;fork 與 dup2 都是例外(共享同一 entry)。另外 file table、v-node table 是全系統共享,descriptor table 才是 per-process,方向別記反。
10.9 I/O Redirection 與 dup2 (p.945)
Linux shell 提供 I/O 重導向運算子,把 standard input/output 關聯到磁碟檔案,例如 linux> ls > foo.txt 讓 ls 的 stdout 重導向到 foo.txt(Web server 執行 CGI 程式時做的是同類的事,見 11-Network-Programming/04-Web-Servers-and-Tiny)。實作方式之一是 dup2:
#include <unistd.h>
int dup2(int oldfd, int newfd); // 成功回傳非負 descriptor,錯誤回傳 -1
- 語意:把 descriptor table entry
oldfd複製到 entrynewfd,覆蓋newfd原本的內容;若newfd原本已 open,dup2會先 closenewfd再複製。 - 記法:資料流向是
oldfd → newfd(來源在前、目的在後)。重導向 stdin 到 descriptor 5:dup2(5, 0)或dup2(5, STDIN_FILENO)(Problem 10.4)。
dup2(4,1) 前後變化(Figure 10.12 → Figure 10.15, p.946)
呼叫前 fd 1(stdout)→ File A(終端機)、fd 4 → File B(磁碟檔),refcnt 皆為 1。呼叫 dup2(4,1) 後:
呼叫前: 呼叫後 dup2(4,1):
fd 1 ──► File A [refcnt=1] fd 1 ──┐ File A [refcnt=0 → 刪除]
fd 4 ──► File B [refcnt=1] ├──► File B [refcnt=2]
fd 4 ──┘
- 兩個 descriptor 都指向 File B;File A 被關閉,其 file table 與 v-node entry 被刪除;File B 的 refcnt 增為 2。
- 從此寫到 stdout 的資料全部重導向到 File B。
- 因為 fd 1 與 fd 4 現在共享同一 file table entry → 共享 file position(Problem 10.5 的關鍵)。
shell 實作 prog > file 的流程:fork → 子行程 open(file) 得到某 fd → dup2(fd, 1) → execve(prog)。因為 execve 不會重設 descriptor table,重導向得以跨越程式載入而保留(參見 08-Exceptional-Control-Flow/04-Process-Control 與 Homework 10.9 的情境)。
10.10 Standard I/O (p.947)
C 語言定義一組更高階的 I/O 函式,稱為 standard I/O library(libc),是 Unix I/O 的高階替代:
| 功能 | 函式 |
|---|---|
| 開/關檔 | fopen, fclose |
| 讀寫 bytes | fread, fwrite |
| 讀寫字串 | fgets, fputs |
| 格式化 I/O | scanf, printf |
- Standard I/O 把 open file 建模為 stream:對程式設計師而言就是一個指向
FILE結構的指標。 - 每個 ANSI C 程式開始時有三個已開啟的 stream,對應三個 descriptor:
#include <stdio.h>
extern FILE *stdin; /* standard input (descriptor 0) */
extern FILE *stdout; /* standard output (descriptor 1) */
extern FILE *stderr; /* standard error (descriptor 2) */
FILEstream = file descriptor + stream buffer 的抽象。stream buffer 的目的與 Rio read buffer 相同:減少昂貴的 Linux I/O 系統呼叫次數。- 例:重複呼叫
getc逐字元讀檔——第一次呼叫時 library 用一次read系統呼叫填滿 stream buffer,之後的getc只要 buffer 還有未讀 bytes 就直接從 buffer 取,不再進 kernel。
10.11 該用哪一套 I/O 函式?(p.947)
三層 I/O 套件的關係(Figure 10.16):
┌──────────────────────────────────────────┐
│ C application program │
└──────┬──────────────────────────┬─────────┘
│ │
┌───────────▼──────────┐ ┌───────────▼──────────┐
│ Standard I/O 函式 │ │ Rio 函式 │
│ fopen fdopen fread │ │ rio_readn rio_writen │
│ fwrite fscanf fprintf│ │ rio_readinitb │
│ sscanf sprintf fgets │ │ rio_readlineb │
│ fputs fflush fseek │ │ rio_readnb │
│ fclose │ │ │
└───────────┬──────────┘ └───────────┬──────────┘
│ │
┌──────▼──────────────────────────▼─────────┐
│ Unix I/O 函式(經 system call) │
│ open read write lseek stat close │
│ (實作於 OS kernel) │
└────────────────────────────────────────────┘
- Unix I/O 實作在 kernel 內;Rio 與 standard I/O 都是「架在 Unix I/O 之上」實作的。
- Rio 是本書為教學開發的
read/writerobust wrapper:自動處理 short count,並提供高效的緩衝式文字行讀取(見 10-System-IO/02-Rio-Package-and-File-Metadata)。 - Standard I/O 則是功能更完整的緩衝式替代方案,含
printf/scanf等格式化 I/O。
三條使用準則 (p.948)
| 準則 | 內容 |
|---|---|
| G1 | 儘量使用 standard I/O:磁碟與終端機裝置 I/O 的首選。多數 C 程式設計師終其職涯只用 standard I/O(例外:stat 在 standard I/O 沒有對應函式) |
| G2 | 不要用 scanf 或 rio_readlineb 讀 binary 檔:它們是為文字檔設計的。binary 檔可能充斥與行結尾無關的 0xa bytes,會使程式以怪異、不可預測的方式失敗 |
| G3 | network socket 上一律用 Rio:standard I/O 用在網路上會有棘手問題(見下) |
為什麼 socket 不能用 standard I/O (p.948–949)
Standard I/O stream 是 full duplex(同一 stream 可讀可寫),但有兩條文件記載不清的限制,與 socket 的限制彼此衝突:
- 限制 1(輸出後接輸入):input 函式不能緊跟在 output 函式之後,中間必須呼叫
fflush、fseek、fsetpos或rewind。fflush清空 stream buffer;後三者用 Unix I/O 的lseek重設 file position。 - 限制 2(輸入後接輸出):output 函式不能緊跟在 input 函式之後,中間必須有
fseek、fsetpos或rewind——除非該 input 函式遇到 EOF。 - 致命點:socket 上呼叫
lseek是非法的。限制 1 還能用「每次輸入前先 flush buffer」的紀律繞過;限制 2 唯一的繞法是對同一 socket descriptor 開兩個 stream:
FILE *fpin, *fpout;
fpin = fdopen(sockfd, "r");
fpout = fdopen(sockfd, "w");
- 但這又要求對兩個 stream 都呼叫
fclose才能釋放記憶體、避免 memory leak——而兩次fclose都會嘗試關閉同一個 socket descriptor,第二次必然失敗。循序程式尚可容忍,在多執行緒程式中關閉已關閉的 descriptor 是災難配方(見 12-Concurrent-Programming/04-Parallelism-and-Thread-Safety)。
Socket 上的格式化 I/O 替代方案:輸出用 sprintf 先在記憶體格式化字串,再用 rio_writen 送出;輸入用 rio_readlineb 讀整行文字,再用 sscanf 抽取欄位。
10.12 Summary (p.949)
- Linux 以 Unix I/O 模型提供少量系統層級函式:open/close/read/write、取 metadata、I/O redirection;read/write 受 short count 影響,應用程式必須正確處理——因此建議用 Rio 而非直接呼叫 Unix I/O。
- kernel 用三個資料結構表示 open files:descriptor table(per-process)的 entry 指向 open file table(共享)的 entry,再指向 v-node table(共享)的 entry;此結構同時解釋了 file sharing 與 I/O redirection。
- Standard I/O library 架在 Unix I/O 之上,對多數應用是更簡單的首選;但因 standard I/O 與網路檔案的限制互不相容,網路應用應改用 Unix I/O(Rio)。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
同檔 open 兩次,Read(fd1) 後 Read(fd2) 讀到什麼?(Problem 10.2) |
兩個 fd 各有 file table entry → 各自 file position → fd2 仍讀第 1 個 byte,輸出 c = f(不是 o) |
open 一次後 fork,子先讀 1 byte、父再讀(Problem 10.3) |
父子共享同一 file table entry 與 file position → 父讀到第 2 個 byte,輸出 c = o |
開兩次後 Read(fd2) 再 Dup2(fd2,fd1) 再 Read(fd1)(Problem 10.5) |
dup2 後 fd1 指向 fd2 的 entry(position 已在 1)→ 讀到第 2 個 byte,輸出 c = o |
| 「如何把 stdin 重導向到 descriptor 5?」(Problem 10.4) | dup2(5, 0),即 dup2(5, STDIN_FILENO);oldfd 在前、newfd 在後 |
dup2(4,1) 後 refcnt 變化 |
原 fd1 指向的檔案 refcnt 減 1(歸零則刪 entry);fd4 指向的檔案 refcnt 加 1(1→2) |
| 「file table entry 何時被 kernel 刪除?」 | refcnt 歸零時;fork 後父子都要 close 該 descriptor 才會刪除 |
| 「哪張表是 per-process?哪些共享?」 | descriptor table 每 process 一張;open file table 與 v-node table 全系統共享 |
| 「stream buffer 的目的?」 | 與 Rio read buffer 相同:減少昂貴的 I/O 系統呼叫(getc 一次 read 填 buffer,之後從 buffer 取) |
「binary 檔可以用 scanf/rio_readlineb 讀嗎?」 |
不行(G2):binary 檔的 0xa bytes 與行結尾無關,會導致不可預測的失敗 |
| 「socket 該用哪套 I/O?為什麼不用 standard I/O?」 | 用 Rio(G3);stream 的 I/O 順序限制需要 lseek,但 socket 上 lseek 非法;開雙 stream 又導致重複 close 同一 descriptor |
| 「socket 上要格式化 I/O 怎麼辦?」 | 輸出:sprintf + rio_writen;輸入:rio_readlineb + sscanf |
「open 回傳哪個 descriptor?」 |
永遠回傳目前最小的未使用 descriptor(Problem 10.1/10.6 型考題基礎) |