在上下文切換的文章中,學習并分析了系統 CPU 使用率高的問題,剩下的等待 I/O 的 CPU 使用率(以下簡稱為 iowait)升高,也是最常見的一個服務器性能問題。今天就來看一個多進程 I/O 的案例,并分析這種情況。
1. 進程狀態
當 iowait 升高時,進程很可能因為得不到硬件的響應,而長時間處于不可中斷狀態。從 ps 或者 top 命令的輸出中,可以發現它們都處于 D 狀態,也就是不可中斷狀態(Uninterruptible Sleep)。
1.1 進程狀態介紹
top 和 ps 是最常用的查看進程狀態的工具。
$ topPID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28961 root 20 0 43816 3148 4040 R 3.2 0.0 0:00.01 top620 root 20 0 37280 33676 908 D 0.3 0.4 0:00.01 app1 root 20 0 160072 9416 6752 S 0.0 0.1 0:37.64 systemd1896 root 20 0 0 0 0 Z 0.0 0.0 0:00.00 devapp2 root 20 0 0 0 0 S 0.0 0.0 0:00.10 kthreadd4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H6 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 mm_percpu_wq7 root 20 0 0 0 0 S 0.0 0.0 0:06.37 ksoftirqd/0
- R:是 Running 或 Runnable 的縮寫,表示進程在 CPU 的就緒隊列中,正在運行或者正在等待運行。
- D:是 Disk Sleep 的縮寫,也就是不可中斷狀態睡眠(Uninterruptible Sleep),一般表示進程正在跟硬件交互,并且交互過程不允許被其他進程或中斷打斷。
- Z:是 Zombie 的縮寫,它表示僵尸進程,也就是進程實際上已經結束了,但是父進程還沒有回收它的資源(比如進程的描述符、PID 等)。
- S:是 Interruptible Sleep 的縮寫,也就是可中斷狀態睡眠,表示進程因為等待某個事件而被系統掛起。當進程等待的事件發生時,它會被喚醒并進入 R 狀態。
- I:是 Idle 的縮寫,也就是空閑狀態,用在不可中斷睡眠的內核線程上。前面說了,硬件交互導致的不可中斷進程用 D 表示,但對某些內核線程來說,它們有可能實際上并沒有任何負載,用 Idle 正是為了區分這種情況。要注意,D 狀態的進程會導致平均負載升高, I 狀態的進程卻不會。
- T或者t:Stopped 或 Traced 的縮寫,表示進程處于暫停或者跟蹤狀態。向一個進程發送 SIGSTOP 信號,它就會因響應這個信號變成暫停狀態(Stopped);再向它發送 SIGCONT 信號,進程又會恢復運行(如果進程是終端里直接啟動的,則需要你用 fg 命令,恢復到前臺運行)。
- X:是 Dead 的縮寫,表示進程已經消亡,所以你不會在 top 或者 ps 命令中看到它。
1.1.1 D 不可中斷狀態
該狀態是為了保證進程數據與硬件狀態一致,而且分成兩個場景
- 正常場景:
- 不可中斷狀態在很短時間內就會結束。所以,短時的不可中斷狀態進程,我們一般可以忽略。
- 異常場景
- 系統或硬件發生了故障,進程可能會在不可中斷狀態保持很久,甚至導致系統中出現大量不可中斷進程。這時,就得注意下,系統是不是出現了 I/O 等性能問題。
1.1.2 Z 僵尸進程
這是多進程應用很容易碰到的問題。主要也分為兩個場景
- 正常場景
- 當一個進程創建了子進程后,它應該通過系統調用 wait() 或者 waitpid() 等待子進程結束,回收子進程的資源;而子進程在結束時,會向它的父進程發送 SIGCHLD 信號,所以,父進程還可以注冊 SIGCHLD 信號的處理函數,異步回收資源。
- 異常場景
- 如果父進程沒這么做,或是子進程執行太快,父進程還沒來得及處理子進程狀態,子進程就已經提前退出,那這時的子進程就會變成僵尸進程。換句話說,父親應該一直對兒子負責,善始善終,如果不作為或者跟不上,都會導致“問題少年”的出現。
通常,僵尸進程持續的時間都比較短,在父進程回收它的資源后就會消亡;或者在父進程退出后,由 init 進程回收后也會消亡。
僵尸進程的危害:大量的僵尸進程會用盡 PID 進程號,導致新進程不能創建,所以這種情況一定要避免。
2. 案例分析:多進程應用場景
2.1 環境準備
- 機器配置:4 CPU,8GB 內存,Ubuntu 22.04.5,1臺機器。
- 預先安裝 docker、sysstat、dstat 等工具。dstat 是一個新的性能工具,它吸收了 vmstat、iostat、ifstat 等幾種工具的優點,可以同時觀察系統的 CPU、磁盤 I/O、網絡以及內存使用情況。
安裝過程:略
2.2 部署測試應用
注意:這里部署完了一定要立刻進行接下來的步驟,不然立刻關閉這個容器,否則時間長了會把系統的負載打滿,導致無法操作。
root@yunwei-virtual-machine:~# docker run --privileged --name=app -itd feisky/app:iowait
27b316cda218482c9870ad54908ec4a4f4daf0ec52d8b488e064c1530b617d74
root@yunwei-virtual-machine:~# docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
27b316cda218 feisky/app:iowait "/app" 20 seconds ago Up 5 seconds app
然后通過ps命令查看應用是否部署成功,如下圖就表示部署成功了:
從上圖,可以發現多個 app 進程已經啟動,并且它們的狀態分別是 Ss+、D+、S+。其中,S 表示可中斷睡眠狀態,D 表示不可中斷睡眠狀態,我們在前面剛學過,那后面的 s 和 + 是什么意思呢?s 表示這個進程是一個會話的領導進程,而 + 表示前臺進程組。
2.3 什么是進程組和會話
- 進程組表示一組相互關聯的進程,比如每個子進程都是父進程所在組的成員;
- 而會話是指共享同一個控制終端的一個或多個進程組。
比如,我們通過 SSH 登錄服務器,就會打開一個控制終端(TTY),這個控制終端就對應一個會話。而我們在終端中運行的命令以及它們的子進程,就構成了一個個的進程組,其中,在后臺運行的命令,構成后臺進程組;在前臺運行的命令,構成前臺進程組。
2.4 查看系統資源使用情況
這里匯總一下,上圖的問題:
- 第一行 load average: 4.59, 2.10, 1.35。
- 光是1分鐘的負載,就已經打滿了(機器4C),這個時候系統的性能很明顯已經受到的影響。而且隨著時間推移,負載還在不斷上升。
- 第二行?有1個正在運行的進程,但是有12個僵尸進程,這個僵尸進程也是隨著時間的推移在不斷增多。
- 第三四五六行?用戶態和內核態的cpu使用率都很低,但是iowait有3個CPU>95%,一個73.8,這很明顯不正常。
- 進程狀態:CPU使用率最高的進程也只占用了1%,但有好幾個進程處于 D 狀態,它們可能在等待 I/O,但光憑這里并不能確定是它們導致了 iowait 升高。
那么現狀總結下來就是:
- iowait 太高了,導致系統的平均負載升高,甚至超過了系統 CPU 的個數。
- 僵尸進程在不斷增多,說明有程序沒能正確清理子進程的資源。
2.5 iowait分析
推薦使用dstat ,它的好處是可以同時查看 CPU 和 I/O 這兩種資源的使用情況,便于對比分析。
root@yunwei-virtual-machine:~# dstat 1 10 # 間隔1秒輸出10組數據
You did not select any stats, using -cdngy by default.
--total-cpu-usage-- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai stl| read writ| recv send| in out | int csw0 1 85 13 0| 57M 18k| 0 0 | 0 0 | 320 3670 3 0 97 0| 411M 0 | 606B 338B| 0 0 |1172 16420 2 10 88 0| 499M 0 | 846B 248B| 0 0 |1212 19030 2 0 98 0| 319M 4096B| 858B 330B| 0 0 | 913 13351 3 0 96 0| 372M 4096B| 786B 162B| 0 0 |1109 14631 7 0 92 0| 472M 32k| 546B 162B| 0 0 |1469 18190 3 0 97 0| 435M 0 | 546B 162B| 0 0 |1218 17380 4 4 92 0| 568M 0 | 666B 146B| 0 0 |1481 21200 2 0 97 0| 348M 0 | 906B 383B| 0 0 |1021 14000 4 6 89 0| 471M 0 | 846B 146B| 0 0 |1339 1843
從 dstat 的輸出,我們可以看到,每當 iowait 升高(wa)時,磁盤的讀請求(read)都會很大。這說明 iowait 的升高跟磁盤的讀請求有關,很可能就是磁盤讀導致的。
2.6 查找導致iowait升高的進程
繼續在剛才的終端中,運行 top 命令,觀察 D 狀態的進程:
觀察一會兒按 Ctrl+C 結束
然后隨便找一個D狀態進程的PID,使用pidstat -d來分析io使用情況:
root@yunwei-virtual-machine:~# pidstat -d -p 22540 1 3 # 每隔一秒輸出3組數據
Linux 6.8.0-60-generic (yunwei-virtual-machine) 2025年06月18日 _x86_64_ (4 CPU)16時51分57秒 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
16時51分58秒 0 22540 65536.00 0.00 0.00 0 app
16時51分59秒 0 22540 0.00 0.00 0.00 0 app
16時52分00秒 0 22540 65536.00 0.00 0.00 0 app
Average: 0 22540 43690.67 0.00 0.00 0 app
通過結果可以發現,就是這個app進程在讀取磁盤,并且每秒最多有64m的讀取。
2.7 查找進程到底在執行什么樣的IO操作
回顧一下進程用戶態和內核態的區別:
- 進程想要訪問磁盤,就必須使用系統調用,所以接下來,重點就是找出 app 進程的系統調用了。
strace 正是最常用的跟蹤進程系統調用的工具。所以,我們從 pidstat 的輸出中拿到進程的 PID 號,比如 6082,然后在終端中運行 strace 命令,并用 -p 參數指定 PID 號:
$ strace -p 6082
strace: attach: ptrace(PTRACE_SEIZE, 6082): Operation not permitted
這里居然報錯了,顯示“不允許操作!”。
$ ps aux | grep 6082
root 6082 0.0 0.0 0 0 pts/0 Z+ 13:43 0:00 [app] <defunct>
然后通過ps查看進程,發現進程已經變成了僵尸進程(Z),僵尸進程是已經退出運行的進程,是沒有辦法分析它的系統調用的。
但此時系統的iowait依然還是處于一個異常狀態,而使用top、pidstat這類的工具,也無法查出更多有用的信息,怎么辦?使用基于事件記錄的動態追蹤工具。
2.7.1 使用perf查看調用棧信息
root@yunwei-virtual-machine:/tmp# perf record -g # 生成調用用棧文件,約15s后CTRL C退出。
root@yunwei-virtual-machine:/tmp# ll -rth
-rw------- 1 root root 61M 6月 26 16:33 perf.dataroot@yunwei-virtual-machine:/tmp# perf report # 查看調用棧
接著,找到我們關注的 app 進程,按回車鍵展開調用棧,你就會得到下面這張調用關系圖:
這個圖里的 swapper 是內核中的調度進程,可以先忽略掉。
可以發現, app 的確在通過系統調用 sys_read() 讀取數據。并且從 new_sync_read 和 blkdev_direct_IO 能看出,進程正在對磁盤進行直接讀,也就是繞過了系統緩存,每個讀請求都會從磁盤直接讀,這就可以解釋我們觀察到的 iowait 升高了。
看來,罪魁禍首是 app 內部進行了磁盤的直接 I/O 啊!
下面的問題就容易解決了。我們接下來應該從代碼層面分析,究竟是哪里出現了直接讀請求。查看源碼文件 app.c,你會發現它果然使用了 O_DIRECT 選項打開磁盤,于是繞過了系統緩存,直接對磁盤進行讀寫。
open(disk, O_RDONLY|O_DIRECT|O_LARGEFILE, 0755)
直接讀寫磁盤,對 I/O 敏感型應用(比如數據庫系統)是很友好的,因為你可以在應用中,直接控制磁盤的讀寫。但在大部分情況下,我們最好還是通過系統緩存來優化磁盤 I/O,換句話說,刪除 O_DIRECT 這個選項就是了。
3. 僵尸進程
僵尸進程是因為父進程沒有回收子進程的資源而出現的,那么,要解決掉它們,就要找到它們的根兒,也就是找出父進程,然后在父進程里解決。
3.1 使用pstree查看進程關系
# -a 表示輸出命令行選項
# p表PID
# s表示指定進程的父進程
$ pstree -aps 3084
systemd,1└─dockerd,15006 -H fd://└─docker-containe,15024 --config /var/run/docker/containerd/containerd.toml└─docker-containe,3991 -namespace moby -workdir...└─app,4009└─(app,3084)
運行完,你會發現 3084 號進程的父進程是 4009,也就是 app 應用。這種怎么辦呢?查看 app 應用程序的代碼,看看子進程結束的處理是否正確,比如有沒有調用 wait() 或 waitpid() ,抑或是,有沒有注冊 SIGCHLD 信號的處理函數。找到并修復就好了。
4. 小結
iowait 高不一定代表 I/O 有性能瓶頸。當系統中只有 I/O 類型的進程在運行時,iowait 也會很高,但實際上,磁盤的讀寫遠沒有達到性能瓶頸的程度。
因此,碰到 iowait 升高時,需要先用 dstat、pidstat 等工具,確認是不是磁盤 I/O 的問題,然后再找是哪些進程導致了 I/O。
等待 I/O 的進程一般是不可中斷狀態,所以用 ps 命令找到的 D 狀態(即不可中斷狀態)的進程,多為可疑進程。但這個案例中,在 I/O 操作后,進程又變成了僵尸進程,所以不能用 strace 直接分析這個進程的系統調用。
這種情況下,我們用了 perf 工具,來分析系統的 CPU 時鐘事件,最終發現是直接 I/O 導致的問題。這時,再檢查源碼中對應位置的問題,就很輕松了。
而僵尸進程的問題相對容易排查,使用 pstree 找出父進程后,去查看父進程的代碼,檢查 wait() / waitpid() 的調用,或是 SIGCHLD 信號處理函數的注冊就行了。