Chienomi

シェルスクリプトで並列実行制御を行う

zsh

シェルスクリプトを書く場合において、処理を順次おこないたいことは多いはずだ。 多くのディレクトリや処理のリストなど、順に処理を適用していくケース。

まず、この設計だが、「ある特定の場所でしか実行されないスクリプト」はnoexecが指定されていない限りはそのルートに設置すべきだと思う。 対象ディレクトリごとにスクリプトが異なってくる場合はその対象ディレクトリごとにスクリプトを設置する。 もしそうでなく、その違いがディレクトリのパス自体に含まれるのであれば、単純に対象ディレクトリに空のドットファイルを置けばよいと思う。

例えば、…/.somescriptを実行するのであれば、トップに

for i in **/.somescript
do
  ( cd ${i:h}; zsh .somescript )
done

のようなスクリプトを書けば良い。Gitが「Gitを実行するディレクトリがリポジトリになる」という仕様なので、このように方法にらも随分なれてしまった。 もし、パス要素自体が重要になるのであれば、

for i in **/.target
do
  fooscript ${${i:h}:t}
done

という方法も考えられる。

それは良いとして、その各処理が時間がかかるとしたらどうだろう? これは別にディレクトリ単位であった場合に限らない。テキストファイルに1行1エントリ形式で書いて読みながら処理する場合も同様だ。

別に最初からターゲットをグループ分けしてもいいし、xargsを使って3つずつ実行する、といったことで並列化することもできる。 だが、できれば常に3スレッドで実行する、といったモデルのほうが好ましいのではなかろうか。

これを実現するためにシェルスクリプトで並列実行制御したいのだが、残念ながらこれはかなり難しい。 Zshでもzthreadがあるような話も目にするのだが、まぁ実際はそうもいかなそうだ。

並列実行の難しさは、「同時アクセス」にある。あるリソースに同時にアクセスした場合、いろいろな形でおかしなことになる。 ファイルデスクリプタを共有すれば良いのではないかと考えたのだが、

worker() {
  workern=$1
  typeset val
  while read val
  do
    print "Worker $workern: $val"
  done
}

print -l {1..100} | (
  for n in {1..3}
  do
    worker $n &
  done
)

wait

結局read時に同時アクセスするとおかしな値を取ることになり(空文字列だったり、複数行がぐちゃぐちゃに混ざったものだったりする)、ちゃんと動作しない。

なお、ここでのポイントをまとめておこう。

  • ()はfork子プロセスを生成し、子プロセスで実行する。
  • この子プロセスに渡されたパイプは、子プロセス自体の標準入力として受け取ることになる。リダイレクトしないプロセスはこのファイルデスクリプタを共有する
  • &でバックグラウンドで実行する。子プロセスを生成したかどうかは関係がない
  • 外部コマンドはfork+execで子プロセスを生成するが、関数は生成しない
  • waitはジョブを共有待ち合わせる。引数なしですべてのジョブを待つ
  • 同じファイルデスクリプタを共有している場合、IOの位置もいずれかのプロセスが動かせばすべてのプロセスで動く

結局、アクセスしたら要素をひとつ返してくれるQueueがほしいのだ。

そこでまじめに考えてみた。一番単純なのはflockを使う方法なのだが単純にはいかない。プロセスの中で処理したければ、ファイルデスクリプタを使った、複雑な方法が必要になる。 その中で比較的スマートに処理できると考えたのがGistのスクリプトだ。

この場合、.lockは空ファイルであり、単なるロックでしかない。無駄なファイルを作るのにはちょっと抵抗があるが、方法としては比較的簡単だ。 この方法はbashでもほぼ同様に書くことができる。あまりzshらしい方法とは言えない。

なんか悔しいので、Zshらしい方法として、Socketを使うという方法を提案してみる。 UNIXドメインソケットはファイルパスを用いてプロセス間通信を行う。TCP同様、サーバーが接続を受け付け、IOを確立するものだ。

一般的にはサーバーは並列処理できるように接続の受け付けはマルチスレッドで行う。

zsocket -l foo
integer server=$REPLY
    
while zsocket -a $server
do
(
  integer io=$REPLY
  ...
) &
done

だが、シングルスレッドで行った場合はどうなるか。listenはしているがacceptはしていない状態だと接続しようとするclientはブロックされ、acceptされるまで待たされることになる。 結果的に、あるリソースにアクセスし、供給する部分がシングルスレッドになる。同時にアクセスしてもproducerはそれをブロックして順番に渡していくことになる。

これはごく普通のマルチプロセスモデルであり、Perlはthreadが非推奨で、UNIXドメインソケットの利用を推奨している。 そのため特に目新しいものではないのだが、Zshでおこなう(シェルスクリプトで行う)というとまたちょっと味があるのではないか。

Gist