Edited at

bash4のコプロセスのチュートリアル

More than 1 year has passed since last update.


はじめに

bashのバージョン4から使えるコプロセス、どういった機能か調べてみました。なお co-process であって子プロセスではありません。

やや扱い辛さを感じるところではあるのですが、有効活用の道が見つけていければ、と思います。


機能比較


プログラムとのデータの遣り取り

bashには、ファイル ( 名前付きパイプ含む ) を新しく作らずに、プログラムとデータを遣り取りする方法として、パイプ ( cmdA | cmdB のような )、コマンド置換 ( var=$( cmdX ) のような )、プロセス置換 ( cmd > >( cmdX ) のような ) という方法が用意されています。

しかし、これらの方法には共通の制限があります。それはコマンドの出力を同じコマンドへの入力としてフィードバックできないということです。

もちろん、一旦コマンドが実行終了するまで待って、次同じコマンドを実行する時に渡してあげれば良いのですが。終了する前まだ動いている途中にはできないのです。

これは対話的な処理をスクリプトで自動的に行うことができないことを意味します。そのため、古今東西expectのような対話ツールが用意されたりしていたのですが。


コプロセス

そこで出てくるのがコプロセスということになります。コプロセスはバッググラウンドでプログラムを動作させ、そのプログラムからの入力、プログラムへの出力を両方制御できるという機能です。

ちょうど感覚としては、パイプを入力用と出力用に同時に指定したようなもので、実際内部的にはパイプを2組作ることをしています。( perl使いであればopen2という関数を目にしたことがあるのではないでしょうか。それと同じです。)


事例

では、「入力、出力を両方制御」というのはどういうものか、事例を挙げて紹介したいと思います。なお、実用性については保証しません…。


事例1: cpコマンドの対話実行


ネタ

cpはご存じファイルのコピーを行うコマンドですが、-iという対話実行オプションが用意されています。このオプションを使えば、既存のファイルを上書きする場合に確認が入ります。これにより意図しない上書きを回避できるというわけです。こんな感じですね。

$ cp -i a b  ← a,bというファイルが既にあるものとする

cp: overwrite 'b'? y ← y と入力することで上書き実行、nなら回避

人力でコマンド実行しているのであれば、プロンプトが出る度に人間が判断して y/n を入力させて行けば良いのですが、これをスクリプトで自動実行したいとなると、事前に対象になるファイルをリストアップするなど、cpコマンド以外で色々処理する必要が出てきます。


適用シナリオ

そこでコプロセスを使ってみます。シナリオとしては次の通りです。


  1. cpをコプロセスとして起動する

  2. 上書き確認のメッセージをコプロセスから読み込む

  3. 上書きしていいかどうか適当に判断したりし、y/n をコプロセスに渡す

  4. コプロセスの終了を確認する

なお、注意が必要なのはメッセージが無ければ特に何もしない、というところです。或いは、メッセージが複数回出てくるようなら、その分処理を繰り返すということを考える必要もあるでしょう。


サンプル実行

ということで、実際にログインシェル上で試してみます。既に a b の2ファイルがあり、上書きが発生する状況を想定してください。

$ coproc cpjob { cp -i a b 2>&1 >/dev/null; }

[1] 72
$ read -u ${cpjob[0]} -d '?' msg
$ echo "$msg"
cp: overwrite 'b'
$ echo y >&${cpjob[1]}
$ wait %1
[1]+ Done coproc cpjob { cp -i a b 2>&1 > /dev/null; }
$


  • コプロセスの起動


    • coprocコマンドで起動します。今回はこれにcpjobという名前を付けていますが省略可能です。その場合はCOPROCという名前になります。

    • コプロセスではブレースで括って複数コマンド実行が可能です。1コマンドで、かつ名前を省略する場合はブレースなしにもできるのですが、毎回ブレースありで良いんじゃないでしょうか。なお、セミコロンには注意。

    • コプロセスから受け取れるのは標準出力から出されたメッセージです。今回、cpコマンドの標準エラーから出るメッセージを読み込みたいので、リダイレクトで差し替えています。なお、リダイレクトはブレースの外側で指定しても良いです。

    • 起動したコプロセスは一種のジョブとして扱われます。今回[1]とジョブ番号 1 が割り当てられるのが分かります。



  • コプロセスからの読み込み


    • 今回はreadコマンドによりmsg変数に読み込んでいます。

    • どこからかというと、コプロセス名 cpjob がファイルディスクリプタを保存する配列変数になっていまして、その0番目の値を-uオプションで指定しています。

    • 通常readは、行単位での読み込みを行います。しかしcpの出す確認メッセージは改行がありません。なので、いつまで経っても行が終わらず読み込み完了となりません。そこで、メッセージの最後の?-dオプションで行区切りと指定します。

    • 実際にechoコマンドで変数の内容を出力してみると、上書き確認メッセージが受け取れていることが分かります。



  • コプロセスへy/nを渡す


    • 今回は固定で y を渡すことにします。

    • 読み込みと似ていますが、今度は cpjob 配列変数の 1番目のファイルディスクリプタを使います。そのため>&${cpjob[1]}というようなリダイレクトを指定します



  • コプロセスの終了確認


    • コピー処理が終われば勝手に終了するのですが一応。waitコマンドによって終了を待ちます。ここは通常のジョブと同じです。




