第11章非同期プログラミング

コンピュータの中心部、つまりプログラムを構成する個々の手順を実行する部分は、プロセッサと呼ばれます。 これまで見てきたプログラムは、作業が完了するまでプロセッサをビジー状態に保つものです。数値を操作するループのようなものが実行される速度は、ほぼプロセッサの速度に依存します。
しかし、多くのプログラムはプロセッサの外部にあるものと対話します。たとえば、コンピュータネットワークを介して通信したり、ハードディスクからデータを取得したりします。これはメモリからデータを取得するよりもはるかに低速です。
このようなことが起こっているときに、プロセッサをアイドル状態にしておくのはもったいないことです。その間に、プロセッサが行える他の作業があるかもしれません。これは一部、オペレーティングシステムによって処理され、複数の実行中のプログラム間でプロセッサを切り替えます。しかし、単一のプログラムがネットワークリクエストを待っている間に処理を進められるようにしたい場合、これは役に立ちません。
非同期性
同期プログラミングモデルでは、物事は一度に1つずつ起こります。時間のかかるアクションを実行する関数を呼び出すと、アクションが完了して結果を返すことができる場合にのみ戻ります。これは、アクションにかかる時間だけプログラムを停止させます。
非同期モデルでは、複数のことが同時に起こることが許されます。アクションを開始すると、プログラムは実行を続けます。アクションが完了すると、プログラムは通知を受け、結果(たとえば、ディスクから読み取られたデータ)にアクセスできます。
ネットワークから2つのリソースを取得し、結果を組み合わせるプログラムという小さな例を使用して、同期プログラミングと非同期プログラミングを比較できます。
リクエスト関数が作業完了後にのみ返る同期環境では、このタスクを実行する最も簡単な方法は、リクエストを1つずつ行うことです。 これには、2番目のリクエストが最初のリクエストが完了した後にのみ開始されるという欠点があります。合計所要時間は、少なくとも2つの応答時間の合計になります。
同期システムにおけるこの問題の解決策は、追加の制御スレッドを開始することです。 スレッドは、オペレーティングシステムによって他のプログラムと実行がインターリーブされる可能性のある別の 実行中のプログラムです。最新のコンピュータのほとんどは複数のプロセッサを搭載しているため、複数のスレッドが異なるプロセッサ上で同時に実行されることさえあります。2番目のスレッドは2番目のリクエストを開始し、両方のスレッドが結果が返ってくるのを待ってから、結果を組み合わせるために再同期します。
次の図では、太線はプログラムが通常実行に費やす時間を表し、細線はネットワークの待機時間を表しています。同期モデルでは、ネットワークにかかる時間は、特定の制御スレッドのタイムラインの一部です。非同期モデルでは、ネットワークアクションを開始すると、概念的にはタイムラインが分割されます。アクションを開始したプログラムは実行を続け、アクションはそれと並行して行われ、完了時にプログラムに通知します。
違いを説明する別の方法は、アクションが完了するのを待つことは同期モデルでは暗黙的ですが、非同期モデルでは明示的であり、私たちの制御下にあります。
非同期性は諸刃の剣です。直線的な制御モデルに適合しないプログラムを表現しやすくしますが、直線的なプログラムを表現するのをより厄介にすることもあります。この章の後半では、この厄介さに対処する方法をいくつか紹介します。
2つの重要なJavaScriptプログラミングプラットフォーム(ブラウザとNode.js)はどちらも、スレッドに依存するのではなく、時間がかかる可能性のある操作を非同期にします。スレッドを使用したプログラミングは非常に難しいことで知られているため(プログラムが一度に複数のことを行っている場合、プログラムが何をしているかを理解することははるかに困難です)、これは一般的に良いことと考えられています。
カラスの技術
ほとんどの人は、カラスが非常に賢い鳥であることを知っています。道具を使ったり、 aheadを計画したり、物事を覚えたり、さらにはこれらのことを互いに伝え合ったりすることができます。
ほとんどの人が知らないのは、彼らが私たちからよく隠されている多くのことができるということです。 カラス科の専門家(やや風変わりではありますが)から、カラスの技術は人間の技術にそれほど遅れをとっておらず、追いついてきていると聞いています。
たとえば、多くのカラス文化は、コンピューティングデバイスを構築する能力を持っています。これらは、人間のコンピューティングデバイスのように電子機器ではなく、小さな昆虫の行動を通じて動作します。シロアリと近縁の種であり、カラスと共生関係を築いています。鳥は彼らに食物を提供し、その見返りに昆虫は複雑なコロニーを建設し、運営します。コロニー内の生物の助けを借りて、計算を実行します。
このようなコロニーは通常、大きく、寿命の長い巣の中にあります。鳥と昆虫は協力して、昆虫が住み、働く巣の小枝の間に隠された球根状の粘土構造のネットワークを構築します。
他のデバイスと通信するために、これらのマシンは光信号を使用します。カラスは特別な通信茎に反射材を埋め込み、昆虫はこれらの光を別の巣に反射させて、データを一連の高速フラッシュとしてエンコードします。これは、途切れのない視覚的な接続を持つ巣だけが通信できることを意味します。
私たちの友人であるカラス科の専門家は、ローヌ川のほとりにあるHières-sur-Amby村のカラスの巣のネットワークをマッピングしました。この地図は、巣とその接続を示しています

