從事Linux主機建設和運維的同事們在工作中應該經常會遇到批量修改配置信息或部署應用環境的需求,需要根據需求依次登錄目標主機執行一些命令或腳本,使用shell腳本的循環語句是實現這一需求最直觀方式。但是普通的for或do while循環都是串行執行的,腳本耗時每個循環耗時*循環次數,在較大規模實施或者目標語句耗時較長的情況下,串行方式的循環腳本執行時間也不容忽視。
要減少執行串行循環的耗時,自然要考慮如何用並行方式解決。在shell之外有一些現成的管理部署工具如parallel、ansible、puppet、saltstack都能解決併發執行多任務的問題,但生產系統一般不允許隨意安裝新軟件,因而我們這裏只討論不借助工具,只使用shell腳本如何實現併發執行多任務。
串行執行循環時,腳本中每一次循環對應的子進程都是腳本執行所處shell的前臺進程,同一時間一個shell只能有一個前臺進程,要做到並行執行多個進程,意味着腳本中的循環要放到執行環境shell的後臺,作爲後臺進程去執行。
直接使用後臺執行
先來看下循環串行執行的情況。
腳本的循環內容以sleep爲例,下同。
vim fork-0.sh
|
運行結果如下圖所示:
可以看到腳本執行時間30秒與預期10輪*3秒一致。
如果打開另一個窗口watch sleep進程的話,可以看到同一時刻只有1個sleep進程在跑:
修改腳本,採用循環並行執行的方式。
vim fork-1.sh
|
可以看到腳本執行耗時爲3秒,與預期1輪*3秒一致。
watch sleep進程,可以看到同一時刻有10個PPID相同的sleep進程在跑:
這種方式從功能上實現了使用shell腳本並行執行多個循環進程,但是它缺乏控制機制。
for設置了Njob次循環,同一時間Linux就觸發Njob個進程一起執行。假設for裏面執行的是scp,在沒有pam_limits和cgroup限制的情況下,很有可能同一時刻過多的scp任務會耗盡系統的磁盤IO、連接數、帶寬等資源,導致正常的業務受到影響。
一個應對辦法是在for循環裏面再嵌套一層循環,這樣同一時間,系統最多隻會執行內嵌循環限制值的個數的進程。不過還有一個問題,for後面的wait命令以循環中最慢的進程結束爲結束(水桶效應)。如果嵌套循環中有某一個進程執行過程較慢,那麼整體這一輪內嵌循環的執行時間就等於這個“慢”進程的執行時間,整體下來腳本的執行效率還是受到影響的。
2使用模擬隊列來控制進程數量
要控制後臺同一時刻的進程數量,需要在原有循環的基礎上增加管理機制。
一個方法是以for循環的子進程PID做爲隊列元素,模擬一個限定最大進程數的隊列(只是一個長度固定的數組,並不是真實的隊列)。隊列的初始長度爲0,循環每創建一個進程,就讓隊列長度+1。當隊列長度到達設置的併發進程限制數之後,每隔一段時間檢查隊列,如果隊列長度還是等於限制值,那麼不做操作,繼續輪詢;如果檢測到有併發進程執行結束了,那麼隊列長度-1,輪詢檢測到隊列長度小於限制值後,會啓動下一個待執行的進程,直至所有等待執行的併發進程全部執行完。
vim para-2.sh
#!/bin/bash Njob=15 #任務總數 Nproc=5 #最大併發進程數 function PushQue { #將PID值追加到隊列中 Que="$Que $1" Nrun=$(($Nrun+1)) }
function GenQue { #更新隊列信息,先清空隊列信息,然後檢索生成新的隊列信息 OldQue=$Que Que=""; Nrun=0 for PID in $OldQue; do if [[ -d /proc/$PID ]]; then PushQue $PID fi done }
function ChkQue { #檢查隊列信息,如果有已經結束了的進程的PID,那麼更新隊列信息 OldQue=$Que for PID in $OldQue; do if [[ ! -d /proc/$PID ]]; then GenQue; break fi done }
for ((i=1; i<=$Njob; i++)); do echo "progress $i is sleeping for 3 seconds zzz…" sleep 3 & PID=$! PushQue $PID while [[ $Nrun -ge $Nproc ]]; do # 如果Nrun大於Nproc,就一直ChkQue ChkQue sleep 0.1 done done wait
echo -e "time-consuming: $SECONDS seconds" #顯示腳本執行耗時#!/bin/bash |
運行結果如下圖所示:
可以看到腳本執行時間9秒與預期3輪*3秒一致。
watch sleep進程,可以看到同一時刻只有5個sleep進程在跑,與我們限制的數量相符:
這種使用隊列模型管理進程的方式在控制了後臺進程數量的情況下,還能避免個別“慢”進程影響整體耗時的問題:
3使用fifo管道特性來控制進程數量
管道是內核中的一個單向的數據通道,同時也是一個數據隊列。具有一個讀取端與一個寫入端,每一端對應着一個文件描述符。
命名管道即FIFO文件,通過命名管道可以在不相關的進程之間交換數據。FIFO有路徑名與之相關聯,以一種特殊設備文件形式存在於文件系統中。
FIFO有兩種用途:
-
FIFO由shell使用以便數據從一條管道線傳輸到另一條,爲此無需創建臨時文件,常見的操作cat file|grepkeyword就是這種使用方式;
-
FIFO用於客戶進程-服務器進程程序中,已在客戶進程與服務器進程之間傳送數據,下面的例子將使用這種方式。
根據FIFO文件的讀規則,如果有進程寫打開FIFO,且當前FIFO內沒有數據,對於設置了阻塞標誌的讀操作來說,將一直阻塞狀態。
利用這一特性可以實現一個令牌機制。設置一個行數等於限定最大進程數Nproc的fifo文件,在for循環中設置創建一個進程時先read一次fifo文件,進程結束時再write一次fifo文件。如果當前子進程數達到限定最大進程數Nproc,則fifo文件爲空,後續執行的併發進程被讀fifo命令阻塞,循環內容被沒有觸發,直至有某一個併發進程執行結果並做寫操作(相當於將令牌還給池子)。
需要注意的是,當併發數較大時,多個併發進程即使在使用sleep相同秒數模擬時,也會存在進程調度的順序問題,因而並不是按啓動順序結束的,可能會後啓動的進程先結束。
vim para-3.sh
#!/bin/bash Njob=15 #任務總數 Nproc=5 #最大併發進程數 mkfifo ./fifo.$$ && exec 777<> ./fifo.$$ && rm -f ./fifo.$$ #通過文件描述符777訪問fifo文件 for ((i=0; i<$Nproc; i++)); do #向fifo文件先填充等於Nproc值的行數 echo "init time add $i" >&777 done
for ((i=0; i<$Njob; i++)); do { read -u 777 #從fifo文件讀一行 echo "progress $i is sleeping for 3 seconds zzz…" sleep 3 echo "real time add $(($i+$Nproc))" 1>&777 #sleep完成後,向fifo文件重新寫入一行 } & done wait echo -e "time-consuming: $SECONDS seconds" |
運行結果如下圖所示:
可以看到腳本執行時間9秒與預期3輪*3秒一致。
watch sleep進程,同樣可以看到同一時刻只有5個sleep進程。
4總結
並行多進程的循環語句能提高腳本執行效率。
例1這種沒有控制機制,同一時間可能觸發大量併發進程的腳本在生產環境中儘量避免使用,嵌套循環也儘量少用。
例2例3分別使用數組元素模擬隊列和利用fifo讀寫阻塞性兩種方式實現了後臺進程數量的控制,適宜作爲批量操作的shell腳本模版。
5後記
關於執行順序的問題,把例2採用隊列方式的例子中的動作 sleep 3修改成sleep$[$RANDOM/10000*5],執行結果仍然是順序的。雖然例3的方式其執行過程是亂序的,考慮到如果使用腳本只是查詢統計信息,可以利用Excel中的lookup、match、indirect函數進行信息整理,也是行得通的