LoginSignup
3
2

More than 1 year has passed since last update.

[翻譯] 構造化された並行処理に関するnotes..というかgo文は有害と思われる

Last updated at Posted at 2020-11-05

並行処理のAPIには当然コードを並行して走らせる手段がある。以下に様々なAPIの例を載せるので見て欲しい。

go myfunc();                                // Go言語

pthread_create(&thread_id, NULL, &myfunc);  /* C言語 と POSIX threads */

spawn(modulename, myfuncname, [])           % Erlang

threading.Thread(target=myfunc).start()     # Python と threads

asyncio.create_task(myfunc())               # Python と asyncio

書き方や用語は異なれどやってる事は同じである。どれもがmyfuncを並行するように立ち上げ、立ち上げ元へはmyfuncの完了を待たずにすぐに処理が戻るのだ。また別のやり方としてcallback関数がある。

QObject::connect(&emitter, SIGNAL(event()),        // C++ と Qt
                 &receiver, SLOT(myfunc()))

g_signal_connect(emitter, "event", myfunc, NULL)   /* C と GObject */

document.getElementById("myid").onclick = myfunc;  // Javascript

promise.then(myfunc, errorhandler)                 // Javascript と Promise

deferred.addCallback(myfunc)                       # Python と Twisted

future.add_done_callback(myfunc)                   # Python と asyncio

これもやはり書き方は異なれどやっている事は同じである。特定のeventが起きたらmyfuncが立ち上がるように予約し、予約元へはmyfuncの完了を待たずに処理が戻るのだ。(時にはPromise.all,Promise.raceのようにお洒落な形をとったりもするが本質はどれも同じ。)

そしてこれが全てなのである。現在ある汎用的な並行処理のAPIは全て上に挙げた物のどれかの形に落ち着いているのだ。しかし奇妙なことに私が最近作ったlibraryであるTrioはどちらの方法も採っていない。もしmyfuncと別の関数を並行させたいなら以下のように書くことになる。

async with trio.open_nursery() as nursery:
    nursery.start_soon(myfunc)
    nursery.start_soon(別の関数)

多くの人は最初にこのnurseryに出会った時に「なんで字下げされたcodeブロックがあるの?」「nurseryって何なの?なんで新しくtaskを立ち上げる前にこれをわざわざ作らないといけないの?」などと困惑するようである。そしてそれまでに培った並行処理のノウハウが通じないことに気付いて苛立つのである。確かにnurseryは一見風変わりでlibraryの構成要素としては少々高水準過ぎるので皆の反応は理解できるが少し待って欲しい。

この投稿では私は皆にnurseryが実は風変わりではなくむしろloopや関数呼び出しのような基本的な制御フローと同等の物であると知って欲しいのと共に、上に出てきたようなthread生成やcallback関数は全てnurseryに置き換えるべきであると主張したい。

「お前は何を言ってるんだ」って?実は似たような事は過去に起きているんだ。goto文はかつては制御フローの主役だったけど今となっては笑いのネタにしか使われない。幾つかの言語はまだgotoを持っているけどそれは初期の物と比べるとあまりにも非力だし、ほとんどの言語はそもそもgotoを持っていない。一体gotoに何が起こったんだ?これにはほとんどの人がもう忘れたであろう昔のとある出来事が関係している。そしてそれはこの投稿にもすごく関わってくるんだ。だからまずはgotoがなんであったか思い出すことから始めて、それが並行処理に何を教えてくれるのか見ていこう。

goto文って何?

軽く歴史を振り返ってみよう。初期の頃のプログラムはアセンブリ言語かそれ以上に原始的なやり方で作られていた。それがあまりに非効率だってことで1950年代にIBMのJohn BackusやRemington RandのGrace Hopperのような人達がFORTRANやFLOW-MATIC(後継のCOBOLで有名)といった言語を作り始めた。FLOW-MATICは当時としてはすごく野心的で、人間にとっての分かりやすさを第一に、機械にとっての都合は二の次に設計されていた。(Pythonの曽曽曽祖父みたいな物と考えてくれていいよ)。実際にどんなコードだったのか見てみよう。

