非同期プログラミング

泥が沈むのを静かに待つことができるのは誰か?
行動の瞬間まで静止していられるのは誰か?

老子、Tao Te Ching
Illustration showing two crows on a tree branch

コンピューターの中心部分、つまりプログラムを構成する個々のステップを実行する部分は、プロセッサと呼ばれます。これまで見てきたプログラムは、作業が完了するまでプロセッサを忙しくさせ続けます。数値を操作するループのようなものの実行速度は、ほぼ完全にコンピューターのプロセッサとメモリの速度に依存します。

しかし、多くのプログラムはプロセッサの外にあるものとやり取りします。たとえば、コンピューターネットワークを介して通信したり、ハードディスクからデータを読み取ったりします(メモリから取得するよりもはるかに遅いです)。

このようなことが起こっているとき、プロセッサをアイドル状態にしておくのは残念です。その間にできる他の作業があるかもしれません。これは部分的にオペレーティングシステムによって処理されます。オペレーティングシステムは、複数の実行中のプログラム間でプロセッサを切り替えます。しかし、単一のプログラムがネットワーク要求を待っている間に進捗を上げたい場合、これは役に立ちません。

非同期性

同期プログラミングモデルでは、処理は一度に一つずつ行われます。長時間実行されるアクションを実行する関数を呼び出すと、アクションが完了し、結果を返すことができる場合にのみ戻ります。これは、アクションにかかる時間だけプログラムを停止させます。

非同期モデルでは、複数の処理を同時に実行できます。アクションを開始すると、プログラムは実行を続けます。アクションが完了すると、プログラムに通知され、結果(たとえば、ディスクから読み取られたデータ)にアクセスできます。

ネットワークを介して2つのリクエストを行い、その結果を組み合わせるプログラムという小さな例を使用して、同期と非同期プログラミングを比較できます。

リクエスト関数が作業が完了した後にのみ戻る同期環境では、このタスクを実行する最も簡単な方法は、リクエストを次々と行うことです。これには、最初のリクエストが完了するまで2番目のリクエストが開始されないという欠点があります。かかる合計時間は、2つの応答時間の合計以上になります。

この問題に対する解決策は、同期システムでは、追加の制御スレッドを開始することです。スレッドとは、その実行がオペレーティングシステムによって他のプログラムとインターリーブされる可能性のある別の実行中のプログラムです。最新のコンピューターのほとんどは複数のプロセッサを含むため、複数のスレッドは異なるプロセッサで同時に実行されることさえあります。2番目のスレッドは2番目のリクエストを開始し、両方のスレッドは結果が戻るのを待ち、その後、結果を組み合わせるために再同期します。

次の図では、太線はプログラムが通常実行に費やす時間を表し、細い線はネットワーク待ちに費やす時間を表します。同期モデルでは、ネットワークにかかる時間は、特定の制御スレッドのタイムラインの一部です。非同期モデルでは、ネットワークアクションを開始すると、プログラムはネットワーク通信がそれと同時に発生している間も実行を続け、完了時にプログラムに通知します。

Diagram of showing control flow in synchronous and asynchronous programs. The first part shows a synchronous program, where the program's active and waiting phases all happen on a single, sequential line. The second part shows a multi-threaded synchronous program, with two parallel lines, on which the waiting parts happen alongside each other, causing the program to finish faster. The last part shows an asynchronous program, where the multiple asynchronous actions branch off from the main program, which at some point stops, and then resumes whenever the first thing it was waiting for finishes.

もう1つの違いの説明としては、アクションの完了を待つことが同期モデルでは暗黙的であるのに対し、非同期モデルでは明示的(制御下にある)であることです。

非同期性は両刃の剣です。直線的な制御モデルに合わないプログラムの作成を容易にしますが、直線に従うプログラムの作成をより面倒にする可能性もあります。この面倒さを軽減する方法については、この章の後半で説明します。

主要なJavaScriptプログラミングプラットフォームであるブラウザとNode.jsの両方では、しばらく時間がかかる可能性のある操作は、スレッドに依存するのではなく、非同期になります。スレッドを使用したプログラミングは非常に難しいことで知られているため(プログラムが複数の処理を同時に行っている場合、プログラムが何をしているかを理解することははるかに困難です)、これは一般的に良いことと考えられています。

コールバック

非同期プログラミングの1つのアプローチは、何かを待つ必要がある関数に、追加の引数であるコールバック関数を渡すことです。非同期関数はプロセスを開始し、プロセスが完了したときにコールバック関数が呼び出されるように設定してから戻ります。

例として、Node.jsとブラウザの両方で使用できる`setTimeout`関数は、指定されたミリ秒数待ってから関数を呼び出します。

setTimeout(() => console.log("Tick"), 500);

待機は一般的に重要な作業ではありませんが、特定の時間に何かが起こるようにしたり、アクションが予想以上に長くかかっているかどうかを確認する必要がある場合に非常に役立ちます。

