超實用 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.jpgb.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 中平行處理多個指令,增加執行速度。

本文環境

$ 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.jpgb.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 清除右邊空白
  • rllr 清除左右邊空白,使用 --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 或是 -rparallel 指令忽略空白:

$ 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 參數,就可以將資料直接連接在一起,例如:

$ 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

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!