発展形

とは言え、分かり切ってる処理を単にコプロセスにしても面白くないので、一応実用ぽい形に発展させてみます。

ディレクトリ対ディレクトリのコピーで、上書きが発生する場合には自動でバックアップする、という機能を盛り込んだスクリプトです。

「cpにも-bとか--backupってあるよね?」とか「そこまでするならrsyncじゃね?」というツッコミは無しでお願いします。


dircp.sh

#!/bin/bash

SRC=${1:-src}
DST=${2:-dst}

coproc cpjob { LANG=C cp -rTi "$SRC" "$DST" 2>&1 >/dev/null; }

while read -d '?' -u ${cpjob[0]} msg; do
echo "*debug: $msg"
fname=${msg#* overwrite \'}
fname=${fname%\'
*}
mv "$fname" bak/
echo y >&${cpjob[1]}
done

wait


複数ファイルのコピーとなりうるので、上書き確認をループであるだけ処理している点、メッセージの中からファイル名を取り出してバックアップ処理を行っている他は、上のサンプル実行と大きく変わらないと思います。

なお、このスクリプトには大きな問題がありcpコマンドが上書き確認以外のエラーメッセージを出力したらどうするの?というところ、全くケアできていません。なので、そんな人もいないとは思いますが、絶対に実運用には投入しないでください


事例2: sedによる自己フィードバック(ネタ)


ネタ

今度は実用性を全く求めないネタです。sedではループ処理ができることは良く知られていると思いますが、これを「自分の出力を再入力することで同じようなループ処理を行う」と、コプロセスで実現してみよう、となります。

今回は、「1行の中から1文字ずつ a を抜いて都度出力し、なくなったらそこで終わり」とします。sedのループでは次のような感じになります。

$ sed -ne ':L; s/a//p; tL; q' <<< abababab

bababab
bbabab
bbbab
bbbb
$

簡単に言うと、


  • aの1文字削除に成功したらpの指定により出力する

  • aの1文字削除に成功していたら次のtLでループ先頭に戻る

  • aの1文字削除ができなければ、tLをスルーしてqで終了する

ということをやっているわけです。


実装例

これをコプロセスで実現するために、次のようなシナリオを考えます。


  • sedはコプロセスとして起動し、ループではなく1行単位で処理させる

  • sedの出力を入力に戻すプロセス(単純にはcat)を別途実行する

  • sedの出力をteeで二重化して表示にあてる

実装は次のようになります。


self-feedback.sh

#!/bin/bash

exec 3>&1
coproc sedjob { sed -u -ne 's/a//p; t; q' | tee /dev/fd/3; }
cat >&${sedjob[1]}
cat <&${sedjob[0]} >&${sedjob[1]}

wait


最初に実行するcatが初期入力をコプロセスのsedに渡し、次のcatがsedと協調して自己フィードバックします。

今度はsedではtLのようなループ繰り返しではなく、単にtで次行の処理に移るようにしています。

実行してみると、ちゃんと自己フィードバックしていることが分かります。

$ ./self-feedback.sh <<< abababab

bababab
bbabab
bbbab
bbbb
$


コプロセスならではのハマり処

ところで、事例2において、コプロセスとして実行するsedに-uオプションが付いていたことに気付いたでしょうか。これはロング形式だと--unbufferedで、出力のバッファリングを無効にするオプションです。実はこのオプションを指定しないとスクリプトがストールしてしまいます。

これは何故か。そもそもsedは自分の出力が次の入力となるなんてことは意識していません。つまり、1行分の出力データができたらすぐに吐き出さないと、今度自分が読み込むデータがなくなってしまうわけですが、そんな事情は知らないので、ひたすらデータを溜めて(バッファリングして)まとめて出力しようとし、入力データがいつまで経っても来ないという状況に陥ります。これがストールを引き起こす理由です。

要は入出力のデッドロックなのですが、これはネットワーク通信を行うプログラミングを実装する場面なら当然意識されるところ、スクリプトの実装で意識される場面はあまり無いのではないかと思います。

でもじゃあ、コプロセスの場合だけ問題になるのはなぜか? と言うと、通常のパイプ等ではデータの流れが1方向なので、データの切れ目を考える必要がないこと、tty/ptyを介したインタラクティブ実行の場合はライブラリが良いように介入してくれることが理由となるでしょう。

なお、sedの場合は-uによって制御が効くわけですが、そのようなオプションがない場合でも setbuf コマンドをかませることで同様の効果が得られる場合があります。

行単位でデータを吐き出させるのであれば、setbuf -oL sed … のようにすれば良いです。


終わりに

どうしてもコプロセスでないと、という場面がどれくらいあるのか、まだ掴めてはいないのですが、スクリプトの実装の幅を広げるために、一度使ってみるのはいかがでしょうか?