最近の言語とは違ってifブロックやloopブロックや関数呼び出しが無いことに気付いたと思う。FLOW-MATICにはブロックの区切りや字下げなどは無くてただ命令文が平に並んでいるだけなんだ。これは別にそれが要らないくらいコードが小規模だったからってわけじゃなくて、そもそもブロック構文という物がまだ生み出されていなかったからなんだ。

代わりにFLOW-MATICには二種類の制御フローがあった。通常は上から下に向かって一つずつ命令が実行される(sequential)。でももしJUMP TOのような特別な命令が実行されると処理の流れは何処か別の場所に飛ばされるんだ(goto)。例えば13番は2番へ処理を飛ばす。

最初に挙げた並行処理の例と同じでこのような"一方通行の処理のジャンプ"の用語は当時まだ統一されていない。ここではJUMP TOだけどこの投稿ではgotoと呼ぶことにする。そして以下がコードに含まれる全てのgotoだ。

これを見て困惑したのなら君は正常だ。このようなgotoを使ったプログラミング手法はFLOW-MATICがアセンブリ言語から受け継いだ物の一つで、強力でかつハードウェアの実際の動作をよく表してはいるけど使いこなすのは難しい。絡み合った矢印は"スパゲティコード"という言葉が生まれた理由でもある。明らかに改良の必要があるだろう。

でも...gotoの具体的に何が問題なんだろう?何故ある制御フローは良くてある物は駄目なんだろう?どうやって良い物だけを選べば良いんだろう?当時はその良し悪しがまだ分かってなくてプログラムの問題を直すのは困難だった。

go文って何?

ここでいったん歴史の話は置いておこう。誰もがgotoが悪である事を知っている。でもそれが並行処理と何の関係があるんだ?ここではGo言語の有名なgo文を例に考えてみよう。go文は"goroutine"と呼ばれる軽量threadを立ち上げるための物だ。

// Golang
go myfunc();

これもまた図を書いたほうがいいかな?でも今回は先程出てきた物とは少し違うんだ、処理の流れが分裂するからね。書くとしたら以下のようになるだろう。

親goroutine(緑の線)からすると処理の流れは逐次(sequential)だ。上から始まってすぐに下から出てくる。でも子(ラベンダーの線)からすると処理の流れは上から始まって途中でmyfuncへ飛ぶ。そしてこれは通常の関数呼び出しとは違って一方通行だ。myfuncには完全に新しいスタックが割り当てられ、ランタイムはそれが何処で始まったかなんて覚えていない。

実はこれは何もGo言語だけに限った話ではない。最初に挙げた並行処理の例の全てがそうなのだ。例えば

  • threading系のlibraryはthreadと後で合流するための何らかの手段を大抵提供するがそれは言語自身のあずかり知らぬ物であってthread生成自体は上の図と同じだ。
  • callback関数の登録も本質的には「とあるeventが起きるまで停まって待っているthread」を作るのと同じだ。もちろん実装方法こそ異なるが処理の流れという点から見るとやはり同じである。
  • FuturePromiseも同じで...(省略)

このような物は異なる見た目で色んな所に現れるがどれにも共通して言えるのは (1) 処理の流れが分裂して (2) その片方は一方通行のジャンプをし (3) もう片方はすぐさま呼び出し元に戻る事だ。一度自分でそういったcodeがどれだけあるか数えてみるといい、至る所で見つかるから。

ところでこの種の制御フローを言い表す標準的な言葉は存在していない。だからgoto文が全ての"gotoのような物"の総称になったように私はgo文をこれらの総称として使いたいと思う。何故goを選んだかって?理由の一つはGo言語が最も純粋な形でこの制御フローを表してくれたからで、別の理由は...えっとそろそろ私が話を何処にもっていこうとしているか気付いたかもしれない。次の二つの図を見比べて欲しいんだけど何か似ていない?