一般的な非同期操作のもう1つの例は、デバイスのストレージからファイルを読み取ることです。ファイルの内容を文字列として読み取り、コールバック関数に渡す`readTextFile`関数があるとします。

readTextFile("shopping_list.txt", content => {
  console.log(`Shopping List:\n${content}`);
});
// → Shopping List:
// → Peanut butter
// → Bananas

`readTextFile`関数は標準JavaScriptの一部ではありません。ブラウザとNode.jsでのファイルの読み取り方法については、後の章で説明します。

コールバックを使用して複数の非同期アクションを連続して実行すると、アクションの後で計算の継続を処理する新しい関数を渡す必要があります。2つのファイルを比較し、その内容が同じかどうかを示すブール値を生成する非同期関数は、次のようになります。

function compareFiles(fileA, fileB, callback) {
  readTextFile(fileA, contentA => {
    readTextFile(fileB, contentB => {
      callback(contentA == contentB);
    });
  });
}

このプログラミングスタイルは機能しますが、インデントレベルは各非同期アクションごとに増加します。ループ内の非同期アクションをラップするなど、より複雑な処理を行うことは面倒になる可能性があります。

ある意味、非同期性は伝染性があります。非同期的に動作する関数を呼び出す関数は、その結果を配信するためにコールバックや同様のメカニズムを使用して、それ自体が非同期である必要があります。コールバックを呼び出すことは、単に値を返すよりもやや複雑でエラーが発生しやすいので、プログラムの大きな部分をこのように構成する必要があるのは好ましくありません。

Promise

非同期プログラムを構築する少し異なる方法は、コールバック関数を渡す代わりに、非同期関数が(将来の)結果を表すオブジェクトを返すようにすることです。このように、そのような関数は実際には意味のある何かを返し、プログラムの形状は同期プログラムの形状により近づきます。

これが標準クラス`Promise`の目的です。Promiseは、まだ利用できない可能性のある値を表すレシートです。待っているアクションが完了したときに呼び出すべき関数を登録できる`then`メソッドを提供します。Promiseが解決されると、つまりその値が利用可能になると、そのような関数(複数ある可能性があります)は結果値で呼び出されます。既に解決済みのPromiseに対して`then`を呼び出すことは可能です。関数は依然として呼び出されます。

Promiseを作成する最も簡単な方法は、`Promise.resolve`を呼び出すことです。この関数は、渡した値がPromiseでラップされるようにします。既にPromiseであれば、単に返されます。そうでない場合、渡した値を結果としてすぐに解決する新しいPromiseを取得します。

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

すぐに解決されないPromiseを作成するには、`Promise`をコンストラクタとして使用できます。やや奇妙なインターフェースを持っています。コンストラクタは引数として関数を期待し、それをすぐに呼び出して、Promiseを解決するために使用できる関数を渡します。

たとえば、`readTextFile`関数のPromiseベースのインターフェースを作成する方法は次のとおりです。

function textFile(filename) {
  return new Promise(resolve => {
    readTextFile(filename, text => resolve(text));
  });
}

textFile("plans.txt").then(console.log);

コールバックスタイルの関数とは異なり、この非同期関数は意味のある値(将来のある時点でファイルの内容を提供するというPromise)を返すことに注目してください。

`then`メソッドの便利な点は、それ自体が別のPromiseを返すことです。これは、コールバック関数が返す値、またはその値がPromiseである場合、そのPromiseが解決する値に解決します。したがって、複数の`then`呼び出しを「チェーン」して、一連の非同期アクションを設定できます。

ファイル名でいっぱいのファイルを読み込み、そのリストの中からランダムなファイルの内容を返すこの関数は、この種の非同期Promiseパイプラインを示しています。

function randomFile(listFile) {
  return textFile(listFile)
    .then(content => content.trim().split("\n"))
    .then(ls => ls[Math.floor(Math.random() * ls.length)])
    .then(filename => textFile(filename));
}

この関数は、一連のthen呼び出しの結果を返します。最初のPromiseは、ファイルのリストを文字列としてフェッチします。最初のthen呼び出しはその文字列を行の配列に変換し、新しいPromiseを生成します。2番目のthen呼び出しはその中からランダムな行を選び、単一のファイル名を生成する3番目のPromiseを生成します。最後のthen呼び出しはこのファイルを読み込むため、関数全体の結果は、ランダムなファイルの内容を返すPromiseとなります。

このコードでは、最初の2つのthen呼び出しで使用されている関数は、関数が返されるときにthenによって返されるPromiseにすぐに渡される通常の値を返します。最後のthen呼び出しはPromise(textFile(filename))を返すため、実際の非同期ステップとなります。

最後のステップだけが実際に非同期であるため、これらすべてのステップを単一のthenコールバック内で実行することも可能でした。しかし、同期データ変換のみを行うthenラッパーの種類は、非同期結果の処理済みバージョンを生成するPromiseを返したい場合など、多くの場合に役立ちます。

function jsonFile(filename) {
  return textFile(filename).then(JSON.parse);
}

jsonFile("package.json").then(console.log);

