Process、Thread 與 Coroutine 高頻
這是 OS 面試的第一題,幾乎必問。重點不是背定義,而是能說出三者在記憶體空間、建立成本、context switch 成本、隔離性、適用場景上的差異,並連結到你寫的服務(例如 Python 的 GIL、Go 的 goroutine、Nginx 的 worker 模型)。
基本定義
- Process(行程)
- 資源分配的單位。擁有獨立的虛擬位址空間(code、data、heap、stack)、file descriptor table、signal handler、credentials(uid/gid)等。一個 process 掛掉通常不會影響其他 process。
- Thread(執行緒)
- CPU 排程的單位。同一 process 內的 threads 共享位址空間、heap、全域變數、fd table,但各自有獨立的 stack、暫存器(register)、program counter 與 thread-local storage。在 Linux 上 thread 其實是「共享較多資源的 task」,都是透過
clone()建立,kernel 統一稱為 task(task_struct)。 - Coroutine(協程)
- 使用者空間(user space)的協作式排程單位。切換由程式自己(或 runtime)在
await/yield/ channel 操作等「讓出點」主動觸發,不需進 kernel。Python 的asyncio、Go 的 goroutine(由 Go runtime 做 M:N 排程,且自 Go 1.14 起支援非同步搶占)、Kotlin coroutine 都屬此類。
三者比較
| 面向 | Process | Thread | Coroutine |
|---|---|---|---|
| 位址空間 | 獨立 | 共享(同 process) | 共享(同 thread / process) |
| 建立成本 | 高:建立 page table、fd table 複製(fork 有 COW 減輕) | 中:配置 stack(Linux 預設虛擬 8MB,實際用多少才配多少)、kernel task 結構 | 極低:只是一個物件 + 小 stack(goroutine 初始 2KB 可成長;Python coroutine 是 frame 物件) |
| Context switch | 最貴:要切換 page table(CR3),可能造成 TLB flush(有 PCID 可緩解),cache 也變冷 | 較便宜:同位址空間不需換 page table,但仍要進 kernel、存取暫存器 | 最便宜:純 user space 存/還原少量暫存器,無 syscall |
| 排程方式 | Kernel 搶占式(preemptive) | Kernel 搶占式 | 協作式(cooperative);Go 是 runtime 搶占 |
| 隔離性 / 穩定性 | 最好,一個崩潰不影響別人 | 差:一個 thread segfault 整個 process 掛 | 差:一個 coroutine 阻塞(呼叫 blocking I/O)整個 event loop 卡住 |
| 溝通方式 | IPC(pipe、socket、shared memory) | 共享記憶體 + lock / condition variable | 共享記憶體(單執行緒通常免 lock)、channel、queue |
| 可同時數量 | 數百~數千 | 數千~上萬(受記憶體與 kernel 限制) | 數十萬~百萬 |
| 適合 | 需要隔離、利用多核(Python 繞過 GIL) | CPU 密集的平行運算(非 Python)、blocking 函式庫 | 大量 I/O 密集的併發連線(API gateway、爬蟲、RTB bidder) |
Context switch 到底貴在哪?
Context switch 的直接成本是:從 user mode 陷入 kernel、儲存目前 task 的暫存器與狀態、排程器挑選下一個 task、還原其暫存器,若是不同 process 還要切換 page table。一般量級是微秒(µs)等級。但更大的成本是間接成本:
- CPU cache 污染:新 task 的工作集不在 L1/L2 cache,前一段時間大量 cache miss。
- TLB miss:換 page table 後原本快取的虛擬→實體位址轉換失效。
- 分支預測器狀態也變得不準。
所以當 vmstat 看到 cs(每秒 context switch)飆到數十萬,而 throughput 沒有上升,通常代表 thread 太多、lock 競爭嚴重,或是大量短小的 blocking I/O。
stack + registers"] T2["Thread 2
stack + registers"] T1 --> H1 T2 --> H1 subgraph EL["Thread 2 上的 Event Loop"] C1["coroutine a"] C2["coroutine b"] C3["coroutine c"] end T2 --> EL end subgraph P2["Process B(另一個位址空間)"] H2["Heap / Data / Code"] end P1 -. "IPC:pipe / socket / shm" .- P2
連結到 Python 與 Go
Python
CPython 有 GIL,同一時間只有一個 thread 執行 Python bytecode。所以:I/O 密集用 asyncio 或 thread(等 I/O 時會釋放 GIL);CPU 密集用 multiprocessing / 多個 worker process(gunicorn -w)。Python 3.13 起有實驗性的 free-threaded build(PEP 703),但生產環境多數仍以 GIL 版本為主。
Go
Goroutine 由 runtime 以 M:N 模型排程:G(goroutine)、M(OS thread)、P(processor,數量 = GOMAXPROCS)。網路 I/O 底層用 netpoller(Linux 上是 epoll),goroutine 等待時被 park,不佔 OS thread。所以 Go 寫的服務可以「一個連線一個 goroutine」而不爆炸。
不要只答「process 有獨立記憶體、thread 共享」。好的回答結構:(1) 資源 vs 排程單位;(2) 建立與切換成本差異及原因(page table、TLB、cache);(3) 隔離性 trade-off;(4) 結合語言:Python GIL 讓 CPU-bound 工作要用多 process,I/O-bound 用 asyncio;(5) 舉例:Nginx 用「少量 worker process + 每個 worker 一個 epoll event loop」,兼顧多核與高併發。常見追問:「coroutine 裡呼叫了 time.sleep() 或 requests.get() 會怎樣?」→ 整個 event loop 被阻塞,所有其他 coroutine 都停住,要用 asyncio.sleep、aiohttp,或 loop.run_in_executor 丟到 thread pool。
以 RTB(real-time bidding)bidder 為例:每秒數萬到數十萬個 bid request,每個要在約 100ms 內回應,而且大多數時間在等 feature store / Redis / 模型服務。若「一個 request 一個 OS thread」,thread 數量和 context switch 會先成為瓶頸;以 event loop / goroutine 處理大量 I/O 併發、再用多個 process 或 GOMAXPROCS 吃滿多核,是廣告平台常見的做法。
Process 生命週期:fork/exec、COW、Zombie 與 Orphan 高頻
fork() 與 exec()
Unix 建立新程式的經典兩步驟:
fork():複製目前 process,產生 child。回傳值在 parent 是 child 的 PID,在 child 是 0,失敗回傳 -1。child 繼承 parent 的記憶體內容(COW)、開啟的 fd、環境變數、signal 處置方式等。exec*()(如execve):把目前 process 的記憶體映像換成新程式,PID 不變,已開啟的 fd 預設會保留(除非設了O_CLOEXEC/FD_CLOEXEC)。- parent 呼叫
wait()/waitpid()回收 child 的結束狀態。
Shell 執行 ls 就是:fork 出 child → child exec /bin/ls → parent wait。分成兩步的好處是,在 fork 和 exec 之間 child 可以調整環境:重新導向 fd(實現 >、|)、改工作目錄、降權限、設定 resource limit。
import os, sys
pid = os.fork()
if pid == 0:
# child:把 stdout 導到檔案後 exec,相當於 shell 的 `ls -l > out.txt`
fd = os.open("out.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
os.dup2(fd, 1) # 讓 fd 1(stdout)指向 out.txt
os.close(fd)
os.execvp("ls", ["ls", "-l"]) # 成功的話不會回來
sys.exit(127)
else:
_, status = os.waitpid(pid, 0) # 回收 child,避免 zombie
print("child exit code:", os.waitstatus_to_exitcode(status))
Copy-on-Write(COW)
如果 fork 真的把幾 GB 記憶體整份複製,會非常慢。實際上 fork 只複製 page table,並把 parent 與 child 的頁面都標成唯讀。任一方寫入某頁時觸發 page fault,kernel 才把那一頁複製一份給寫入者。
- 好處:fork + 立刻 exec 幾乎不複製資料;Redis 的
BGSAVE/ AOF rewrite 用 fork 拿到一個「時間點快照」來寫 RDB。 - 陷阱:Redis 在 fork 期間如果寫入量大,大量頁面被複製,記憶體可能接近翻倍 → 這就是 Redis 建議保留記憶體餘裕、並且注意
vm.overcommit_memory的原因。另外開啟 Transparent Huge Pages 時,COW 的單位變成 2MB,寫一個 byte 就複製 2MB,延遲與記憶體都變差,Redis 官方建議關掉 THP。 - Python 的陷阱:CPython 的 reference count 會寫入物件 header,所以即使 child「只讀」物件,也會因為 refcount 變動而觸發 COW。gunicorn
--preload想共享模型記憶體,效果常不如預期;Python 3.12 起有 immortal objects、gc.freeze()可以緩解一部分。
Process 狀態
ps 的 STAT 欄位常見值:
| 代碼 | 狀態 | 說明 |
|---|---|---|
R | Running / Runnable | 正在 CPU 上跑,或在 run queue 等 CPU |
S | Interruptible sleep | 等待事件(socket 有資料、timer),可被 signal 喚醒。大部分閒置的服務都在這 |
D | Uninterruptible sleep | 通常在等磁碟 / NFS I/O,連 SIGKILL 都殺不掉,直到 I/O 完成。大量 D 狀態 = I/O 有問題 |
T | Stopped | 被 SIGSTOP / Ctrl-Z 暫停,或被 debugger trace |
Z | Zombie | 已結束但 parent 還沒 wait 回收 |
I | Idle kernel thread | 閒置的 kernel thread(不計入 load) |
Zombie 與 Orphan
Zombie(殭屍行程)
child 結束後,kernel 保留它的 PID 與 exit status(佔一個 process table entry,幾乎不佔記憶體),等 parent 用 wait() 讀取。parent 若一直不 wait,zombie 就會累積。zombie 無法被 kill(它已經死了),解法是讓 parent 回收(修 bug、送 SIGCHLD),或殺掉 parent,讓 zombie 變成 orphan 由 init 收養後回收。大量 zombie 可能用光 PID(kernel.pid_max)。
Orphan(孤兒行程)
parent 先死,child 還活著。kernel 會把它重新指派給 init(PID 1)或最近一個設了 PR_SET_CHILD_SUBREAPER 的祖先。init 會負責 wait 它,所以 orphan 本身無害。daemon 化(double fork)就是刻意製造 orphan。
# 找出 zombie 以及它們的 parent
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
# 看 parent 是誰,再決定是修 parent 還是重啟它
ps -o pid,cmd -p <PPID>
在 Python 用 subprocess.Popen 卻從不呼叫 wait() / communicate(),或在容器內以自己的程式當 PID 1 又會 spawn 子行程(例如 shell script 跑多個背景工作),都會累積 zombie。容器內建議用 tini 或 docker run --init,詳見 signal 小節的 PID 1 問題。
「fork 之後記憶體會複製嗎?」→ 不會立即複製,COW;說出 page table 標唯讀、寫入時 page fault 才複製;再舉 Redis BGSAVE 的實例與記憶體翻倍風險,就是加分答案。追問「多執行緒程式 fork 會怎樣」→ child 只會有呼叫 fork 的那一個 thread,若其他 thread 當時持有 lock(例如 malloc 的 lock),child 可能死鎖,因此多執行緒程式 fork 後應立即 exec,Python 3.12 也會對此發出 DeprecationWarning,且 3.14 起 Linux 上 multiprocessing 預設改為 forkserver。
CPU 排程:CFS、nice 與 CPU affinity 進階
CFS 的核心概念
Linux 2.6.23 到 6.5 的預設排程器是 CFS(Completely Fair Scheduler),6.6 起改為以 EEVDF 取代,但「公平分配 CPU 時間」的觀念一脈相承,面試講 CFS 即可:
- 每個 task 維護一個 vruntime(虛擬執行時間):實際執行時間依權重換算。權重越高(nice 越低),vruntime 長得越慢。
- Runnable 的 task 依 vruntime 存在一棵紅黑樹,排程器每次挑最左邊(vruntime 最小、最「吃虧」)的 task 來跑,插入/刪除 O(log n)。
- 沒有固定時間片,而是依 runnable 數量把一個「排程週期」分給大家(有最小粒度避免切太碎)。
- I/O 密集的 task 常在睡眠,vruntime 增長少,醒來時能快速搶到 CPU → 互動性與回應延遲較好。
另外還有即時排程類別:SCHED_FIFO、SCHED_RR(優先於一般 task,用 chrt 設定),以及 SCHED_DEADLINE。一般後端服務不需要,誤用還可能讓系統卡死。
nice 與 renice
nice 值範圍 -20(最優先)到 19(最不優先),預設 0。每差 1 級權重約差 1.25 倍,CPU 分配約差 10%。只有 root 可以調低 nice(提高優先權)。
# 以低優先權跑批次任務,避免影響線上服務
nice -n 10 python batch_report.py
# 調整已在執行的 process
renice -n 5 -p 12345
# I/O 優先權(搭配 CFQ/BFQ 等支援的 I/O scheduler)
ionice -c3 -p 12345 # idle class:只有磁碟閒時才做 I/O
CPU affinity 與 NUMA
CPU affinity 是把 process/thread 綁在特定 CPU core 上,好處是 cache 保持熱、避免跨 NUMA node 存取記憶體;代價是失去排程彈性,綁錯會讓某些 core 過載。
taskset -c 0-3 ./server # 啟動時限制只能在 CPU 0~3
taskset -cp 12345 # 查看某 process 的 affinity
taskset -cp 4,5 12345 # 修改
numactl --hardware # 看 NUMA 拓樸
numactl --cpunodebind=0 --membind=0 ./server # CPU 與記憶體都綁 node 0
高效能網路服務常見組合:網卡 IRQ 綁到特定 core(/proc/irq/*/smp_affinity,或用 irqbalance)、開啟 RSS/RPS 把封包分散到多個 core,應用 worker 綁到相同 NUMA node。Nginx 有 worker_cpu_affinity auto;。
在容器中,「看得到的 CPU 數」不等於「能用的 CPU」。K8s 設定 limits.cpu: 2 是透過 cgroup CFS quota 限制「每 100ms 只能用 200ms CPU 時間」,但 os.cpu_count() 仍回傳主機的核心數。若依此開 64 個 worker,會被嚴重 throttle、延遲尖峰暴增。Go 1.25 起 runtime 預設會依 cgroup CPU limit 設定 GOMAXPROCS,舊版可用 uber-go/automaxprocs;Python 則應明確設定 worker 數。查 throttling:cat /sys/fs/cgroup/cpu.stat 看 nr_throttled、throttled_usec。
虛擬記憶體:Paging、Page Fault、TLB、Page Cache、Swap 與 OOM 高頻
「記憶體一直漲」「為什麼 free 顯示快用完了」「容器被 OOMKilled」是線上最常見的問題,而它們的答案都在虛擬記憶體的機制裡。
虛擬位址與 Paging
每個 process 看到的是自己的虛擬位址空間(64 位元 x86 上 user space 約 128TB),由 MMU 透過 page table 轉換成實體位址。記憶體以 page 為單位管理,x86-64 預設 4KB,另有 huge page(2MB、1GB)。好處:
- 隔離:process 之間無法存取彼此的記憶體。
- 按需配置(demand paging):
malloc(1GB)只是保留虛擬位址,真正寫入時才配置實體頁面。 - 共享:多個 process 載入同一個 shared library,實體頁面只有一份。
- 可以把不常用的頁面 swap 出去,或用
mmap把檔案映射進記憶體。
Page Fault
| 類型 | 何時發生 | 成本 |
|---|---|---|
| Minor fault | 頁面已在記憶體(例如 page cache 中、或 COW、或第一次存取剛配置的匿名頁),只需建立 page table 對應 | 低(微秒以下到微秒級) |
| Major fault | 頁面需要從磁碟讀入(swap 回來、或 mmap 的檔案不在 page cache) | 高(受磁碟延遲支配,SSD 約數十到上百微秒,HDD 可到毫秒級) |
| Invalid(segfault) | 存取未映射或沒有權限的位址 | process 收到 SIGSEGV |
# 看某 process 的 minor/major fault 數
ps -o pid,min_flt,maj_flt,cmd -p 12345
# 系統層級每秒 fault 數
sar -B 1 5 # majflt/s 持續升高 = 在讀 swap 或冷檔案
TLB(Translation Lookaside Buffer)
每次記憶體存取都查多層 page table 太慢(x86-64 是 4 或 5 層),所以 CPU 有 TLB 快取最近的位址轉換。TLB 容量有限(大約數十到數千筆),4KB page 時能覆蓋的記憶體不多;大型 in-memory 資料結構(例如巨大的 hash table、模型權重)會頻繁 TLB miss。Huge page 一筆 TLB 涵蓋 2MB,可以大幅減少 miss,資料庫與 JVM 常用。Process context switch 時切換 page table,TLB 需要失效(現代 CPU 用 PCID 標記避免全部 flush)。
Page Cache 與 free 的正確解讀
Linux 會把讀寫過的檔案內容留在空閒記憶體中當快取(page cache),下次讀取直接命中記憶體。所以「free 很少」通常不是問題。
$ free -h
total used free shared buff/cache available
Mem: 31Gi 12Gi 1.2Gi 320Mi 18Gi 18Gi
Swap: 2.0Gi 0B 2.0Gi
free:完全沒用到的記憶體,少是正常的。buff/cache:page cache + buffer,大部分可回收。available:估計「不需要 swap 還能給新程式用的量」,看這個欄位判斷是否吃緊。
寫入時資料先進 page cache 成為 dirty page,由 kernel 背景 flush 到磁碟(vm.dirty_background_ratio、vm.dirty_ratio 控制門檻)。需要保證落盤時要呼叫 fsync(),這也是資料庫 WAL 的關鍵。清 cache 測試用:sync; echo 3 > /proc/sys/vm/drop_caches(生產環境不要隨便做,會造成大量冷讀)。
RSS vs VSZ(以及 PSS、USS)
- VSZ / VIRT
- 虛擬記憶體大小:所有映射的位址空間,包含尚未使用的保留區、shared library、mmap 的檔案。Go、Java 程式 VSZ 常常好幾 GB,不代表真的用掉。
- RSS / RES
- Resident Set Size:目前實際在 RAM 中的頁面,包含共享的 library 頁面,所以把多個 process 的 RSS 加總會高估。
- PSS
- Proportional Set Size:共享頁面按共享的 process 數均分,適合計算多 worker(例如 gunicorn)總用量。
smem或/proc/PID/smaps_rollup可看。 - USS
- Unique Set Size:只屬於這個 process 的頁面,殺掉它能釋放的量。
ps -o pid,rss,vsz,cmd -p 12345 # 單位 KB
cat /proc/12345/status | grep -E 'VmRSS|VmSize|VmSwap|Threads'
cat /proc/12345/smaps_rollup # Rss / Pss / Shared / Private 細項
pmap -x 12345 | sort -k3 -n | tail # 哪個 mapping 用最多
Swap 與 swappiness
Swap 把不常用的匿名頁面寫到磁碟,騰出 RAM。對延遲敏感的服務而言,一旦 hot path 上的頁面被 swap 出去,回應延遲會從微秒變毫秒,比直接 OOM 還難查。常見做法:
- 延遲敏感服務把
vm.swappiness調低(如 1~10),或乾脆不配 swap,讓問題以 OOM 的形式快速暴露(K8s 節點傳統上要求關閉 swap,較新版本才逐步支援)。 - 觀察
vmstat 1的si/so(swap in/out),持續非零就是記憶體不足的訊號。
Overcommit 與 OOM Killer
Linux 預設允許配置超過實體記憶體的虛擬記憶體(因為大多數 malloc 不會全部用到):
vm.overcommit_memory | 行為 |
|---|---|
| 0(預設) | 啟發式:明顯不可能滿足的配置才拒絕 |
| 1 | 永遠允許。Redis 官方建議設 1,避免 fork 做 BGSAVE 時因「理論上可能需要兩倍記憶體」而失敗 |
| 2 | 嚴格:總承諾量不得超過 swap + RAM × overcommit_ratio%,超過則 malloc 失敗 |
當實際記憶體真的耗盡、又無法回收時,OOM killer 會依 oom_score(主要依記憶體用量,加上 oom_score_adj 調整,範圍 -1000~1000)挑一個 process 用 SIGKILL 殺掉。
dmesg -T | grep -i -E 'killed process|out of memory'
journalctl -k | grep -i oom
cat /proc/12345/oom_score
echo -1000 > /proc/12345/oom_score_adj # 讓關鍵 process 免於 OOM(謹慎使用)
容器中的 OOM 是 cgroup 層級:容器記憶體超過 memory.max(K8s 的 limits.memory)時,只在該 cgroup 內挑 process 殺,K8s 顯示 OOMKilled、exit code 137(128 + 9,代表 SIGKILL)。注意 cgroup 的記憶體計算也包含該容器產生的 page cache,但 page cache 可以回收,真正導致 OOM 的通常是匿名記憶體(heap)。
「free 顯示只剩 1GB,要加記憶體嗎?」→ 先看 available,解釋 page cache;再看 vmstat 的 si/so 與 major fault 是否異常,以及有沒有 OOM 紀錄。「RSS 和 VSZ 差在哪?哪個代表真實用量?」→ RSS 較接近但含共享頁,多 process 加總用 PSS。能主動提到 exit code 137 與 cgroup OOM,代表你真的維運過容器。
廣告與推薦服務常把 ML 模型、使用者 profile、黑白名單等載入記憶體以降低延遲。這類服務要注意:(1) 用 PSS 評估多 worker 的真實記憶體;(2) 模型熱更新時新舊模型會短暫共存,記憶體 limit 要留餘裕,否則換模型那一刻 OOMKilled;(3) 大型唯讀資料可考慮 mmap 檔案,多 process 透過 page cache 共享同一份。
User Space vs Kernel Space、System Call 與 File Descriptor 高頻
User Space 與 Kernel Space
CPU 有特權等級(x86 的 ring 0 / ring 3)。Kernel 在 ring 0,可以直接操作硬體、page table;應用程式在 ring 3,任何需要硬體或共享資源的操作(讀檔、送封包、配置記憶體頁、建立 process)都必須透過 system call 請 kernel 代勞。這是安全與穩定的基礎:應用程式 bug 不會直接弄壞 kernel 資料結構。
System Call 的流程與成本
- 應用呼叫 libc 包裝函式(例如
read()),libc 把 syscall 編號與參數放進暫存器。 - 執行
syscall指令,CPU 切換到 kernel mode,跳到 kernel 的進入點。 - Kernel 檢查參數、執行對應的處理(例如從 socket buffer 複製資料到 user buffer)。
- 回到 user mode,回傳結果或
-1並設定errno。
一次 syscall 的直接成本約在數十到數百奈秒等級(2018 年 Meltdown 緩解措施 KPTI 讓它變貴不少),重點是次數:每次 read() 只讀 1 byte 的程式,會被 syscall 開銷拖垮。這就是 buffered I/O、batching(writev、sendmmsg、pipeline)、io_uring 存在的理由。gettimeofday、clock_gettime 透過 vDSO 在 user space 完成,不需真的陷入 kernel。
# 統計一個 process 用了哪些 syscall、各花多少時間(-f 包含 threads)
strace -c -f -p 12345 # Ctrl-C 後輸出摘要表
# 只看網路相關 syscall,並顯示每次耗時
strace -f -tt -T -e trace=network -p 12345
File Descriptor 與「Everything is a file」
在 Unix 中,一般檔案、目錄、socket、pipe、終端機、裝置(/dev/null)、甚至 eventfd、timerfd、epoll instance 都用 file descriptor(fd) 這個小整數來代表,並共用 read/write/close 介面。0、1、2 分別是 stdin、stdout、stderr。
結構上有三層:每個 process 的 fd table → 系統層級的 open file table(記錄 offset、開啟模式)→ inode(檔案本身)。fork 後 parent 與 child 的 fd 指向同一個 open file entry,所以會共享 offset;dup2 就是讓某個 fd 號碼指向另一個 entry。
ls -l /proc/12345/fd | head # 列出 process 開了哪些 fd
ls /proc/12345/fd | wc -l # fd 數量
cat /proc/12345/limits | grep 'open files'
lsof -p 12345 | awk '{print $5}' | sort | uniq -c | sort -rn # 依類型(REG/IPv4/sock...)統計
ulimit -n 與 fd 上限
每個 TCP 連線至少佔一個 fd,高併發服務很容易撞到上限。上限分好幾層:
| 層級 | 設定 | 說明 |
|---|---|---|
| Process(soft/hard) | ulimit -n、/etc/security/limits.conf、systemd 的 LimitNOFILE=、Docker 的 --ulimit nofile= | soft 常見預設 1024;process 可自行把 soft 調到 hard 上限 |
| 單一 process 最大值 | fs.nr_open | hard limit 不能超過它,預設 1048576 |
| 全系統 | fs.file-max、/proc/sys/fs/file-nr | 整台機器的 open file 上限與目前用量 |
/etc/security/limits.conf 是 PAM 在「登入 session」時套用的,對 systemd 啟動的服務無效。systemd 服務要在 unit 檔寫 LimitNOFILE=65535。另外在 shell 裡 ulimit -n 只影響這個 shell 及其子行程。確認實際生效值一律看 /proc/PID/limits。
「Too many open files」(EMFILE)排查
- 確認是哪個 process:從錯誤 log 找出服務,取得 PID。
- 看上限與現況:
cat /proc/PID/limits與ls /proc/PID/fd | wc -l。若 fd 數接近上限,確認是「真的需要這麼多」還是「洩漏」。 - 看 fd 是什麼:
lsof -p PID,大部分是 socket?是哪個對端?ss -tanp | grep pid=PID看狀態分佈。大量CLOSE_WAIT= 程式沒有 close 對端已關閉的連線(典型 leak)。大量REG同一檔案 = 開檔沒關。 - 觀察趨勢:每分鐘記錄 fd 數,若單調上升且與流量無關,就是 leak。
- 短期止血:調高
LimitNOFILE並重啟;長期修正:用with/defer/ try-finally 確保關閉、HTTP client 共用連線池並設定 timeout、確認 response body 有被讀完並關閉(Go 的resp.Body.Close()是經典 leak 點)。
相似的錯誤 ENFILE(Too many open files in system)代表撞到整機的 fs.file-max。
import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(f"soft={soft}, hard={hard}")
# 在程式啟動時把 soft 提高到 hard(不需 root)
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
# 永遠用 context manager,確保例外時也會關閉 fd
with open("/var/log/app/access.log") as f:
for line in f:
pass
I/O 模型:Blocking、Non-blocking、select/poll/epoll、io_uring 與 Zero-copy 高頻 深入
這是「Linux 高效能網路服務」的核心。Nginx、Redis、Node.js、Go netpoller、Python asyncio 底層全都是 epoll(或 BSD 的 kqueue)。
五種 I/O 模型(Stevens 分類)
一次網路讀取分兩階段:(1) 等資料到達 kernel buffer;(2) 從 kernel buffer 複製到 user buffer。
| 模型 | 階段 1(等資料) | 階段 2(複製) | 說明 |
|---|---|---|---|
| Blocking I/O | 阻塞 | 阻塞 | 最簡單;一連線一 thread |
| Non-blocking I/O | 立即回 EAGAIN,需要輪詢 | 阻塞 | 單獨用會 busy loop 浪費 CPU |
| I/O multiplexing | 阻塞在 select/poll/epoll,一次等多個 fd | 阻塞 | 主流高併發模型(Reactor) |
| Signal-driven I/O | 資料到時送 SIGIO | 阻塞 | 少用 |
| Asynchronous I/O | 不阻塞 | 不阻塞(kernel 完成後通知) | Proactor;Linux 上的現代實作是 io_uring |
注意:前四種在 POSIX 定義下都算「同步 I/O」,因為階段 2 的複製仍由呼叫者阻塞等待。epoll 告訴你「可以讀了」(readiness),io_uring 告訴你「讀完了」(completion)。
select vs poll vs epoll
| 面向 | select | poll | epoll |
|---|---|---|---|
| fd 數量限制 | FD_SETSIZE,通常 1024 | 無固定上限 | 無固定上限 |
| 每次呼叫傳遞 | 整個 bitmap 從 user 複製到 kernel | 整個 pollfd 陣列複製 | 只在 epoll_ctl 註冊/修改時傳一次,kernel 端持續維護 |
| kernel 檢查方式 | 每次掃描全部 fd,O(n) | O(n) | fd 就緒時由 callback 放入 ready list,epoll_wait 只回傳就緒的,O(就緒數) |
| 使用者取得結果 | 要再掃一次 bitmap 找誰就緒 | 要掃整個陣列 | 直接拿到就緒 fd 陣列 |
| 觸發模式 | Level | Level | Level 或 Edge |
| 跨平台 | 幾乎都有 | POSIX | Linux 限定(BSD/macOS 用 kqueue) |
關鍵 insight:10 萬個連線中通常只有少數同時活躍。select/poll 每次都付出 O(全部連線) 的成本,epoll 只付 O(活躍連線)。這就是 C10K 問題的解法。
epoll 的三個 API
epoll_create1(flags)- 建立一個 epoll instance,回傳 fd。內部有一棵紅黑樹存放「關注的 fd」,加上一個「就緒 list」。
epoll_ctl(epfd, op, fd, event)- 新增(ADD)、修改(MOD)、刪除(DEL)關注的 fd 與事件(
EPOLLIN、EPOLLOUT、EPOLLET、EPOLLONESHOT…)。 epoll_wait(epfd, events, maxevents, timeout)- 阻塞直到有 fd 就緒或逾時,回傳就緒的事件陣列。
Level-triggered vs Edge-triggered
Level-triggered(LT,預設)
只要 buffer 裡還有資料沒讀完,每次 epoll_wait 都會再通知。寫起來比較安全,讀一部分也沒關係。select/poll 都是 LT。
Edge-triggered(ET,EPOLLET)
只在狀態「變化」時(新資料到達)通知一次。收到通知後必須在 non-blocking 模式下一直讀到 EAGAIN,否則剩下的資料不會再觸發通知,連線就「卡住」。好處是減少重複喚醒,Nginx 使用 ET。
多 thread 共用一個 epoll 時還會遇到驚群(thundering herd):一個事件喚醒多個 thread。可用 EPOLLEXCLUSIVE、EPOLLONESHOT,或每個 worker 各自 listen 並設定 SO_REUSEPORT 讓 kernel 分配連線。
用 Python 寫一個 epoll echo server
import select
import socket
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9000))
srv.listen(1024) # backlog:accept queue 長度(仍受 somaxconn 上限)
srv.setblocking(False)
ep = select.epoll()
ep.register(srv.fileno(), select.EPOLLIN)
conns: dict[int, socket.socket] = {}
pending: dict[int, bytes] = {}
try:
while True:
for fd, events in ep.poll(timeout=1):
if fd == srv.fileno():
# ET 模式下要 accept 到 EAGAIN;這裡用 LT 也照樣把能接的都接完
while True:
try:
conn, _ = srv.accept()
except BlockingIOError:
break
conn.setblocking(False)
conns[conn.fileno()] = conn
ep.register(conn.fileno(), select.EPOLLIN | select.EPOLLRDHUP)
elif events & (select.EPOLLHUP | select.EPOLLRDHUP | select.EPOLLERR):
ep.unregister(fd)
conns.pop(fd).close()
pending.pop(fd, None)
elif events & select.EPOLLIN:
data = conns[fd].recv(65536)
if not data: # 對端關閉
ep.unregister(fd)
conns.pop(fd).close()
continue
pending[fd] = pending.get(fd, b"") + data
ep.modify(fd, select.EPOLLOUT)
elif events & select.EPOLLOUT:
sent = conns[fd].send(pending[fd])
pending[fd] = pending[fd][sent:]
if not pending[fd]: # 寫完才改回關注讀取
ep.modify(fd, select.EPOLLIN | select.EPOLLRDHUP)
finally:
ep.close()
srv.close()
實務上你不會手寫 epoll,asyncio 的 selector event loop 已經包好了(selectors.DefaultSelector 在 Linux 就是 EpollSelector)。但面試要能說出「event loop = epoll_wait + 回呼 / 恢復 coroutine」。
io_uring 簡介
Linux 5.1 引入的非同步 I/O 介面。應用與 kernel 共享兩個 ring buffer:Submission Queue(SQ) 放請求、Completion Queue(CQ) 放結果。
- 一次
io_uring_enter可以提交大量請求,甚至開啟 SQPOLL 模式由 kernel thread 輪詢,幾乎零 syscall。 - 真正的 completion-based 非同步,支援檔案 I/O(舊的 Linux AIO 只對 O_DIRECT 好用)、網路、timer 等。
- 代價:API 複雜、安全性問題曾讓部分雲端平台與容器 runtime 預設停用它;生態仍在成熟中。面試提到「知道它解決 syscall 開銷與檔案非同步 I/O 的問題」即可。
Zero-copy:sendfile、splice、mmap
傳統「讀檔再送出 socket」:
read():磁碟 → page cache(DMA)→ user buffer(CPU 複製)write():user buffer → socket buffer(CPU 複製)→ 網卡(DMA)
共 4 次資料搬移、2 次 user/kernel 切換來回。sendfile(out_fd, in_fd, offset, count) 讓資料在 kernel 內從 page cache 直接到 socket,省去兩次 CPU 複製與 user space 往返;網卡支援 scatter-gather 時,甚至只傳描述子給網卡。Nginx 的 sendfile on;、Kafka 把 log segment 送給 consumer 都是此原理。限制:資料不經過 user space,所以無法在中途加工(TLS 加密需 kTLS 支援)。
import os, socket
def serve_file(conn: socket.socket, path: str) -> None:
with open(path, "rb") as f:
size = os.fstat(f.fileno()).st_size
conn.sendall(f"HTTP/1.1 200 OK\r\nContent-Length: {size}\r\n\r\n".encode())
conn.sendfile(f) # Linux 上底層用 os.sendfile,零複製
「為什麼 epoll 比 select 快?」標準答案三點:(1) 不必每次複製整個 fd 集合(註冊一次);(2) kernel 用 callback 維護 ready list,不必線性掃描;(3) 回傳只含就緒 fd。再補 ET/LT 差異與 ET 必須讀到 EAGAIN。進階追問「epoll 對一般檔案有用嗎?」→ 沒有,一般檔案永遠是 ready(對 regular file 註冊 epoll 會回傳 EPERM),磁碟 I/O 慢要靠 thread pool 或 io_uring,這也是 Node.js libuv 用 thread pool 處理檔案 I/O 的原因。
Signal、Graceful Shutdown、PID 1 問題與 IPC 高頻
常見 signal
| Signal | 編號 | 預設行為 | 可捕捉? | 典型用途 |
|---|---|---|---|---|
SIGHUP | 1 | 終止 | 可 | 終端斷線;daemon 慣例用來「重新載入設定」(nginx -s reload) |
SIGINT | 2 | 終止 | 可 | Ctrl-C;Python 預設轉成 KeyboardInterrupt |
SIGQUIT | 3 | 終止 + core dump | 可 | Ctrl-\;Go 程式收到會印出所有 goroutine stack |
SIGKILL | 9 | 強制終止 | 不可 | 最後手段,程式沒有機會清理 |
SIGSEGV | 11 | 終止 + core | 可(但通常不該) | 非法記憶體存取 |
SIGPIPE | 13 | 終止 | 可 | 寫入已關閉的 pipe/socket;網路服務通常忽略它並處理 EPIPE |
SIGTERM | 15 | 終止 | 可 | kill 預設、docker stop、K8s 刪 Pod 時送出;應做 graceful shutdown |
SIGCHLD | 17(x86) | 忽略 | 可 | child 狀態改變,parent 應 wait |
SIGSTOP / SIGCONT | 19 / 18(x86) | 暫停 / 繼續 | STOP 不可 | 作業控制 |
SIGUSR1/2 | 10 / 12(x86) | 終止 | 可 | 自訂,例如重開 log 檔、dump 狀態 |
Graceful Shutdown
目標:部署或縮容時不丟請求。標準流程:
- 收到 SIGTERM,標記為「draining」:readiness probe 開始回失敗,讓 LB / K8s Endpoint 把自己移除。
- 停止 accept 新連線(關閉 listening socket)。
- 等待進行中的請求完成(設上限,例如 20 秒),並對 keep-alive 連線回
Connection: close。 - flush buffer(log、metrics、尚未送出的 Kafka 訊息),關閉 DB 連線池。
- 以 exit code 0 結束。若超過 grace period(K8s 預設
terminationGracePeriodSeconds: 30,Docker stop 預設 10 秒),會被 SIGKILL。
import asyncio, signal
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
data = await reader.readline()
writer.write(b"ok " + data)
await writer.drain()
writer.close()
await writer.wait_closed()
async def main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
server = await asyncio.start_server(handle, "0.0.0.0", 8080)
async with server:
await stop.wait() # 等待 SIGTERM
print("draining...")
server.close() # 停止 accept 新連線
try:
# Python 3.12 起 wait_closed 會等現有連線處理完(舊版會立即返回)
await asyncio.wait_for(server.wait_closed(), timeout=20)
except asyncio.TimeoutError:
print("grace period exceeded, forcing exit")
print("bye")
asyncio.run(main())
K8s 刪除 Pod 時,「送 SIGTERM」和「從 Service Endpoints 移除」是平行發生的,kube-proxy / Ingress 更新規則需要一點時間。若程式收到 SIGTERM 立刻關閉,仍會有新請求打進來而失敗。常見解法是加 preStop hook sleep 5~10,或程式收到 SIGTERM 後先等幾秒再停止 accept。
容器中的 PID 1 問題
在容器的 PID namespace 中,你的程式通常就是 PID 1,這帶來兩個問題:
- Signal 預設行為不適用:kernel 對 PID 1 特別保護,沒有註冊 handler 的 signal 會被忽略(例如 SIGTERM 不會讓它結束)。如果你的程式沒處理 SIGTERM,
docker stop會等 10 秒逾時後 SIGKILL,造成部署變慢且無法優雅關閉。 - Zombie 回收:PID 1 要負責 wait 所有 orphan。一般應用程式不會做這件事,若會 spawn 子行程就會累積 zombie。
另一個常見坑:Dockerfile 用 shell form CMD python app.py,實際 PID 1 是 /bin/sh -c,sh 不會轉送 SIGTERM 給 python。解法:
# 用 exec form,讓 python 直接成為 PID 1
CMD ["python", "app.py"]
# 或加上輕量 init(tini)負責轉送 signal 與回收 zombie
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "app.py"]
# 或執行時加 docker run --init
若必須用 shell script 當 entrypoint,最後一行用 exec python app.py,讓 python 取代 shell 的 PID。
IPC:行程間通訊
| 方式 | 特性 | 典型用途 |
|---|---|---|
| Pipe(匿名) | 單向 byte stream,只能在有親緣關係的 process 間(fork 繼承 fd);buffer 預設 64KB,寫滿會阻塞 | Shell 的 |、parent 收集 child 輸出 |
| Named pipe(FIFO) | 有檔案系統路徑,任意 process 可開啟 | 簡單的本機串流 |
| Unix domain socket | 雙向、支援 stream/datagram、可傳遞 fd 與驗證對方 uid(SO_PEERCRED),不經過 TCP/IP stack,比 loopback TCP 快 | Nginx → gunicorn/php-fpm、Docker daemon(/var/run/docker.sock)、PostgreSQL / MySQL 本機連線、sidecar |
Shared memory(shm_open + mmap、System V shm) | 最快,零複製;但需要自行同步(semaphore、futex、lock-free 結構) | 大量資料共享、Python multiprocessing.shared_memory |
| Message queue(POSIX / System V) | kernel 管理的訊息佇列,有邊界 | 較少見 |
| Signal | 只能傳「編號」,資訊量極少 | 通知(reload、shutdown) |
| TCP socket | 可跨機器 | 微服務之間 |
# Nginx 透過 Unix socket 轉給同機的 gunicorn,省去 TCP 開銷與 port 管理
upstream app {
server unix:/run/gunicorn.sock;
}
「SIGTERM 與 SIGKILL 的差別?」→ TERM 可捕捉、讓程式清理;KILL 無法捕捉也無法忽略,由 kernel 直接終止。然後主動延伸到 graceful shutdown 流程、K8s 的 grace period 與 preStop、容器 PID 1 不處理 signal 的坑。這是「做過線上部署」的人才講得出來的細節。
Cgroups 與 Namespaces:容器的基礎 進階
容器不是虛擬機,而是「一般的 Linux process + 隔離(namespaces)+ 資源限制(cgroups)+ 分層檔案系統(overlayfs)」。理解這點才能解釋為什麼容器啟動快、為什麼共用 kernel、為什麼 top 在容器裡看到的數字可能誤導人。
Namespaces:讓 process 看到不同的世界
| Namespace | 隔離的東西 | 效果 |
|---|---|---|
| PID | process ID | 容器內第一個 process 是 PID 1,看不到主機上其他 process |
| NET | 網路介面、路由表、iptables、port | 每個容器有自己的 eth0 與 localhost,可以重複 listen 80 port |
| MNT | 掛載點 | 容器有自己的根檔案系統 |
| UTS | hostname、domain name | 容器有自己的 hostname |
| IPC | System V IPC、POSIX message queue | 共享記憶體互不干擾 |
| USER | uid/gid 對應 | 容器內的 root 對應到主機上的非特權 uid(rootless 容器) |
| CGROUP | cgroup 根目錄視圖 | 容器只看到自己的 cgroup 樹 |
| TIME | 部分時鐘(monotonic、boottime) | 較新加入,較少用 |
K8s 的 Pod 內多個容器共享 NET namespace(所以可以用 localhost 互連,sidecar 模式的基礎),也可選擇共享 PID namespace。
lsns # 列出系統上的 namespaces
sudo unshare --pid --fork --mount-proc bash # 開一個新的 PID namespace,裡面 ps 只看到自己
# 進入某個容器的 network namespace,用主機上的工具(tcpdump、ss)除錯
PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
sudo nsenter -t "$PID" -n ss -tanp
Cgroups:限制與統計資源
cgroups(control groups)把 process 分組,對每組限制並統計 CPU、記憶體、I/O、PID 數量。現代發行版多半使用 cgroup v2(統一階層,掛在 /sys/fs/cgroup)。
| 資源 | cgroup v2 檔案 | K8s / Docker 對應 | 超過時的行為 |
|---|---|---|---|
| CPU 上限 | cpu.max(如 200000 100000 = 每 100ms 週期最多 200ms) | limits.cpu、--cpus | Throttle(被暫停到下個週期),不會被殺,但延遲變差 |
| CPU 權重 | cpu.weight | requests.cpu、--cpu-shares | 只在競爭時影響比例 |
| 記憶體上限 | memory.max | limits.memory、-m | 先嘗試回收 page cache,仍不夠則 cgroup OOM kill |
| 記憶體軟限制 | memory.high | — | 超過時強力回收並減速,不殺 |
| Process 數量 | pids.max | --pids-limit | fork 失敗(防 fork bomb) |
| Block I/O | io.max、io.weight | --device-read-bps 等 | 限速 |
# 找出某 process 屬於哪個 cgroup
cat /proc/12345/cgroup
# 在容器內看自己的限制與用量(cgroup v2)
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/cpu.max
cat /sys/fs/cgroup/cpu.stat # nr_throttled、throttled_usec:CPU throttle 次數與時間
cat /sys/fs/cgroup/memory.events # oom、oom_kill 計數
systemd-cgtop # 依 cgroup 顯示即時資源用量
容器內的 free、top、/proc/meminfo、nproc 預設顯示的是主機的數值(除非使用 LXCFS)。JVM、Go runtime、Python worker 數若依此自動配置,可能開出遠超 limit 的 heap 或 thread。新版 JVM(JDK 10+ 的 container support)、Go 1.25+ 會讀 cgroup;其他情境請明確設定。
「容器和 VM 的差別?」→ 容器共用 host kernel,以 namespaces 隔離視圖、cgroups 限制資源,啟動是建立 process 等級(毫秒到秒),密度高;VM 有獨立 kernel 與 hypervisor 虛擬化硬體,隔離更強但更重。追問「CPU limit 設太低會怎樣?」→ 被 CFS quota throttle,p99 延遲飆高但 CPU 平均使用率看起來不高,這是很多團隊對延遲敏感服務只設 request、不設 CPU limit 的原因。
systemd Unit 與 journalctl 基礎
大多數 Linux 發行版用 systemd 當 PID 1,負責啟動服務、監控重啟、管理依賴與 cgroup。不跑 K8s 的機器(例如 bare-metal 上的 bidder、Nginx、HAProxy、資料收集 agent)通常都用 systemd 管理。
一個生產等級的 service unit
# /etc/systemd/system/bidder.service
[Unit]
Description=RTB bidder API
Documentation=https://wiki.example.com/bidder
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bidder
Group=bidder
WorkingDirectory=/opt/bidder
EnvironmentFile=/etc/bidder/env
ExecStart=/opt/bidder/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker \
-w 8 -b 0.0.0.0:8080 app:app
ExecReload=/bin/kill -HUP $MAINPID
# 崩潰自動重啟,但避免無限快速重啟
Restart=on-failure
RestartSec=2
StartLimitIntervalSec=60
StartLimitBurst=5
# graceful shutdown:先送 SIGTERM,最多等 30 秒再 SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=30
# 資源設定(透過 cgroup 生效)
LimitNOFILE=200000
MemoryMax=4G
CPUQuota=400%
TasksMax=4096
# 基本安全強化
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/log/bidder
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Type=simple(預設,ExecStart 的 process 即主行程)、exec(等 exec 成功才算啟動)、forking(傳統 daemon 自己 fork 到背景,要配PIDFile)、notify(程式透過sd_notify告知「已就緒」)、oneshot(一次性任務)。After=vsWants=/Requires=After只管「順序」,Wants/Requires才管「是否一起啟動」。常見錯誤是只寫 After 以為就有依賴。Restart=no、on-failure(非 0 exit、被 signal 殺、逾時)、always。
常用操作
sudo systemctl daemon-reload # 修改 unit 檔後必做
sudo systemctl enable --now bidder # 開機自動啟動 + 立即啟動
systemctl status bidder # 狀態、主 PID、最近幾行 log、cgroup 內的 process
sudo systemctl reload bidder # 執行 ExecReload
sudo systemctl restart bidder
systemctl cat bidder # 顯示實際生效的 unit(含 drop-in)
sudo systemctl edit bidder # 建立 drop-in override,不改原檔
systemctl show bidder -p LimitNOFILE -p MemoryMax
systemctl list-units --failed
systemd-analyze blame # 開機時各 unit 花多久
journalctl
journalctl -u bidder -f # 即時追蹤(類似 tail -f)
journalctl -u bidder --since "10 min ago"
journalctl -u bidder --since "2026-11-03 09:00" --until "2026-11-03 09:30"
journalctl -u bidder -p err -b # 本次開機以來 error 以上等級
journalctl -u bidder -o json-pretty -n 5 # 結構化輸出
journalctl -k # 只看 kernel 訊息(等同 dmesg)
journalctl --disk-usage
sudo journalctl --vacuum-size=2G # 清理舊 journal
服務起不來時的 SOP:systemctl status 看 exit code → journalctl -u xxx -n 100 --no-pager 看錯誤 → systemctl cat 確認生效的設定 → 用 unit 裡的 User 與環境變數手動執行一次 ExecStart 重現。常見原因:權限(ProtectSystem 擋住寫入)、環境變數沒載入(systemd 不會讀 .bashrc)、相對路徑、port 已被佔用。
常用指令實戰大全 高頻
面試時常被問「線上服務變慢,你會先下什麼指令?」。下表是依「USE 方法」(Utilization、Saturation、Errors)整理的工具地圖,接著逐一給出實戰用法。
| 資源 | 使用率 | 飽和度(排隊) | 錯誤 |
|---|---|---|---|
| CPU | top、mpstat -P ALL 1 | vmstat 1 的 r、load average、cgroup nr_throttled | dmesg(MCE) |
| 記憶體 | free -h、vmstat | vmstat 的 si/so、sar -B majflt | dmesg 的 OOM |
| 磁碟 | iostat -xz 1 的 %util | aqu-sz、await | dmesg 的 I/O error、smartctl |
| 網路 | sar -n DEV 1 | ss -lnt 的 Recv-Q、netstat -s 的 overflow/drop | ip -s link 的 errors/dropped、重傳數 |
ps / top / htop
ps aux --sort=-%cpu | head -15 # CPU 用最多的 process
ps aux --sort=-rss | head -15 # 記憶體(RSS)用最多
ps -eo pid,ppid,user,stat,etime,nlwp,rss,cmd --sort=-rss | head # 自訂欄位(nlwp=thread 數)
ps -eLf | grep myapp | wc -l # thread 數
ps -T -p 12345 # 某 process 的所有 thread(SPID)
pstree -p 12345 # 樹狀看父子關係
top -c # 顯示完整指令;按 1 看各 CPU;按 P/M 依 CPU/記憶體排序
top -H -p 12345 # 看某 process 內每個 thread 的 CPU
top -b -n 1 | head -30 # 批次模式,可寫進腳本或 log
top 第三行的 CPU 細項要會讀:us(user)、sy(kernel,高代表 syscall 或中斷多)、ni、id(idle)、wa(iowait,CPU 閒著在等 I/O)、hi/si(硬/軟中斷,網路封包多時 si 高)、st(steal,VM 被 hypervisor 搶走的時間,雲端上超賣的訊號)。htop 是互動加強版,可以樹狀顯示、直接送 signal。
free / vmstat
$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 1203400 102400 18874368 0 0 5 40 8200 15000 35 8 56 1 0
12 4 0 980000 102400 18874000 0 0 8000 9000 9100 42000 60 15 5 20 0
r- Runnable(含正在跑的)task 數。長期大於 CPU 核心數 = CPU 飽和。
b- 處於 D 狀態(不可中斷睡眠,多為 I/O)的 task 數。
si / so- 每秒 swap in/out,非零就要注意記憶體。
bi / bo- block device 讀/寫量(KB/s)。
in / cs- 每秒中斷數 / context switch 數。
wa- iowait 百分比。注意它只代表「CPU 閒著且有 I/O 未完成」,不等於磁碟一定滿載。
iostat / sar / mpstat / pidstat
iostat -xz 1 # 每秒、顯示延伸欄位、略過沒活動的裝置
# r/s w/s:IOPS;rkB/s wkB/s:吞吐量
# r_await w_await:平均每個 I/O 的延遲(ms,含排隊)—— 最重要
# aqu-sz:平均佇列長度;%util:裝置忙碌時間比例(對 SSD/NVMe 這種可平行的裝置,100% 不一定代表到極限)
mpstat -P ALL 1 # 每顆 CPU 的使用率,找出「某一顆被打滿」(單執行緒瓶頸或中斷集中)
pidstat -u -r -d 1 # 每個 process 的 CPU / 記憶體 / 磁碟 I/O
pidstat -t -p 12345 1 # 每個 thread
sar -n DEV 1 # 每張網卡的 rx/tx 封包與流量
sar -n TCP,ETCP 1 # 每秒主動/被動建立的連線數、重傳數
sar -q 1 # run queue 與 load average
sar -u -f /var/log/sysstat/sa03 # 讀取歷史資料(需啟用 sysstat 收集),回頭看昨晚 3 點發生什麼
df / du
df -h # 各檔案系統的空間
df -i # inode 使用量:空間沒滿卻「No space left on device」,可能是 inode 用光(大量小檔)
du -sh /var/log/* | sort -rh | head # 找出最大的目錄
du -xh --max-depth=1 / 2>/dev/null | sort -rh | head # -x 不跨檔案系統
ncdu /var # 互動式(若已安裝)
lsof / ss / netstat
lsof -i :8080 # 誰在用 8080 port
lsof -p 12345 | wc -l # 某 process 開了多少 fd
lsof +L1 # 已被刪除但仍被開啟的檔案(link count < 1)
lsof -u bidder | head
ss -lntp # 所有 TCP listening socket 與對應 process
ss -tanp state established '( dport = :6379 )' # 連到 Redis 的連線
ss -s # 摘要:各狀態連線數
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn # 依 TCP 狀態統計
ss -tin dst 10.0.0.5 # -i 顯示 rtt、cwnd、retrans 等 TCP 內部資訊
ss -lnt # LISTEN 列的 Recv-Q = 目前 accept queue 長度;Send-Q = backlog 上限
netstat -s | grep -i -E 'listen|overflow|retrans' # accept queue 溢出、重傳統計
nstat -az | grep -i -E 'ListenOverflows|ListenDrops|TcpRetransSegs'
netstat(net-tools)在新系統上已被 ss(iproute2)取代,ss 直接讀 kernel 的 netlink,大量連線時快很多。
strace / perf
# strace:看 process 在做什麼 syscall,適合「卡住了但不知道卡在哪」
strace -f -tt -T -p 12345 # -f 追 thread,-tt 時間戳,-T 每個 syscall 耗時
strace -f -e trace=openat,read -p 12345 # 只看檔案相關
strace -c -f -p 12345 # 統計摘要(Ctrl-C 結束)
strace -f -e trace=connect -o /tmp/s.txt curl -s https://example.com >/dev/null
# 注意:strace 用 ptrace,會讓目標明顯變慢,生產環境只能短時間使用
# perf:CPU profiling,幾乎沒有 overhead 的取樣
perf top -p 12345 # 即時看哪些函式最耗 CPU
perf record -F 99 -g -p 12345 -- sleep 30 # 以 99Hz 取樣 30 秒,含 call graph
perf report --stdio | head -50
perf stat -p 12345 -- sleep 10 # cycles、instructions、IPC、cache-misses、context-switches
# 生成 flame graph(Brendan Gregg 的 FlameGraph 工具)
perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > cpu.svg
Python 程式用 perf 只會看到 _PyEval_EvalFrameDefault 之類的直譯器函式,要看到 Python 函式名稱需用 py-spy(py-spy top --pid 12345、py-spy record -o out.svg --pid 12345、py-spy dump --pid 12345 看每個 thread 卡在哪一行),或 Python 3.12+ 的 -X perf 支援。Go 則用內建的 pprof。詳見 第 8 章。
dmesg / tcpdump
dmesg -T | tail -50 # 人類可讀時間;看 OOM、segfault、磁碟錯誤、網卡 link down
dmesg -T --level=err,warn
dmesg -T | grep -i -E 'oom|segfault|nf_conntrack: table full|I/O error'
tcpdump -i any -nn port 8080 -c 100 # -nn 不解析主機名與 port 名
tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-syn|tcp-rst) != 0' # 只看 SYN/RST
tcpdump -i eth0 -nn host 10.0.0.5 and port 6379 -w redis.pcap # 存檔給 Wireshark 分析
tcpdump -i eth0 -A -s0 'tcp port 80 and (tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x47455420)' # 只抓 HTTP GET
curl / dig
curl -sv https://api.example.com/health # 看 request/response header 與 TLS 資訊
curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' https://api.example.com/
curl --resolve api.example.com:443:10.0.0.7 https://api.example.com/ # 指定 IP 測特定後端
curl -X POST -H 'Content-Type: application/json' -d '{"id":1}' localhost:8080/bid
dig api.example.com +short
dig api.example.com @8.8.8.8 +trace # 從根伺服器一路追蹤解析
dig -x 10.0.0.7 # 反解
更多網路排查細節見 第 4 章。
文字處理組合技:grep / awk / sed / xargs / sort / uniq / jq
假設 Nginx access log 使用如下格式(最後一欄是 $request_time,單位秒):
203.0.113.9 - - [03/Nov/2026:10:00:01 +0800] "POST /bid HTTP/1.1" 200 512 "-" "Go-http-client/1.1" 0.012
198.51.100.4 - - [03/Nov/2026:10:00:01 +0800] "GET /health HTTP/1.1" 200 2 "-" "kube-probe/1.30" 0.001
# 1) Top 10 來源 IP
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10
# 2) 狀態碼分佈
awk '{print $9}' access.log | sort | uniq -c | sort -rn
# 3) 5xx 最多的 URL(去掉 query string)
awk '$9 ~ /^5/ {split($7, a, "?"); print a[1]}' access.log | sort | uniq -c | sort -rn | head
# 4) p50 / p95 / p99 latency(最後一欄,nearest-rank 法)
awk '{print $NF}' access.log | sort -n | awk '
function pct(p, i) { i = int(NR * p); if (i < NR * p) i++; if (i < 1) i = 1; return v[i] }
{ v[NR] = $1 }
END { if (NR) printf "count=%d p50=%.3f p95=%.3f p99=%.3f max=%.3f\n", NR, pct(0.50), pct(0.95), pct(0.99), v[NR] }'
# 5) 每分鐘請求數(QPS 趨勢):取時間欄到「分」
awk '{print substr($4, 2, 17)}' access.log | uniq -c | tail -20
# 6) 每個 endpoint 的平均延遲
awk '{sum[$7] += $NF; cnt[$7]++} END {for (k in sum) printf "%.4f %6d %s\n", sum[k]/cnt[k], cnt[k], k}' access.log | sort -rn | head
# 7) 即時監看錯誤
tail -F access.log | grep --line-buffered ' 5[0-9][0-9] '
# 8) 壓縮過的舊 log 一起分析
zcat access.log.*.gz | cat - access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head
# 9) sed:就地替換設定、擷取時間區間
sed -i.bak 's/^worker_processes .*/worker_processes auto;/' /etc/nginx/nginx.conf
sed -n '/03\/Nov\/2026:10:00/,/03\/Nov\/2026:10:05/p' access.log > window.log
# 10) xargs:平行處理
find /var/log/app -name '*.log' -mtime +7 -print0 | xargs -0 -r gzip
cat hosts.txt | xargs -P 10 -I{} ssh {} 'uptime' # 10 台同時執行
# 11) jq:JSON log(結構化 log)
jq -r 'select(.status >= 500) | .path' app.jsonl | sort | uniq -c | sort -rn | head
jq -s 'map(.latency_ms) | sort | .[(length*0.99|floor)]' app.jsonl # p99
jq -r '[.ts, .user_id, .latency_ms] | @tsv' app.jsonl | head
uniq 只合併相鄰的重複行,所以一定要先 sort。大檔案 sort 可加 LC_ALL=C(以 byte 比較,快很多)與 -S 2G --parallel=4。log 太大時先 head -n 1000000 或用時間區間切出來再算,不要在線上機器把 CPU 打滿。p99 的精確計算需要排序全部資料(O(n log n) 記憶體),量大時用 t-digest / HDR Histogram 等近似演算法,這也是 Prometheus histogram 的思路。
同一件事用 Python 寫(面試白板常要求):
import math
import sys
from collections import Counter
def percentile(sorted_vals: list[float], p: float) -> float:
"""nearest-rank 法:p 介於 0~100。"""
if not sorted_vals:
raise ValueError("empty")
k = math.ceil(p / 100 * len(sorted_vals)) # 第 k 小(1-based)
return sorted_vals[max(k, 1) - 1]
ips: Counter[str] = Counter()
latencies: list[float] = []
with open(sys.argv[1], encoding="utf-8", errors="replace") as f:
for line in f:
parts = line.split()
if len(parts) < 2:
continue
ips[parts[0]] += 1
try:
latencies.append(float(parts[-1]))
except ValueError:
pass
for ip, n in ips.most_common(10):
print(f"{n:8d} {ip}")
latencies.sort()
for p in (50, 95, 99):
print(f"p{p} = {percentile(latencies, p) * 1000:.1f} ms")
Kernel 調校:高併發網路服務 深入
預設值是為「一般用途」設計的,高 QPS、大量短連線、大量對外連線的服務(API gateway、RTB bidder、反向代理)常常需要調整。原則:先量測、確認瓶頸指標,再調參數;一次改一個;記錄在設定管理(Ansible 等)裡。
先理解兩個佇列:SYN queue 與 accept queue
上限約 tcp_max_syn_backlog"] SQ -- "收到 client 的 ACK
三次交握完成" --> AQ["Accept queue(全連線)
上限 = min(backlog, somaxconn)"] AQ -- "應用呼叫 accept()" --> APP["應用程式取得 fd"] AQ -. "佇列已滿:丟棄 ACK 或回 RST
ListenOverflows++" .-> DROP["連線失敗 / 逾時重試"]
若應用 accept 太慢(例如 event loop 被 CPU 密集工作卡住),accept queue 會滿,新連線被丟棄,client 看到的是「連線建立偶爾要 1 秒、3 秒」(SYN 重傳的退避時間),這種 tail latency 很難從應用 log 看出來。檢查:ss -lnt 看 LISTEN 的 Recv-Q 是否接近 Send-Q;nstat -az TcpExtListenOverflows TcpExtListenDrops 是否持續增加。
重要參數一覽
| 參數 | 作用 | 常見調整 | 風險 / 注意 |
|---|---|---|---|
net.core.somaxconn | listen backlog(accept queue)的系統上限 | Linux 5.4 前預設 128,之後 4096;高併發可設 8192~65535 | 應用程式的 listen(backlog) 也要一起調大(Nginx listen 80 backlog=...),兩者取小。佇列太長只是把等待藏起來,根本解是應用 accept 夠快 |
net.ipv4.tcp_max_syn_backlog | SYN queue(半連線)上限 | 調大到數千~數萬 | SYN flood 防護主要靠 tcp_syncookies=1(預設開啟) |
net.core.netdev_max_backlog | 網卡收包後、kernel 協定堆疊處理前的佇列 | 大流量時調大 | 看 /proc/net/softnet_stat 第二欄 dropped 是否增加 |
net.ipv4.ip_local_port_range | 對外連線可用的臨時 port(ephemeral port)範圍 | 預設 32768 60999(約 2.8 萬個);大量對外連線可改 1024 65000 | 需避開本機服務 listen 的 port(可用 ip_local_reserved_ports)。同一組(src IP、dst IP、dst port)才會共用這個池,增加後端 IP 或來源 IP 也能擴充 |
net.ipv4.tcp_tw_reuse | 允許「主動建立連線」時重用處於 TIME_WAIT 的 port(依賴 TCP timestamps 判斷安全) | 設 1 可緩解 client 端 port 耗盡;較新 kernel 預設 2(僅對 loopback 啟用) | 只對 outbound 連線有效,對 server 端被動接收的連線無用。不要用 tcp_tw_recycle:它在 NAT 後面會誤丟封包,Linux 4.12 已移除 |
net.ipv4.tcp_max_tw_buckets | TIME_WAIT 數量上限,超過直接銷毀 | 維持預設或依需要調整 | 直接銷毀 TIME_WAIT 有理論上的資料錯亂風險,治本是用 keep-alive / 連線池減少短連線 |
net.ipv4.tcp_fin_timeout | FIN_WAIT_2 狀態的逾時(不是 TIME_WAIT 時間) | 預設 60 秒,可降到 15~30 | 常被誤以為能縮短 TIME_WAIT;Linux 的 TIME_WAIT 固定 60 秒寫死在 kernel |
fs.file-max / fs.nr_open | 全系統 / 單 process 最大 fd 數 | 依連線量調大,並配合 LimitNOFILE | 每個 fd 有少量 kernel 記憶體開銷 |
net.core.rmem_max / wmem_max | socket 收/送 buffer 能透過 setsockopt 設定的上限 | 高頻寬、高延遲(大 BDP)連線時調大,例如 16MB | buffer 越大每連線記憶體越多;10 萬連線 × 大 buffer 可能吃掉數 GB |
net.ipv4.tcp_rmem / tcp_wmem | TCP 自動調整 buffer 的 min / default / max 三個值 | 例如 4096 87380 16777216 | kernel 有 auto-tuning,通常只需調 max;對大量小請求的 API 服務,大 buffer 沒幫助反而浪費記憶體 |
net.ipv4.tcp_slow_start_after_idle | 連線閒置後是否重新慢啟動 | 長連線、突發流量的服務可設 0 | 對網路壅塞較不友善 |
net.ipv4.tcp_keepalive_time 等 | TCP keepalive 探測(預設 7200 秒後才開始探測) | 需要早點偵測死連線時調短,或在應用層 socket 選項設定 | 太頻繁會增加封包 |
net.netfilter.nf_conntrack_max | 連線追蹤表上限(有用 iptables/NAT 時) | 大量連線的節點要調大 | 表滿時 dmesg 出現 nf_conntrack: table full, dropping packet,新連線被丟 |
vm.swappiness | 回收記憶體時偏好 swap 匿名頁的程度 | 延遲敏感服務 1~10 | 設 0 在某些 kernel 版本下可能讓 OOM 更早發生 |
# 查看目前值
sysctl net.core.somaxconn net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse
# 暫時修改(重開機失效)
sudo sysctl -w net.core.somaxconn=65535
# 永久:寫入 /etc/sysctl.d/ 再載入
cat <<'EOF' | sudo tee /etc/sysctl.d/90-highconn.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 16384
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
fs.file-max = 2097152
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
EOF
sudo sysctl --system
(1) 容器有自己的 network namespace,很多 net.* 參數是 per-namespace 的,在主機上改了容器內不一定生效;K8s 需要透過 Pod 的 securityContext.sysctls 設定(部分被視為 unsafe,需 kubelet 允許)。(2) 從網路上複製一整份「最佳化 sysctl」直接套用是危險的,像 tcp_tw_recycle 這類參數曾造成 NAT 環境下大量連線失敗。
RTB bidder 一端要接 ad exchange 的大量請求,另一端要對 Redis、feature service、模型服務發出大量內部請求。常見瓶頸:(1) 對下游用短連線,造成 TIME_WAIT 爆量與 ephemeral port 耗盡(connect: cannot assign requested address,即 EADDRNOTAVAIL)→ 治本是連線池與 keep-alive;(2) accept queue 溢出造成偶發的 1 秒延遲,在 100ms 預算下等於直接 timeout、錯失競價。
故障排查劇本與 Load Average 高頻 深入
Load Average 的真正意義
uptime 顯示的三個數字是過去 1、5、15 分鐘的指數加權移動平均,計算的是「系統中處於 R(runnable,含正在跑和排隊等 CPU) 與 D(不可中斷睡眠,通常在等磁碟/NFS I/O) 狀態的 task 數量」。
- Linux 把 D 狀態算進去(其他 Unix 多半只算 CPU),所以 load 高不一定是 CPU 忙,也可能是 I/O 卡住。
- 要和 CPU 核心數比較:8 核機器 load 8 代表大約剛好滿載;load 16 且全是 R,代表平均每個核有一個在排隊。
- 看趨勢:1 分鐘值 > 15 分鐘值表示正在上升,反之在下降。
- 容器中看到的 load 是整台主機的,參考價值有限。
登入機器的前 60 秒
改編自 Netflix 效能團隊著名的檢查清單,依序執行,快速建立全貌:
uptime # load 趨勢
dmesg -T | tail # OOM、硬體錯誤、TCP drop 等 kernel 訊息
vmstat 1 5 # r、b、si/so、us/sy/wa/st
mpstat -P ALL 1 3 # 是否單一 CPU 不平衡
pidstat 1 3 # 哪個 process 吃 CPU
iostat -xz 1 3 # 磁碟延遲與使用率
free -h # available
sar -n DEV 1 3 # 網卡流量是否到上限
sar -n TCP,ETCP 1 3 # 連線建立速率、重傳
top # 最後再綜覽
劇本一:「機器 load 很高」
top -H 找 thread
perf / py-spy 找熱點函式"] C -- "sy 高" --> E["strace -c 看 syscall 分佈
是否大量小 read/write、lock 競爭、context switch"] C -- "st 高" --> F["VM 被搶資源:換機型或聯繫雲端"] B -- "b 高(D 狀態)" --> G["iostat -xz:await、%util
ps 找 D 狀態 process
是否 NFS / 磁碟故障 / swap"] D --> H["修正:程式最佳化、擴容、限流"] E --> H G --> H
# 列出 D 狀態的 process 與它們卡在哪個 kernel 函式
ps -eo pid,stat,wchan:32,cmd | awk '$2 ~ /D/'
cat /proc/12345/stack # 需要 root;看 kernel stack
劇本二:「CPU 100%,找出是哪個 thread、哪段程式」
top按P找出 CPU 最高的 PID(例如 12345)。top -H -p 12345(或ps -T -o spid,pcpu,comm -p 12345 --sort=-pcpu | head)找出最忙的 thread ID(TID,例如 12399)。- 依語言對應 thread:
- Java:
printf '%x\n' 12399轉成 16 進位,再jstack 12345 | grep -A 20 'nid=0x306f'。 - Python:
py-spy dump --pid 12345直接列出每個 thread 的 Python stack(含 native thread id);py-spy top --pid 12345即時看熱點函式。 - Go:開
net/http/pprof,go tool pprof http://host:6060/debug/pprof/profile?seconds=30。 - C/C++ 或通用:
perf top -t 12399或perf record -g -t 12399;gdb -p 12345後thread apply all bt。
- Java:
- 常見原因:無窮迴圈或錯誤的重試迴圈(沒有 backoff)、regex 災難性回溯(catastrophic backtracking)、大量 JSON 序列化、GC 頻繁、熱點資料結構(例如 O(n) 的 list 查詢放在 hot path)、busy-wait 輪詢。
劇本三:「記憶體一直漲」
- 確認是誰在漲:
ps aux --sort=-rss | head,或監控上看 RSS 曲線。若是整機used漲但 process 沒漲,看/proc/meminfo的Slab(kernel 物件,例如大量 dentry、conntrack)、Shmem(tmpfs、/dev/shm)。 - 判斷是 leak 還是正常成長:快取暖機、page cache 會漲到一個平台;leak 則與時間或請求數成正比、永不回落。
- 看是哪種記憶體:
/proc/PID/smaps_rollup的 Anonymous(heap)vs 檔案映射;pmap -x PID是否有越來越多的 anon 區塊。 - 語言層工具:
- Python:
tracemalloc(比較兩個 snapshot 的差異)、objgraph、memray。常見元兇:無上限的 dict/list 當快取、functools.lru_cache用在 method 上持有 self、全域 list 不斷 append、循環參照搭配__del__、C extension 的 leak。 - Go:
pprofheap profile(-inuse_space)、goroutine 數量是否一直增加(goroutine leak 是 Go 最常見的 leak)。 - Java:heap dump + MAT。
- Python:
- 注意「RSS 不降」不一定是 leak:glibc malloc 釋放後不一定歸還 OS(記憶體碎片、多個 arena),可嘗試
MALLOC_ARENA_MAX=2或改用 jemalloc。 - 止血:設定 memory limit 讓它被重啟(K8s)、gunicorn 的
--max-requests讓 worker 定期重生;但要追到根因。
import tracemalloc
tracemalloc.start(25) # 記錄 25 層 stack
snap1 = tracemalloc.take_snapshot()
# ... 跑一段流量 ...
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:10]:
print(stat) # 哪一行程式新增最多記憶體
劇本四:「磁碟滿了,但 du 找不到是誰」
經典情境:df -h 顯示 / 用了 100%,但 du -sh /* 加總只有 60%。原因幾乎都是檔案已被刪除,但仍有 process 開著它。
原理:rm 只是移除目錄項(unlink),inode 的 link count 變 0;但只要還有 process 持有開啟的 fd,kernel 就不會釋放資料區塊。du 透過目錄走訪計算,看不到它;df 看檔案系統的區塊統計,算得到它。最常見的是有人 rm 掉正在寫入的大 log 檔,而應用程式仍持續寫入。
# 找出已刪除但仍被開啟的檔案
lsof +L1
lsof -nP | grep '(deleted)'
# 輸出範例:java 12345 app 7w REG 253,1 53687091200 1234 /var/log/app/app.log (deleted)
# 解法 1:讓程式重新開檔(若支援)—— 例如送 SIGHUP / SIGUSR1 讓它 reopen log,或重啟服務
# 解法 2:不重啟,直接把該 fd 截斷為 0(空間立即釋放)
: > /proc/12345/fd/7
# 或 truncate -s 0 /proc/12345/fd/7
預防:用 logrotate 的 copytruncate(複製後截斷,不需程式配合,但有極短時間的 log 遺失風險)或 postrotate 送 signal 讓程式 reopen;不要直接 rm 正在寫的檔案,要清空就用 truncate -s 0 或 : > file。另外別忘了檢查 df -i(inode 耗盡)以及被掛載點遮住的目錄(先寫入 /data 後才掛載磁碟,原本的檔案被遮住,du 也看不到)。
其他常見劇本速查
| 症狀 | 先看什麼 | 常見原因 |
|---|---|---|
| 偶發 1s / 3s 的連線延遲 | nstat ListenOverflows、SYN 重傳 | accept queue 溢出、conntrack 滿、封包遺失 |
cannot assign requested address | ss -s TIME_WAIT 數、port range | 短連線對同一後端打太多,ephemeral port 耗盡 |
大量 CLOSE_WAIT | ss -tanp state close-wait | 應用程式沒有 close 對端已關閉的連線(程式 bug) |
| p99 延遲高但 CPU 不高 | cgroup cpu.stat throttled、GC log、鎖競爭 | CPU limit throttle、GC pause、下游慢、連線池耗盡在排隊 |
| 容器反覆重啟 exit 137 | kubectl describe pod、memory.events | OOMKilled 或 liveness probe 失敗被殺 |
No space left on device 但 df 有空間 | df -i | inode 用完(大量小檔,例如 session 檔、cache 檔) |
排查題考的是有沒有系統化的方法,而不是背指令。回答結構:(1) 先確認影響範圍與時間點(從監控看是單機還是全部、何時開始、是否與部署相關);(2) 由上而下(USE 方法:CPU、記憶體、磁碟、網路的使用率/飽和/錯誤)縮小範圍;(3) 定位到 process → thread → 程式碼;(4) 先止血(回滾、擴容、限流、重啟)再追根因;(5) 事後補監控與告警、寫 postmortem。能講出「deleted but open file」「D 狀態算進 load」「CLOSE_WAIT 是自己的 bug」這類細節,會讓面試官相信你真的處理過。
C++ 對照:RAII 與 Smart Pointer 進階
Appier 的 C++ 團隊面試曾問 RAII、smart pointer、virtual function。即使你主力是 Python / Go,理解 RAII 也能幫你說清楚「資源管理」這件事 —— 它與本章的 fd leak、記憶體 leak、lock 未釋放是同一個主題。
RAII(Resource Acquisition Is Initialization)
核心思想:把資源的生命週期綁定到物件的生命週期。建構子取得資源(開檔、鎖、配置記憶體、socket),解構子釋放資源。因為 C++ 保證區域物件離開 scope 時(包括例外拋出時的 stack unwinding)一定會呼叫解構子,資源就不會洩漏。
#include <cstdio>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unistd.h>
#include <fcntl.h>
// 自己包一個 fd 的 RAII 類別
class FileDescriptor {
public:
explicit FileDescriptor(const std::string& path)
: fd_(::open(path.c_str(), O_RDONLY | O_CLOEXEC)) {
if (fd_ < 0) throw std::runtime_error("open failed: " + path);
}
~FileDescriptor() { if (fd_ >= 0) ::close(fd_); }
// 禁止複製(避免 double close),允許移動
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }
FileDescriptor& operator=(FileDescriptor&& other) noexcept {
if (this != &other) {
if (fd_ >= 0) ::close(fd_);
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
int get() const { return fd_; }
private:
int fd_;
};
std::mutex mu;
int counter = 0;
void handle(const std::string& path) {
FileDescriptor fd(path); // 取得資源
std::lock_guard<std::mutex> lock(mu); // 鎖也是 RAII
++counter;
if (counter % 2 == 0) throw std::runtime_error("boom");
} // 不論正常返回或丟例外:先 unlock,再 close(fd)(解構順序與建構相反)
Smart Pointer
| 類型 | 所有權 | 成本 | 使用時機 |
|---|---|---|---|
std::unique_ptr<T> | 獨佔;不可複製,只能 std::move 轉移 | 與裸指標相同(零額外成本,使用預設 deleter 時) | 預設選擇;工廠函式回傳、pimpl、容器中存放多型物件 |
std::shared_ptr<T> | 共享;reference count 歸零時釋放 | 控制區塊 + 原子遞增/遞減(多執行緒安全但有成本) | 真的有多個擁有者且生命週期無法預測時 |
std::weak_ptr<T> | 不擁有;觀察 shared_ptr 管理的物件,用 lock() 取得暫時的 shared_ptr | — | 打破循環參照(parent 持有 child 的 shared_ptr,child 以 weak_ptr 指回 parent)、快取 |
#include <memory>
#include <vector>
struct Model { virtual ~Model() = default; virtual double predict() const = 0; };
struct LogisticModel : Model { double predict() const override { return 0.42; } };
std::unique_ptr<Model> load_model() {
return std::make_unique<LogisticModel>(); // 例外安全、只配置一次
}
int main() {
std::vector<std::unique_ptr<Model>> models;
models.push_back(load_model()); // 所有權移入 vector
auto shared = std::make_shared<LogisticModel>(); // 控制區塊與物件一次配置
std::weak_ptr<LogisticModel> watcher = shared;
if (auto p = watcher.lock()) { p->predict(); } // 物件仍存在才使用
}
(1) RAII 的定義與「例外安全」的關係;(2) 預設用 unique_ptr,需要共享所有權才用 shared_ptr,因為後者有原子操作與額外配置成本;(3) make_shared / make_unique 的好處(例外安全、一次配置);(4) shared_ptr 循環參照會 leak,用 weak_ptr 解;(5) 多型基底類別的解構子要是 virtual,否則透過基底指標 delete 時只呼叫基底解構子,是未定義行為。
對照其他語言:Python 的 with(context manager)、Go 的 defer、Java 的 try-with-resources,都是「確定性釋放資源」的不同解法;Python 的 GC 雖然靠 refcount 通常會即時回收,但不應依賴 __del__ 來關 fd。
面試自測題
Process、thread、coroutine 的差別?各自適合什麼場景?
Process 是資源分配單位,有獨立虛擬位址空間與 fd table,隔離性最好但建立與切換最貴(要換 page table、TLB/cache 變冷)。Thread 是排程單位,同 process 內共享 heap 與 fd,各有自己的 stack 與暫存器,切換不需換位址空間,但一個 thread 崩潰會拖垮整個 process,共享資料要上鎖。Coroutine 在 user space 由程式或 runtime 協作式切換,不需 syscall,成本極低,可同時存在數十萬個,適合大量 I/O 等待的服務,但任何 blocking 呼叫都會卡住整個 event loop。結合 Python:CPU-bound 用多 process 繞過 GIL,I/O-bound 用 asyncio;Go 用 goroutine + netpoller 以 M:N 排程兼顧兩者。追問:Nginx 的模型 → master + 每核一個 worker process,每個 worker 跑 epoll event loop。
fork() 之後記憶體會被整份複製嗎?有什麼實際影響?
不會,Linux 使用 copy-on-write:fork 只複製 page table,將雙方頁面標成唯讀,任一方寫入時觸發 page fault 才複製那一頁。所以 fork + exec 很便宜。實際影響:Redis BGSAVE 靠 fork 取得快照,但若 fork 期間寫入量大,被修改的頁都要複製,記憶體最壞接近翻倍;THP 開啟時複製單位變 2MB 更嚴重,所以 Redis 建議關 THP、設 vm.overcommit_memory=1。Python 的 refcount 寫入會讓「唯讀」物件也觸發 COW,因此 gunicorn preload 共享記憶體的效果常打折。多執行緒程式 fork 後 child 只有一個 thread,可能繼承到被鎖住的 mutex,應盡快 exec。
什麼是 zombie process 和 orphan process?如何處理大量 zombie?
Zombie 是已結束但 parent 還沒呼叫 wait 回收 exit status 的 process,只佔 process table entry 與 PID。Orphan 是 parent 先結束的 process,會被 init(或 subreaper)收養,由它負責 wait,所以無害。大量 zombie 代表 parent 有 bug(沒處理 SIGCHLD / 沒 wait)。zombie 本身 kill 不掉,處理方式是修 parent、讓 parent 回收,或結束 parent 讓 init 收養後回收。容器中自己的程式當 PID 1 又會 spawn 子行程時特別常見,解法是用 tini 或 docker run --init。風險:zombie 太多可能耗盡 PID。
Load average 是什麼?8 核機器 load 是 20 代表什麼?
Load average 是 1/5/15 分鐘的指數移動平均,計算處於 R(執行中或等 CPU)與 D(不可中斷睡眠,多半是磁碟 I/O)狀態的 task 數。Linux 特別把 D 算進去,所以 load 高不一定是 CPU 忙。8 核 load 20 代表平均有 20 個 task 想用 CPU 或卡在 I/O:要用 vmstat 1 看 r 與 b 欄位區分。若是 r 高 → CPU 飽和,用 top/pidstat/perf 找熱點;若是 b 高 → 看 iostat 的 await、找 D 狀態 process(可能是 NFS 卡住或磁碟壞)。另外比較 1 分鐘與 15 分鐘值判斷趨勢,容器內看到的是整台主機的 load。
free 顯示 free 只剩 500MB,需要擔心嗎?RSS 與 VSZ 有什麼不同?
不一定。Linux 會把閒置記憶體用作 page cache,free 欄位低是正常的,應該看 available(不需 swap 還能配置的量)以及 vmstat 的 si/so 是否非零、是否有 OOM 紀錄。VSZ 是虛擬位址空間總大小,含保留但未使用的區域與共享 library,Go/Java 程式常常好幾 GB 也沒關係;RSS 是實際在 RAM 的頁面,但包含共享頁,多個 worker 加總會高估,要算真實總量用 PSS(smaps_rollup、smem)。
OOM killer 是如何運作的?容器的 OOM 有何不同?
Linux 預設允許 overcommit,當實際記憶體耗盡且無法回收(page cache 回收完、swap 也滿或沒有)時,OOM killer 依 oom_score(主要依記憶體用量,加上 oom_score_adj -1000~1000 調整)挑一個 process 送 SIGKILL。可在 dmesg / journalctl -k 看到 "Killed process"。容器則是 cgroup 層級:使用量超過 memory.max 時只在該 cgroup 內挑 process 殺,K8s 顯示 OOMKilled、exit code 137(128+9)。預防:合理設定 request/limit、找 leak、模型熱更新時保留新舊共存的空間。
select、poll、epoll 有什麼差別?為什麼 epoll 適合高併發?
select 用 bitmap,有 FD_SETSIZE(通常 1024)上限,每次呼叫要把整個集合複製進 kernel,kernel 線性掃描,回來後應用還要再掃一次;poll 拿掉了數量上限但仍是 O(n)。epoll 把「註冊」與「等待」分開:epoll_ctl 註冊一次,kernel 用紅黑樹存放關注的 fd,fd 就緒時透過 callback 放入 ready list,epoll_wait 只回傳就緒的 fd,成本與活躍連線數成正比而非總連線數。在 10 萬連線、只有少數活躍的場景差異巨大。追問:epoll 不支援 regular file(永遠 ready),檔案 I/O 要 thread pool 或 io_uring。
Edge-triggered 與 level-triggered 的差別?使用 ET 要注意什麼?
LT(預設)只要 buffer 還有資料,每次 epoll_wait 都會通知,比較不容易寫錯;ET 只在狀態變化(新資料到達)時通知一次。用 ET 必須把 fd 設為 non-blocking,並在收到通知後迴圈讀取(accept 也一樣)直到回傳 EAGAIN,否則殘留資料不會再觸發事件,連線看起來就「卡住」。ET 減少重複喚醒、效率較高,Nginx 使用 ET。此外多 thread 共用 epoll 時要注意驚群,可用 EPOLLEXCLUSIVE、EPOLLONESHOT 或 SO_REUSEPORT。
什麼是 zero-copy?sendfile 省了什麼?
傳統 read + write 把檔案送到 socket 要經過:磁碟 → page cache(DMA)→ user buffer(CPU copy)→ socket buffer(CPU copy)→ 網卡(DMA),兩次 CPU 複製與多次 user/kernel 切換。sendfile 讓資料在 kernel 內從 page cache 直接送到 socket,省掉兩次 CPU 複製與 user space 往返;網卡支援 scatter-gather 時連 socket buffer 的複製也可省。用例:Nginx 靜態檔案(sendfile on)、Kafka 把 log segment 傳給 consumer。限制:資料無法在 user space 加工,HTTPS 需 kTLS 才能搭配。
SIGTERM、SIGKILL、SIGINT、SIGHUP 差在哪?怎麼實作 graceful shutdown?
SIGTERM(15)是禮貌的終止請求,可以捕捉並清理,是 kill、docker stop、K8s 刪 Pod 的預設;SIGKILL(9)無法捕捉或忽略,kernel 直接終止,沒有清理機會;SIGINT(2)是 Ctrl-C;SIGHUP(1)原本是終端斷線,daemon 慣例用來 reload 設定。Graceful shutdown:收到 SIGTERM → readiness 改為失敗讓 LB 摘除 → 停止 accept 新連線 → 等進行中請求完成(有上限)→ flush log/metrics/佇列、關閉連線池 → exit 0。K8s 要注意 SIGTERM 與移除 endpoint 是平行的,常加 preStop sleep 幾秒;grace period 預設 30 秒,超過就 SIGKILL。
為什麼容器中的程式有時候 docker stop 要等 10 秒才結束?
常見兩個原因:(1) Dockerfile 用 shell form CMD python app.py,PID 1 是 /bin/sh -c,sh 不會把 SIGTERM 轉送給 python;(2) 程式本身是 PID 1 但沒註冊 SIGTERM handler,kernel 對 PID 1 不套用 signal 的預設動作,SIGTERM 被忽略。結果都是 docker 等滿 10 秒後送 SIGKILL。解法:使用 exec form CMD ["python", "app.py"]、entrypoint script 最後用 exec、程式明確處理 SIGTERM,或用 tini / --init 當 PID 1 負責轉送 signal 與回收 zombie。
遇到 "Too many open files" 你會怎麼排查?
先確認是 EMFILE(process 上限)還是 ENFILE(整機 file-max)。找到 PID 後看 /proc/PID/limits 的 open files 與 ls /proc/PID/fd | wc -l 實際數量;用 lsof -p PID 看 fd 種類,若多是 socket,再用 ss -tanp 看狀態與對端:大量 CLOSE_WAIT 代表程式沒關連線;大量同一檔案代表開檔沒關。觀察 fd 數是否隨時間單調上升(leak)或只是流量大。短期調高 systemd 的 LimitNOFILE(注意 limits.conf 對 systemd 服務無效),長期修正:with/defer 關閉資源、HTTP client 共用連線池、Go 記得 resp.Body.Close()、設定 timeout。
df 顯示磁碟 100%,但 du 加總只有 60%,可能原因與解法?
最常見是檔案已被 rm,但仍有 process 開著 fd:unlink 只移除目錄項,資料區塊要等最後一個 fd 關閉才釋放,du 走目錄看不到,df 看區塊統計看得到。用 lsof +L1 或 lsof | grep deleted 找出 PID 與 fd,解法是讓程式 reopen log(送 SIGHUP/USR1)或重啟,或直接 : > /proc/PID/fd/N 截斷。預防:logrotate 用 copytruncate 或 postrotate 通知,清 log 用 truncate 而非 rm。其他可能:目錄被掛載點遮住、inode 用完(df -i)、保留給 root 的區塊(ext4 預設 5%)。
線上服務 CPU 100%,如何找到是哪個 thread、哪段程式造成?
top 找 PID → top -H -p PID 找最忙的 TID → 依語言對應:Java 把 TID 轉 16 進位到 jstack 找 nid;Python 用 py-spy dump/top --pid(不需重啟、overhead 低);Go 用 pprof CPU profile;C/C++ 用 perf top -t TID 或 perf record -g 產生 flame graph。如果 top 顯示 sy 高,則用 strace -c 看是否 syscall 過多。常見原因:沒有 backoff 的重試迴圈、regex 回溯、大量序列化、GC、hot path 上 O(n) 的資料結構。先止血(限流、回滾、擴容),再修根因。
服務的記憶體一直漲,你的排查步驟?
(1) 用監控確認是哪個 process、成長是否與時間/請求數線性且不回落(區分 leak 與快取暖機);(2) 若整機漲但 process 沒漲,看 /proc/meminfo 的 Slab、Shmem;(3) 用 smaps_rollup / pmap 區分 heap 與檔案映射;(4) 語言工具:Python tracemalloc 比較 snapshot、memray;Go pprof heap 與 goroutine 數;Java heap dump;(5) 常見元兇:無上限快取、全域容器、lru_cache 綁 self、goroutine leak、C extension;(6) 注意 glibc malloc 不一定歸還 OS,可試 MALLOC_ARENA_MAX 或 jemalloc;(7) 止血用 memory limit 自動重啟、gunicorn max-requests,但要追根因。
高併發網路服務常調整哪些 kernel 參數?各有什麼風險?
somaxconn 與 tcp_max_syn_backlog:加大 accept / SYN queue,避免突發流量時 ListenOverflows;需同時調大應用的 listen backlog,且佇列長只是延後問題。ip_local_port_range:擴大 ephemeral port,緩解對外短連線的 port 耗盡。tcp_tw_reuse=1:outbound 連線可重用 TIME_WAIT port,依賴 timestamps;絕不使用已移除的 tcp_tw_recycle(NAT 下會丟包)。file-max/nr_open + LimitNOFILE:fd 上限。rmem/wmem:大 BDP 連線提升吞吐,但大量連線時吃記憶體。nf_conntrack_max:避免 conntrack 表滿丟包。原則:先量測指標(nstat、ss、dmesg)再調,一次一個、納入設定管理;容器內部分參數是 per-namespace。根本解多半是連線池與 keep-alive。
容器與 VM 的差別?cgroups 的 CPU limit 設太低會發生什麼?
容器是共用 host kernel 的 process,用 namespaces(PID、NET、MNT、UTS、IPC、USER、CGROUP)隔離視圖、cgroups 限制資源、overlayfs 提供分層映像,所以啟動快、密度高;VM 有獨立 kernel 與 hypervisor,隔離更強但更重。CPU limit 是用 CFS quota 實作(每 100ms 週期配額),用完就被 throttle 到下一週期,表現為 p99 延遲尖峰,但平均 CPU 使用率看起來不高;可看 cpu.stat 的 nr_throttled。另外容器內 nproc、free 顯示主機數值,runtime 若據此配置 worker/heap 會出問題。
System call 為什麼比一般函式呼叫貴?如何減少它的影響?
System call 要從 user mode 切到 kernel mode(ring 3 → ring 0),保存/還原狀態、檢查參數,資料常需在 user 與 kernel buffer 之間複製;Meltdown 後的 KPTI 讓切換更貴,也會影響 cache/TLB。減少方式:buffered I/O(一次讀寫大塊)、批次操作(writev、sendmmsg、Redis pipeline)、零複製(sendfile/splice)、io_uring 批次提交、vDSO(clock_gettime 不用陷入 kernel),以及用 epoll 而非忙碌輪詢。量測用 strace -c 看次數與時間分佈,或 perf 看 sy 時間。
C++ 的 RAII 是什麼?unique_ptr 和 shared_ptr 怎麼選?
RAII 把資源生命週期綁到物件生命週期:建構時取得、解構時釋放,C++ 保證離開 scope(含例外的 stack unwinding)時呼叫解構子,所以不會 leak,例如 std::lock_guard、檔案 handle、smart pointer。預設用 unique_ptr:獨佔、只能 move、幾乎零成本;只有真正需要多個擁有者時才用 shared_ptr,它有控制區塊與原子 refcount 成本;循環參照用 weak_ptr 打破。建立時用 make_unique/make_shared。多型基底類別解構子要 virtual。對照 Python 的 with、Go 的 defer。