Chienomi

シェル芸: 並列処理するシェルスクリプトで表示も並列(分割)にする @4種

shellscript

  • TOP
  • Old Archives
  • シェル芸: 並列処理するシェルスクリプトで表示も並列(分割)にする @4種

概要

シェルスクリプトでの並列実行時

( worker 1 ) &
( worker 2 ) &
( worker 3 ) &

各Workerが情報を出力する(例えばffmpegなど)場合ぐちゃぐちゃになって表示されてしまう。

特にrun時間が長い場合、表示も分けて並列で(multipane, split view, STDOUT splitting)表示したいところだ。

考えはするもののなかなか難易度も高く、実際にやる人は少ないと思う。 特にASCIIエスケープシーケンスを伴う場合や、プログラムがTTYを要求する場合は初歩的な知識では不足する。

いずれもなかなかのシェル芸なので、楽しんでいただけたら幸いだ。

tput (縦並び・水平分割 split horizontal)

シェル芸奥義tputを使えば横分割も可能。tput cupでカーソル移動を実現している。

Draw() {
    typeset -i tlines=$(( $(tput lines) - 3 ))
    
    tput clear
    for i in {1..$wokrkers}
    print "Worker$i"
    print "-------------------------------------------------------------------"
    cat .output.worker$i.tail
    tput cup $(( tlines / $workers * $i )) 0
}

stty -icanon time 0 min 0
tput smcup

worker_pid=()
for i in {1..$workers}
do
  ( worker $i > .output.worker$i ) &
  worker_pid+=($!)
done

(
  while sleep 2
  do
    for i in {1..$workers}
    do
      tail -n $(( ( $(tput lines) - 3 ) / $workers - 3 )) .output.worker${i} > .output.worker{i}.tail
    done
    Draw
  done
) &

displayer_pid=$!
wait ${worker_pid[@]}
kill $displayer_pid
tput rmcup
stty sane
  • 各workerは出力をファイルに保存する
  • 子プロセスとして定期的に画面出力を行うdisplayerを起動する
  • メインプロセスではdisaplyerのpidを保持しており、workerをすべてwaitしてからこのプロセスを停止する
  • displayerはsleepを使ったタイマーループ
  • tputで画面クリアやカーソル位置の移動を行っている
  • 開始行の位置は行数をワーカー数で割った数 * ワーカーナンバーに等しいが、余裕をもたせるため全体行からは3、出力行数は3をひいている。(出力行には2行が追加されるので、1行空白をもたせている)

なお、Zshの正数除算は切り捨てが行われる。

multiview (横並び・垂直分割 vertical split, パイプ)

それほど難しい表示でない場合はmultiviewが便利である。インストールは

$ sudo npm -g multiview

でできる。

これで

( worker 1 ) | multiview -s "worker1" -c conworkers &
( worker 2 ) | multiview -s "worker2" -c conworkers &
( worker 3 ) | multiview -s "worker3" -c conworkers &
multiview -x -c conworkers

で大丈夫だ。

ただし、multiviewが表示される前に大量に流し込んだり、multiviewで表示可能な速度(結構遅い)より早く流し込んだりするとえらいことになる。 (簡単に言えばバッファがあふれる)

なお、ASCIIエスケープシーケンスを含むものも認識はするが、画面表示がおかしくなったり、スローダウンしたりするのでおすすめできない。

Terminator

ffmpegのようなプログラムは進行状態をかなり緻密に標準出力に書き出す。 進行状態を書くプログラムはどれも似たようなもので、だいたいがカーソル位置を動かすASCIIエスケープシーケンスによって実現している。

このようなプログラムの場合、少なくともcolumnやtputを使う方法はうまくいかない。

そこで登場するのがTerminatorである。 この方法は恐らくここにあるすべての方法の中で最も理想的に動作する。 ただし方法自体はあまり美しくない。

まず、Terminatorをインストールしたら起動し、右クリックでレイアウトを施してから再び右クリックで設定を開き、レイアウトとして保存する。 さらに、ここでCustom commandとして専用のセットプランスクリプトを用意する。

#!/bin/zsh
integer lockfd
exec {lockfd}>> .stty
flock -x $lockfd
print $TTY >&$lockfd
exec {lockfd}>&-
exec cat

これにより、Terminator上で開かれるシェルはすべて.sttyというファイルに自身の$TTYを出力してからcatに置き換わる。

やっていることは

  1. .sttyファイルに対してflockによるロックを伴って$TTYを書き込む
  2. catにexecする

だけである。

あとは次のようにする。

[[ -e ./.stty ]] && rm .stty
terminator -l 3workers &
terminator_pid=$!
worker_pids=()
ptys=($(cat .stty))
for i in {1..$workers}
do
  ( exec 1> $ptys[$i]; worker $i ) &
  worker_pids+=($!)
done
wait $worker_pids[@]
kill $terminator_pid

ffmpegなどはリダイレクトやパイプを拒否するので、予め/dev/pts/nにリダイレクトした上でworkerを起動している。youtube-dlのようにリダイレクトを受け入れるのであれば