一般的に、Promiseを、値がいつ到着するかの問題をコードが無視できるようにするデバイスとして考えることが有用です。通常の値は、参照する前に実際に存在する必要があります。Promiseされた値とは、すでに存在する可能性もあるし、将来のある時点で出現する可能性もある値です。Promiseに関して定義され、then呼び出しでそれらを接続することによって、計算は、入力が利用可能になるにつれて非同期的に実行されます。

失敗

通常のJavaScript計算は、例外をスローすることによって失敗する可能性があります。非同期計算は、多くの場合、そのようなものが必要です。ネットワークリクエストが失敗したり、ファイルが存在しなかったり、非同期計算の一部であるコードが例外をスローしたりする可能性があります。

コールバックスタイルの非同期プログラミングにおける最も差し迫った問題の1つは、コールバックにエラーが適切に報告されることを保証することが非常に困難になることです。

一般的な慣習としては、アクションが失敗したことを示すためにコールバックの最初の引数を使用し、成功したときにアクションによって生成された値を渡すために2番目の引数を使用します。

someAsyncFunction((error, value) => {
  if (error) handleError(error);
  else processValue(value);
});

このようなコールバック関数は、常に例外を受け取ったかどうかをチェックし、呼び出す関数がスローする例外を含む、それらが引き起こす可能性のある問題がすべてキャッチされ、適切な関数に渡されるようにする必要があります。

Promiseを使用すると、これが容易になります。Promiseは、解決済み(アクションが正常に完了した)か拒否済み(失敗した)のいずれかになります。解決ハンドラ(thenで登録されているもの)は、アクションが成功した場合にのみ呼び出され、拒否はthenによって返される新しいPromiseに伝播されます。ハンドラーが例外をスローすると、そのthen呼び出しによって生成されたPromiseは自動的に拒否されます。非同期アクションのチェーンのいずれかの要素が失敗すると、チェーン全体の結果は拒否済みとしてマークされ、失敗した時点以降は成功ハンドラーは呼び出されません。

Promiseを解決すると値が提供されるのと同様に、Promiseを拒否すると値(通常は拒否の理由と呼ばれます)も提供されます。ハンドラー関数内の例外が拒否の原因となる場合、例外値が理由として使用されます。同様に、ハンドラーが拒否されるPromiseを返す場合、その拒否は次のPromiseに流れ込みます。すぐに拒否される新しいPromiseを作成するPromise.reject関数があります。

このような拒否を明示的に処理するために、Promiseにはcatchメソッドがあり、Promiseが拒否されたときに呼び出されるハンドラーを登録します。これは、thenハンドラーが通常の解決を処理する方法と同様です。また、thenと非常に似ており、新しいPromiseを返します。これは、Promiseが通常に解決されると元のPromiseの値に解決され、そうでない場合はcatchハンドラーの結果に解決されます。catchハンドラーがエラーをスローすると、新しいPromiseも拒否されます。

簡略化として、thenは拒否ハンドラーを2番目の引数として受け入れるため、単一のメソッド呼び出しで両方のタイプのハンドラーをインストールできます:.then(acceptHandler, rejectHandler)

Promiseコンストラクターに渡される関数は、解決関数とともに、新しいPromiseを拒否するために使用できる2番目の引数を受け取ります。

私たちのreadTextFile関数が問題に遭遇した場合、エラーをコールバック関数に2番目の引数として渡します。私たちのtextFileラッパーは、実際にはその引数をチェックして、失敗によって返されるPromiseが拒否されるようにする必要があります。

function textFile(filename) {
  return new Promise((resolve, reject) => {
    readTextFile(filename, (text, error) => {
      if (error) reject(error);
      else resolve(text);
    });
  });
}

このようにして、thencatchの呼び出しによって作成されたPromise値のチェーンは、非同期値または失敗が移動するパイプラインを形成します。このようなチェーンはハンドラーを登録することによって作成されるため、各リンクには成功ハンドラーまたは拒否ハンドラー(またはその両方)が関連付けられています。結果の種類(成功または失敗)に一致しないハンドラーは無視されます。一致するハンドラーは呼び出され、その結果によって、次にどのような値が来るかが決まります。非Promise値を返す場合は成功、例外をスローする場合は拒否、Promiseを返す場合はPromiseの結果です。

new Promise((_, reject) => reject(new Error("Fail")))
  .then(value => console.log("Handler 1:", value))
  .catch(reason => {
    console.log("Caught failure " + reason);
    return "nothing";
  })
  .then(value => console.log("Handler 2:", value));
// → Caught failure Error: Fail
// → Handler 2: nothing

最初のthenハンドラー関数は、パイプラインのその時点でPromiseが拒否を保持しているため、呼び出されません。catchハンドラーはその拒否を処理し、値を返し、それが2番目のthenハンドラー関数に渡されます。

キャッチされない例外が環境によって処理されるのと同様に、JavaScript環境は、Promiseの拒否が処理されていないことを検出し、これをエラーとして報告できます。

カーラ

