工場で製品を組み立てるときの人や生産機械の配置の代表的な方法に、「ライン生産方式」と「セル生産方式」がある。ライン生産方式というのはいわゆる流れ作業のことで、ベルトコンベアがあり、流れてくる部品を作業者がひたすら捌く方法だ。作業は役割分担されており、一人の作業者は1つの作業しかしない。T型フォードの生産以来、100年の歴史がある方法だ。
セル生産方式の方は馴染みがないかもしれないが、これはデジタルカメラなどの精密機械を作るときにしばしば使われる方法で、一人に1つ屋台のようなものが与えられて、作業者がそこで1種類~数種類の製品を最初から最後まで組み立てる方法だ。屋台は職人の仕事場を効率的にしたもので、手の届く範囲の棚という棚に部品が置かれている。作業者は、移動せずに組み立て作業に没入できるようになっている。また、仕様が少し違う製品を同じ屋台で組むことができるため変化に柔軟だ。
この2つの方式は両極端な方法だ。一般的には、簡単であるという理由からライン生産方式が採用されることが多い。作業者が覚えることが少ない、品質にばらつきが出にくい、あまり考えないで速く手を動かせるなどの理由から、本当の意味での大量生産に向いている。セル生産は難しいので、それに見合うメリットがないと採用されない。
UNIX哲学にある「単機能コマンドをパイプでつなぐ」という文言は、「データをライン生産方式で処理する」とよく似ている。UNIXでも生産工学でも、まずはパイプラインやベルトコンベアをどのように敷設するかということが、どうやって効率よくデータや製品を産出するかを考える第1歩になる。従来はCPUのコアが1つが普通だったので、パイプの効果というのはあまり重視されなかったようである。しかし現在ではCPUコアが複数あるのが一般的なので、生産工学の「常識」がUNIXでも実現している。
そういう前置きを書いておいてなんだが、本記事では生産工学では難しい方のセル生産方式的なやり方に焦点をあてて説明する。同じ処理を並列に実行してCPUを使い切る方法だ。リスト1に典型例を示す。
$ gzip data1 & $ gzip data2 & $
実行中にtopで見てみると、CPUが2つ使われていることが分かる。
$ top -n 1 -c | sed -n '/PID/,$p' | head -n 5 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 28700 ueda 20 0 8800 668 400 R 97 0.0 0:11.06 gzip data1 28701 ueda 20 0 8800 668 400 R 97 0.0 0:09.37 gzip data2 1089 root 20 0 203m 30m 8612 S 2 0.5 6:55.93 /usr/b... 22748 ueda 20 0 862m 52m 18m S 2 0.9 0:30.24 /usr/l... $
コマンドやオプションの後ろに&をつけると、そのコマンドが終わらないうちに次のコマンドを打つことができる。&ありのコマンドは「バックグラウンドプロセス」として実行されている。これを使えば、CPUが2つ以上あるコンピュータで、並列処理を行うことができる。
ただ、やる前に言っておくが、このような処理で性能を引き出すには、セル生産方式同様、さまざまな条件が揃う必要がある。たとえば上のgzipの例では、data1とdata2が同じHDD上にあると、いくらCPUを2個分使っていてもHDDは1個であり、2つのデータを同時に読み出すことはできない。だから、これが必ず一個のプロセスで実行するより早いかどうかは分からない。また、いわゆるMapReduceのような操作の場合には、データを最初に2つに分ける処理も同時にはできないので、そこで性能が頭打ちになる可能性がある。
そして案外、ネックになるのは人間の頭の方だ。たとえば端末上でバックグラウンドプロセスをいろいろ立ち上げたら、立ち上げた人間の方も何が走っているか把握していないと結果を収集できない。
一方で、処理するデータ量や計算量が大きいなど諸条件が揃うと時間を短縮できるので、知っておいて損することはない。
まずは端末での操作方法のおさらいをする。立ち上げるときは、後ろに&をつける。
$ sleep 1000 & [1] 24474 $ <- プロンプトが表示される $
バックグランドプロセスの制御にはkill(1)コマンドないしはシェルのkill組み込みコマンドを使用する。どちらも実体はほとんど同じだ。kill(1)にジョブ番号ないしはプロセスIDを指定してコマンドを実行すると、対象のプロセスを終了させることができる。
ジョブ番号は、上のリストの[1] 23374の[1]の方だ。23374がプロセスIDとなる。ジョブ番号はバックグラウンドプロセスを立ち上げたときに表示されるほか、後からjobsという組み込みコマンドで確認できる。ジョブ番号を指定する場合にはリスト3のように「%番号」と記述する。
$ sleep 1000 & [1] 31487 $ jobs [1]+ 実行中 sleep 1000 & $ kill %1 $ jobs [1]+ Terminated sleep 1000 $ jobs $
シェルスクリプトでも&をつけるとバックグラウンドプロセスになる。たとえば、リスト4のように書けば、バックアップを並列で行うことができる。
$ cat BACKUP #!/bin/sh tar zcvf vm.tar.gz ~/VM/ > /dev/null 2>&1 & tar zcvf old.tar.gz ~/OLD/ > /dev/null 2>&1 & $
リスト4のシェルスクリプトを実行して、実際にtar(1)コマンドが並列で動作しているかを確認する。
$ cat BACKUP $ ./BACKUP $ top -n 1 -b | head $ cat hoge | sed -n '/PID/,$p' | head -n 4 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 4981 ueda 20 0 8800 652 432 R 79 0.0 0:26.23 gzip 4982 ueda 20 0 8800 632 428 R 77 0.0 0:27.07 gzip 31938 ueda 20 0 2693m 1.1g 1.1g S 9 19.6 18:57.67 VirtualBox $
シェルスクリプトから起動したバックグラウンドプロセスは、端末からjobsコマンドを叩いても状態を見ることができない。プロセスIDを指定してkillコマンドで止めるか、コマンド名を指定してkillallコマンドで止める。
リスト5はkill(1)を使う方法だ。ps(1)を使ってプロセスを確認し、kill(1)コマンドを使ってプロセスを終了する。まとめて作業させたいのであれはリスト6のようにコマンドを組み合わせて実行すればよい。
$ ./BACKUP $ ps PID TTY TIME CMD 4364 pts/1 00:00:00 bash 9818 pts/1 00:00:00 tar 9819 pts/1 00:00:00 tar 9820 pts/1 00:00:01 gzip 9821 pts/1 00:00:01 gzip 9822 pts/1 00:00:00 ps $ kill 9818 $ ps PID TTY TIME CMD 4364 pts/1 00:00:00 bash 9819 pts/1 00:00:00 tar 9821 pts/1 00:00:10 gzip 9823 pts/1 00:00:00 ps $ kill 9819 $ ps PID TTY TIME CMD 4364 pts/1 00:00:00 bash 9824 pts/1 00:00:00 ps $
$ ./BACKUP $ ps PID TTY TIME CMD 4364 pts/1 00:00:00 bash 9725 pts/1 00:00:00 tar 9726 pts/1 00:00:00 tar 9727 pts/1 00:00:01 gzip 9728 pts/1 00:00:01 gzip 9729 pts/1 00:00:00 ps $ ps | grep tar | awk '{print $1}' | xargs kill $ ps PID TTY TIME CMD 4364 pts/1 00:00:00 bash 9852 pts/1 00:00:00 ps $
プロセス名で終了させる場合にはリスト7のようにkillall(1)コマンドを使用する。この方法は名前に一致するものを終了するため、意図しないプロセスまで終了することがある。使用前にps(1)コマンドで実行されているプロセスを確認した方がよい。
$ ./BACKUP $ killall tar $ ps PID TTY TIME CMD 4364 pts/1 00:00:00 bash 9861 pts/1 00:00:00 ps $
さてシェルスクリプトBACKUPだが、tarを2つ立ち上げたらすぐスクリプトが終わってしまう。これではいつ終わったか分からないので個人的には不便だと思う。そこで、2つのtarが終わらないとBACKUPが終わらないように細工をする。
$ cat BACKUP #!/bin/sh tar zcvf vm.tar.gz ~/VM/ > /dev/null 2>&1 & tar zcvf old.tar.gz ~/OLD/ > /dev/null 2>&1 & wait $
wait組み込みコマンドを引数なしで実行すると、対象となるシェルまたはシェルスクリプトから起動されたすべてのバックグラウンドプロセスが終了するまで処理を待つようになる。wait組み込みコマンドはバックグラウンドプロセスを活用するとき使用する。便利なコマンドなので覚えておくとよいだろう。
waitを使わないでも実装できる。たとえば、ファイルをセマフォの手段として使用し、終了の合図として活用する方法がある。バックグラウンド処理が終わったときに空ファイルを置き、それを待つというコードを書けばよい。リスト9に例を示す。
$ cat BACKUP.WAIT #!/bin/sh rm -f ./sem.1 ./sem.2 { tar zcvf vm.tar.gz ~/VM/ > /dev/null 2>&1 touch ./sem.1 } & { tar zcvf old.tar.gz ~/OLD/ > /dev/null 2>&1 touch ./sem.2 } & while sleep 3 ; do if [ -e ./sem.1 -a -e ./sem.2 ] ; then rm -f ./sem.1 ./sem.2 exit 0 fi done $
バックグラウンドで処理させたいコマンド達を{ }で囲んでグループ化し、}の後ろに&をつけてバックグラウンドで実行する。tar(1)の処理が完了した後で、空のファイルを作る。空ファイルができるときはその前のtar(1)がすでに終わっていることが保証されるので、2つの空ファイルがあれば、処理が終わったと判断できる。
touch(1)コマンドではなく、シェルのリダイレクトのみを使ってファイルを作成する方法もある。その場合は: > ./sem.1という記述になる。:は真値で終了する何もしない組み込みコマンド。true(1)コマンドと同じだ。何もしないコマンドの出力をファイルにすると、空のファイルができる。rm(1)コマンドには-fを指定する。オプションなしでrm(1)だけ書くと sem.1、sem.2 が無い場合にエラーメッセージが出るので、-fオプションでそれを抑制している。
16行目以降は空ファイルを待つコードだ。3秒ごとにファイルの有無を確認して、あったらスクリプトを終了する。リスト10に、この仕組みの動作を確かめるスクリプトと実行結果を示す。実行例のように、書いた順序と出力が逆になっており、非同期で処理が進んでいることが分かる。
$ cat WAIT #!/bin/sh rm -f ./sem.a ./sem.b { sleep 1; echo hogeA; :> ./sem.a; } & { echo hogeB; :> ./sem.b; } & while sleep 3 ; do [ -e ./sem.a -a -e ./sem.b ] && exit 0 done $ ./WAIT hogeB <- すぐ出る hogeA <- 1秒後 $ <- 3秒後 $
リスト9のスクリプトは処理の終わりまで待っているわけだが、当然、Ctrl-cしてもtar(1)は止まらない。Ctrl-cに対応してtar(1)を終了するようにスクリプトを組むこともできる。
リスト11は、Ctrl-cされたらtar(1)を止め、残る余計なファイルも消すシェルスクリプトだ。
$ cat BACKUP.TRAP #!/bin/sh -xv EXIT(){ ps | grep tar | self 1 | xargs kill rm -f ./vm.tar.gz ./old.tar.gz while ! rm ./sem.1 ; do sleep 1 ; done while ! rm ./sem.2 ; do sleep 1 ; done exit 1 } trap EXIT 2 rm -f ./sem.1 ./sem.2 { tar zcvf vm.tar.gz ~/VM/ > /dev/null 2>&1 touch ./sem.1 } & { tar zcvf old.tar.gz ~/OLD/ > /dev/null 2>&1 touch ./sem.2 } & while sleep 3 ; do if [ -e ./sem.1 -a -e ./sem.2 ] ; then rm ./sem.1 ./sem.2 exit 0 fi done $
スクリプトの冒頭でtrap組み込みコマンドを使ってシグナルハンドリングを実施している。Ctrl-cは割り込みシグナル(INT)を発生させる。シグナルというのは、プログラム実行中になんらかの情報を通知する仕組みだ。Ctrl-cのシグナル番号は2とされている。trap EXIT 2の指定で、シグナル2を受け取った場合にEXIT組み込みコマンドを実行せよ、という指定になる。
EXIT関数では、6行目でtar.gzファイルを削除して、7、8行目でsem.1、sem.2を削除している。17行目、22行目のtar(1)が終了されるとそれぞれ18行目、23行目のtouch(1)が実行されるので、それを待ち受けてファイルを消す。EXIT関数の中身と18、23行目のtouch(1)は非同期に起こるので、whileで待たないと素通りすることがある。while ! rm ./sem.1 ; do ... ; done でrmが成功するまでループする。
実行結果をリスト12に示す。lsすると分かるように、Ctrl-c後には余計なファイルが残らない。
$ ls BACKUP BACKUP.TRAP BACKUP.WAIT WAIT $ ./BACKUP.TRAP ^C <- 割り込み! ./BACKUP.TRAP: 18 行: 12065 Terminated tar zcvf vm.tar.gz ~/VM/ &>/dev/null ./BACKUP.TRAP: 23 行: 12067 Terminated tar zcvf old.tar.gz ~/OLD/ &>/dev/null $ ls BACKUP BACKUP.TRAP BACKUP.WAIT WAIT $
バックグラウンドプロセスを使って処理を並列化するシェルスクリプトを扱った。こうした並列化は、データの整列の際に非常に有効だ。整列というのは、データの量が2倍になると計算量が3倍になったり4倍になったりする性質がある。そのため、ファイルを最初に均等分割して整列をじしし、あとからマージすると、負荷分散した以上の効果を得ることができる。
Software Design 2012年9月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【9】CPUに効率よく仕事をさせる(2) ― 同一処理をバックグラウンドで並列実行」より加筆修正後転載