[[ -e ./.stty ]] && rm .stty
terminator -l 3workers &
terminator_pid=$!
worker_pids=()
ptys=($(cat .stty))
for i in {1..$workers}
do
  worker $i >& $ptys[$i] &
  worker_pids+=($!)
done
wait $worker_pids[@]
kill $terminator_pid

とすることもできる。

なお、exec catとしているが、実のとろここのcatプログラムは一切使われない。 出力はcatに書き込んでいるのではなく、仮想端末そのものに対して書き込んでいるからだ。

catにしている理由は

  • プロンプトが邪魔になるのでシェルを起動したくない
  • catならタイムアウトで終了したりしない
  • シェルプロセスを終了する方法としてシェルプロセスをkillしたり親端末をkillしたりする以外にcatに対してEOFを送るという手段が確保できる

からだ。

GNU screen

今回の本命である。表示がある程度バグるものの、Terminatorよりは美しい方法で、multiviewよりもうまくいく。

まずはレイアウト用の設定ファイルを用意しておく。 縦分割のほうが速いが、横分割のほうが乱れにくい。

layout new
split
split
screen 0
title worker1
focus next
screen 1
title worker2
focus next
screen 2
title worker3
detach

さらに先程と同じように起動用のスクリプトも用意しておく。

これで準備は完了だ。

[[ -e ./.stty ]] && rm .stty
screen -s ./.worker_tty_setup -h 1 -S workerinfo -c .multiworker.screen_config -t workerinfo
sleep 10
ptys=($(cat .stty))
( 
  for i in {1..$workers}
  do
    ( exec 1> $ptys[$i]; worker $i ) &
  done
  
  wait
  screen -S workerinfo -X quit
)
screen -S workerinfo -r

Terminatorの場合は事前の設定が必要で、任意数のワーカーを起動することは事実上不可能なのだが、 screenを使う場合は実行時にscreenの設定ファイルを生成するようにすれば可能というメリットもある。

任意数のワーカーを使う場合は分割は諦めたほうが良い。 その場合でもウィンドウ切り替えで処理でき、並べたい場合は任意にレイアウトできるため柔軟性は高い。 さらに分割を諦めるのであれば設定ファイルを使わずに-dmオプションで起動し、-X screenオプションでウィンドウを増やしていくこともできる。 ビューの分割はデタッチ状態ではできない。

このスクリプトで起動後にsleepしているのは、screen起動時に起動シェルセットアップ状態に関係なくデタッチされるため、.sttyの書き込みが間に合わないからだ。

補助知識

端末について

基本的に古の端末は文字だけを扱うので、「構造的に組版される」ようにできている。 つまり、座標はX何文字目、Y何文字目という扱いになるのだ。

互換性のために現在でもこのような仕様になっているが、実際にはドット単位で取り扱えるようになっている場合も多い。

だが、互換性をもっている以上、文字数を数える構造は今も持っている。 だからtput linesで端末の行数をカウントすることができるのだ。 これは現在のグラフィカルなアプリケーションの多くでは割と難しい。

端末の互換性

一般的にはVT100という古の端末をエミュレートするようにできているのだが、互換性をもつ範囲でも微妙に違うためうまく動作しないことがある。 これは、現在の端末はX Window System上で動作するためにVT100ではできないようなことがいろいろできるためだ。

tputはそのような端末の互換性問題を吸収する。 例えばclearは単に現在のウィンドウの行数だけ空行を出力するような仕様の端末も多いが、tput clearすると表示そのものがクリアされる場合が多い。

Terminator

ビューを分割できることで有名なターミナルエミュレータ。 ちなみに、分割自体はKonsoleでもできる。

pts

仮想端末はそれぞれ/dev/pts/nという形でアクセスできる。 これは「端末に対して読み書きする」プログラムは少なくないからだ。

これは端末そのものであり、このファイルに書き込めば端末の画面上に表示されることになる。 「端末上で動作しているプロセスに対する読み書き」ではないことに注意してほしい。

典型的にはwallやtalkといったプログラムがこれを使う。 SSHがリダイレクトとは別に端末で操作できるのも、標準入出力ではなく端末デバイスを直接取り扱うためだ。

exec

シェルコマンドのexecは続く引数をコマンドとして実行し、そのコマンドで現プロセスを置き換えるが、引数がない場合は自分自身を自分自身で置き換えることになるため何もしない。

ただし、このときリダイレクトしているとファイルディスクリプタのオープンは行う。 そしてファイルディスクリプタをオープンしたプロセスで置き換えることになるため現プロセスでファイルディスクリプタは開きっぱなしになる。

という理屈はさておき、現在のシェルでファイルディスクリプタを開いたままにしておきたい場合にexecを使うのはそういう呪文なのである。 空のexecを使うシーンは他にないため、呪文だと思っても割と差し支えない。

基本的には

exec command 9> file

とするのと同じことである。 この場合commandではリダイレクトが効くのでファイルディスクリプタ9は開きっぱなしであり、そのコマンドでファイルディスクリプタ9に対して書き込みができる。 ここでcommandがなければ自分自身になるため、当該シェルプロセス上でファイルディスクリプタが開きっぱなしになる。

なお、{fd}>という書き方はZshで整数型変数を使う場合のみ可能な特殊な書き方。