ベルリンは晴天です。廃止された古い空港の滑走路は、サイクリストやインラインスケーターで賑わっています。ゴミ箱近くの芝生では、カラスの群れが騒がしく群がり、観光客からサンドイッチを奪おうとしています。

その中の一羽のカラスが目立ちます。右翼に白い羽が数本ついた、大型のボサボサしたメスです。彼女は熟練した自信で人に餌をねだり、長い間これをやっていることを示唆しています。老人が他のカラスの騒ぎに気を取られていると、彼女はさりげなく舞い降り、彼の半分食べられたパンを手中から奪い、飛び去ります。

一日中ここでふざけているように見える他のカラスとは対照的に、大型のカラスは目的意識を持っています。彼女は戦利品を持って、格納庫の屋根に向かってまっすぐ飛び、通風口に消えていきます。

建物の内部では、奇妙なコツコツという音が聞こえます。柔らかく、しかし執拗です。それは、未完成の階段の屋根の下の狭い空間から聞こえます。カラスはそこに座っており、盗んだ軽食、6台のスマートフォン(そのうち数台は電源が入っている)、そしてケーブルの塊に囲まれています。彼女はくちばしでスマートフォンの1つの画面を素早くタップしています。そこに文字が現れています。もしあなたがよく知らなければ、彼女はタイピングをしていると思うでしょう。

このカラスは仲間から「cāāw-krö」として知られています。しかし、これらの音は人間の声帯には不向きなので、ここではカーラと呼びます。

カーラはやや変わったカラスです。若い頃、彼女は人間の言語に魅了され、人々の言葉を盗み聞きして、彼らの言っていることをよく理解するようになりました。その後、彼女の関心は人間の技術に移り、彼女はスマートフォンを盗んで研究し始めました。彼女の現在のプロジェクトはプログラミングの学習です。彼女が隠れた実験室でタイピングしているテキストは、実際には非同期JavaScriptコードの一部です。

侵入

カーラはインターネットが大好きです。しかし厄介なことに、彼女が作業しているスマートフォンは、プリペイドデータを使い果たそうとしています。建物には無線ネットワークがありますが、アクセスにはコードが必要です。

幸いにも、建物の無線ルータは20年前のもので、セキュリティが不十分です。調査の結果、カーラはネットワーク認証メカニズムに利用できる欠陥があることを発見しました。ネットワークに参加する際に、デバイスは正しい6桁のパスコードを送信する必要があります。アクセスポイントは、正しいコードが提供されたかどうかによって、成功または失敗メッセージで応答します。しかし、部分的なコード(たとえば、3桁のみ)を送信する場合、その桁がコードの正しい先頭であるかどうかに応じて、応答が異なります。間違った番号を送信すると、すぐに失敗メッセージが返されます。正しい番号を送信すると、アクセスポイントはさらに桁を待ちます。

これにより、数字の推測を大幅に高速化できます。カーラは、失敗がすぐに返されないものが見つかるまで、順番に各番号を試すことで、最初の桁を見つけることができます。1桁あれば、同じ方法で2桁目を見つけることができ、このようにして、パスコード全体がわかるまで続けることができます。

CarlaにはjoinWifi関数があると仮定します。ネットワーク名とパスコード(文字列)が与えられると、この関数はネットワークへの接続を試み、成功した場合はPromiseを解決し、認証に失敗した場合はPromiseを拒否します。最初に必要なのは、時間がかかりすぎると自動的にPromiseを拒否する仕組みです。これにより、アクセスポイントが応答しない場合でも、プログラムは迅速に先に進むことができます。

function withTimeout(promise, time) {
  return new Promise((resolve, reject) => {
    promise.then(resolve, reject);
    setTimeout(() => reject("Timed out"), time);
  });
}

これは、Promiseは一度しか解決または拒否できないという事実を利用しています。引数として与えられたPromiseが先に解決または拒否された場合、その結果がwithTimeoutによって返されるPromiseの結果になります。一方、setTimeoutが先に発火してPromiseを拒否した場合、それ以降の解決または拒否の呼び出しは無視されます。

パスコード全体を見つけるには、プログラムは各桁を試すことで、繰り返し次の桁を探し出す必要があります。認証に成功した場合は、目的の桁が見つかったことがわかります。すぐに失敗した場合は、その桁が間違っていたことがわかり、次の桁を試す必要があります。要求がタイムアウトした場合は、別の正しい桁が見つかったことになり、別の桁を追加して続行する必要があります。

forループ内でPromiseを待つことはできないため、Carlaは再帰関数を用いてこのプロセスを駆動します。この関数は、各呼び出しで、現時点でわかっているコードと、次に試す桁を取得します。何が起こるかによって、完成したコードを返すか、自身を再帰的に呼び出して、コードの次の位置の解読を開始するか、別の桁で再試行するかを決定します。

function crackPasscode(networkID) {
  function nextDigit(code, digit) {
    let newCode = code + digit;
    return withTimeout(joinWifi(networkID, newCode), 50)
      .then(() => newCode)
      .catch(failure => {
        if (failure == "Timed out") {
          return nextDigit(newCode, 0);
        } else if (digit < 9) {
          return nextDigit(code, digit + 1);
        } else {
          throw failure;
        }
      });
  }
  return nextDigit("", 0);
}

