National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 72 24°C PM2.5 13
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 04

管線與訊號

行程之間怎麼溝通:pipe、重新導向、訊號與 Ctrl+C
Interaction 35 minute(s)

Learning Objectives

  • 畫出 fd 表、開啟檔案與 inode 的關係,說明 fork 之後共用了什麼
  • 用 pipe 在行程之間傳資料,判斷什麼時候讀得到 EOF、什麼時候會卡住
  • 用 dup2 做重新導向,親手組出 ls | wc -l
  • 說明常見訊號的來源與預設動作,判斷處理函數裡可以做什麼

兩支互不認識的程式怎麼接起來

ls | wc -l 可以算出目錄裡有幾個檔案。寫 ls 的人和寫 wc 的人不需要認識,兩支程式裡也都沒有管線相關的程式碼:ls 只是寫到 stdout,wc 只是從 stdin 讀。

能這樣接,靠的是 Unix 的一個設計:程式不管輸出到底去了哪裡,只管寫到 fd 1。fd 1 接到什麼,是啟動它的 Shell 事先安排的。而上一課看到,fd 會留過 exec。

這一課先看 fd 的結構,再用管線把兩個行程接起來,最後看行程之間另一種溝通方式:訊號。

fd 表與開啟檔案

檔案描述子(file descriptor,fd)是一個小整數,是程式手上的「號碼牌」。open、pipe 回傳它,read、write、close 用它。每個行程啟動時已經有三個:0 是 stdin、1 是 stdout、2 是 stderr。新的 fd 永遠拿目前最小的空號。

它背後有三層:

A: fd 0,1,2 --> terminal
A: fd 3 ---+
           +--> open file --> inode
B: fd 3 ---+    offset = 13

A、B 是兩個行程,左邊是它們各自的 fd 表;open file 是核心的開啟檔案表裡的一個項目,記著讀寫位置(offset)和模式;inode 是檔案本身(第 17 課)。

重點是中間那一層:讀寫位置記在開啟檔案,不是記在 fd 表裡。fork 複製 fd 表,兩個行程的 fd 3 指向同一個開啟檔案,所以共用同一個位置。這是刻意的設計:Shell 執行 (指令一; 指令二) > out.txt 時,兩個指令可以依序寫下去,不會互相覆蓋。

真正的 Linux 上試試看,子行程先寫,父行程接著寫,然後從頭讀回來:

int fd = open("shared.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) { write(fd, "child\n", 6); return 0; }
wait(NULL);
write(fd, "parent\n", 7);
位置 13,檔案內容:
child
parent

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

如果是兩個行程各自 open 同一個檔案,就會是兩個開啟檔案、各有自己的位置,後寫的會蓋掉先寫的。

在 os-lab 的「檔案與 fd」面板裡,「共用」欄就是這個開啟檔案被幾個 fd 指著。

pipe 與 EOF

pipe(fd) 在核心裡建立一塊緩衝區,給你兩個 fd:fd[0] 讀、fd[1] 寫(int fd[2] 是兩個整數的陣列,傳給函數時核心會把結果填進去)。先 pipe 再 fork,父子兩邊都拿到這兩個 fd,就接上了。

子行程  write(fd[1]) ──► [ 核心裡的緩衝區 ] ──► read(fd[0])  父行程

資料是位元組流,先寫的先讀,讀走就沒有了;也沒有訊息邊界,分三次寫的資料可能一次讀到。

讀取端要怎麼知道資料寫完了?這是管線最容易出錯的地方:

情況 read 的結果
管線裡有資料 回傳讀到的位元組數
管線是空的,還有任何 fd 指向寫入端 阻塞,等人寫入
管線是空的,所有寫入端都關閉了 回傳 0,也就是 EOF

注意是「所有」。fork 之後父子各有一份 fd[1],寫入端關掉自己那份,只要對方手上還有,讀取端就等不到 EOF。讀取的那個行程如果自己沒有關 fd[1],它會等自己。所以規則是:用不到的那一端,一定要關。

真正的 Linux 上,讀取端沒有關掉自己的寫入端,用 alarm(1) 當保險,一秒後用訊號結束它:

讀到 6 個位元組
讀取端一直等不到 EOF,1 秒後被訊號 14(SIGALRM)終止

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

互動練習:找出為什麼卡住

子行程寫三行到管線,父行程一直讀到 EOF。這個練習把管線容量設成只有 8 個位元組,方便看見「滿了」,系統呼叫面板看得到資料是一批一批讀到的。真實的 Linux 預設是 65536,而且一次寫入不超過 4096 位元組時不會被拆開,所以分批的情況會和這裡不同。「檔案與 fd」面板可以看到每個行程手上的 fd。

真實的管線容量與 SIGPIPE

真正的 Linux 上,用非阻塞模式一直寫,直到寫不下去:

F_GETPIPE_SZ = 65536
寫了 65536 個位元組之後滿了:Resource temporarily unavailable

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

65536 位元組是 Linux 的預設(16 頁,每頁 4 KB),程式可以調整,不應該假設它是固定值。

反過來的情況是:讀取端都關閉了,還要寫入。沒有人會讀這些資料,再寫下去沒有意義,所以核心送 SIGPIPE 給寫入的行程,預設動作是終止它。如果程式忽略這個訊號,write 會回傳 -1,errno 是 EPIPE:

沒有處理:子行程被訊號 13 終止
忽略 SIGPIPE:write 回傳 -1,Broken pipe

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

這就是 yes | head -n 3 會結束的原因:yes 會永遠印下去,但 head 讀完三行就結束,yes 下一次寫入時就被 SIGPIPE 終止了。網路伺服器則常常忽略 SIGPIPE,改成檢查 write 的回傳值,否則一個客戶端斷線就會讓整個伺服器結束。

dup2:Shell 怎麼做重新導向

dup2(oldfd, newfd) 讓 newfd 改成指向 oldfd 指著的開啟檔案;newfd 原本開著的話會先被關掉。

Shell 執行 ls > out.txt 的步驟,就是在 fork 和 exec 中間做這件事:

pid_t pid = fork();
if (pid == 0) {
    int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, 1);      // 現在 fd 1 指向 out.txt
    close(fd);        // 原本的號碼牌用不到了
    execlp("ls", "ls", NULL);
}

ls 什麼都不知道,它照常寫 fd 1,內容就進了檔案。這就是 fork 和 exec 分開的好處:中間可以安排子行程的環境,被啟動的程式完全不用配合。

ls | wc -l 用同樣的方法,只是接到管線。步驟:

  1. pipe(fd) 建立管線。
  2. fork 第一個子行程:dup2(fd[1], 1),關掉 fd[0]、fd[1],exec ls。
  3. fork 第二個子行程:dup2(fd[0], 0),關掉 fd[0]、fd[1],exec wc。
  4. 父行程(Shell)關掉 fd[0]、fd[1],然後 wait 兩個子行程。

步驟 3 和 4 的 close 不能省。wc 要讀到 EOF 才會印出結果,少關任何一份寫入端,它就永遠等下去。

真正的 Linux 上跑同樣的結構(這個環境沒有 ls,所以兩邊用自己寫的程式碼代替 exec,一邊印三行,一邊數行數):

讀到 EOF,共 3 行

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

互動練習:親手組出 ls | wc -l

教學核心的 /bin 裡有十個小程式。程式骨架已經 fork 好兩個子行程,由你接上管線。任務會讓你一步一步看到每個步驟少了什麼。

訊號:核心打斷行程的方式

管線傳的是資料。訊號(signal)只傳一件事:「某件事發生了」。收到訊號時,行程不管正在做什麼都會被打斷。

訊號 編號(x86) 常見來源 預設動作
SIGINT 2 在終端機按 Ctrl+C 終止
SIGKILL 9 kill -9;記憶體不足時的 OOM killer 終止,無法攔截
SIGSEGV 11 存取不合法的記憶體(CPU 例外轉來的) 終止並產生 core dump
SIGPIPE 13 寫入沒有讀取端的管線 終止
SIGTERM 15 kill 指令的預設訊號 終止
SIGCHLD 17 子行程結束 忽略

行程可以對大部分訊號指定要怎麼處理:照預設、忽略(SIG_IGN),或交給自己寫的處理函數(signal handler)。

void on_int(int sig) {          // 收到訊號時,核心讓這個函數插進來執行
    write(1, "got SIGINT\n", 11);
}

