数回に分けて、継続についてのもろもろを、時系列にそって書きます。
そもそも「継続」という学問があるようなのですが、私はここ最近に至るまでそんな学問があるとも知らずに継続を扱ってきたこともあって、きっと厳密性は全く無いと思います。ただ、経験から得られた知識であれど、実際に動作させたという知見があるので、もしそういった方からのアプローチが役に立つのであればそれはそれでいいんじゃないかという妄想によりますので、控えめによろしくお願いします (;´Д`)
@pocketberserker gkbr
— kekyo (@kekyo2) 2016年3月22日
※そこんとこは「継続」で言うところの何某だよ、みたいなコメントがあると嬉しいです
ちなみに、近日「継続勉強会」があるようです。うぅ、東京うらやましい…
原点
2000年よりも昔、まだインターネットが大学でしか使えなかったころ、巷ではシリアル通信によるパソコン通信なるものがコミュニケーションの主流でした。そのころに私は某BBSシステムを改良したシステムをフリーソフトウェア(この頃は定義さえ怪しい)として公開して配布していました。
驚いたことに、これはいまだにベクターで公開されており、ソースコードも公開していたので、今回のネタの原点から振り返ってみることが可能でした(肝心のコードはあまりに古く、色々アレなので、ポインタについては勘弁してくださいw)。
コードはTurbo PASCALで書かれており、一部がアセンブラ(x86)です。プラットフォームはMS-DOSであり、RS-232C(シリアル)のドライバも一から書かなければならない頃です。そのBBSシステムは、多チャンネルに対応していたため、複数の電話回線から複数のモデムを通じてのセッションを同時に裁く必要があります。UNIXでいえば、物理的なシリアル何回線かに対して、シリアルコンソールでgettyに接続する感じでしょうか。
現代的なコードであれば、スレッドを使って個々の接続を個別のスレッドで処理するような感じです。しかし相手はMS-DOS、もちろんスレッドなんてありませんし、プロセスは分離されず子プロセスのみでコンカレント動作はありません。MS-DOSを知らない方には想像つかないかもしれませんが…
となると、多チャンネルの操作は、ステートマシンの手動実装、つまり「ステート値」とその値に依存する「巨大なswitch-case」のような実装、そして細切れにされた各ステートの処理の乱立、という、酷すぎて保守したくないコードのようなものにならざるを得ません。
しかし、このシステムには画期的な、ある小さなコードの断片がありました。
code segment byte public assume cs:code, ds:data public transfer trans_stack struc ; arguments of transfer. tr_bp dw ? return dd ? proc2 dd ? proc1 dd ? trans_stack ends transfer proc far push bp mov bp, sp les di, [bp].proc1 mov es:[di], sp ;現在のスタックポインタをproc1が示すtrans_stackに保存 mov es:[di+2],ss cli mov sp, word ptr [bp].proc2 ; proc2が示すtrans_stackのポインタをスタックポインタに設定 mov ss, word ptr [bp].proc2+2 sti pop bp ret 8 transfer endp code ends end
このコードはx86のアセンブラで、「transfer」と呼ばれるメソッドが定義されています。引数に指定されている「trans_stack」構造体へのポインタを使って、現在のスタックポインタを「無理やり別のスタック位置に入れ替える」操作を行います。
初めてこのコードを見たときは「衝撃」でした(今でもよく覚えている。いまだにこの方面のことに興味があるのは、これのせいかもしれない)。
何しろ動作中にスタックポインタを書き換えるのです。しかもこのコードでは、proc2がどこからやってくるのかがよくわからないため、スタックポインタがどのような値になるのか想像もつきません。更にスタックポインタを書き換えた状態で、「pop」とか「ret」を実行しているのです。一体なにがpopされるのか、その後retでどこに戻るというのか… 頭がパニックになります。
「機械語」 – 先日のILのキホンでも言ったんですが、このような低レベルのコードは、「書いたとおりに動作する」のです。ILの場合は明らかに不正なコード片はCLRが止めてしまいますが、機械語の場合はほぼ書いたとおりに実行されます。
スタックポインタがどこを指しているのかわかりませんが、その「どこか」から値をpopし、更に値を取得してそこに処理が遷移する(ret)のです。特にretは、その動作から、以下のようなジャンプ命令のように振る舞います。
; @tempというレジスタがあったと仮定して: pop @temp jmp @temp
つまり、あらかじめ「新しいスタックポインタ」が示すメモリ領域に、bpに復元すべき値とジャンプ先のアドレスを書き込んでおき、そこを指すようにtrans_stack構造体を初期化しておいてtransferを呼び出せば、見事ジャンプ先に遷移することになります。
で、こんなめんどくさくてわかりにくい事を何故行うのかというと、一度遷移に成功すれば、再びtransferを呼び出すことで、「以前使っていたスタックポインタの位置から処理を継続できる」からです。
transferは「call命令」で呼び出されることを前提にしています。つまり、transferに遷移してきたとき、すでに戻るべき処理位置へのポインタはスタックに積まれており、これが次回のtransferからretするときに復元されます。元コードはTurbo PASCALで書かれていましたが、Cで書くと以下のような疑似的なコードとなります。
// 疑似コードです。動きません extern void transfer(trans_stack* fromtr, trans_stack* totr); static trans_stack main_tr; static trans_stack sub_tr; // subのtransfer-processエントリポイント static void sub() { auto i = 1000; while (1) { printf("sub(): %d\n", i++); // subからmainに転送 transfer(&sub_tr, &main_tr); } } // main(のtransfer-process) void main() { // bpの初期値は0で良い sub_tr.tr_bp = 0; // transfer-processの初期位置はsub関数の先頭 sub_tr.return = ⊂ // subのためのスタックを確保 sub_tr.proc2 = malloc(0x1000); for (auto i = 0; i < 10000; i++) { printf("main(): %d\n", i); // mainからsubに転送 transfer(&main_tr, &sub_tr); } }
よーく見てください。このコードを実行すると、mainのprintfとsubのprintfを交互に実行します。そして、mainで10000回処理を行った後はプログラムが終了します。subは無限ループなので、終了しても放置されていますね。いずれにしても、このプログラムに「スレッド」はありません。環境はMS-DOSです。しかしながら、「transfer」を呼び出す必要があることを除けば、まるでスレッドが存在するかのようです。
こうすれば、各「transfer-process」の暗黙のステートは、それぞれのスタック(mainのスタックと、mallocで確保されたメモリ)に隠されており、独立性を保ち、不要なステートマシンを作る必要がなくなります。当然、各シリアル制御のコードは大幅に簡略化されるでしょう。実際、transferがなければまともなコードにはならなかったはずです。
例えば、上記のsub関数内の自動変数「i」は、mallocで確保されたスタックメモリ領域内にその値が格納されています。コードのどこにも、明示的にmallocのメモリを読み書きしている個所がありませんが、これはスタックなのです。そして自動変数はスタック内に割り当てられます。つまり、subを実行するtransfer-processは、subの実行に必要な暗黙のステートを、スタック内で管理しているのです。そして、transferを呼び出すたびに、このスタックは切り替えられます。
このような構造を「コルーチン(coroutine)」と呼びます。
もちろん、この時にはこれが「継続」と呼ばれる何かだとは、全く気が付いていませんでした。