Chapter 03

作業系統與 Linux 實戰

職缺明寫「Linux 開發經驗,包含 runtime 設定與資源優化」與「Linux 上的高效能網路服務」。本章從 process / thread / coroutine、虛擬記憶體、I/O 模型(epoll)、signal 與容器基礎,一路講到 kernel 參數調校、常用指令實戰與故障排查劇本,目標是讓你在面試中能把「原理」和「線上真的出事時怎麼查」串在一起講。

預估閱讀 75 分鐘

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 都屬此類。

三者比較

面向ProcessThreadCoroutine
位址空間獨立共享(同 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。

flowchart TB subgraph P1["Process A(獨立位址空間)"] direction TB H1["Heap / Data / Code(共享)"] T1["Thread 1
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。

Appier 情境

以 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 建立新程式的經典兩步驟:

  1. fork():複製目前 process,產生 child。回傳值在 parent 是 child 的 PID,在 child 是 0,失敗回傳 -1。child 繼承 parent 的記憶體內容(COW)、開啟的 fd、環境變數、signal 處置方式等。
  2. exec*()(如 execve):把目前 process 的記憶體映像換成新程式,PID 不變,已開啟的 fd 預設會保留(除非設了 O_CLOEXEC / FD_CLOEXEC)。
  3. 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 欄位常見值:

代碼狀態說明
RRunning / Runnable正在 CPU 上跑,或在 run queue 等 CPU
SInterruptible sleep等待事件(socket 有資料、timer),可被 signal 喚醒。大部分閒置的服務都在這
DUninterruptible sleep通常在等磁碟 / NFS I/O,連 SIGKILL 都殺不掉,直到 I/O 完成。大量 D 狀態 = I/O 有問題
TStopped被 SIGSTOP / Ctrl-Z 暫停,或被 debugger trace
ZZombie已結束但 parent 還沒 wait 回收
IIdle kernel thread閒置的 kernel thread(不計入 load)
stateDiagram-v2 [*] --> Runnable: fork / clone Runnable --> Running: 排程器選中 Running --> Runnable: 時間片用完 / 被搶占 Running --> Sleeping_S: 等待事件(socket、timer) Running --> Sleeping_D: 等待磁碟 I/O Sleeping_S --> Runnable: 事件到達 / 收到 signal Sleeping_D --> Runnable: I/O 完成 Running --> Stopped: SIGSTOP Stopped --> Runnable: SIGCONT Running --> Zombie: exit() Zombie --> [*]: parent wait() 回收

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,代表你真的維運過容器。

Appier 情境

廣告與推薦服務常把 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 的流程與成本

  1. 應用呼叫 libc 包裝函式(例如 read()),libc 把 syscall 編號與參數放進暫存器。
  2. 執行 syscall 指令,CPU 切換到 kernel mode,跳到 kernel 的進入點。
  3. Kernel 檢查參數、執行對應的處理(例如從 socket buffer 複製資料到 user buffer)。
  4. 回到 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_openhard 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)排查

  1. 確認是哪個 process:從錯誤 log 找出服務,取得 PID。
  2. 看上限與現況:cat /proc/PID/limits 與 ls /proc/PID/fd | wc -l。若 fd 數接近上限,確認是「真的需要這麼多」還是「洩漏」。
  3. 看 fd 是什麼:lsof -p PID,大部分是 socket?是哪個對端?ss -tanp | grep pid=PID 看狀態分佈。大量 CLOSE_WAIT = 程式沒有 close 對端已關閉的連線(典型 leak)。大量 REG 同一檔案 = 開檔沒關。
  4. 觀察趨勢:每分鐘記錄 fd 數,若單調上升且與流量無關,就是 leak。
  5. 短期止血:調高 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

面向selectpollepoll
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 陣列
觸發模式LevelLevelLevel 或 Edge
跨平台幾乎都有POSIXLinux 限定(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」。

sequenceDiagram participant App as Event loop(user space) participant K as Kernel participant NIC as 網卡 App->>K: epoll_ctl(ADD, fd1..fdN) App->>K: epoll_wait()(阻塞) NIC->>K: 封包到達,中斷 + softirq 處理 TCP K->>K: fd7 socket buffer 有資料,放入 ready list K-->>App: 回傳 [fd7: EPOLLIN] App->>K: recv(fd7)(複製資料到 user buffer) App->>App: 恢復等待 fd7 的 coroutine App->>K: epoll_wait()(再次等待)

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」:

  1. read():磁碟 → page cache(DMA)→ user buffer(CPU 複製)
  2. 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編號預設行為可捕捉?典型用途
SIGHUP1終止可終端斷線;daemon 慣例用來「重新載入設定」(nginx -s reload)
SIGINT2終止可Ctrl-C;Python 預設轉成 KeyboardInterrupt
SIGQUIT3終止 + core dump可Ctrl-\;Go 程式收到會印出所有 goroutine stack
SIGKILL9強制終止不可最後手段,程式沒有機會清理
SIGSEGV11終止 + core可(但通常不該)非法記憶體存取
SIGPIPE13終止可寫入已關閉的 pipe/socket;網路服務通常忽略它並處理 EPIPE
SIGTERM15終止可kill 預設、docker stop、K8s 刪 Pod 時送出;應做 graceful shutdown
SIGCHLD17(x86)忽略可child 狀態改變,parent 應 wait
SIGSTOP / SIGCONT19 / 18(x86)暫停 / 繼續STOP 不可作業控制
SIGUSR1/210 / 12(x86)終止可自訂,例如重開 log 檔、dump 狀態

Graceful Shutdown

目標:部署或縮容時不丟請求。標準流程:

  1. 收到 SIGTERM,標記為「draining」:readiness probe 開始回失敗,讓 LB / K8s Endpoint 把自己移除。
  2. 停止 accept 新連線(關閉 listening socket)。
  3. 等待進行中的請求完成(設上限,例如 20 秒),並對 keep-alive 連線回 Connection: close。
  4. flush buffer(log、metrics、尚未送出的 Kafka 訊息),關閉 DB 連線池。
  5. 以 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,這帶來兩個問題:

  1. Signal 預設行為不適用:kernel 對 PID 1 特別保護,沒有註冊 handler 的 signal 會被忽略(例如 SIGTERM 不會讓它結束)。如果你的程式沒處理 SIGTERM,docker stop 會等 10 秒逾時後 SIGKILL,造成部署變慢且無法優雅關閉。
  2. 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隔離的東西效果
PIDprocess ID容器內第一個 process 是 PID 1,看不到主機上其他 process
NET網路介面、路由表、iptables、port每個容器有自己的 eth0 與 localhost,可以重複 listen 80 port
MNT掛載點容器有自己的根檔案系統
UTShostname、domain name容器有自己的 hostname
IPCSystem V IPC、POSIX message queue共享記憶體互不干擾
USERuid/gid 對應容器內的 root 對應到主機上的非特權 uid(rootless 容器)
CGROUPcgroup 根目錄視圖容器只看到自己的 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、--cpusThrottle(被暫停到下個週期),不會被殺,但延遲變差
CPU 權重cpu.weightrequests.cpu、--cpu-shares只在競爭時影響比例
記憶體上限memory.maxlimits.memory、-m先嘗試回收 page cache,仍不夠則 cgroup OOM kill
記憶體軟限制memory.high—超過時強力回收並減速,不殺
Process 數量pids.max--pids-limitfork 失敗(防 fork bomb)
Block I/Oio.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 顯示即時資源用量
flowchart LR subgraph Host["Linux Host(共用同一個 kernel)"] K["Kernel:namespaces + cgroups + overlayfs"] subgraph C1["容器 A"] A1["PID 1: python app.py"] end subgraph C2["容器 B"] B1["PID 1: nginx master"] B2["nginx worker"] end K -- "PID/NET/MNT namespace 隔離" --> C1 K -- "cgroup: cpu.max / memory.max" --> C2 end
常見陷阱

容器內的 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= vs Wants=/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)整理的工具地圖,接著逐一給出實戰用法。

資源使用率飽和度(排隊)錯誤
CPUtop、mpstat -P ALL 1vmstat 1 的 r、load average、cgroup nr_throttleddmesg(MCE)
記憶體free -h、vmstatvmstat 的 si/so、sar -B majfltdmesg 的 OOM
磁碟iostat -xz 1 的 %utilaqu-sz、awaitdmesg 的 I/O error、smartctl
網路sar -n DEV 1ss -lnt 的 Recv-Q、netstat -s 的 overflow/dropip -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

flowchart LR C["Client 送 SYN"] --> SQ["SYN 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.somaxconnlisten backlog(accept queue)的系統上限Linux 5.4 前預設 128,之後 4096;高併發可設 8192~65535應用程式的 listen(backlog) 也要一起調大(Nginx listen 80 backlog=...),兩者取小。佇列太長只是把等待藏起來,根本解是應用 accept 夠快
net.ipv4.tcp_max_syn_backlogSYN 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_bucketsTIME_WAIT 數量上限,超過直接銷毀維持預設或依需要調整直接銷毀 TIME_WAIT 有理論上的資料錯亂風險,治本是用 keep-alive / 連線池減少短連線
net.ipv4.tcp_fin_timeoutFIN_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_maxsocket 收/送 buffer 能透過 setsockopt 設定的上限高頻寬、高延遲(大 BDP)連線時調大,例如 16MBbuffer 越大每連線記憶體越多;10 萬連線 × 大 buffer 可能吃掉數 GB
net.ipv4.tcp_rmem / tcp_wmemTCP 自動調整 buffer 的 min / default / max 三個值例如 4096 87380 16777216kernel 有 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 環境下大量連線失敗。

Appier 情境

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 很高」

flowchart TD A["load average 高"] --> B{"vmstat:r 高還是 b 高?"} B -- "r 高(CPU 排隊)" --> C{"top:us 高還是 sy 高?"} C -- "us 高" --> D["pidstat / top 找 process
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、哪段程式」

  1. top 按 P 找出 CPU 最高的 PID(例如 12345)。
  2. top -H -p 12345(或 ps -T -o spid,pcpu,comm -p 12345 --sort=-pcpu | head)找出最忙的 thread ID(TID,例如 12399)。
  3. 依語言對應 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。
  4. 常見原因:無窮迴圈或錯誤的重試迴圈(沒有 backoff)、regex 災難性回溯(catastrophic backtracking)、大量 JSON 序列化、GC 頻繁、熱點資料結構(例如 O(n) 的 list 查詢放在 hot path)、busy-wait 輪詢。

劇本三:「記憶體一直漲」

  1. 確認是誰在漲:ps aux --sort=-rss | head,或監控上看 RSS 曲線。若是整機 used 漲但 process 沒漲,看 /proc/meminfo 的 Slab(kernel 物件,例如大量 dentry、conntrack)、Shmem(tmpfs、/dev/shm)。
  2. 判斷是 leak 還是正常成長:快取暖機、page cache 會漲到一個平台;leak 則與時間或請求數成正比、永不回落。
  3. 看是哪種記憶體:/proc/PID/smaps_rollup 的 Anonymous(heap)vs 檔案映射;pmap -x PID 是否有越來越多的 anon 區塊。
  4. 語言層工具:
    • Python:tracemalloc(比較兩個 snapshot 的差異)、objgraph、memray。常見元兇:無上限的 dict/list 當快取、functools.lru_cache 用在 method 上持有 self、全域 list 不斷 append、循環參照搭配 __del__、C extension 的 leak。
    • Go:pprof heap profile(-inuse_space)、goroutine 數量是否一直增加(goroutine leak 是 Go 最常見的 leak)。
    • Java:heap dump + MAT。
  5. 注意「RSS 不降」不一定是 leak:glibc malloc 釋放後不一定歸還 OS(記憶體碎片、多個 arena),可嘗試 MALLOC_ARENA_MAX=2 或改用 jemalloc。
  6. 止血:設定 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 addressss -s TIME_WAIT 數、port range短連線對同一後端打太多,ephemeral port 耗盡
大量 CLOSE_WAITss -tanp state close-wait應用程式沒有 close 對端已關閉的連線(程式 bug)
p99 延遲高但 CPU 不高cgroup cpu.stat throttled、GC log、鎖競爭CPU limit throttle、GC pause、下游慢、連線池耗盡在排隊
容器反覆重啟 exit 137kubectl describe pod、memory.eventsOOMKilled 或 liveness probe 失敗被殺
No space left on device 但 df 有空間df -iinode 用完(大量小檔,例如 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。