signal(SIGINT, on_int);         // 註冊:收到 SIGINT 時呼叫 on_int(傳的是函數名稱)

處理函數執行完,程式回到被打斷的地方繼續。

SIGKILL 和 SIGSTOP 是例外,不能攔截也不能忽略,所以系統永遠有辦法終止失控的程式。一般的關閉流程會先送 SIGTERM,給程式機會存檔收尾,過一段時間還不結束才送 SIGKILL。

新程式應該用 sigaction 而不是 signal,不同系統上 signal 的細節不一樣。教學核心只提供 signal,觀念相同。

真正的 Linux 上,四種情況的結果:

攔截 SIGKILL:Invalid argument
got SIGTERM, cleaning up
SIGTERM,有處理函數:正常結束,結束碼 3
SIGTERM,沒有處理函數:被訊號 15 終止
SIGKILL,有 SIGTERM 處理函數:被訊號 9 終止
寫入 NULL 指標:被訊號 11 終止

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

互動練習:按下 Ctrl+C

程式會印 5 次,每次睡 1 秒(虛擬時鐘,不用真的等)。用「自動播放」或「執行一步」跑,趁程式還沒結束時按「Ctrl+C」。如果按「跑到結束」,程式一路跑完就來不及按了。

處理函數裡不能做什麼

處理函數可能在任何兩個指令之間插進來。想像主程式正在 printf 裡面,更新緩衝區和計數器更新到一半,訊號來了,處理函數又呼叫 printf:它看到的是更新到一半的資料,結果無法預期。malloc 也有一樣的問題。

所以處理函數裡只能呼叫 async-signal-safe 的函數,man 7 signal-safety 有完整清單。

可以用 不可以用
write、_exit、read、kill printf 與整個 stdio、malloc、free、exit

練習裡的 on_int 用 write 就是這個原因。最常見的安全寫法是只設一個旗標,由主程式去檢查:

volatile sig_atomic_t got = 0;

void on_usr1(int sig) {
    got = 1;
}

int main(void) {
    signal(SIGUSR1, on_usr1);
    raise(SIGUSR1);
    if (got) {
        printf("處理函數只設旗標,主程式看到旗標才印這一行\n");
    }
    return 0;
}
處理函數只設旗標,主程式看到旗標才印這一行

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

volatile sig_atomic_t 告訴編譯器這個變數可能隨時被改、而且讀寫不會被打斷到一半。教學核心不支援 volatile,所以這段只放真實輸出。

行程之間溝通還有一種方式是共享記憶體:兩個行程把同一塊實體記憶體對應進自己的位址空間,不用經過核心複製,這要等第 14 課看過頁表之後才會清楚。

確認一下

1.

子行程把資料寫進管線之後結束了,父行程的讀取迴圈卻一直不結束。最可能的原因是什麼?

2.

執行 yes | head -n 1,yes 是怎麼結束的?

3.

Shell 執行 ls > out.txt 時,dup2 是誰在什麼時候呼叫的?

4.

這段程式執行完,檔案 a.txt 的內容是什麼?

int fd = open("a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) {
    write(fd, "AAA", 3);
    return 0;
}
wait(NULL);
write(fd, "BBB", 3);
5.

為什麼處理函數裡不應該呼叫 printf?

6.

要關閉一個正在寫檔的服務,為什麼通常先送 SIGTERM,不直接送 SIGKILL?

第 1 次作答

重點整理

  • fd 是行程手上的號碼牌,指向核心的開啟檔案;讀寫位置在開啟檔案裡,fork 之後父子共用。
  • pipe 給一對 fd。空管線的 read 會阻塞,直到所有寫入端都關閉才回傳 EOF,所以用不到的一端要關。滿了的管線讓 write 阻塞;讀取端全關時 write 收到 SIGPIPE。
  • dup2 加上 fd 留過 exec,讓 Shell 能在 fork 和 exec 之間安排重新導向,被啟動的程式不用配合。
  • 訊號打斷行程,可以攔截的訊號能自訂處理函數;SIGKILL、SIGSTOP 不能攔截。處理函數裡只用 async-signal-safe 的函數,最安全的是只設旗標。

下一課回到核心的另一個問題:這麼多就緒的行程,下一個該輪到誰,CPU 排程演算法。

這一課完成了!
Next lesson:CPU 排程演算法
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。