実はgo文はgoto文の一種なんだ。 (訳者感想:この流れで"一種"と言うのは強引に感じる。)

並行処理を含むプログラムを書くのが難しいのはよく知られていることだ。そしてgotoを使ったプログラムもだ。その理由はもしかすると同じなんじゃないだろうか?現代の言語においてgotoの持つ問題はほとんど解決している。だからどのように解決されたのかを学べば並行処理のAPIをより良いものにできるんじゃないだろうか?

gotoに何が起こった?

でgotoの一体何がそんなに問題だったんだろう?1960年代後半Edsger W. Dijkstraはそれに関して詳しく述べた二つの文書を書き上げた。goto文は有害と思われる構造化プログラミングに関するnotesだ。

抽象化の阻害者goto

当時Dijkstraは規模の大きなソフトウェアをどうやれば正しく作れるかに頭を悩ませていた。例えば次の言葉は聞いたことがあるだろう。

programのtestによってbugを見つけ出すことはできても全く無いことは証明できない

そうこれは構造化プログラミングに関するnotesからの一文だ。そんな彼が最も悩んでいたのは抽象化だ。彼は人の頭で一度に把握できないくらいの規模のプログラムを作るにはどうすればいいのか悩んでいたのだ。そしてその為にはプログラムの各部分が「詳細は知らないがとにかく機能する物(訳注:長いので以後は原文"black box"の直訳で"黒箱くろばこ"と書き表す)」として扱える必要がある。例えば以下のようなpythonコードを見た時

print("Hello world!")

あなたはprintがどのように実装されているかなんて気にしなくてよく、ただそれが与えられた文字列を表示することだけを知っていれば良い。そうすれば頭のエネルギーを別の所にまわす事ができる。Dijkstraはプログラミング言語にこのような抽象化を求めていた。因みにこの頃は既にブロック構文が編み出されていて、例えばALGOLのような言語は5つの制御フローを持っていた。


下三つの高水準な制御フローはgotoを使って自分で実装する事もできて、実際初期の頃は人々はそれらをただの便利な簡易表記としてしか捉えていなかった。でもDijkstraは違っていてこう言った。

これらの図を見比べて欲しい。gotoとそれ以外には大きな違いがある。goto以外の全ては処理の流れは上から始まり -> [何かをして] -> 下から出てくる。これを黒箱の規則と呼ぶことにしよう。制御フローがこの形をとっている限り、どこからやって来て中で何をしてようが全体をsequentialと見做す事ができる。また嬉しいことにこれはたくさんの制御フローが幾重に組み合わさっていても成立する。

例えば

print("Hello world!")

このcodeを見た時、処理の流れがどうなっているのかを知るためにわざわざprintの定義を見に行く必要はない。もしかするとprintの中にはloopがあり、そのloopの中にはif/elseによる分岐があり、その分岐の中には別の関数呼び出しがあるかもしれない。でもそんなことはどうでも良いのだ。printに入ったらいずれ必ず処理の流れはそこから戻ってくるのを知っているのだから。

そんな事言われなくても分かってるよとあなたは思うかもしれない。でももし言語がgotoを持っているとそうはいかないんだ。...(省略)

これがDijkstraの革新的な提案だ。

私達はifやloopや関数呼び出しをgotoの簡易表記と考えるのではなくそれ自体を言語の基本的な構成要素として捉えるべきである。そしてgotoは完全に排除すべきである。

2018年となった今となっては誰もがそう考えるだろう。でも自分達の慣れ親しんだ道具があって「その道具を安全に使えるほど私達は賢くはないから完全に無くしてしまおう」と言われた時のプログラマー達の反応がどんなものだったか分かるかい?1969年この提案に対する意見は分かれていた。Donald Knuthはgotoを擁護したし、他の既にgotoを使いこなしていた人達は自分がやりたい事をより制限の強い物で表現し直さなければならないことに憤った。