アクセスポイントは、不正な認証要求に約20ミリ秒で応答する傾向があるため、安全のために、この関数は要求のタイムアウト前に50ミリ秒待機します。

crackPasscode("HANGAR 2").then(console.log);
// → 555555

Carlaは頭を傾け、ため息をつきます。コードがもう少し推測しにくいものだったら、もっと満足できたでしょう。

非同期関数

Promiseを使っても、この種の非同期コードは記述が面倒です。Promiseは、冗長で、恣意的に見える方法で結び付ける必要があることがよくあります。非同期ループを作成するために、Carlaは再帰関数を導入せざるを得ませんでした。

解読関数が実際に行っていることは完全に線形です。常に前のアクションが完了するのを待ってから、次のアクションを開始します。同期プログラミングモデルでは、より簡単に表現できます。

朗報は、JavaScriptでは、非同期計算を記述するために擬似同期コードを記述できることです。async関数は暗黙的にPromiseを返し、その本体内で他のPromiseをawaitできます。これは同期的に見えます。

crackPasscode関数はこのように書き換えることができます。

async function crackPasscode(networkID) {
  for (let code = "";;) {
    for (let digit = 0;; digit++) {
      let newCode = code + digit;
      try {
        await withTimeout(joinWifi(networkID, newCode), 50);
        return newCode;
      } catch (failure) {
        if (failure == "Timed out") {
          code = newCode;
          break;
        } else if (digit == 9) {
          throw failure;
        }
      }
    }
  }
}

このバージョンでは、関数の二重ループ構造(内部ループは0から9までの桁を試行し、外部ループはパスコードに桁を追加します)がより明確に示されています。

async関数は、functionキーワードの前にasyncという単語を付けることでマークされます。メソッドも、名前の前にasyncを付けることでasyncにすることができます。このような関数またはメソッドが呼び出されると、Promiseを返します。関数が何かを返すとすぐに、そのPromiseは解決されます。本体で例外がスローされると、Promiseは拒否されます。

async関数の内部では、式の前にawaitという単語を付けることで、Promiseが解決されるのを待ってから関数の実行を続行できます。Promiseが拒否された場合、awaitの時点で例外が発生します。

このような関数は、通常のJavaScript関数のように一度に最初から最後まで実行されるわけではありません。代わりに、awaitのある時点で停止することができ、後で再開することができます。

ほとんどの非同期コードでは、この表記法は、Promiseを直接使用するよりも便利です。多くの場合、直接Promiseを操作する必要があるため、Promiseの理解は依然として必要です。しかし、Promiseを組み合わせる際には、async関数は一般的に、then呼び出しのチェーンよりも記述しやすいです。

ジェネレータ

関数を一時停止してから再開するこの機能は、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された値がイテレータによって生成される次の値になります。関数が返ると(例では決して返りません)、イテレータは完了します。

ジェネレータ関数を使用すると、イテレータの記述がはるかに簡単になることがよくあります。第6章の練習問題のGroupクラスのイテレータは、このジェネレータで記述できます。

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式の結果になります。

カラスの芸術プロジェクト

ある朝、Carlaは格納庫の外の滑走路から見慣れない音を聞いて目を覚まします。屋根の端によじ登ると、人間が何か準備をしているのが見えます。たくさんの電気ケーブル、ステージ、そして何らかの大きな黒い壁が建設されています。

好奇心旺盛なカラスであるCarlaは、壁を詳しく調べます。それは、ケーブルに接続された多数の大きなガラス張りのデバイスで構成されているようです。デバイスの裏面には「LedTec SIG-5030」と書かれています。

簡単なインターネット検索で、これらのデバイスのユーザーマニュアルが見つかりました。これらは、プログラム可能なアンバーLEDライトのマトリックスを備えた交通標識のようです。人間の意図はおそらく、イベント中にそれらに何らかの情報を表示することでしょう。興味深いことに、画面はワイヤレスネットワーク経由でプログラムできます。建物のローカルネットワークに接続されている可能性がありますか?

ネットワーク上の各デバイスにはIPアドレスが割り当てられ、他のデバイスはそのアドレスを使用してメッセージを送信できます。第13章で詳しく説明します。Carlaは、自分の電話にはすべて10.0.0.2010.0.0.33のようなアドレスが割り当てられていることに気づきます。このようなすべてのアドレスにメッセージを送信し、いずれかがマニュアルで説明されているインターフェースに応答するかどうかを確認する価値があるかもしれません。

第18章では、実際のネットワークで実際の要求を行う方法を示します。この章では、ネットワーク通信のためにrequestという簡略化されたダミー関数を使用します。この関数は、ネットワークアドレスとメッセージ(JSONとして送信できるものなら何でもかまいません)の2つの引数を取り、指定されたアドレスのコンピュータからの応答を解決するか、問題があった場合は拒否するPromiseを返します。

