複数プロセス

前章では,継続を利用すれば実行中のプログラムが自分の状態を把握し, それを保存して後で再開することができることを示した. この章で扱う計算処理のモデルは, 計算機が単一のプログラムを実行するのでなく,独立したプロセスの集合を実行するというものだ. プロセスの概念はプログラムの状態という概念と深い対応関係にある. 前章で示したマクロの上にさらにもう一層のマクロを書くことで, Common Lispプログラムの中に複数プロセスを埋め込むことができる.

プロセスの抽象化

複数プロセスは,複数の処理を同時に行わなければならないプログラムを表現するのに便利だ. 伝統的なCPUはインストラクションを1つずつ実行する. 「複数プロセスは同時に複数の処理を行う」というのは, このハードウェア上の制限をどうにか乗り越えるという意味ではない. それを使って,抽象化の新たな段階でものを考えられるようになるということだ. つまりコンピュータがある時点で何を行うか,逐一指定しなくともよい. 仮想メモリによって,コンピュータが実際に備えているより多くのメモリがあるかのように思えるのと同様, プロセスの概念によって,コンピュータが同時に複数のプログラムを実行できるかのように思っていられる.

プロセスの研究は伝統的にはOSの分野で行われていた. しかし抽象化の観点としてのプロセスの利便性はOSの世界に限られてはいない. プロセスは他のリアルタイムアプリケーションやシミュレーションでも同様に便利なものだ.

複数プロセスに基づいてなされた仕事の大部分は,ある種の問題の回避に費されてきた. デッドロックは複数プロセスに伴う古典的問題の一つだ. すなわち,相手より先に敷居を越えるのを互いに拒む2人の人のように, 2つのプロセスが何もせずに,共にもう片方が何かを行うのを待ち続けることだ. 他にも,一貫していない状態にあるシステムに対するクエリの問題もある. 例えば,システムが預金をある口座から別の口座に移している最中に残高が問い合わせられる場合だ. この章で扱うのはプロセスによる抽象化のみだ. ここで示されたコードは, デッドロックや一貫していない状態を防ぐアルゴリズムのテストに使えるかもしれないが, コードそのものはそれらの問題に対する防御措置を一切取っていない.

この章における実装は,この本の全てのプログラムが暗黙に従っている規則に従っている. すなわち「なるだけLispとぶつかるな」だ. 気持ちとしては,プログラムはできる限りプログラミング言語の修正に近付くべきで, そのプログラミング言語で書かれた個別のアプリケーションになるべきではない. プログラムをLispと調和するように作れば,ぴったり合った部品で作られた機械のように頑健になる. また,労力の節約にもなる. 驚く程の量の処理をLispに肩代りさせられることもある.

この章の目的は,複数プロセスに対応したプログラミング言語を作ることだ. ここでの戦略は,新オペレータをいくつか追加して,Lispをそのようなものに変えてゆくことだ. 新プログラミング言語の基本要素は次のようなものだ.

関数は,前章のマクロ=defun=lambdaで定義される.

プロセスは関数呼び出しによって生成される. (一つの関数から生成されていても)アクティブなプロセスの数に制限はない. 各プロセスには優先度が定まっており,その初期値はプロセスを生成するときの引数で与えられる.

wait式を関数内で使うことができる. wait式は,変数とテスト式と,実行本体となるコードを引数に取る. プロセスがwait式に出会うと,テスト式が真を返すまでその時点で処理が中断される. プロセスが再始動すると, wait式に与えた変数がテスト式の返り値に束縛された状態でコード本体が評価される. テスト式は普通は副作用を持つべきではない. いつ,どの位の頻度で評価が行われるかの保証がないからだ.

スケジューリングは優先度に基づいて行われる. 再始動し得る全てのプロセスのうち,最高の優先度を持つプロセスをシステムが選んで始動させる.

他に始動するプロセスがないときにはデフォルト・プロセスが始動する. それはread-eval-printループの一種だ.

大抵のオブジェクトは実行中に生成や破棄ができる. 実行中のプロセスで新しい関数を定義することもできるし, 別のプロセスを始動したり終了させたりもできる.

継続により,Lispプログラムの状態を保持することができる. 複数の状態を同時に保持していられることは,複数のプロセスがあることと大して違わない. 複数プロセスを実装するには,前章で定義したマクロから始めて60行弱のコードしか必要ない.