最終的には現代の言語はDijkstraの最初の構想ほど厳密な制御構造を持つことにはならなかったものの1概ねそれに沿って設計される事となった。特に処理を黒箱とするための最も大事な要素である関数はどの言語でも完璧に構想通りに設計されていると思う。breakで別の関数へ移ることはできないし、returnは現在の関数から抜けるだけでそれ以上は飛べない。

そしてこれはgoto自体にも影響を与えた。幾つかの言語はまだgoto相当の物を持っているがどれにも強い制限がかけられていて、飛べる範囲は最大の物でも同じ関数内に限られている。かくして初期の制限無しのgotoは姿を消すこととなった(アセンブリ言語2を使わない限りは)。Dijkstraの勝ちだ。


(左が初期のgotoで右がC言語やC#やGo言語などで見られる弱められたgotogotoが関数を超えられなくなった事で顔を裂かれる事は無くなったけど靴に小便をかけられることはある。)

goto文を無くす事で得られる思わぬ利

gotoが姿を消すと面白い事が起こった。言語設計者は制御フローが構造化されていることを前提とした機能を盛り込めるようになったのだ。例えばPythonは後片付けを手軽に行う為の構文を持っている、withだ。

# Python
with open("my-file") as file_handle:
    ...

このコードでは...の部分を実行中はfileが開いていることが保証され、...を過ぎると直ちにfileは閉じられる。現代の言語は大抵似たような機能(RAII, using, try-with-resource, defer, ...)を持ち、どれもが制御フローが構造化されている事を前提としている。これがもしgotoがあったとして急にwithブロック内に飛び込んできたらどうなると思う?fileはその時開いているのだろうか閉じているのだろうか?もし普通にwithブロックを終えるのに代えてgotoで飛び出したら?fileは閉じられるべきなのだろうか?gotoがある限りこの機能がうまく立ちゆかない事が分かるだろう。

error処理にも似たような事が言える。errorが起きた時あなたのコードは何をすべきだろうか?よくあるのはスタックを巻き戻して呼び出し元にerrorへの対応を委ねるやり方だ。最近の言語は大抵これを簡単に行う為の仕組みとして例外または別の形でのerror伝搬ができるようになっている。ただこれが可能なのはその言語がスタックを持ち、"呼び出し元"という概念がある場合のみだ。FLOW-MATICのスパゲティコードを思い出して欲しいのだが、もしあのコードの途中で例外を起こすとしたら一体何処へ飛ぶべきだというのか!

goto文はもう二度と

(省略)

go文は有害と思われる

以上がgotoの歴史だ。さてこれまで言って來たことがどれだけgo文にも当てはまるだろうか?実は...全てなんだ!

go文は抽象化を妨げる

もし言語がgotoを許したらどの関数もgotoになりうると言ったのは覚えているだろうか?(訳注:私が訳してないだけで原文ではそう言っている)。実はほとんどの並行処理のframeworkにおいてgo文に同じことが言えるんだ。あなたが呼んだ関数は裏方taskを作っているかもしれない。その関数から戻ってきた時そのtaskはまだ生きてるのだろうか?それを知るにはソースコードを読み辿るしかない。そのtaskはいつ終わるのだろうか?実に難しい。go文があると実は関数は制御フローを尊重しない存在であり黒箱にはなれないのだ。私の並行処理に関する最初の投稿ではこの事を"因果関係の違反"と呼んでasyncioTwistedを用いたプログラムにおいて起こる様々な問題(背圧,後片付け,等...)の根源である事も突き止めた。

go文は後片付けを妨げる

先程出てきたwithの例を再び見て欲しい。

# Python
with open("my-file") as file_handle:
    ...

さっきは私は「...の部分を実行中はfileが開いていることが保証され、...を過ぎると直ちにfileは閉じられる」と言った。でももし...が裏方taskを作っていたらどうなるだろうか?withブロック内で完結していると思われた処理は実はwithブロックを抜けた後も続いていて、その後すでに閉じてしまったfileを使おうとして落ちてしまうかもしれない。そしてこれもなのだが只このコードを眺めているだけでは分からない。...内で呼ばれている関数のソースコードを読み辿っていかないと裏方taskが作られるかどうかなんてわからないからね。

もしこのコードを正しく動かしたいなら何らかの方法で裏方taskを把握し、それらが完了した時にfileが閉じられる仕組みを自分で作らないといけない。一応それはlibrary側がtaskの完了を知らせてくれる仕組みを持っていれば可能であるが、残念なことに持ってないことが多い。ただ持っていたとしても言語の力(withの後片付け)には頼らない事になるので大昔のように手動で後片付けをしなければならないのだ。

go文はerror処理を妨げる

さっき述べた通り現代の言語は例外のような仕組み(errorを検出しそれを然るべき所まで運ぶ仕組み)を持っている。でもそれは"呼び出し元"が分かる場合に限られる。そして新しいtaskを作ったりcallback関数を結びつけた時点でそれはもう分からない。(訳注:例えばcallback関数でerrorが起きた時にそれの呼び出し元である例えばevent loopまでを辿ることはできても、callback関数を結びつけた関数を知ることはできないという意味だと思う。"呼び出し元"というよりは"予約元/起動元"と呼ぶべきか)。その結果私の知る主要な並行処理framekworkはどうするかというと皆単に諦めるのだ。もし裏方taskでerrorが起きてそれが手動で捌かれなかった場合、ランタイムはそれを捨て大事に至らないことを祈るのだ。運が良ければconsoleに何か書き込んでくれるかも知れない。(私が使ったことのあるsoftwareで"何か出力して処理は続行"をerrorに対するいい戦略だと考えているのは古くて汚いFORTRANのlibraryだけである、but here we are)。驚くことにthread処理の正確さで名高いRustですらここでは罪を犯している。もし裏方threadが暴走してもRustはただerrorを捨て幸運を祈るのだ

もちろん注意を払ってthreadの完了を待てばあなたはそれらのsystemで正しくerrorを捌くことはできる。あるいはTwistedのerrbacksJavascriptのPromise.catchのように独自のerror伝搬の仕組みを作る事でもできる。でもそれはつまるところ既に言語自体が持っている機能の (1)用途が限定されていて (2)壊れやすい 版を再実装している事に他ならない。また"traceback"や"debugger"のような便利な物も失うことになる。そしてたった一度Promise.catchを呼び忘れただけで原因の特定が困難なバグの海に放り込まれる事になるのだ。仮にそれらの問題をどうにかして乗り越えたとしてもたどり着いた先は同じような事をする仕組みが二つある冗長な状態である。

go文はもう二度と

gotoがプログラミング言語の原始的な構成要素であったようにgoは並行処理の原始的な構成要素なのである。下位層のschedulerの動作をよく表していてまた高水準な制御フローを実装するのに十分なほどの力を持ってはいるが、gotoと同じで抽象化を妨げるのだ。だから選択肢として残しておくだけでもその言語は使いにくい物となる。

嬉しいことにDijkstraは問題をどう解決すればいいのか教えてくれた。それは

  • go文と同じ力を持っていてなおかつ黒箱の規則に従う物を見つけて
  • それを並行処理frameworkの基本構成要素として取り込み、他のいかなる形のgo文もframeworkに含めなければ良いのだ。

それがTrioだ。

nursery: 構造化されたgo文

核となる考え方はこうだ。処理を分裂させた場合は後で合流するのが普通である。例えば3つの処理を同時に行いたいなら処理の流れは以下のようになるだろう。

処理の流れが一つの矢印が入るところから始まり一つの矢印が出ていくことに気付いただろうか?だからこれはDijkstraの黒箱の規則に従っている。問題はこれをどうやってcode上に具現化するかだ。実はそういった事をする物は既に幾つかあるのだが私の案はそれらとは少し異なるし、私の物の方が優れている(特にこれを一つの要素で完結させたい場合は)。また並行処理の世界は広大複雑で全てのやり方の短所長所や歴史を辿っていくと切りがなくて話が逸れかねないので、それに関しては別の投稿で話すことにし、ここではあくまで私の案を説明することに徹する。でも決してこれを私が何か画期的な物を発明した等とは思わないで欲しい。私は色んな物から着想を得たうえでただ巨人の肩の上に立ってるだけなのだから。3

ともかく具体的なやり方はこうだ。まず最初に並行させたいtaskの住処であるnurseryを作らない限りは並行処理を行えないようにする。(訳注:nurseryの直訳は保育所・託児所)。nurseryは nursery block を開く事で作れ、Trioの場合これはPythonのasync with構文を用いることになる。

このようにすることでnurseryオブジェクトが作られ、as nurseryによってそれが変数nurseryに入れられる。そしてそのstart_soonメソッドを使うことで処理を並行させられる。(上のcode場合ではmyfuncanotherfuncを並行させている)。この時にはnursery block内に書かれたcodeも並行処理の一部になっている。

重要なのはnursery blockが全ての子taskの終了を必ず待つ事だ。だから処理の流れは以下のようになる。章の冒頭に載せた図に一致しているのが分かるだろう。

そしてこの設計は幾つかの物をもたらす。順にそれを見ていこう。

nurseryは関数の抽象度を保つ

go文の根本的な問題は、関数を呼んだ時にその関数が自身の寿命を超えて生き続ける裏方taskを立ち上げる可能性がある事だった。nurseryではこれは問題にならない。どんなasync関数でもnurseryを開いて複数のtaskを並行させられるけど関数はその全てが終わらないと戻ることができないからね。

nurseryへはいつでも子taskを加えられる

上記の制御フローを満たす物としては例えば次のような物も考えられる。

run_concurrently([myfunc, anotherfunc])

これは渡された関数を並行させその終了を待つのだがこのやり方には問題がある。それはあらかじめ並行させたいtask全てを用意しておかなければいけない事で、これは時に不都合なのである。例えばserver側のcodeには一般的にclientからの接続を待ち受けるloopがあり、一つの接続要求に対して一つのtaskを立ち上げるのが普通だ。これをtrioではどう書くのかというと

async with trio.open_nursery() as nursery:
    while True:
        incoming_connection = await server_socket.accept()
        nursery.start_soon(connection_handler, incoming_connection)

という風に全く取るに足らない。もしこれをrun_concurrently()で実現しようとすると面倒くさい事になるのは想像に難くない。やるとするなら内部でnurseryを使うのが簡単だろうが正直その必要すらない。nurseryを直接使ったcodeで十分読みやすいからね。

非常手段もある

でももし本当に起動元の関数よりも長生きする裏方taskを作りたかったら?その時はnurseryを子taskへ渡せば良い。nursery block内のcodeだけがnursery.start_soon()を呼べるなんて決まりは何処にも無いからね4。nurseryへの参照を持つ者は誰でもそのnurseryへtaskを加えて良い。

import trio

async def parent_task():
    async with trio.open_nursery() as nursery:
        nursery.start_soon(child_task, nursery)  # nurseryを子に渡している (A)
    print('end of parent_task')

async def child_task(nursery):
    nursery.start_soon(delay_print, 1, 'child_taskが立ち上げたtask')
    await delay_print(0.1, 'child_task')
    print('end of child_task')

async def delay_print(delay, value):
    await trio.sleep(delay)
    print('delay_print():', value)

trio.run(parent_task)
# child_taskが立ち上げたtaskがchild_taskの寿命を超えている!

delay_print(): child_task
end of child_task                        # child_taskの完了
delay_print(): child_taskが立ち上げたtask  # child_taskが立ち上げたtaskの完了
end of parent_task

つまりこれは事実上黒箱の規則を破る関数が書けるという事だ。ただし以下の制限を伴う。

  • 明示的にnurseryを受け渡さないといけないため規則を破る者と破らない者がひと目で見分けられる。(A行)
  • その裏方taskは依然として属するnurseryの範囲内でしか生きられない。
  • 自身が参照可能なnurseryしか当然他人には渡せない。

だからこれは誰でも何時いつでも制限なく生き続ける裏方taskが作れてしまうgo文とは大きく異なる。...(省略)

nurseryのような物を自分で作ることもできる

(省略)

いや本当なんだ、nurseryは必ず全ての子taskが終わるまで待つんだ

(省略)

後片付けが機能する

nurseryは黒箱の規則に従っているのでwithblockが機能する。裏方taskが既に閉じてしまったfileにアクセスしてしまう事は無い。

# 訳注: このようにnurseryをfile_handleのwith blockの内側で開けば、このnurseryに属する子taskが生きている間は
# file_handleも生きていることが保証される。(もちろん明示的に閉じなければの話)
async def some_func():
    with open("my-file") as file_handle:
        async with trio.open_nursery() as nursery:
           ...

error伝搬が機能する

先程言ったようにほとんどの並行処理のsystemでは裏方で起きたerrorは捌かれなかったとき単に捨てられ、それに対してこちらができる事は何も無い。でもTrioにおいてはnurseryは必ず子taskの終了を待つ、つまりは親(nurseryを開いたtask)が常に存在している。それを考えると捌かれなかったerrorに対してできる事がある。もしある子task内で起きた例外が捌かれなかった場合は親task内でそれを再発生させるのだ。そして通常のcall stackで例外が関数の呼び出し元へ運ばれるようにtask階層の根に向かって例外を運ぶのだ。

根に向かって例外を運ぶという事は親taskは例外の発生源のnursery blockを終わらせなければならないが、nurseryにはまだ別の実行中の子taskが残っているかもしれない。どうしたものか?答えは、nurseryはその時直ちに他の子task全てに中断をかけるのだ。そして例外を再発生させる前にそれらの終了を待つのだ。...(省略)

これが意味するのは、もしあなたが自分の使っている言語でnurseryを実装したいなら中断の仕組みとnurseryをうまく繋いであげる必要があるという事だ。もしC#やGo言語で見られるように手動で中断を管理しているのならこれは一筋縄ではいかないかもしれない。

go文を無くすことで得られる思わぬ利

goto文を無くすことで言語がwithblockや例外のような新しい機能を得られたようにgo文でも似たような事が言える。例えば

  • Trioにおいてtaskは木構造で管理されるため他のlibraryと比べて処理の流れの把握が楽で信頼性も高い。(詳しくは人間のための中断と時間制限を)。
  • TrioはPythonの並行処理libraryの中で唯一開発者の期待通りにCtrl+Cが機能する(詳細)。これはnurseryの持つ例外伝搬の仕組み無くしては成り立たない。

nurseryって実際どうなの?

さて、ここまではあくまで理論だ。実際の使い勝手はどんなもの何だろう?

実のところそれは実際に多くの人が試してくれないと分からない。私自身は今の所Trioは良くできていると思うが、もしかするとなんらかの調整が必要になるかもしれない。かつて構造化programmingの初期の構想の厳密さを諦めてbreakcontinueを許したようにね。

そしてもしあなたが並行処理の経験が豊富で今Trioを学んでいる所だとしたら、あなたは時々Trioを扱いにくく感じるかもしれない。あなたは新しいやり方を覚えないといけないのだ。かつて1970年代のprogrammerにとってgotoを用いずにcodeを書くことが大変だったように。

Knuthののちの発言:
おそらくgotoを尊重するあまり誰もが犯してしまう最悪の間違いは、"構造化programming"とはprogramを普通に書き(訳注:ここでの普通とはかつての普通、つまりgotoを使った書き方を指す)そこからgotoを除く事だと考えてしまうことである。でも本当はgotoは最初から存在すべきではないのだ!私達が本当にやりたいのはgotoの事をそもそも思い浮かべずにprogramを考えることである。何故なら本当にgotoが必要になることは滅多に無いのだから。私達が用いる言語は私達の思考過程に大きく影響を与える。だからDijkstraは言語に新しい機能を求めているんだ、programの複雑化へいざなgotoを避けてより明瞭な考え方ができるようにね。

これは私がこれまでnurseryを使ってきて感じた事と同じだ。nurseryは分かりやすい考え方をうながし、堅牢な設計に導き、扱いも簡単で、ともかく全てにおいて優れているのだ。nurseryが持つ一見不便に思える制約すらも実際にはprogramの複雑化を防ぐ盾となっている。Trioは私を良いprogrammerへ導いてくれたのだ。

その一例がHappy Eyeballs algorithm(RFC 8305)である。(訳注:このalgorithmはどうやら複数の接続先の内どれか一つにさえ繋げられれば良い時の効率的な実装を考える物の様で、各接続先に対して時間差をつけて接続を試みるのが最良とされているようです)。Twistedにおける最善の実装を見るとcode量はpythonで600行近くにのぼり、まだ少なくとも一つ以上の論理bugが残っている。これが同じ物をTrioで実装したところcode量はなんと15分の1にまで減った。何よりすごいのは数カ月ではなくたった数分でcodeが書けた事と、最初の試みで正しいlogicを組めた事だ。私は他のframeworkでこんな事ができたことは一度もない、私がTrio以上に慣れ親しんだ物も含めてね。詳しくは先月のPyninsulaでの私の講演を観て欲しい。...(省略)

まとめ

よくある並行処理の手法(go文、thread生成、callback関数、Future、Promise)は全てgoto文の一種である。それも現代的な弱められたgotoではなく古き悪しき関数境界を破るgotoである。例え私達自身が直接使わなくてもそれは危険なのである。何故なら処理の流れを分かりづらくして抽象化を妨げるし、後片付けや例外伝搬のような便利な言語機能の邪魔もするからだ。よってgotoと同じで現代の言語からは取り除くべきなのである。

nurseryは安全で便利で言語機能を100%活かす代替手段をもたらし、またTrioの中断scopeやCtrl-C処理に見られるような便利で強力な機能ももたらし、可読性・生産性・正確性を格段にあげる。

残念ながら全ての恩恵を得るには古いものを完全に取り除かなければならない。そのためには並行処理のframeworkを一から作り直さないといけないだろう、かつてgotoを無くすために新しい言語が要ったように。でも私達のほとんどは良いものを得られた事を喜んでいるし、私自身もnurseryに切り替える事で後悔はしないと思っている。なによりTrioがnurseryを使った設計が現実的である事を示してくれている。

Comments

この投稿に関する議論はここで。

謝辞

Many thanks to Graydon Hoare, Quentin Pradet, and Hynek Schlawack for comments on drafts of this post. Any remaining errors, of course, are all my fault.

Credits: Sample FLOW-MATIC code from this brochure (PDF), as preserved by the Computer History Museum. Wolves in Action, by i:am. photography / Martin Pannier, licensed under CC-BY-SA 2.0, cropped. French Bulldog Pet Dog by Daniel Borker, released under the CC0 public domain dedication.


  1. 入れ子になった複数の制御構造をbreakcontinuereturnを用いて一気に抜けられる点がDijkstraの構想に反している 

  2. Webアセンブリなんかは低級言語ですらgotoを持たない事が可能でまた望ましい事を示している reference, rationale 

  3. For those who can't possibly pay attention to the text without first knowing whether I'm aware of their favorite paper, my current list of topics to include in my review are: the "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's article on Structured concurrency and work on libdill, and crossbeam::scope / rayon::scope in Rust. [Edit: I've also been pointed to the highly relevant golang.org/x/sync/errgroup and github.com/oklog/run in Golang.] If I'm missing anything important, let me know

  4. もし既に閉じてしまったnurseryのstart_soonを呼ぶと例外が起こる。逆にいうと例外が起こらなければtaskが終了するまでnursery blockが開いたままであることは保証されている。自分でnurseryを実装する場合はこの部分の同期に気をつけたほうが良い。 

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2