驚くべき収斂進化の例として、カラスのコンピュータはJavaScriptを実行します。この章では、それらのための基本的なネットワーキング関数をいくつか作成します。
コールバック
非同期プログラミングへの1つのアプローチは、低速なアクションを実行する関数に追加の引数、_コールバック関数_をとらせることです。アクションが開始され、完了すると、コールバック関数が結果とともに呼び出されます。
例として、Node.jsとブラウザの両方で使用可能な`setTimeout`関数は、指定されたミリ秒数(1秒は1000ミリ秒)待機してから関数を呼び出します。
setTimeout(() => console.log("Tick"), 500);
待機は一般的にそれほど重要なタイプの作業ではありませんが、アニメーションの更新や、何かが指定された時間よりも長くかかっているかどうかを確認する場合などに役立ちます。
コールバックを使用して複数の非同期アクションを連続して実行すると、アクション後に計算を続行するために新しい関数を渡す必要があることを意味します。
ほとんどのカラスの巣のコンピュータには、長期データストレージバルブがあります。そこでは、後で取得できるように、小枝に情報が刻まれています。エッチング、またはデータの検索には少し時間がかかるため、長期ストレージへのインターフェースは非同期であり、コールバック関数を使用します。
ストレージバルブは、JSONエンコード可能なデータの名前で保存します。カラスは、` "food caches"`という名前で食べ物を隠した場所に関する情報を保存するかもしれません。これは、実際のキャッシュを記述する他のデータの断片を指す名前の配列を保持できます。 _Big Oak_の巣のストレージバルブで食料貯蔵庫を検索するために、カラスはこのようなコードを実行できます
import {bigOak} from "./crow-tech"; bigOak.readStorage("food caches", caches => { let firstCache = caches[0]; bigOak.readStorage(firstCache, info => { console.log(info); }); });
(すべてのバインディング名と文字列は、カラス語から英語に翻訳されています。)
このプログラミングスタイルは機能しますが、別の関数になってしまうため、非同期アクションごとにインデントレベルが増加します。複数のアクションを同時に実行するなど、より複雑なことを行うと、少し厄介になる可能性があります。
カラスの巣のコンピュータは、リクエストとレスポンスのペアを使用して通信するように構築されています。つまり、1つの巣が別の巣にメッセージを送信し、その後すぐにメッセージを送り返して、受信を確認し、場合によってはメッセージで尋ねられた質問への返信を含めます。
各メッセージには、処理方法を決定する種類のタグが付けられます。私たちのコードは、特定のリクエスト種類に対してハンドラを定義でき、そのようなリクエストが来ると、ハンドラが呼び出されてレスポンスが生成されます。
"./
モジュールによってエクスポートされるインターフェースは、通信のためのコールバックベースの関数を提供します。ネストには、リクエストを送信する send
メソッドがあります。最初の3つの引数として、ターゲットネストの名前、リクエストの種類、リクエストの内容を期待し、4番目と最後の引数として、レスポンスが来たときに呼び出す関数を期待します。
bigOak.send("Cow Pasture", "note", "Let's caw loudly at 7PM", () => console.log("Note delivered."));
しかし、ネストがそのリクエストを受信できるようにするには、まず "note"
という名前のリクエスト種類を定義する必要があります。リクエストを処理するコードは、このネストコンピュータだけでなく、この種類のメッセージを受信できるすべてのネストで実行する必要があります。カラスが飛んで行き、すべてのネストにハンドラコードをインストールすると仮定します。
import {defineRequestType} from "./crow-tech"; defineRequestType("note", (nest, content, source, done) => { console.log(`${nest.name} received note: ${content}`); done(); });
defineRequestType
関数は、新しい種類のリクエストを定義します。この例では、指定されたネストにメモを送信するだけの "note"
リクエストのサポートを追加しています。私たちの実装は、リクエストが到着したことを確認できるように console.log
を呼び出します。ネストには、その名前を保持する name
プロパティがあります。
ハンドラに与えられる4番目の引数 done
は、リクエストが完了したときに呼び出す必要があるコールバック関数です。ハンドラの戻り値をレスポンス値として使用した場合、リクエストハンドラ自体が非同期アクションを実行できないことを意味します。非同期作業を行う関数は、通常、作業が完了する前に戻り、完了時にコールバックが呼び出されるようにします。そのため、レスポンスが利用可能になったことを知らせるために、何らかの非同期メカニズム(この場合は別のコールバック関数)が必要です。
ある意味で、非同期性は伝染性があります。非同期的に動作する関数を呼び出す関数は、コールバックまたは同様のメカニズムを使用して結果を配信する必要があります。コールバックを呼び出すことは、単に値を返すよりもいくらか複雑でエラーが発生しやすいため、プログラムの大部分をそのように構造化する必要があるのはあまり良くありません。
Promise
抽象的な概念を扱う場合、それらの概念を値で表すことができれば、作業が容易になることがよくあります。非同期アクションの場合、将来のある時点で関数が呼び出されるようにする代わりに、この将来のイベントを表すオブジェクトを返すことができます。
これが、標準クラス Promise
の目的です。promise は、ある時点で完了し、値を生成する可能性のある非同期アクションです。値が利用可能になったときに、関心のある人に通知できます。
promise を作成する最も簡単な方法は、 Promise.resolve
を呼び出すことです。この関数は、指定した値が promise でラップされていることを保証します。既に promise である場合は、単に返されます。そうでない場合は、値を結果としてすぐに完了する新しい promise が取得されます。
let fifteen = Promise.resolve(15); fifteen.then(value => console.log(`Got ${value}`)); // → Got 15
promise の結果を取得するには、 then
メソッドを使用できます。これは、promise が解決され、値が生成されたときに呼び出されるコールバック関数を登録します。単一の promise に複数のコールバックを追加でき、promise が既に解決(完了)した後でも、それらは呼び出されます。
しかし、それは then
メソッドが行うことのすべてではありません。別の promise を返します。これは、ハンドラ関数が返す値に解決されるか、それが promise を返す場合は、その promise を待ってからその結果に解決されます。
promise を、値を非同期リアリティに移動するためのデバイスと考えることは有用です。通常の値は単にあります。約束された値は、既に存在しているか、将来のある時点で表示される可能性のある値です。promise で定義された計算は、そのようなラップされた値に対して作用し、値が利用可能になると非同期に実行されます。
promise を作成するには、 Promise
をコンストラクターとして使用できます。やや奇妙なインターフェースがあります。コンストラクターは引数として関数を期待し、それをすぐに呼び出して、promise を解決するために使用できる関数を渡します。たとえば、 resolve
メソッドを使用する代わりに、このように動作するため、promise を作成したコードのみがそれを解決できます。
readStorage
関数にpromise ベースのインターフェースを作成する方法は次のとおりです。
function storage(nest, name) { return new Promise(resolve => { nest.readStorage(name, result => resolve(result)); }); } storage(bigOak, "enemies") .then(value => console.log("Got", value));
この非同期関数は、意味のある値を返します。これが promise の主な利点です。非同期関数の使用を簡素化します。コールバックを渡す代わりに、promise ベースの関数は通常の関数に似ています。引数として入力を受け取り、出力を返します。唯一の違いは、出力がまだ利用できない可能性があることです。
失敗
通常の JavaScript 計算は、例外をスローすることで失敗する可能性があります。非同期計算には、多くの場合、そのようなものが必要です。ネットワークリクエストが失敗したり、非同期計算の一部であるコードが例外をスローしたりする可能性があります。
コールバックスタイルの非同期プログラミングで最も差し迫った問題の1つは、エラーがコールバックに適切に報告されるようにすることが非常に難しいことです。
広く使用されている規則は、コールバックへの最初の引数を使用してアクションが失敗したことを示し、2番目の引数にはアクションが成功したときに生成された値が含まれることです。このようなコールバック関数は、常に例外を受け取ったかどうかを確認し、発生した問題(呼び出す関数によってスローされた例外を含む)がキャッチされて正しい関数に渡されるようにする必要があります。
Promise はこれを容易にします。解決(アクションが正常に完了)または拒否(失敗)のいずれかになります。解決ハンドラ( then
で登録されている)は、アクションが成功した場合にのみ呼び出され、拒否は then
によって返される新しい promise に自動的に伝播されます。また、ハンドラが例外をスローすると、 then
呼び出しによって生成された promise が自動的に拒否されます。そのため、非同期アクションのチェーンの要素が失敗した場合、チェーン全体の結果は拒否としてマークされ、失敗したポイントを超えて成功ハンドラは呼び出されません。
promise の解決が値を提供するのと同じように、promise を拒否すると値も提供されます。これは通常、拒否の理由と呼ばれます。ハンドラ関数の例外が拒否の原因となった場合、例外値が理由として使用されます。同様に、ハンドラが拒否された promise を返すと、その拒否は次の promise に流れます。すぐに拒否された新しい promise を作成する Promise.reject
関数があります。
このような拒否を明示的に処理するために、promise には、 then
ハンドラが通常の解決を処理する方法と同様に、promise が拒否されたときに呼び出されるハンドラを登録する catch
メソッドがあります。また、 then
と非常によく似ており、新しい promise を返します。これは、通常どおり解決された場合は元の promise の値に解決され、そうでない場合は catch
ハンドラの結果に解決されます。 catch
ハンドラがエラーをスローした場合、新しい promise も拒否されます。
略記として、 then
は拒否ハンドラも2番目の引数として受け入れるため、両方の種類のハンドラを1回のメソッド呼び出しでインストールできます。
Promise
コンストラクターに渡された関数は、解決関数と並んで2番目の引数を受け取ります。これは、新しい promise を拒否するために使用できます。
then
および catch
の呼び出しによって作成された promise 値のチェーンは、非同期値またはエラーが移動するパイプラインと見なすことができます。このようなチェーンはハンドラを登録することによって作成されるため、各リンクには成功ハンドラまたは拒否ハンドラ(または両方)が関連付けられています。結果のタイプ(成功または失敗)と一致しないハンドラは無視されます。ただし、一致するものは呼び出され、その結果によって、次にどのような種類の値が来るかが決まります。promise 以外の値を返した場合は成功、例外をスローした場合は拒否、promise のいずれかを返した場合は promise の結果です。
new Promise((_, reject) => reject(new Error("Fail"))) .then(value => console.log("Handler 1")) .catch(reason => { console.log("Caught failure " + reason); return "nothing"; }) .then(value => console.log("Handler 2", value)); // → Caught failure Error: Fail // → Handler 2 nothing
キャッチされていない例外が環境によって処理されるのと同様に、JavaScript 環境は promise の拒否が処理されないことを検出し、これをエラーとして報告できます。
ネットワークは難しい
カラスのミラーシステムが信号を送信するのに十分な光がない場合や、信号の経路を何かが遮っている場合があります。信号が送信されても受信されない可能性があります。
現状では、 send
に指定されたコールバックが呼び出されないだけになり、問題があることに気付かずにプログラムが停止する可能性があります。レスポンスが得られない一定期間の後、リクエストがタイムアウトしてエラーを報告できれば幸いです。
多くの場合、送信エラーは、車のヘッドライトが光信号を妨害するなどのランダムな事故であり、リクエストを再試行するだけで成功する可能性があります。そのため、リクエスト関数が諦める前に、リクエストの送信を数回自動的に再試行するようにします。
また、promise が良いものであると判断したので、リクエスト関数が promise を返すようにします。表現できるものに関して、コールバックと promise は同等です。コールバックベースの関数をラップして、promise ベースのインターフェースを公開できます。また、その逆も可能です。
リクエストとそのレスポンスが正常に配信された場合でも、レスポンスはエラーを示している可能性があります。たとえば、リクエストが定義されていないリクエストタイプを使用しようとした場合、またはハンドラがエラーをスローした場合などです。これをサポートするために、 send
と defineRequestType
は、前に述べた規則に従います。コールバックに渡される最初の引数は、失敗の理由(存在する場合)であり、2番目の引数は実際の結果です。
これらは、ラッパーによって promise の解決と拒否に変換できます。
class Timeout extends Error {} function request(nest, target, type, content) { return new Promise((resolve, reject) => { let done = false; function attempt(n) { nest.send(target, type, content, (failed, value) => { done = true; if (failed) reject(failed); else resolve(value); }); setTimeout(() => { if (done) return; else if (n < 3) attempt(n + 1); else reject(new Timeout("Timed out")); }, 250); } attempt(1); }); }
Promiseは一度だけ解決(または拒否)できるため、これは機能します。最初に`resolve`または`reject`が呼び出された時点でPromiseの結果が決定され、別のリクエストが完了した後に返ってきたリクエストによって引き起こされるさらなる呼び出しは無視されます。
再試行のための非同期ループを構築するには、再帰関数を使用する必要があります。通常のループでは、停止して非同期アクションを待つことができません。 `attempt`関数は、リクエストを送信する単一の試行を行います。また、250ミリ秒後に応答がない場合に、次の試行を開始するか、これが3回目の試行の場合は、`Timeout`のインスタンスを理由としてPromiseを拒否するタイムアウトも設定します。
4分の1秒ごとに再試行し、4分の3秒後に応答がない場合は諦めるというのは、確かにやや恣意的です。リクエストが実際に届いたが、ハンドラーが少し時間がかかっているだけの場合は、リクエストが複数回配信される可能性さえあります。その問題を念頭に置いてハンドラーを作成します。重複したメッセージは無害であるはずです。
一般的に、今日は世界クラスの堅牢なネットワークを構築するわけではありません。しかし、それは問題ありません。カラスはまだコンピューティングに関してそれほど高い期待を持っていません。
コールバックから完全に分離するために、`defineRequestType`のラッパーも定義します。これにより、ハンドラー関数はPromiseまたはプレーンな値を返し、それをコールバックに接続できます。
function requestType(name, handler) { defineRequestType(name, (nest, content, source, callback) => { try { Promise.resolve(handler(nest, content, source)) .then(response => callback(null, response), failure => callback(failure)); } catch (exception) { callback(exception); } }); }
`Promise.resolve`は、`handler`によって返された値がまだPromiseでない場合に、それをPromiseに変換するために使用されます。
`handler`の呼び出しは、`try`ブロックでラップする必要があることに注意してください。これは、発生した例外がコールバックに正しく渡されるようにするためです。これは、生のコールバックでエラーを適切に処理することの難しさをよく示しています。このような例外を適切にルーティングすることを忘れやすく、そうしないと、障害が正しいコールバックに報告されません。Promiseはこれをほぼ自動的に行うため、エラーが発生しにくくなります。
Promiseの集合
各ネストコンピューターは、送信距離内にある他のネストの配列を`neighbors`プロパティに保持します。現在到達可能なネストを確認するには、それぞれに`"ping"`リクエスト(単に応答を要求するリクエスト)を送信して、どれが返ってくるかを確認する関数を作成できます。
同時に実行されているPromiseの集合を扱う場合、`Promise.all`関数が役立ちます。これは、配列内のすべてのPromiseが解決されるのを待って、これらのPromiseが生成した値の配列(元の配列と同じ順序)に解決されるPromiseを返します。いずれかのPromiseが拒否された場合、`Promise.all`の結果自体が拒否されます。
requestType("ping", () => "pong"); function availableNeighbors(nest) { let requests = nest.neighbors.map(neighbor => { return request(nest, neighbor, "ping") .then(() => true, () => false); }); return Promise.all(requests).then(result => { return nest.neighbors.filter((_, i) => result[i]); }); }
隣接ノードが利用できない場合、結合されたPromise全体が失敗することは望ましくありません。なぜなら、それでも何もわからないからです。そのため、隣接ノードのセットをマップしてリクエストPromiseに変換する関数は、成功したリクエストを`true`に、拒否されたリクエストを`false`にするハンドラーをアタッチします。
結合されたPromiseのハンドラーでは、`filter`を使用して、対応する値がfalseである要素を`neighbors`配列から削除します。これは、`filter`が現在の要素の配列インデックスをフィルタリング関数に2番目の引数として渡すという事実を利用しています(`map`、`some`、および同様の高階配列メソッドも同じように動作します)。
ネットワークフラッディング
ネストが隣接ノードとのみ通信できるという事実は、このネットワークの有用性を大きく阻害します。
ネットワーク全体に情報をブロードキャストするための1つの解決策は、隣接ノードに自動的に転送されるリクエストタイプを設定することです。これらの隣接ノードは、次にそれをそれぞれの隣接ノードに転送し、ネットワーク全体がメッセージを受信するまでこれが続きます。
import {everywhere} from "./crow-tech"; everywhere(nest => { nest.state.gossip = []; }); function sendGossip(nest, message, exceptFor = null) { nest.state.gossip.push(message); for (let neighbor of nest.neighbors) { if (neighbor == exceptFor) continue; request(nest, neighbor, "gossip", message); } } requestType("gossip", (nest, message, source) => { if (nest.state.gossip.includes(message)) return; console.log(`${nest.name} received gossip '${ message}' from ${source}`); sendGossip(nest, message, source); });
ネットワーク全体に同じメッセージを永遠に送信し続けることを避けるために、各ネストはすでに見たゴシップ文字列の配列を保持します。この配列を定義するために、すべてのネストでコードを実行する`everywhere`関数を使用して、ネストの`state`オブジェクトにプロパティを追加します。これは、ネストローカルの状態を保持する場所です。
ネストが重複したゴシップメッセージを受信すると(これは全員が盲目的に再送信するため非常に発生しやすい)、それを無視します。しかし、新しいメッセージを受信すると、メッセージを送信したネストを除くすべての隣接ノードに興奮して伝えます。
これにより、新しいゴシップが、水中のインクの染みのようにネットワーク全体に広がります。一部の接続が現在機能していない場合でも、特定のネストへの代替ルートがある場合、ゴシップはそこを通って到達します。
このスタイルのネットワーク通信は*フラッディング*と呼ばれます。すべてのノードが情報を入手するまで、ネットワークに情報を氾濫させます。
`sendGossip`を呼び出して、村全体にメッセージが流れるのを見ることができます。
sendGossip(bigOak, "Kids with airgun in the park");
メッセージルーティング
特定のノードが単一の他のノードと通信する場合、フラッディングはあまり効率的なアプローチではありません。特にネットワークが大きい場合、多くの無駄なデータ転送が発生します。
代替アプローチは、メッセージが宛先に到達するまでノードからノードへとホップする方法を設定することです。これに伴う難しさは、ネットワークのレイアウトに関する知識が必要になることです。遠く離れたネストの方向にリクエストを送信するには、どの隣接ネストが宛先に近づけるかを知る必要があります。間違った方向に送信しても、あまり役に立ちません。
各ネストは直接の隣接ノードについてのみ知っているため、ルートを計算するために必要な情報がありません。これらの接続に関する情報をすべてのネストに何らかの方法で menyebarkan する必要があります。できれば、ネストが放棄されたり、新しいネストが構築されたりしたときに、時間の経過とともに変化できるようにする方法が望ましいです。
再びフラッディングを使用できますが、特定のメッセージがすでに受信されているかどうかを確認する代わりに、特定のネストの新しい隣接ノードのセットが現在保持しているセットと一致するかどうかを確認します。
requestType("connections", (nest, {name, neighbors}, source) => { let connections = nest.state.connections; if (JSON.stringify(connections.get(name)) == JSON.stringify(neighbors)) return; connections.set(name, neighbors); broadcastConnections(nest, name, source); }); function broadcastConnections(nest, name, exceptFor = null) { for (let neighbor of nest.neighbors) { if (neighbor == exceptFor) continue; request(nest, neighbor, "connections", { name, neighbors: nest.state.connections.get(name) }); } } everywhere(nest => { nest.state.connections = new Map(); nest.state.connections.set(nest.name, nest.neighbors); broadcastConnections(nest, nest.name); });
比較では`JSON.stringify`が使用されます。これは、オブジェクトまたは配列の`==`は、2つがまったく同じ値である場合にのみtrueを返すためです。これは、ここで必要なものではありません。JSON文字列を比較することは、その内容を比較するための粗雑ですが効果的な方法です。
ノードはすぐに接続のブロードキャストを開始します。これは、一部のネストが完全に到達不能でない限り、すべてのネストに現在のネットワークグラフのマップをすばやく提供するはずです。
グラフでできることの1つは、第7章で見たように、グラフ内のルートを見つけることです。メッセージの宛先へのルートがある場合、どの方向に送信するかを知ることができます。
この`findRoute`関数は、第7章の`findRoute`と非常によく似ており、ネットワーク内の特定のノードに到達する方法を検索します。ただし、ルート全体を返す代わりに、次のステップだけを返します。その次のネストは、ネットワークに関する現在の情報を使用して、メッセージを*どこ*に送信するかを決定します。
function findRoute(from, to, connections) { let work = [{at: from, via: null}]; for (let i = 0; i < work.length; i++) { let {at, via} = work[i]; for (let next of connections.get(at) || []) { if (next == to) return via; if (!work.some(w => w.at == next)) { work.push({at: next, via: via || next}); } } } return null; }
これで、長距離メッセージを送信できる関数を構築できます。メッセージが直接の隣接ノード адресован されている場合、通常どおり配信されます。そうでない場合は、オブジェクトにパッケージ化され、`"route"`リクエストタイプを使用してターゲットに近い隣接ノードに送信されます。これにより、その隣接ノードが同じ動作を繰り返します。
function routeRequest(nest, target, type, content) { if (nest.neighbors.includes(target)) { return request(nest, target, type, content); } else { let via = findRoute(nest.name, target, nest.state.connections); if (!via) throw new Error(`No route to ${target}`); return request(nest, via, "route", {target, type, content}); } } requestType("route", (nest, {target, type, content}) => { return routeRequest(nest, target, type, content); });
これで、ネットワークホップが4つ離れている教会の塔にあるネストにメッセージを送信できます。
routeRequest(bigOak, "Church Tower", "note", "Incoming jackdaws!");
プリミティブな通信システムの上に複数の機能レイヤーを構築して、使いやすくしました。これは、実際のコンピューターネットワークがどのように機能するかについての優れた(ただし簡略化された)モデルです。
コンピューターネットワークの際立った特性は、信頼性がないことです。それらの上に構築された抽象化は役立ちますが、ネットワーク障害を抽象化することはできません。そのため、ネットワークプログラミングは通常、障害を予期して対処することに重点が置かれています。
非同期関数
重要な情報を保存するために、カラスはネスト全体に情報を複製することが知られています。そうすれば、タカがネストを破壊しても、情報は失われません。
独自のストレージバルブにない特定の情報を取得するために、ネストコンピューターは、それを持っているネストが見つかるまで、ネットワーク内のランダムな他のネストに問い合わせる場合があります。
requestType("storage", (nest, name) => storage(nest, name)); function findInStorage(nest, name) { return storage(nest, name).then(found => { if (found != null) return found; else return findInRemoteStorage(nest, name); }); } function network(nest) { return Array.from(nest.state.connections.keys()); } function findInRemoteStorage(nest, name) { let sources = network(nest).filter(n => n != nest.name); function next() { if (sources.length == 0) { return Promise.reject(new Error("Not found")); } else { let source = sources[Math.floor(Math.random() * sources.length)]; sources = sources.filter(n => n != source); return routeRequest(nest, source, "storage", name) .then(value => value != null ? value : next(), next); } } return next(); }
connections
は Map
なので、Object.keys
は使用できません。keys
*メソッド* はありますが、これは配列ではなくイテレータを返します。イテレータ(または反復可能な値)は Array.from
関数を使用して配列に変換できます。
Promise を使用しても、これはやや扱いにくいコードです。複数の非同期アクションが分かりにくい方法で連鎖されています。ネストをループするために、再び再帰関数 (next
) が必要です。
そして、このコードが実際に行っていることは完全に線形です。つまり、常に前のアクションが完了するまで待ってから次のアクションを開始します。同期プログラミングモデルでは、より簡単に表現できます。
朗報として、JavaScript では、非同期計算を記述するために疑似同期コードを書くことができます。async
関数は、暗黙的に Promise を返し、その本体で他の Promise を *同期的に見える* ように await
できる関数です。
findInStorage
は次のように書き直すことができます。
async function findInStorage(nest, name) { let local = await storage(nest, name); if (local != null) return local; let sources = network(nest).filter(n => n != nest.name); while (sources.length > 0) { let source = sources[Math.floor(Math.random() * sources.length)]; sources = sources.filter(n => n != source); try { let found = await routeRequest(nest, source, "storage", name); if (found != null) return found; } catch (_) {} } throw new Error("Not found"); }
async
関数は、function
キーワードの前に async
という単語を付けることでマークされます。メソッドも、名前の前に async
を書くことで async
にすることができます。このような関数またはメソッドが呼び出されると、Promise が返されます。本体が何かを返すとすぐに、その Promise は解決されます。例外がスローされた場合、Promise は拒否されます。
findInStorage(bigOak, "events on 2017-12-21") .then(console.log);
async
関数内では、await
という単語を式の前に置くことで、Promise が解決されるまで待機し、その後に関数の.実行を継続することができます。
このような関数は、通常の JavaScript 関数のように、最初から最後まで一気に実行されることはなくなります。代わりに、await
がある任意の時点で *凍結* され、後で再開できます。
非自明な非同期コードの場合、この表記法は通常、Promise を直接使用するよりも便利です。同期モデルに適合しない、複数の.アクションを同時に実行するなどの処理を行う必要がある場合でも、await
と Promise の直接使用を簡単に組み合わせることができます。
ジェネレーター
関数を一時停止してから再開するこの機能は、async
関数だけのものではありません。JavaScript には *ジェネレーター* 関数と呼ばれる機能もあります。これらは似ていますが、Promise はありません。
function*
(function
という単語の後にアスタリスクを付ける)を使用して関数を定義すると、ジェネレーターになります。ジェネレーターを呼び出すと、第6章ですでに見たイテレータが返されます。
function* powers(n) { for (let current = n;; current *= n) { yield current; } } for (let power of powers(3)) { if (power > 50) break; console.log(power); } // → 3 // → 9 // → 27
最初に powers
を呼び出すと、関数は開始時に凍結されます。イテレータで next
を呼び出すたびに、関数は yield
式に到達するまで実行されます。yield
式は関数を一時停止し、yield された値をイテレータによって生成される次の値にします。関数が返されると(この例では決して返されません)、イテレータは完了です。
ジェネレーター関数を使用すると、イテレータの作成がはるかに簡単になることが多いです。Group
クラスのイテレータ(第6章の演習より)は、このジェネレーターを使用して記述できます。
Group.prototype[Symbol.iterator] = function*() { for (let i = 0; i < this.members.length; i++) { yield this.members[i]; } };
反復状態を保持するためのオブジェクトを作成する必要はもうありません。ジェネレーターは yield するたびにローカル状態を自動的に保存します。
このような yield
式は、ジェネレーター関数自体にのみ直接記述でき、その内部で定義した内部関数には記述できません。ジェネレーターが yield 時に保存する状態は、その *ローカル* 環境と yield した位置だけです。
async
関数は特殊なタイプのジェネレーターです。呼び出されると Promise を生成し、返されると(完了すると)解決され、例外がスローされると拒否されます。Promise を yield(await)するたびに、その Promise の結果(値またはスローされた例外)が await
式の結果になります。
イベントループ
非同期プログラムは、断片的に実行されます。各断片は、いくつかのアクションを開始し、アクションが完了または失敗したときに実行されるコードをスケジュールすることがあります。これらの断片の間、プログラムはアイドル状態になり、次のアクションを待ちます。
そのため、コールバックは、それらをスケジュールしたコードによって直接呼び出されるわけではありません。関数内から setTimeout
を呼び出す場合、コールバック関数が呼び出されるまでに、その関数は.既に返されています。また、コールバックが返されたときに、制御はそれをスケジュールした関数に戻りません。
非同期動作は、独自の空の関数呼び出しスタックで発生します。これは、Promise なしの非同期コード全体で例外を管理するのが難しい理由の 1 つです。各コールバックはほとんど空のスタックで開始されるため、例外がスローされたときに catch
ハンドラーはスタック上にありません。
try { setTimeout(() => { throw new Error("Woosh"); }, 20); } catch (_) { // This will not run console.log("Caught!"); }
タイムアウトや着信リクエストなどのイベントがどれほど密接に発生しても、JavaScript 環境は一度に 1 つのプログラムしか実行しません。これは、プログラムの *周囲* で *イベントループ* と呼ばれる大きなループを実行していると考えることができます。実行すべきことがない場合、そのループは停止します。ただし、イベントが発生すると、イベントはキューに追加され、それらのコードが 1 つずつ実行されます。2 つのことが同時に実行されることはないため、実行に時間のかかるコードは他のイベントの処理を遅らせる可能性があります。
この例では、タイムアウトを設定しますが、タイムアウトの予定時刻を過ぎてもぐずぐずするため、タイムアウトが遅延します。
let start = Date.now(); setTimeout(() => { console.log("Timeout ran at", Date.now() - start); }, 20); while (Date.now() < start + 50) {} console.log("Wasted time until", Date.now() - start); // → Wasted time until 50 // → Timeout ran at 55
Promise は常に新しいイベントとして解決または拒否されます。Promise が既に解決されている場合でも、それを待機すると、コールバックがすぐに実行されるのではなく、現在のスクリプトが完了した後に実行されます。
Promise.resolve("Done").then(console.log); console.log("Me first!"); // → Me first! // → Done
後の章では、イベントループ上で実行される他のさまざまなタイプのイベントについて説明します。
非同期のバグ
プログラムが同期的に、一度に実行される場合、プログラム自体が行う変更以外に状態の変更は発生しません。非同期プログラムの場合は異なります。実行中に *ギャップ* があり、その間に他のコードが実行される可能性があります。
例を見てみましょう。カラスの趣味の 1 つは、毎年村全体で孵化するヒナの数を数えることです。巣は、この数をストレージバルブに保存します。次のコードは、特定の年のすべての巣から数を列挙しようとしています。
function anyStorage(nest, source, name) { if (source == nest.name) return storage(nest, name); else return routeRequest(nest, source, "storage", name); } async function chicks(nest, year) { let list = ""; await Promise.all(network(nest).map(async name => { list += `${name}: ${ await anyStorage(nest, name, `chicks in ${year}`) }\n`; })); return list; }
async name =>
の部分は、アロー関数も、前に async
という単語を付けることで async
にできることを示しています。
コードはすぐに疑わしいようには見えません... async
アロー関数を巣のセットにマップして Promise の配列を作成し、次に Promise.all
を使用して、これらすべてが待機してから、それらが構築したリストを返します。
しかし、これは深刻な問題です。常に 1 行の出力のみが返され、応答が最も遅かった巣がリストされます。
chicks(bigOak, 2017).then(console.log);
問題は +=
演算子にあります。これは、ステートメントの実行開始時の list
の *現在の* 値を取得し、await
が完了すると、list
バインディングをその値に文字列を追加したものに設定します。
ただし、ステートメントの実行開始時刻と終了時刻の間には、非同期のギャップがあります。map
式は、リストに何かが追加される *前* に実行されるため、各 +=
演算子は空の文字列から開始し、ストレージの取得が完了すると、list
を 1 行のリスト(空の文字列にその行を追加した結果)に設定します。
これは、マップされた Promise から行を返し、バインディングを変更することによってリストを構築する代わりに、Promise.all
の結果で join
を呼び出すことで簡単に回避できます。いつものように、新しい値を計算する方が、既存の値を変更するよりもエラーが発生しにくいです。
async function chicks(nest, year) { let lines = network(nest).map(async name => { return name + ": " + await anyStorage(nest, name, `chicks in ${year}`); }); return (await Promise.all(lines)).join("\n"); }
このような間違いは、特に await
を使用する場合に発生しやすいため、コードのギャップがどこにあるかを認識しておく必要があります。JavaScript の *明示的な* 非同期性(コールバック、Promise、または await
のいずれかによる)の利点は、これらのギャップを比較的簡単に見つけることができることです。
まとめ
非同期プログラミングにより、これらのアクション中にプログラムをフリーズさせることなく、長時間実行されるアクションの待機を表現できます。JavaScript 環境では通常、アクションの完了時に呼び出される関数であるコールバックを使用して、このスタイルのプログラミングを実装します。イベントループは、このようなコールバックが適切なタイミングで 1 つずつ呼び出されるようにスケジュールするため、実行が重複することはありません。
将来完了する可能性のあるアクションを表すオブジェクトである Promise と、同期プログラムであるかのように非同期プログラムを作成できる async
関数を使用すると、非同期プログラミングが容易になります。
演習
メスを追跡する
村のカラスは、特別な任務(例えば、網戸や包装を切り裂くなど)に時折使用する古いメスを所有しています。メスが別の巣に移動されるたびに、それを迅速に見つけ出すために、以前の巣と新しい巣の両方のストレージに、`「scalpel」`という名前でエントリが追加され、新しい場所が値として記録されます。
これは、メスを見つけるには、ストレージエントリのパンくずリストをたどり、その巣自体を指す巣を見つけるまで追跡すればよいことを意味します。
実行された巣から開始して、これを行う`async`関数`locateScalpel`を作成してください。以前に定義した`anyStorage`関数を使用して、任意の巣のストレージにアクセスできます。メスは長い間使用されているため、すべての巣のデータストレージに`「scalpel」`エントリがあると仮定できます。
次に、`async`と`await`を使用せずに、同じ関数をもう一度記述してください。
リクエストの失敗は、両方のバージョンで返されたPromiseの拒否として正しく表示されますか?どのように表示されますか?
async function locateScalpel(nest) { // Your code here. } function locateScalpel2(nest) { // Your code here. } locateScalpel(bigOak).then(console.log); // → Butcher Shop
これは、巣を検索する単一のループで実行できます。現在の巣の名前と一致しない値が見つかった場合は次の巣に移動し、一致する値が見つかった場合はその名前を返します。`async`関数では、通常の`for`ループまたは`while`ループを使用できます。
プレーン関数で同じことを行うには、再帰関数を使用してループを構築する必要があります。これを行う最も簡単な方法は、ストレージ値を取得するPromiseで`then`を呼び出して、その関数がPromiseを返すようにすることです。その値が現在の巣の名前と一致するかどうかによって、ハンドラーはその値を返すか、ループ関数を再び呼び出して作成された別のPromiseを返します。
メイン関数から再帰関数を1回呼び出してループを開始することを忘れないでください。
`async`関数では、拒否されたPromiseは`await`によって例外に変換されます。`async`関数が例外をスローすると、そのPromiseは拒否されます。それでうまくいきます。
前述のように非`async`関数を実装した場合、`then`の動作方法により、エラーが返されたPromiseに自動的に反映されます。リクエストが失敗した場合、`then`に渡されたハンドラーは呼び出されず、それが返すPromiseは同じ理由で拒否されます。
Promise.allの構築
Promiseの配列が与えられた場合、`Promise.all`は、配列内のすべてのPromiseが完了するのを待つPromiseを返します。そして、成功すると、結果値の配列を生成します。配列内のPromiseが失敗した場合、`all`によって返されたPromiseも、失敗したPromiseの失敗理由で失敗します。
`Promise_all`という通常の関数として、このようなものを自分で実装してください。
Promiseは、成功または失敗した後は、再び成功または失敗することはできず、それを解決する関数へのさらなる呼び出しは無視されることに注意してください。これは、Promiseの失敗を処理する方法を簡素化できます。
function Promise_all(promises) { return new Promise((resolve, reject) => { // Your code here. }); } // Test code. Promise_all([]).then(array => { console.log("This should be []:", array); }); function soon(val) { return new Promise(resolve => { setTimeout(() => resolve(val), Math.random() * 500); }); } Promise_all([soon(1), soon(2), soon(3)]).then(array => { console.log("This should be [1, 2, 3]:", array); }); Promise_all([soon(1), Promise.reject("X"), soon(3)]) .then(array => { console.log("We should not get here"); }) .catch(error => { if (error != "X") { console.log("Unexpected failure:", error); } });
`Promise`コンストラクターに渡された関数は、指定された配列内の各Promiseで`then`を呼び出す必要があります。いずれかが成功すると、2つのことが起こる必要があります。結果の値を結果配列の正しい位置に格納する必要があり、これが最後の保留中のPromiseであったかどうかを確認し、そうであれば独自のPromiseを完了する必要があります。
後者は、入力配列の長さに初期化され、Promiseが成功するたびに1を引くカウンターを使用して実行できます。0に達すると、完了です。入力配列が空の場合(したがって、Promiseが解決されることはない場合)の状況を考慮してください。
失敗の処理には多少の考慮が必要ですが、非常に簡単であることがわかります。ラッパーPromiseの`reject`関数を、配列内の各Promiseに`catch`ハンドラーとして、または`then`の2番目の引数として渡すだけで、いずれかのPromiseの失敗がラッパーPromise全体の拒否をトリガーします。