実装

(defstruct proc pri state wait)

(proclaim '(special *procs* *proc*))

(defvar *halt* (gensym))

(defvar *default-proc*
  (make-proc :state #'(lambda (x)
                        (format t "~%>>")
                        (princ (eval (read)))
                        (pick-process))))

(defmacro fork (expr pri)
  `(prog1 ',expr
     (push (make-proc
             :state #'(lambda (,(gensym))
                        ,expr
                        (pick-process))
             :pri      ,pri)
           *procs*)))

(defmacro program (name args &body body)
  `(=defun ,name ,args
           (setq *procs* nil)
           ,@body
           (catch *halt* (loop (pick-process)))))
\caption{プロセス構造体とその生成.} \label{fig:ProcessStructInstantiation}
 (defun pick-process ()
       (multiple-value-bind (p val) (most-urgent-process)
        (setq *proc* p
                  *procs* (delete p *procs*))
        (funcall (proc-state p) val)))

 (defun most-urgent-process ()
       (let ((proc1 *default-proc*) (max -1) (val1 t))
        (dolist (p *procs*)
          (let ((pri (proc-pri p)))
             (if (> pri max)
                   (let ((val (or (not (proc-wait p))
                                      (funcall (proc-wait p)))))
                     (when val
                       (setq proc1 p
                               max     pri
                               val1 val))))))
        (values proc1 val1)))

 (defun arbitrator (test cont)
       (setf (proc-state *proc*) cont
             (proc-wait *proc*) test)
       (push *proc* *procs*)
       (pick-process))

 (defmacro wait (parm test &body body)
       `(arbitrator #'(lambda () ,test)
                      #'(lambda (,parm) ,@body)))

 (defmacro yield (&body body)
       `(arbitrator nil #'(lambda (,(gensym)) ,@body)))

 (defun setpri (n) (setf (proc-pri *proc*) n))

 (defun halt (&optional val) (throw *halt* val))

 (defun kill (&optional obj &rest args)
       (if obj
          (setq *procs* (apply #'delete obj *procs* args))
          (pick-process)))
\caption{プロセスのスケジューリング.} \label{fig:ProcessScheduling}

\reffig{fig:ProcessStructInstantiation}と\reffig{fig:ProcessScheduling}には, 複数プロセスに対応するために必要な全てのコードを示した. \reffig{fig:ProcessStructInstantiation}のコードは,基本的データ構造,デフォルト・プロセス, プロセスの初期化や生成のためのものだ. プロセスすなわちprocsは以下の構造を持つ.

pri
プロセスの優先度で,正の整数.
state
中断されたプロセスの状態を表す継続. プロセスの再始動は,この状態をfuncallに渡すことで行われる.
wait
関数で,これが真を返さないとプロセスは再始動できない. しかし新たに生成されたばかりのプロセスではnilになっている. waitnilのプロセスは常に再始動できる.

プログラムは3つのグローバル変数を使う. *procs*は中断中のプロセスのリスト,*proc*は実行中のプロセス, *default-proc*はデフォルト・プロセスを格納する.

デフォルト・プロセスが実行されるのは,他に実行できるプロセスがないときだ. ちょうどLispのトップレベル環境に似せている. ユーザはこのループ内で, プログラムを停止させたり,中断中のプロセスを再始動させられる式を入力したりできる. デフォルト・プロセスはevalを陽に呼んでいることに注意. ここはそうすることが理に適う数少ない状況の一つだ. 一般に,実行時にevalを呼ぶのはよい考えではない. その理由は以下の2つだ.

効率が悪い
evalは生のリストを渡されるが, それをその場でコンパイルなりインタプリタを使って評価なりせざるを得ない. どちらにせよ,コードを予めコンパイルしておいて呼び出すだけのときより遅くなる.
表現力が低い
式がレキシカル・コンテキストなしで評価されるからだ. とりわけ,評価される式の外側で見えている普通の変数にアクセスできない.

普通,evalを陽に呼ぶことは空港の土産物屋で買いものをするようなものだ. 延々と待たされたあげく,品揃えの悪い,それも二流品ばかりの店に高い金を支払うことになる.

今の状況は上の議論がどちらも当てはまらない稀な例だ. ここでは式を予めコンパイルすることはあり得ない. その時点で読み込んでいるのだから,予めなどというものはない. 同様に,式はそれを包むレキシカル環境を元々参照できない. トップレベルに入力された式はヌル・レキシカル環境の中にあるからだ. 実際,この関数の定義は「ユーザが入力したものを読み込んで評価する」をそのまま反映している.

マクロforkは関数呼び出しによってプロセスを生成する. 関数は=defunによって通常通り定義できる.

(=defun foo (x)
  (format t "Foo was called with ~A.~%" x)
  (=values (1+ x)))

さて関数呼び出しと優先度を引数にforkを呼び出すと,

(fork (foo 2) 25)

新しいプロセスが*procs*にプッシュされる. その新プロセスは優先度が25で, (procの)waitフィールドがnil(まだ始動させられていないため), stateフィールドが2を引数としたfooの呼び出しになっている.

マクロprogramによって,一群のプロセスを生成し,一緒に実行できる. 次の定義は,2つのfork式が, 中断されたプロセスを消去するコードと実行するプロセスを繰り返し選ぶコードに 挟まれたものにマクロ展開される.

(program two-foos (a b)
  (fork (foo a) 99)
  (fork (foo b) 99))

このループの外側ではマクロがタグを設定しており,そこに制御を移すことでプログラムを終了できる. このタグはgensymなので,ユーザのコードが設定したタグと衝突しない. programで定義されたプロセスたちは特に値を返さず, トップレベルから呼ばれることのみを意図している.

プロセスが生成された後, \reffig{fig:ProcessScheduling}に示したプロセスのスケジューリング用のコードが入れ替わりに起動する. 関数pick-processは,再始動可能なプロセスのうち優先度が一番高いものを選んで始動させる. そのプロセスを選ぶのはmost-urgent-processの仕事だ. 停止中のプロセスが始動できるのは,wait式が中にないか, またはwait式が真を返したときだ. 始動可能なプロセスのうちで最も優先度が高いものが選ばれる. 選ばれたプロセスと,その中にwait式があればその返り値がpick-processに渡される. デフォルト・プロセスが常に始動しようとしているので,必ずいずれかのプロセスが選ばれることになる.

(defvar *open-doors* nil)

(=defun pedestrian ()
  (wait d (car *open-doors*)
    (format t "Entering ~A~%" d)))

(program ped ()
  (fork (pedestrian) 1))
\caption{wait式1つを持つプロセス.} \label{fig:OneProcessOneWait}

\reffig{fig:ProcessScheduling}の残りのコードは プロセス間で制御を切替えるためのオペレータを定義している. 待機するための標準的な式はwaitで, \reffig{fig:ProcessScheduling}内の関数pedestrianでも使われている. この例では,プロセスはリスト*open-doors*に要素が含まれない間は待機し, 要素があるときはメッセージを表示する.

> (ped)
>> (push 'door2 *open-doors*)
Entering DOOR2
>> (halt)
NIL
(defvar *bboard* nil)

(defun claim   (&rest f) (push f *bboard*))

(defun unclaim (&rest f) (pull f *bboard* :test #'equal))

(defun check   (&rest f) (find f *bboard* :test #'equal))

(=defun visitor (door)
  (format t "Approach ~A. " door)
  (claim 'knock door)
  (wait d (check 'open door)
    (format t "Enter ~A. " door)
    (unclaim 'knock door)
    (claim 'inside door)))

(=defun host (door)
  (wait k (check 'knock door)
    (format t "Open ~A. " door)
    (claim 'open door)
    (wait g (check 'inside door)
      (format t "Close ~A.~%" door)
      (unclaim 'open door))))

(program ballet ()
  (fork (visitor 'door1) 1)
  (fork (host 'door1) 1)
  (fork (visitor 'door2) 1)
  (fork (host 'door2) 1))
\caption{黒板を使った同期.} \label{fig:SynchroWithBlackboard}

wait=bind(xgoページ)に似ており, それと同様に,最後に評価されなければならないという制限を持つ. waitの後に行いたい処理は,全てその中に入れなければならない. よってwaitを複数回使いたければ,wait式はネストさせなければならない. 互いを考慮した事実を仮定することで, プロセスは\reffig{fig:SynchroWithBlackboard}のようにゴールに到達するための共同作業が行える.

visitorhostにより生成されたプロセスは, 同じドアを与えられたとき,黒板上のメッセージを通じて制御をやりとりする.

> (ballet)
Approach DOOR2. Open DOOR2. Enter DOOR2. Close DOOR2.
Approach DOOR1. Open DOOR1. Enter DOOR1. Close DOOR1.
>>

他にwait式の単純なものがある. yieldの目的は,他の優先度の高いプロセスに始動を機会を譲ることだけだ. プロセスは式setpri(これはカレント・プロセスの優先度を再設定する)の実行後に yieldを実行するとよいかもしれない. waitと同様に, yieldの後に実行したいコードは全てその内部に入れなければならない.

(wait d (car *bboard*) (=values d))

(=defun capture (city)
  (take city)
  (setpri 1)
  (yield
    (fortify city)))

(=defun plunder (city)
  (loot city)
  (ransom city))

(defun take    (c) (format t "Liberating ~A.~%" c))
(defun fortify (c) (format t "Rebuilding ~A.~%" c))
(defun loot    (c) (format t "Nationalizing ~A.~%" c))
(defun ransom  (c) (format t "Refinancing ~A.~%" c))

(program barbarians ()
  (fork (capture 'rome) 100)
  (fork (plunder 'rome) 98))
\caption{優先度の変更の効果.} \label{fig:EffectOfChangingPriority}

\reffig{fig:EffectOfChangingPriority}のプログラムでは, これらのオペレータがどのように協調して動作するかを示した. (訳注: captureは「捕らえる」,plunderは「略奪する」,takeは「取る」, liberateは「(支配から解き放って)自由にする」, fortifyは「要塞化する」,lootは「略奪する」,ransomは「身代金を要求する」, refinanceは「財務を再構築する」という程の意味.) 最初,蛮族 (barbarians) には2つの目的がある. ローマの制圧とそこでの略奪だ. ローマの制圧の方が(わずかに)優先度が高いので,そちらが先に実行される. しかしローマが制圧された後は,プロセスcaptureの優先度は1に下がる. そこでプロセス選択が行われ,その結果,優先度が最大のプロセスplunderが始動する.

> (barbarians)
Liberating ROME.
Nationalizing ROME.
Refinancing ROME.
Rebuilding ROME.
>>

蛮族がローマの宮殿で略奪を行い,貴族の身代金を奪った後にのみプロセスcaptureは停止し, 蛮族は拠点の要塞化に移る.

基盤となっている式waitは,arbitratorを一般化したものだ. この関数はカレント・プロセスを保存し, pick-processを呼んで,何らかのプロセス(大抵は同じもの)を始動させる. これは2つの引数,テスト式と継続を取る. テスト式は停止中のプロセスのproc-waitとして保持され, それを再始動するかどうか決定するために後で呼び出される. 継続はproc-stateに保持されるが, これを呼び出すと停止しているプロセスが再始動することになる.

マクロwaityieldはその継続関数を生成するが, やっていることは単にその本体をλ式で包むことだけだ. 例えば,

(wait d (car *bboard*) (=values d))

(arbitrator #'(lambda () (car *bboard*))
  #'(lambda (d) (=values d)))

に展開される.

コードが\reffig{fig:RestrictionsContPassMacros}の制限を満たしていれば, wait本体のクロージャを生成するとき,現在の継続全体が保存される. 第2引数の=valuesが展開されると,次のようになる.

#'(lambda (d) (funcall *cont* d))

クロージャが*cont*への参照を含むため, 関数waitで停止させられたプロセスは,停止した時点ではどこに置かれていたか, という目印を持つことになる.

オペレータhaltは, 制御をプログラムの展開形が設定したタグに移すことでプログラム全体を停止させる. オプショナル引数にはプログラム全体の値として返すための値を渡せる. デフォルト・プロセスが常に始動しようとしているので, プログラムの終了には明示的にhaltを呼ぶしかない. haltの後にどのようなコードが続いても,評価されることがないので意味がない.

各プロセスは式killを呼ぶことで終了させられる. 引数無しでこれを呼ぶと現在実行中のプロセスが終了させられる. その場合,killはカレント・プロセスの状態を保持しないwaitの一種のようなものだ. killに渡した引数は,プロセスのリストから削除するものを指定する. 現状のプログラムでは参照すべきプロセスの属性が多くないので, killについて説明することは大してない. しかしもっと手の込んだシステムでは, 他にタイムスタンプ,オーナ等の情報をプロセスに関連づけることになるだろう. デフォルト・プロセスはリスト*procs*に含まれていないので,killを適用できない.

「早い」だけではないプロトタイプ

継続を使って模倣したプロセスには,本物のOSのプロセス程の効率は望めない. ならば,この章で示したプログラムの用途は一体何だろう?

それらの便利さは,ちょうどスケッチと同じだ. 実験的なプログラミングやラピッド・プロトタイピングでは, プログラムはそれ自身で完成品なのではなく,むしろ考えを展開させるための乗り物だ. 他の多くの領域では,この目的に使われるものはスケッチと呼ばれる. 建築家は,原則的には,建物全体を頭の中で創り上げることができる. しかし,ほとんどの建築家は鉛筆を手にしていた方が考えが進むようだ. 普通、建物のデザインは手始めにスケッチを何枚か描く中で考える.

ラピッド・プロトタイピングはソフトウェアのスケッチだ. 建築家の下描きのように,ソフトウェアのプロトタイプは軽々としたタッチで描かれることが多い. アイディアを形にする最初期の一歩では,費用と効率の考慮は無視される. この段階での結果は,建築不可能な建物や,救いようもなく非効率的なソフトウェアになりがちだ. それであってもスケッチの価値は変わらない. なぜなら

  1. 情報を簡潔に運ぶ入れ物であり,
  2. 実験する機会を与える

からだ.

この章で説明したプログラムは,その前の章のプログラムと同様,スケッチであり, 複数プロセスの概要を大雑把な筆遣いで提示している. 商用ソフトウェアで使える程効率的ではないだろうが, 複数プロセスの他の側面を使って,スケジューリングのアルゴリズムなどを実験するにはとても便利だ.

第22--24章では,継続の他の応用を示す. いずれも商用ソフトウェアで使える程効率のよいものではない. Lispとラピッドプロトタイピングは共に発展してきたので, Lispには特にプロトタイプを意図した, 属性リスト,キーワード引数,そしてリストといった非効率的だが便利な機能が沢山備わっている. 継続も恐らくこの仲間に入る. 継続はプログラムが必要にするより多くの状態を保存する. だから,例えば後で出てくる継続ベースで実装されたPrologは, そのプログラミング言語を理解する方法としてはよいが,実装方法としては非効率的だ.

この本が重視するのはLispで実現できる抽象化であって,効率性に関わる話題ではない. しかし,Lispはプロトタイプを書くための言語であるばかりでなく, 商用ソフトウェアを書くための言語でもあるということを認識しておくことは重要だ. 「Lispは遅い」との評判があるとすれば, それは多分に多くのプログラマがプロトタイプで止まってしまったせいだ. Lispでは速いプログラムを簡単に書けるが,残念なことに,遅いプログラムも簡単に書ける. Lispプログラムの初期版はダイアモンドのようなものだ. 小さく,透き通っていて,そして非常に高価だ. それをそのままにしておきたいという欲求は大きいかもしれない.

他のプログラミング言語では,書いたプログラムを動作させるという骨の折れる課題に成功しさえすれば, その効率性はすでに許容可能なものだろう. 床にタイルを敷くとき,タイルが爪程の大きさだったら,無駄になるタイルも少ない. この考えに基づいてプログラムを開発していた人には, 「プログラムが動作したらそこで開発は完了する」という考えを乗り越えるのは難しいかもしれない. 「Lispを使うとプログラムが手間要らずで書ける. ああ,しかしそうすると遅いんだ.」と彼は思うかもしれない. 実際は,どちらも当てはまらない. 速いプログラムは実現できるし,しかしそれには手間をかけなければならない. この点では,Lispを使うのは,貧しい国でなく豊かな国に暮らすようなものだ. 痩せた体型を維持するために働かなければならないのは困ったことかもしれないが, 命をつなぐために働いており,太ることなど問題外,という状況よりはずっとよい.

抽象度の低いプログラミング言語では,機能を求めて開発することになる. Lispでは,速度を求めて開発することになる. 幸運なことに,速度を求めて開発する方が容易だ. 大抵のプログラムでは,スピードに深刻に影響する部分は数か所しかない.


←: 継続     ↑: On Lisp     →: 非決定性

Copyright (c) 2003-2011 野田 開     NODA Kai <nodakai@gmail.com>