マニュアルによると、{"command": "display", "data": [0, 0, 3, …]}のようなコンテンツを持つメッセージを送信することで、SIG-5030標識に表示される内容を変更できます。ここでdataは、各LEDドットの明るさを表す数値を1つずつ保持します。0はオフ、3は最大輝度を表します。各標識は幅50個、高さ30個のライトで構成されているため、更新コマンドでは1500個の数値を送信する必要があります。

このコードは、ローカルネットワーク上のすべてのアドレスに表示更新メッセージを送信して、何が機能するかを確認します。IPアドレスの数値はそれぞれ0から255の範囲を取ります。送信するデータでは、ネットワークアドレスの最後の数値に対応する数のライトをアクティブにします。

for (let addr = 1; addr < 256; addr++) {
  let data = [];
  for (let n = 0; n < 1500; n++) {
    data.push(n < addr ? 3 : 0);
  }
  let ip = `10.0.0.${addr}`;
  request(ip, {command: "display", data})
    .then(() => console.log(`Request to ${ip} accepted`))
    .catch(() => {});
}

これらのアドレスのほとんどは存在しないか、そのようなメッセージを受け入れないため、catch呼び出しにより、ネットワークエラーでプログラムがクラッシュするのを防ぎます。他の要求が完了するのを待つことなく、すべての要求がすぐに送信されるため、一部のマシンが応答しない場合に時間を無駄にしません。

ネットワークスキャンを開始した後、Carlaは結果を見るために外に出かけます。彼女の喜びにも増して、すべての画面に左上の隅にライトのストライプが表示されています。それらはローカルネットワーク上にあり、コマンドを受け入れます。彼女はすぐに各画面に表示されている数値を書き留めます。9つの画面があり、3行3列に配置されています。それらのネットワークアドレスは以下のとおりです。

const screenAddresses = [
  "10.0.0.44", "10.0.0.45", "10.0.0.41",
  "10.0.0.31", "10.0.0.40", "10.0.0.42",
  "10.0.0.48", "10.0.0.47", "10.0.0.46"
];

これで、あらゆる種類のいたずらをする可能性が開けます。彼女は壁に巨大な文字で「カラス万歳、人間はよだれ垂らし」と表示することもできます。しかし、それは少し粗野な感じがします。代わりに、彼女は夜に画面全体を覆う飛んでいるカラスのビデオを表示する計画です。

カーラは、1.5秒の映像を繰り返しループ再生することでカラスの羽ばたきを表現できる適切なビデオクリップを見つけました。9つの画面(それぞれ50×30ピクセルを表示可能)に合わせるため、カーラはビデオを切り取り、サイズ変更して、1秒間に10枚の150×90ピクセルの画像シリーズを作成します。次に、それらを9つの長方形に分割し、ビデオの暗い部分(カラスがいる部分)を明るい光に、明るい部分(カラスがいない部分)を暗く処理することで、黒い背景にアンバー色のカラスが飛んでいる効果を作り出します。

彼女は、各フレームが9つのピクセルセットの配列(画面ごとに1つ)で表され、標識が期待する形式のフレームの配列を保持するように、clipImages変数を設定しています。

ビデオの1フレームを表示するには、カーラはすべての画面に一度にリクエストを送信する必要があります。しかし、現在のフレームが正しく送信される前に次のフレームの送信を開始しないように、またリクエストが失敗したときに気付くために、これらのリクエストの結果を待つ必要もあります。

Promiseには、Promiseの配列を、結果の配列に解決する単一のPromiseに変換するために使用できる静的メソッドallがあります。これは、いくつかの非同期アクションを並行して実行し、すべてが完了するのを待ってから、その結果で何かを行う(または少なくとも失敗しないことを確認するために待つ)ための便利な方法を提供します。

function displayFrame(frame) {
  return Promise.all(frame.map((data, i) => {
    return request(screenAddresses[i], {
      command: "display",
      data
    });
  }));
}

これは、frame(表示データ配列の配列)内の画像をマップして、リクエストPromiseの配列を作成します。次に、それらをすべて組み合わせたPromiseを返します。

再生中のビデオを停止できるようにするために、プロセスはクラスでラップされています。このクラスには、stopメソッドで再生が再び停止されるまで解決されない非同期playメソッドがあります。

function wait(time) {
  return new Promise(accept => setTimeout(accept, time));
}

class VideoPlayer {
  constructor(frames, frameTime) {
    this.frames = frames;
    this.frameTime = frameTime;
    this.stopped = true;
  }

  async play() {
    this.stopped = false;
    for (let i = 0; !this.stopped; i++) {
      let nextFrame = wait(this.frameTime);
      await displayFrame(this.frames[i % this.frames.length]);
      await nextFrame;
    }
  }

  stop() {
    this.stopped = true;
  }
}

wait関数は、指定されたミリ秒後に解決されるPromiseにsetTimeoutをラップします。これは、再生速度を制御するのに役立ちます。

