超實用 parallel 指令教學
Posted on Nov 23, 2023 in Unix-like 命令列教學 by Amo Chen ‐ 8 min read
江湖闖久了,遇到資料處理相關的工作是常有的事,譬如為一堆 jpg 圖檔產生縮圖(thumbnail),或者把一堆 TSV 檔轉換成 CSV 格式,又或者把一堆資料夾分別用 tar 指令壓縮起來,諸如此類的。
針對這些情況,我個人很常用 awk 指令(詳見 awk 指令教學)產生所需要的指令,再丟到 shell 逐一執行,例如:
$ ls -l *.jpg | awk '{ print "tar -czvf "$9".tar.gz "$9}' | sh
上述指令中 ls -l *.jpg | awk '{ print "tar -czvf "$9".tar.gz "$9}'
的部分是為每 1 個 jpg 檔案產生 1 個對應的壓縮指令的字串,例如當前資料夾下有 a.jpg
與 b.jpg
2 個檔案的話,就會產生下列 2 行字串:
tar -czvf a.jpg.tar.gz a.jpg
tar -czvf b.jpg.tar.gz b.jpg
上述字串再以 | sh
交給 shell 執行。
不過這種用法的缺點就是指令會逐一執行,如果有 300 個檔案,每個處理要 1 秒,那麼就得等待 300 秒才能完成工作。
如果能夠將這些工作平行處理,將可以大大減少執行時間,這就是本文要介紹的 parallel
指令的最大用途,讓人能在 shell 中平行處理多個指令,增加執行速度。
本文環境
- macOS
- Homebrew
- parallel
- findutils
$ brew install parallel findutils
使用 parallel 指令時, parallel 會顯示 1 段文字,希望大家如果寫論文或發表著作時有使用 parallel 處理資料的話,可以在引用的地方加上 parallel
, 以幫助他們推廣這項工具,如果不想再次顯示這段文字,只要輸入以下指令:
$ parallel --citation
並輸入 will cite
後按 Enter 即可。
parallel 指令介紹
parallel
是 Unix-like 作業系統中常見的工具指令,可以讓使用者平行執行 shell scripts 或者指令,採 GPLv3 授權。
parallel
可以輕鬆地在多核心 CPU 上平行執行指令,最大程度地利用系統資源,加速執行速度。對於需要處理大型資料集或批量作業的工作非常有用。它也支援控制平行的執行數量、執行順序等等,可以跟各種指令搭配運用,相當強大!
parallel
指令的 syntax 如下:
$ parallel [要執行的指令] ::: [要執行的指令的參數]
例如平行執行 echo a
, echo b
可以寫成:
$ parallel echo ::: a b
初次使用 parallel 指令
回到一開始以 awk 指令搭配 shell 執行多個工作的例子:
$ ls -l *.jpg | awk '{ print "tar -czvf "$9".tar.gz "$9}' | sh
上述指令只要將最後的 sh
改成 parallel
就能讓壓縮指令平行處理:
$ ls -l *.jpg | awk '{ print "tar -czvf "$9".tar.gz "$9}' | parallel
假設指令 ls -l *.jpg | awk '{ print "tar -czvf "$9".tar.gz "$9}'
產生下列字串:
tar -czvf a.jpg.tar.gz a.jpg
tar -czvf b.jpg.tar.gz b.jpg
parallel
的原理就是將上述字串以行為單位,分別交由新的 process 處理,預設情況下, parallel
會嘗試以最佳方式使用所有可用的 CPU 核心進行處理,以提高執行效率,所以上述 2 行指令很有可能是在 2 個完全不同的 processes 平行執行。
認識 parallel 的替代字串(replacement strings)
在初次使用 parallel 的例子中,我們使用 ls
搭配 awk
產生指令字串,但其實使用 parallel
指令可以更簡單,只要搭配它的替代字串功能,可以不需要 awk 幫忙產生指令字串,最終簡化為下列指令:
$ ls *.jpg | parallel tar -czvf {}.tar.gz {}
上述指令中的 {}
就是 parallel
的替代字串,代表輸入進來的完整字串,假設 ls *.jpg
產生的是:
a.jpg
b.jpg
那麼 {}
就會是 a.jpg
與 b.jpg
,如果確定最後 parallel
執行的指令會長怎樣,可以加上 --dryrun
參數查看, --dryrun
參數只會列印 parallel
最後要執行的指令,但是不會執行,是 1 個一定要記住的參數!
$ ls *.jpg | parallel --dryrun tar -czvf {}.tar.gz {}
上述指令執行結果會類似以下:
tar -czvf a.jpg.tar.gz a.jpg
tar -czvf b.jpg.tar.gz b.jpg
如果你覺得 {}
包含副檔名很難看的話,還可以將第 1 個 {}
改為 {.}
拿掉副檔名:
ls *.jpg | parallel --dryrun tar -czvf {.}.tar.gz {}
上述指令執行結果會類似以下,可以看到 {.}
的作用是副檔名:
tar -czvf a.tar.gz a.jpg
tar -czvf b.tar.gz b.jpg
parallel
指令還提供很多替代字串可以使用,例如:
{/}
去掉路徑,/home/a.txt
會變成a.txt
{/.}
去掉路徑與副檔名,/home/a.txt
會變成a
{//}
只保留路徑,/home/a.txt
會變成/home
以檔案內容輸入 parallel
parallel
除了可以使用 |
輸入字串之外,也可以用檔案方式輸入,例如下列檔案 input.txt 中紀錄 4 筆 IP 位址:
1.1.1.1
8.8.8.8
168.95.1.1
168.95.192.1
如果要用該檔案中的內容作為輸入字串,可以用參數 -a
指定檔案,例如:
$ parallel -a input.txt echo '{}'
上述指令執行結果如下,從結果可以看到 parallel
讀取到檔案中的 IP 字串:
1.1.1.1
8.8.8.8
168.95.1.1
168.95.192.1
如果我們想對 IP 內容做些操作,例如執行 whois
指令,並將結果寫到個別檔案,就可以用以下指令:
$ parallel -a input.txt whois {} ">" whois-{}.txt
p.s. 將執行結果輸出到個別檔案一定要用 ">"
上述執行結果成功之後,就會產生多個檔案,裡面都是 whois 指令的執行結果:
$ ls whois*
whois-1.1.1.1.txt whois-168.95.1.1.txt whois-168.95.192.1.txt whois-8.8.8.8.txt
如果不知道 parallel
指令執行什麼,同樣可以用 --dryrun
參數查看:
$ parallel --dryrun -a input.txt whois {1} ">" ip-{1}.txt
上述指令執行結果如下,可以看到是針對各個 IP 執行 whois
指令後輸出到個別檔案:
whois 1.1.1.1 > whois-1.1.1.1.txt
whois 8.8.8.8 > whois-8.8.8.8.txt
whois 168.95.1.1 > whois-168.95.1.1.txt
whois 168.95.192.1 > whois-168.95.192.1.txt
--colsep
參數
同樣以前面的 IP 位址為例。
如果很不幸地,你同事給你的檔案是下列形式,以空白分割欄位,那麼要怎麼只取出第 2 欄的 IP 做使用呢?
ip 1.1.1.1
ip 8.8.8.8
ip 168.95.1.1
ip 168.95.192.1
parallel
指令提供 --colsep
參數,可以讀取資料進來之後,再做分割,例如以空格為單位進行欄位分割,從 1 開始編號,可以用 {數字}
取得欄位值,以上述資料為例, IP 位於第 2 欄,所以可以用 {2}
取得 IP :
$ parallel --dryrun --colsep " " -a input.txt whois {2} ">" whois-{2}.txt
上述指令執行結果如下,可以看到透過 --colsep
就能對字串做欄位分割,進而取得特定欄位的值:
whois 1.1.1.1 > whois-1.1.1.1.txt
whois 8.8.8.8 > whois-8.8.8.8.txt
whois 168.95.1.1 > whois-168.95.1.1.txt
whois 168.95.192.1 > whois-168.95.192.1.txt
--colsep
可以進一步簡化為 -C
參數:
$ parallel --dryrun -C " " -a input.txt whois {2} ">" whois-{2}.txt
如果輸入的字串擁有空白,例如開頭或結尾多 1 個空白,還可以加上 --trim
參數,將開頭或結尾的空白清除:
$ parallel --dryrun -C " " --trim rl -a input.txt whois {2} ">" ip-{2}.txt
--trim
參數支援 4 種設定:
n
不清除空白l
清除左邊空白r
清除右邊空白rl
或lr
清除左右邊空白,使用--colsep
預設會清除開頭與結尾的空白
::::
代表輸入檔案
前述範例我們都使用 -a
參數指定輸入檔案,但其實可以用 4 個冒號( ::::
)取代 -a
參數:
$ parallel --dryrun whois {} ">" whois-{}.txt :::: input.txt
parallel
指令也提供 --arg-file-sep
參數可以將 4 個冒號 ::::
改為自己喜歡的符號,下列範例將 4 個冒號改為 ///
:
$ parallel --dryrun --arg-file-sep "///" whois {} ">" whois-{}.txt /// input.txt
:::
代表從命令列輸入(read from the command line)
除了 4 個冒號, parallel
指令也提供 3 個冒號 :::
代表資料要從命令列輸入,下列範例直接從命令列將資料送給 parallel
指令:
$ parallel --dryrun whois {} ">" whois-{}.txt ::: 1.1.1.1 8.8.8.8
上述指令執行結果如下,可以看到 :::
的效果:
whois 1.1.1.1 > whois-1.1.1.1.txt
whois 8.8.8.8 > whois-8.8.8.8.txt
parallel
指令也提供 --arg-sep
參數可以將 3 個冒號 :::
改為自己喜歡的符號,下列範例將 3 個冒號改為 //
:
$ parallel --dryrun --arg-sep "//" whois {} ">" whois-{}.txt // 1.1.1.1 8.8.8.8
搭配 xargs 指令輸入資料
用 :::
從命令列輸入固然方便,不過需要將資料合併為 1 行,如果不想合併的話,可以搭配 xargs
指令,讓我們直接貼上多行資料,例如下列 xargs
指令以換行做為單位將資料做為 parallel
的參數丟給 parallel
指令:
$ xargs -d\n | parallel --dryrun whois {} ">" whois-{}.txt
上述指令執行之後會等待我們輸入資料,可以複製以下內容並貼上:
1.1.1.1
2.2.2.2
3.3.3.3
最後按下 CTRL + d
代表結束輸入。
接著就會看到以下結果,其中最後 1 行是個空白,這顯然不是我們需要的結果,原因在於 xargs
將最後一行的空白也輸入給 parallel
指令:
whois 1.1.1.1 > whois-1.1.1.1.txt
whois 2.2.2.2 > whois-2.2.2.2.txt
whois 3.3.3.3 > whois-3.3.3.3.txt
whois '' > whois-''.txt
對付輸入空白的情況,可以在 parallel
指令加上 --no-run-if-empty
或是 -r
讓 parallel
指令忽略空白:
$ xargs -d\n | parallel --dryrun --no-run-if-empty whois {1} ">" whois-{1}.txt
多輸入源組合(combinations)
parallel
指令也接受多個輸入來源,預設情況下 parallel
會將輸入來源做組合(combinations),例如下列 2 個輸入源內容分別是:
A
B
與
D
E
如果將這 2 個輸入源都丟給 parallel
指令的話,就會產生 A D
, A E
, B D
, B E
4 種組合(2 行乘以 2 行),可以用以下指令模擬組合的情況:
$ parallel --dryrun echo {1} {2} ::: A B ::: D E
上述指令執行結果如下:
echo A E
echo A D
echo B D
echo B E
這時候第 1 個輸入源就代表欄位 1 {1}
, 第 2 個輸入源就代表欄位 {2}
。
如果輸入源是 CSV 檔(以逗號作為分割),第 2 個輸入源欄位數字就是第 1 個輸入源的最後 1 個數字 + 1 作為開始:
$ parallel --dryrun --colsep "," echo {1} {2} {3} {4} ::: A,B C,D ::: E,F G,H
上述執行結果如下:
echo A B E F
echo A B G H
echo C D E F
echo C D G H
連接輸入源 (–link 參數)
如果你不想將多個輸入源以組合的方式連接,而是直接連接在一起的話,只要加上 --link
參數,就可以將資料直接連接在一起,例如:
$ parallel --dryrun --link echo {1} {2} ::: A B ::: D E
上述執行結果如下,可以看到 A 直接連上 B, B 直接連上 E, 而非以組合方式連接:
echo A D
echo B E
列印工作編號 (Job numbers)
每個 parallel
的指令都有工作編號,假設共有 10 個工作要進行平行處理,那會為每個工作從 1 開始編號到 10, 如果想知道工作編號,可以用取代字串 {#}
列印:
$ parallel echo {#} {1} {2} ::: A B ::: D E
上述指令執行結果如下,可以看到共 4 個工作:
1 A D
2 A E
3 B D
4 B E
限制同時執行數 (–jobs 參數)
如果想調整 parallel
同時執行數的話,可以用 --jobs
或 -j
參數指定數字,例如下列指令指定最多只能 2 個工作同時執行:
$ parallel --jobs 2 echo {1} {2} ::: A B ::: D E
A D
A E
B D
B E
每個工作都會有 1 個 Job slot number, 這個數字代表它在哪個 Job slot 中執行, parallel
用 Job slots 控制同時執行的數量,如果 2 個 Job slots 就代表最多可同時執行 2 個 Job, 如果有某個 Job slot 以及空了,就代表可以拿下一個 Job 出來執行,如果要知道 Job slot number 可以使用取代字串 {%}
查看:
$ parallel --jobs 2 echo {%} {1} {2} ::: A B ::: D E
上述指令執行結果如下,可以看到最多只有 2 個 Job slots:
1 A D
2 A E
1 B D
2 B E
顯示工作執行情況、進度
如果想知道工作的執行進度,例如幾個正在執行、幾個已經完成、平均完成執行的時間等等,可以加上 --eta
或 --progress
參數,例如:
$ parallel --eta sleep ::: 1 3 2 2 1 3 3 2 1
上述指令執行結果如下,可以看到工作數、執行時間等資訊:
Computers / CPU cores / Max jobs to run
1:local / 8 / 8
Computer:jobs running/jobs completed/%of started jobs/Average seconds to complete
ETA: 0s Left: 0 AVG: 0.22s local:0/9/100%/0.3s
除了 --eta
, --progress
參數之外,也可以用 --bar
參數,會改以進度條方式顯示:
$ parallel --bar sleep ::: 1 3 2 2 1 3 3 2 1
實用 parallel 指令集合
以下附上實用的 parallel
指令。
全部 TSV 檔轉 CSV 檔
$ parallel "tr '\t' ',' < {} > {.}.csv" ::: *.tsv
用 parallel 搭配 ffmpeg 指令產生寬度 20 pixels 的縮圖
如果用 shell script 產生每一張縮圖的話,可能會這樣寫:
$ for i in *jpg; ffmpeg -i $i -vf scale=20:-1 ${i%.*}_width_20px.jpg"; done;
但是改用 parallel
指令會變成下列形式,而且更快:
$ parallel "ffmpeg -i {} -vf scale=20:-1 {.}_width_20px.jpg" ::: *.jpg
用 find 指令搭配 parallel 與 grep 指令搜尋 txt 檔是否有指定字串
$ find <路徑> -name "*.txt" | parallel grep -q <字串> {}
總結
parallel
指令是強大的工具,憑藉其平行處理的能力以及可以與其他指令工具整合使用的彈性,讓我們能夠提升指令執行的效率。
本文僅針對常用、實用的功能進行介紹,但提到的部分也是 parallel
指令的冰山一角,有興趣的話,推薦可以進一步閱讀 GNU Parallel Tutorial ,相信有一些是一定能派上用場的!
以上!
Enjoy!
References
GNU Parallel Tutorial — GNU Parallel 20231022 documentation