用 pgid 終止一組管道程序

之前寫 rtklib 的腳本時, 發現把管道丟到背景後,只會拿到管道最後一個程序的 pid, 要用 pgid 才能一次終止所有程序。 於是研究了一下怎麼拿到 pgid,和使用的方式。

正確關掉丟到背景的程序

以前以為在 sh 裡把程序丟到背景後, 就只能用 kill %1 的方式終止。 有一段時間還不知道為什麼腳本執行起來有問題。 最近才發現是 % 的表示法只能在互動式 shell 裡用, 在腳本裡是會被忽略的。 (那時候還開了 dash 測都沒問題, 原來 dash 也有分互動模式和腳本模式, 互動模式才可以用 % 。) 又 google 了很久,才學到可以用 $! 取得丟到背景的程序 pid。

會用到這功能,去年有寫過自動重啟某程序的腳本, 需要判斷該程序是不是還活著, 就踩了很多 $(jobs) 因為是在子程序中執行, 看不到母程序的子程序的搞笑事件。 ( jobs > jobs-list 倒是可以運作,因為不是子程序。)

另外就是 rtklib 的 cli 版本程式, 都會很貼心的忽略 SIGHUP 訊號, 以讓程序在 shell 退出後還能繼續執行; 逼我要手動用 kill 終止。

rtkrcv -o rtkrcv.conf -p 5564 &
pid_rtkrcv=$!

trap on_exit EXIT TERM INT HUP
on_exit() {
    kill $pid_rtkrcv
    trap - EXIT
}

rtklib str2str 的串流問題

之前寫 rtklib 的腳本,因為不想用 http 明文傳登入資訊, 所以接 igs 的星曆都是用 curl 走 https, 再用管道傳給 str2str 串流到 tcp 給 rtkrcv 用。

curl --user-agent 'NTRIP RTKLIB (curl)' \
    https://username:password@products.igs-ip.net/CLK93 \
| str2str -out tcpsvr://:5567 &
pid_ntrip=$!

on_exit() {
    kill $pid_ntrip
    trap - EXIT
}

trap on_exit EXIT INT TERM HUP

那時就有注意到,在終止後雖然 str2str 被殺掉了, 但 curl 仍執行了一段時間, 才因為寫入到已經關閉的程序,發生錯誤自己退出。

後來是解 另一個 bug : str2str 把 serial (com port) 串流到 tcp 時, 如果有多個客戶端同時連接,收到的內容會是錯的。 因為用到二個 str2str,只關掉最後一個程序前面的還是會繼續執行, 所以才想找一次關掉整個管道的方法。

程序群組

以前就有看過程序群組的概念 , 也就是 shell 會把一個管道中的所有程序分到同一個新群組, 這樣就可以一次終止整個群組來結束整個管道。 群組 id 也就是群組長程序 id,也就是群組中第一支程序的 id。 posix c 的 kill 函數,如果傳入的 pid 是負數, 就代表終止該程序群組中的所有程序。

當時沒有想太多,只覺得 shell 會處理好。 現在回頭看,在 kill %1 時,如果 %1 是一組管道, 那 shell 實際上做的就是終止整個程序群組。

而 shell 指令 kill 也和 c 的 kill 函數一樣, 可以接受負數來一次終止群組中的所有程序。 只是如果直接寫 kill -5566 ,會被當成選項, 然後因為沒有 5566 這個項選項就報錯了。

可以用二個減號告訴 kill 之後的都不是選項: kill -- -5566 ,就能正確解析。 或在參數前面加個空白: kill ' -5566' 因為第一個字元不是減號, kill 就不會把該參數當成選項,但仍能解析出負數。

要終止群組需要的是群組 id,也就是群組長的 id,然後傳給 kill。 但問題是在腳本中, $! 是管道中最後一個程序的 id, 而沒辦法知道一組管道的第一支程序的 id。 有二個解決方式,一是把整個管道包在一支子程序裡, 二是用 ps 查出該程序所屬的程序群組。

把管道包進單一子程序

這招是 google 時在 stackoverflow 上第一個找出的方法。 把原本 foo | bar & 寫成 (foo | bar) & , 這樣就只有一支程序,所以最後一支程序也就是第一支程序, 也就是群組長了,在括號中的管道也會同一個群組中。 就能用 kill -- -$! 直接殺掉。 但缺點是會多包一層,多出一個子程序。

~ $ sleep 6m | sleep 8m &
[1] 12661
~ $ (sleep 6m | sleep 8m) &
[2] 12664
~ $ pstree $$
bash(9149)-+-bash(12664)-+-sleep(12665)
           |             `-sleep(12667)
           |-pstree(12669)
           |-sleep(12660)
           `-sleep(12661)

但後來發現不管用,要開啟 job control 功能才有此效果, 在 dash 的非互動模式預設是關閉的, 也查不到是否為 posix 標準。

用 ps 列出該程序所屬的群組

雖然 $! 只有管道中最後一個程序的 id, 但用 ps -j $! 就能看到該程序的群組,顯示為 pgid。 查閱 ps 的手冊後,可以知道如果只要列出 pgid, 可以用以下選項: ps --no-headers --format pgid:1 $!

其中 format 是只列出特定欄位,而 :1 是指定欄位的寬度, 預設是似乎是 5,會出現一個前置空白, 如果搭配 kill 需要負數,要前置減號時會變成 - 5566 , 會無法解析,所以要消掉前置的空白。

pgid() {
    ps --no-headers --format pgid:1 $1
}

str2str -in serial://serial0#ubx \
| str2str -out file://f9p.ubx::T \
          -out tcpsvr://:5567 &
pgid_ublox=`pgid $!`

on_exit() {
    kill -- -$pgid_ublox
    trap - EXIT
}

trap on_exit EXIT INT TERM HUP

子 shell 的問題

寫腳本時碰到一個問題,偶爾 curl 會斷線退出, 所以我需要讓 curl 退出後重啟。 我很直覺寫了個 while 迴圈來處理:

while sleep 5
do curl --user-agent 'NTRIP RTKLIB (curl)' \
    https://username:password@products.igs-ip.net/CLK93
done \
| str2str -out tcpsvr://:5567 &
pgid_ntrip=`pgid $!`

但後來發現這種寫法會讓 sleep curl str2str 三支程序 pgid 都和 shell 一樣, 而不會生出一個新程序群組。 所以在終止整個程序群組時也會終止 shell 自己。

然後我在 shell 又 trap 了終止信號, 造成在終止時發信號給自己, 又觸發一次事件處理,又發一次信號; 最後疊太多層 stack overflow。

on_exit() {
    echo exiting
    kill -- -$pgid_ublox
    trap - EXIT
}

trap on_exit EXIT INT TERM HUP

找到問題後也想不到什麼好的辦法, 畢竟就是不會開新群組,只有原本的群組。 只能先把重複觸發事件處理的問題解決, 在收到信號就直接重置處理函數就不會重複觸發了。

on_exit() {
    trap - EXIT INT TERM HUP
    echo exiting
    kill -- -$pgid_ublox
}

trap on_exit EXIT INT TERM HUP

至於在管道中使用 shell 內建語法造成無法新建群組的問題, 除非打開 job control, 不然也只能用 ps 和 awk 去抓了。

ps -o pgid:1,pid -au $USER \
| awk "(\$1==$pgid && \$2!=$pgid) {print \$2}" \
| xargs kill