let video = new VideoPlayer(clipImages, 100);
video.play().catch(e => {
  console.log("Playback failed: " + e);
});
setTimeout(() => video.stop(), 15000);

スクリーンウォールが設置されている1週間の間、毎晩、暗くなると、巨大なオレンジ色の鳥が神秘的に浮かび上がります。

イベントループ

非同期プログラムは、メインスクリプトを実行することから始まります。メインスクリプトは、多くの場合、後で呼び出されるコールバックを設定します。メインスクリプトとコールバックは、中断されることなく、1つのまとまった単位として完了まで実行されます。しかし、その間、プログラムはアイドル状態になり、何かが起こるのを待つ場合があります。

そのため、コールバックは、それらをスケジュールしたコードによって直接呼び出されるわけではありません。関数内でsetTimeoutを呼び出す場合、コールバック関数が呼び出されるまでに、その関数は既に返されています。そして、コールバックが返されると、制御はそれをスケジュールした関数に戻りません。

非同期動作は、空の関数呼び出しスタックで独自に発生します。これは、Promiseなしでは、非同期コード全体で例外を管理することが非常に難しい理由の1つです。各コールバックはほとんど空のスタックから始まるため、例外をスローするときに、catchハンドラーはスタック上にありません。

try {
  setTimeout(() => {
    throw new Error("Woosh");
  }, 20);
} catch (e) {
  // This will not run
  console.log("Caught", e);
}

タイムアウトや着信リクエストなどのイベントがどれだけ接近して発生しても、JavaScript環境は一度に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

以降の章では、イベントループで実行される他のさまざまな種類のイベントについて説明します。

非同期バグ

プログラムが同期的に、一括で実行されると、プログラム自体が行う変更を除いて、状態の変更は発生しません。非同期プログラムではこれは異なり、他のコードが実行できる実行のギャップが発生する可能性があります。

例を見てみましょう。これは、ファイルの配列内の各ファイルのサイズを報告しようとする関数であり、順番ではなく、すべて同時に読み取るようにしています。

async function fileSizes(files) {
  let list = "";
  await Promise.all(files.map(async fileName => {
    list += fileName + ": " +
      (await textFile(fileName)).length + "\n";
  }));
  return list;
}

async fileName =>の部分は、アロー関数をasyncの前にasyncという単語を付けることでasyncにする方法を示しています。

コードはすぐに疑わしく見えません...asyncアロー関数を名前の配列にマップして、Promiseの配列を作成し、次にPromise.allを使用してすべてを待つ前に、それらが作成するリストを返します。

しかし、このプログラムは完全に壊れています。常に1行の出力しか返さず、読み込みに最も時間がかかったファイルをリストします。

fileSizes(["plans.txt", "shopping_list.txt"])
  .then(console.log);

なぜそうなるのか分かりますか?

問題は+=演算子にあります。これは、ステートメントの実行が開始された時点でのlist現在の値を取り、awaitが完了したときに、listバインディングを追加された文字列に加えた値に設定します。

しかし、ステートメントの実行開始時と完了時の間には、非同期のギャップがあります。map式はリストに何も追加される前に実行されるため、各+=演算子は空の文字列から始まり、ストレージの取得が完了すると、空の文字列にその行を追加した結果、listを設定します。

これは、バインディングを変更することによってリストを構築する代わりに、マップされたPromiseから行を返し、Promise.allの結果にjoinを呼び出すことで簡単に回避できます。いつものように、新しい値を計算する方が、既存の値を変更するよりもエラーが発生しにくいです。

async function fileSizes(files) {
  let lines = files.map(async fileName => {
    return fileName + ": " +
      (await textFile(fileName)).length;
  });
  return (await Promise.all(lines)).join("\n");
}

このような間違いは、特にawaitを使用している場合、簡単に犯してしまい、コードのギャップがどこにあるかを認識する必要があります。JavaScriptの明示的な非同期性(コールバック、Promise、またはawaitによるもの)の利点の1つは、これらのギャップを比較的簡単に発見できることです。

概要

非同期プログラミングにより、プログラム全体をフリーズすることなく、長時間実行されるアクションの待機を表現できます。JavaScript環境は通常、アクションが完了したときに呼び出される関数であるコールバックを使用して、このスタイルのプログラミングを実装します。イベントループは、そのようなコールバックが適切なタイミングで順番に呼び出されるようにスケジュールするため、それらの実行が重なり合うことはありません。

非同期プログラミングは、将来完了する可能性のあるアクションを表すオブジェクトであるPromiseと、非同期プログラムを同期プログラムのように記述できるasync関数によって容易になります。

練習問題

静かな時間

カーラのラボの近くに、モーションセンサーで起動されるセキュリティカメラがあります。これはネットワークに接続されており、アクティブになるとビデオストリームの送信を開始します。発見されたくないため、カーラは、この種の無線ネットワークトラフィックを検出し、外側にアクティビティがあるときはいつでも彼女の隠れ家にライトを付けるシステムを構築しています。そのため、静かにすべき時が分かります。

彼女は、カメラが作動した時間をしばらくの間ログに記録しており、この情報を使用して、平均的な週でどの時間が静かで、どの時間が忙しいかを視覚化したいと考えています。ログは、行ごとに1つのタイムスタンプ番号(Date.now()によって返されるもの)を含むファイルに格納されます。

1695709940692
1695701068331
1695701189163

"camera_logs.txt"ファイルには、ログファイルのリストが格納されています。非同期関数activityTable(day)を作成してください。この関数は、曜日が与えられると、その曜日の各時間(24時間)のカメラネットワークトラフィックの観測回数を表す24個の数字からなる配列を返します。曜日は、Date.getDayで使用されるシステム(日曜日が0、土曜日が6)を使用して数値で識別されます。

サンドボックスで提供されているactivityGraph関数は、このようなテーブルを文字列に要約します。

ファイルの読み取りには、先に定義されたtextFile関数を使用します。ファイル名が与えられると、ファイルの内容を解決するPromiseを返します。new Date(timestamp)は、その時刻のDateオブジェクトを作成します。このオブジェクトには、曜日の値を返すgetDayメソッドと、時刻を返すgetHoursメソッドがあります。

ログファイルリストとログファイル自体の両方において、各データは改行文字("\n")で区切られた個別の行に記述されています。

async function activityTable(day) {
  let logFileList = await textFile("camera_logs.txt");
  // Your code here
}

activityTable(1)
  .then(table => console.log(activityGraph(table)));
表示ヒント…

これらのファイルの内容を配列に変換する必要があります。最も簡単な方法は、textFileによって生成された文字列に対してsplitメソッドを使用することです。ログファイルの場合、これは文字列の配列を生成するため、new Dateに渡す前に数値に変換する必要があることに注意してください。

すべての時間点を曜日の時間テーブルにまとめるには、1日の各時間に対応する数値を保持するテーブル(配列)を作成します。次に、すべてのタイムスタンプ(ログファイルと各ログファイルの数値)をループ処理し、それぞれについて、正しい曜日に発生した場合は、発生した時刻を取得し、テーブルの対応する数値に1を加算します。

非同期関数の結果には、使用する前に必ずawaitを使用してください。そうでないと、文字列を期待した場所にPromiseが残ることになります。

実際のPromise

前の演習の関数をasync/awaitを使用せずに、通常のPromiseメソッドを使用して書き直してください。

function activityTable(day) {
  // Your code here
}

activityTable(6)
  .then(table => console.log(activityGraph(table)));

このスタイルでは、ログファイルのループをモデル化しようとするよりも、Promise.allを使用する方が便利です。async関数では、ループ内でawaitを使用する方が簡単です。ファイルの読み取りに時間がかかる場合、どちらのアプローチの方が実行時間が短くなりますか?

ファイルリストに記載されているファイルのいずれかにタイプミスがあり、読み取りに失敗した場合、そのエラーは関数が返すPromiseオブジェクトにどのように反映されますか?

表示ヒント…

この関数を記述する最も簡単な方法は、一連のthen呼び出しを使用することです。最初のPromiseは、ログファイルのリストを読み取ることで生成されます。最初のコールバックは、このリストを分割し、textFileをマップして、Promise.allに渡すPromiseの配列を取得できます。この最初のthenの戻り値の結果として、Promise.allが返すオブジェクトを返すことができます。

これで、ログファイルの配列を返すPromiseができました。もう一度thenを呼び出し、そこにタイムスタンプのカウントロジックを入れることができます。このような感じです。

function activityTable(day) {
  return textFile("camera_logs.txt").then(files => {
    return Promise.all(files.split("\n").map(textFile));
  }).then(logs => {
    // analyze...
  });
}

または、さらに優れた作業スケジューリングのために、各ファイルの解析をPromise.allの中に配置して、他のファイルが返ってくる前でも、最初のファイルがディスクから返ってきた時点でその作業を開始できるようにすることもできます。

function activityTable(day) {
  let table = []; // init...
  return textFile("camera_logs.txt").then(files => {
    return Promise.all(files.split("\n").map(name => {
      return textFile(name).then(log => {
        // analyze...
      });
    }));
  }).then(() => table);
}

これは、Promiseの構造が作業のスケジュールに実際に影響を与えることを示しています。awaitを使用した単純なループは、プロセスを完全に線形にします。つまり、各ファイルの読み込みが完了するまで待機します。Promise.allを使用すると、複数のタスクが概念的に同時に処理され、ファイルの読み込み中でも進行状況が得られます。これにより高速化できますが、処理順序の予測が難しくなります。この場合、テーブルの数値を増分するだけなので、安全に実行するのは容易です。他の種類の問題では、はるかに困難になる可能性があります。

リスト内のファイルが存在しない場合、textFileによって返されるPromiseは拒否されます。Promise.allは、与えられたPromiseのいずれかが失敗すると拒否されるため、最初のthenに与えられたコールバックの戻り値も拒否されたPromiseになります。これにより、thenによって返されるPromiseが失敗するため、2番目の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の拒否をトリガーするようにします。