世界一わかりみの深いasync/awaitによる非同期処理

アプリケーションとカーネル、libuv の相互作用を示すシステム図。左にカーネル、中央にアプリケーション、右に fd9 の登録とデータベース風容器。

こんにちは、サイオステクノロジー武井です。今回はasync/awaitの動きをOSカーネルのレイヤーから追うことで理解を深めようとしてみます。

目次

1. async/awaitはむずい

async/await、なかなかにとっつきにくい概念だと思います。

「非同期処理には async を付ける」「await しないと結果が取れない」——ルールとしては覚えられます。コードも動きます。でも、こんな疑問にぶつかった経験はないでしょうか。

  • await している間、プログラムは「止まって」いるの? 止まっていないの?
  • 「待つ」のに、なんで速くなるの?
  • await を付け忘れると Promise が返ってくるのはなぜ?
  • そもそも誰が「処理が終わった」ことに気づいて、続きを実行してくれるの?

これらイベントループやepollの仕組みを知っていると、スッキリ解決するのではと思い、今回、一筆したためました。

キーになるのは、イベントループという仕組みと、さらにその下で動く OS カーネルの epoll という機構です。async/await はこの2つの上にかぶせられた「一番上の薄い皮」にすぎません。皮だけを眺めて中身を推測しようとするから難しいのであって、下の層から順に積み上げれば、実はかなり素直な仕組みです。

この記事では、その水面下を「イベントループと fd」→「実際のコードの動き」の順で見ていきます。

2. イベントループとは何者か

「待つ」のはもったいない

プログラムがデータベースや外部 API にアクセスすると、返事が来るまでの待ち時間が発生します。CPU の感覚では、この待ち時間は途方もなく長い。メモリアクセスを「1秒」とすると、ネットワークの応答待ちは「数週間〜数ヶ月」に相当します。

つまり I/O 待ちの間、CPU は膨大にヒマをしています。「このヒマな時間に別の仕事をさせたい」——これが非同期処理のすべての動機です。

1本のスレッドで、全部の「待ち」を見張る

古典的なやり方は、リクエストごとにスレッドを用意して、それぞれに待たせる方式でした。しかしスレッドは1本あたり数MBのメモリを食うため、同時接続数が多くなると厳しいことになります。

そこで発想を逆転させます。1本のスレッドが、大量の「待ち」をまとめて見張り、準備ができたものから順に処理する。レストランでいえば、客1組ごとにウェイターを張り付かせるのではなく、1人のウェイターが全テーブルを担当し、「呼ばれたテーブルにだけ行く」方式です。

この「ぐるぐる回りながら、準備できた仕事を順に捌く」ループこそがイベントループです。

ここで2つの重要な部品が登場します。epoll  fd です。

fd (ファイルディスクリプタ)とは?

fd(ファイルディスクリプタ) は、OS が通信の窓口(ソケットなど)1つ1つに割り振る、ただの整数です。DB への接続なら fd9、外部 API への接続なら fd10、というように、「どの待ち先か」を番号で識別できます。

ポイントは、fd は単なる番号であって、中身のデータでも処理でもないこと。「9番の窓口」「10番の窓口」という札だと思ってください。

epoll とは?

epoll は Linux カーネルの機能で、やることは一言でいうと:

大量の fd を登録しておくと、「データが届いた/送れる状態になった」fd だけをまとめて教えてくれる

イベントループの epoll.wait() は、カーネルへの「どれか準備できるまで寝てるから、できたら起こして」という依頼です。1万個の fd を登録していても、実際に用があるのが2個なら、2個分の番号だけが返ってきます。全部を1個ずつ見回る必要がない——ここが決定的に効率的です。

fd とコールバックは「別の場所」に保管される

もう1つ、仕組みの肝になるのが情報の置き場所です。

  • カーネルが持つのは「監視すべき fd の一覧」と「準備できた fd の一覧」——つまり番号だけ
  • 「fd9 の準備ができたら、どの処理(コールバック)を実行するか」という**対応表(登録簿)**は、Node.js でいえば libuv(ランタイムの C エンジン)が持つ

カーネルからの通知は「fd9 が準備完了」という番号だけ。libuv はその番号を鍵にして、自分の登録簿から「fd9 → このコールバック」を引き当て、実行キューに積みます。番号で照合して、初めて「続きの処理」が動き出すわけです。

この分業を頭に入れた上で、実際のコードを見てみましょう。

3. コードで追う:DB アクセスと API アクセスが「すれ違いで」進む

例えば、こんなコードがあるとします

ユーザー情報を DB から、最新のお知らせを 外部 API から取ってくる——お互いに関係のない2つの非同期処理です。

// DBからユーザーを取得する処理
async function getUser() {
  console.log("A-1: DBに問い合わせを実行");
  const user = await db.findUser(9);        // ← DBアクセス(待ち発生)
  console.log("A-2: DB応答が届いた!");
  return user;
}
// 外部APIからお知らせを取得する処理
async function getNews() {
  console.log("B-1: APIに問い合わせを実行");
  const news = await api.fetchNews();       // ← APIアクセス(待ち発生)
  console.log("B-2: API応答が届いた!");
  return news;
}
// 2つを同時にスタートさせて、両方の完了を待つ
const [user, news] = await Promise.all([getUser(), getNews()]);
console.log("C: 両方そろった!");

実行すると、ログはこの順で出ます。

A-1: DBに問い合わせを実行
B-1: APIに問い合わせを実行      ← DBの返事を待たずにBが始まっている!
A-2: DB応答が届いた!
B-2: API応答が届いた!
C: 両方そろった!

注目してほしいのは A-1 の直後に B-1 が出ることです。DB の応答(A-2)を待たずに、API への問い合わせ(B-1)が始まっています。await で「待っている」はずなのに、なぜ次の処理へ進めるのか——ここで、さきほどのイベントループと fd が登場します。

図と対応させて、裏側を追う

await db.findUser(9) の裏側で起きていることを、4枚の図で「登録 → すれ違い → 完了」の順に追っていきます。登場人物は、コードを走らせるアプリケーション、番号だけを見張るカーネル、対応表を持つlibuv、そしてその対応表であるlibuv の登録簿の4者です。

ステップ1:getUser の番——await に到達し、fd9 を登録する

getUser() が呼ばれ、A-1 のログを出し、await db.findUser(9) に到達します。ここで登録フェーズが動きます。

 

 アプリケーションが libuv に「DB アクセス処理を頼む」と依頼します。DB への問い合わせも、この依頼にのって送り出されます。

 libuv がカーネルに「この接続を見張って」と依頼します。

 カーネルの監視対象に、DB 接続の窓口である fd9 が登録されます。以後カーネルは fd9 を見張り続けます。

 libuv が自分の登録簿に「fd9 → コールバック処理(A-2 以降の続き)」を控えます。

そして決定的なのが次の一歩です。getUser は DB の返事を待ちません。①〜④の依頼と登録だけ済ませて、その場で中断し、制御をイベントループに返します。ウェイターが注文を厨房に通して、すぐテーブルを離れるのと同じです。

ステップ2:空いた隙間で、getNews の番——fd10 も登録する

getUser が席を離れた瞬間、イベントループは次の仕事に取りかかれます。それが getNews() です。B-1 のログが出て、await api.fetchNews() に到達し——まったく同じ①〜④が、今度は別の fd(fd10)で繰り返されます。

 

 アプリケーションが libuv に「API アクセス処理」を依頼(ここで API への問い合わせも実行)。

② ③ libuv がカーネルに依頼し、カーネルの監視対象に fd10 が加わります。

 libuv の登録簿に「fd10 → コールバック処理(B-2 以降の続き)」が控えられます。

そして getNews も席を離れます。

これが「A-1 の直後に B-1 が出る」カラクリです。DB の待ち時間という「ヒマ」に、API への問い合わせという別の仕事が差し込まれたわけです。ステップ2の状態を見てください。カーネルは fd9(DB)と fd10(API)の2つを並べて見張っており、libuv の登録簿にも2つの続きが控えられています。スレッドはどちらの返事も来ていないので epoll.wait() で眠っており、CPU 消費はゼロです。

ステップ3:返事が届いた——fd9 の完了フェーズ

やがて DB の応答が届きます。ここからは完了フェーズです。

 

 見張っていたカーネルが「fd9 にデータが届いた」ことを検知し、libuv に完了通知を送ります。流れるのは「fd9 が準備完了」という番号だけです。

 libuv がその番号を鍵に登録簿を引き、「fd9 → コールバック処理」を取得します。

 そのコールバックがアプリケーション上で実行され、getUser  await の次の行(A-2)から再開します。

ステップ4:少し遅れて、fd10 の完了フェーズ

少し遅れて API の応答も届けば、今度は fd10 についてまったく同じ①〜③が走ります。

 

 カーネルが「fd10 が準備完了」を libuv に通知。

 libuv が登録簿から「fd10 → コールバック処理」を取得。

 コールバックが実行され、getNews  B-2 から再開します。

両方そろったところで Promise.all が解決し、C のログが出て完了です。

ところで、ここまで await db.findUser(9) と一息に書いてきましたが、この一行が裏で返している Promise という存在に、まだきちんと触れていませんでした。実はこの Promise こそ、いま追った図の世界と、私たちが書く JavaScript のコードとをつなぐ「窓口」です。同じ図をもう一度、今度は Promise の視点から見直してみましょう。

4. Promise とは何か

db.findUser(9) はその場で「券」を返す

await db.findUser(9) という一行は、実は2つの動作に分かれています。

const promise = db.findUser(9);   // ① findUser がすぐ何かを返す
const user = await promise;       // ② その「何か」の中身を取り出す

db.findUser(9) を呼ぶと、その場ですぐ戻り値が返ってきます。とはいえ、返ってくるのは DB のデータ……ではありません。まだ返事は届いていないのですから当然です。代わりに返るのが Promise——「結果は今はまだ無いけれど、あとで必ず渡す(か、失敗を伝える)と約束する券」です。レストランの番号札を思い浮かべてください。料理(データ)と引き換えるための、非同期処理の引換券です。

これを図のステップ1に重ねると、こうなります。db.findUser(9) を呼んだ瞬間——①で DB への問い合わせが実行され、③で fd9 がカーネルの監視対象に登録された、あの瞬間——アプリケーションの手元に残るのは引換券(Promise)だけで、中身はまだ空っぽです。

 

券には「ステータス」がある——監視対象と連動している

この引換券は、ただの紙きれではありません。処理が今どこまで進んだかというステータスを持っていて、その移り変わりを1枚にすると次の図になります。

 

図の見方はこうです。

  • 左の pending(保留中) が出発点です。db.findUser(9) を呼んで券を受け取った直後——ステップ1の状態にあたります。まだ結果は無く、券は「保留中」のまま宙に浮いています。
  • ここから券は2方向のどちらか一方にしか進みません。返事が無事に届けば、右上の fulfilled(成功) へ。上向きの矢印に添えた resolve(成功が確定) がその引き金です。これはステップ3で受信コールバックが走り、データが揃った瞬間に相当します。
  • 逆に通信エラーなどで失敗すれば、右下の rejected(失敗) へ。下向きの矢印の reject(失敗が確定) が引き金です。

そして矢印の向きに注目してください。pending から出ていく矢印はあっても、戻ってくる矢印はありません。一度 fulfilled  rejected に確定した券は、二度と別の状態に変わらない——これが「引換券のステータスは一方通行」という意味です。だから同じ Promise を何度 await しても、確定済みの同じ結果が返ってきます。

さらに肝心なのは、この券のステータスが、前章の図でカーネルが見張っている監視対象(fd9)の状態と連動していることです。fd9 にまだ返事が来ていないステップ1〜2の間、券は pending。ステップ3でカーネルが「fd9 準備完了」を通知し、受信コールバックがデータを揃えた瞬間に、券は pending → fulfilled へ動きます。つまり Promise とは、イベントループが見張っている対象のステータスを、開発者が JavaScript の世界から覗くための窓口でもあるわけです。カーネル・libuv という水面下の状態が、この一枚の券に映し出されている、と考えると腑に落ちます。

券から中身を取り出す:本来は .then()

券を握っているだけでは、データは使えません。「券が fulfilled になったら、この処理をして」とあらかじめ登録しておく必要があります。それが .then() です。

const promise = db.findUser(9);      // 引換券を受け取る(pending)
promise.then((user) => {             // 「券が確定したら、これを実行して」と予約
  console.log(user.name);
});

これ、見覚えがないでしょうか。前章の完了フェーズ(ステップ3)で「fd9 → 続きの処理」が登録簿に控えられ、券が確定した瞬間に呼び出される——構造はまったく同じです。.then() に渡した関数こそ、図の登録簿に入る「続き」の正体のひとつです。

await は「券が中身に化けるのを待つ」ための、読みやすい書き方

.then() でもデータは取り出せますが、処理が連なると入れ子が深くなり読みづらくなります(いわゆるコールバック地獄)。そこで await の出番です。

// .then スタイル:券を受け取って、続きをコールバックで書く
db.findUser(9).then((user) => {
  console.log(user.name);
});
// await スタイル:券が中身に化けるのを待って、同期的に受け取る
const user = await db.findUser(9);   // ← 券の中身をそのまま変数へ
console.log(user.name);

await は「右側の Promise(券)が fulfilled になるまで席を離れて待ち、確定したら中の値を取り出して返す」係です。.then() のコールバックを書く代わりに、あたかも同期処理のように、上から下へ書けるようになります。裏で起きていること(登録して席を譲る → 券が確定 → 続きを再開)は前章の図とまったく同じで、await はそれを読みやすい見た目に整えているだけ——ここでも「一番上の薄い皮」なのです。

await を付け忘れると Promise が返る」理由

ここまで分かると、冒頭の疑問——「await を付け忘れると Promise が返ってくるのはなぜ?」——にも答えられます。

const x = db.findUser(9);           // awaitなし
console.log(x);    // → Promise { <pending> }(券そのもの)
const user = await db.findUser(9);  // awaitあり
console.log(user); // → { id: 9, name: "..." }(中身)

await を付けなければ、券(Promise)を中身に引き換える動作が走らないまま次の行へ進みます。だから手元には引換券が残ったまま。「付け忘れると Promise が返る」の正体はこれです。

async は「この関数は引換券を返す」という宣言

ここで、ずっと脇役だった async の正体もはっきりさせておきましょう。async は関数に付けるで、意味はとてもシンプルです。

async を付けた関数の戻り値は、必ず Promise(引換券)に包まれる。

return user と書いても、呼び出し側が受け取るのは user そのものではなく Promise<user>。途中で例外を投げれば、その券は rejected になります。「この関数はすぐには結果を返さない。まず引換券を渡して、中身はあとで確定させる」——それを宣言するのが async です。

async function getUser() {
  return { id: 9, name: "Alice" };   // ← ただのオブジェクトを返しているつもりでも
}
const r = getUser();
console.log(r);   // → Promise { <fulfilled> }(券に包まれている)

そしてもう一つ大事なルールがあります。await(席を離れて待つ動作)が使えるのは、async を付けた関数の中だけです。裏返すと、関数の中で一度でも await したいなら、その関数は async にせざるを得ない。await する=「自分もすぐには終われず、結果はあとで返す」ということなので、当然その関数も引換券を返す関数になる、という筋の通った話です。

async は呼び出し元へ「伝染」していく

この2つのルールを組み合わせると、面白い——そして最初は戸惑う——現象が起きます。async/await が、呼び出しチェーンを上へ上へと伝播していくのです。

// 末端:DBを待つので await が要る → async が必須
async function getUser() {
  const user = await db.findUser(9);
  return user;                       // 戻り値は Promise<user>
}
// 中間:getUser() の中身を使いたい → await する → 自分も async に
async function buildProfile() {
  const user = await getUser();      // ここで await
  return `${user.name} さん`;         // これも Promise<string> になる
}
// 上位:buildProfile() の中身を使いたい → また await → また async
async function handler(req, res) {
  const text = await buildProfile(); // ここでも await
  res.send(text);
}

一番下の getUser が「DB を待つ」ために async になった瞬間、その戻り値は引換券になります。その中身を使いたい buildProfile  await するしかなく、すると buildProfile も引換券を返す関数(async)になる。さらにその上の handler も……と、「あとで返る」という性質が、値を使うすべての呼び出し元へ玉突きで広がっていくわけです。

なぜ途中で断ち切れないのでしょう。「中間の関数が、こっそり結果を待って同期的な値にして返す」ことができれば伝播は止まります。しかしそれは、その場に立ち止まってスレッドをブロックすることに他なりません。イベントループの大前提——待つ間に他の仕事を捌く——を壊してしまいます。だから「待ちがある」という事実は隠せず、型(Promise)として全経路に正直に現れる。これは面倒というより、「この経路には待ちが含まれる」がコード上で追跡できる、むしろ安全な設計だと考えると腑に落ちます。

では、この伝播はどこで止まるのか。答えはエントリポイントです。Express のリクエストハンドラ、main()、イベントリスナ——プログラムの一番外側で誰かが最後に受け止めれば、そこで鎖は終わります。普段のアプリ開発で末端からフレームワークまで async を意識せずに書けているのは、この一番外側をフレームワークが引き受けてくれているからです。私たちは「待ちを使う関数に async を付ける」を末端でやるだけで、あとは自然に上流までつながります。

では、その登録簿に控えられる「続き」——.then() の中身にあたる受信コールバックは、具体的に何なのでしょうか。次章では、それを自分の手で書いてみます。

5. 登録簿に入るのは「受信コールバック」

ここまでで「fd の準備ができたら、対応する処理が実行される」と説明してきました。では、その「対応する処理」——図の登録簿に入っているものの正体は、具体的に何でしょうか。

この受信コールバックは、普段は fetch  axios、DB ドライバといったライブラリが内部で用意しているという点です。「ライブラリが勝手にやってくれる」の正体は、ライブラリの中で誰かがこのコールバックを書いて登録している、というだけ。つまり、自分でも書けます。低レベルな姿から順に見ていきましょう。

レベル1:最も生の姿(ソケット + コールバック)

タイマーもHTTPライブラリも使わず、「fd を監視して、読めたらコールバック」を手で書くと、こうなります。Node の net モジュールが、図の登録簿への登録を担当します。

const net = require("net");
// APIサーバーにTCP接続する(ここでfdが1つ割り当てられる)
const socket = net.connect(80, "example.com", () => {
  // 接続できたらHTTPリクエストを"実行"する(図の①)
  socket.write("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n");
});
// ★これが「登録簿に入るコールバック」そのもの★
//   「このソケット(fd)が読めるようになったら、これを実行して」と登録している
socket.on("data", (chunk) => {
  console.log("受信コールバックが起動:", chunk.toString().slice(0, 50));
});

socket.on("end", () => {
  console.log("受信完了");
});

socket.on("data", ...) に渡している関数——これが図の「fd9 → 受信コールバック」の中身です。.on("data") を呼んだ瞬間、内部で「このfdが読める状態になったら、この関数を呼べ」と libuv の登録簿とカーネルの epoll に登録されます。ライブラリがやっていた「勝手に入れてくれる」の正体は、この .on("data", callback) の一行なのです。

ただしこれはコールバックスタイルなので、await はまだ使えません。await で受けたいなら、次のレベルで Promise に包みます。

レベル2:自分でPromiseを作って、awaitできるようにする

受信コールバックを new Promise の中に入れて、データが全部届いたら resolve する——これで await 可能な関数を自作できます。

const net = require("net");
// API取得を、await可能な関数として自作する
function fetchFromApi() {
  return new Promise((resolve, reject) => {
    const socket = net.connect(80, "example.com", () => {
      socket.write("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n");  // ① 実行
    });

    let received = "";

    // ★登録簿に入る受信コールバック(自分で書いている)★
    socket.on("data", (chunk) => {
      received += chunk.toString();   // 届いたぶんを溜める(届くたびに呼ばれる)
    });

    // 全部届いたらPromiseを解決する
    socket.on("end", () => {
      resolve(received);              // ← ここで pending → fulfilled(ステップ3③の引き金)
    });

    // 失敗したらreject
    socket.on("error", (err) => {
      reject(err);
    });
  });
}
// これで自作関数をawaitできる!
async function main() {
  console.log("問い合わせ前");
  const body = await fetchFromApi();   // ← 自分で作ったPromiseをawait
  console.log("受信完了:", body.slice(0, 50));
}
main();

このコードで、記事の第3章で見た図の全要素が、自分のコードとして揃います。

図の要素上のコードの該当箇所
ステップ1① 問い合わせの実行socket.write(...)
登録簿に入る受信コールバックsocket.on("data", ...)
ステップ3③の引き金(Promise解決)resolve(received)
await の続きconsole.log("受信完了", ...)

new Promise の中身が、これまで「ライブラリが書いていた低レベル処理」だったものです。それを自分の手で書いているのがこのコードで、axios  fetch の内部も、煎じ詰めれば(エラー処理・タイムアウト・再接続などを足しつつ)これと同じことをやっています。

レベル3:「登録簿に入るコールバック」だけを最小で

ライブラリも DB も API も使わず、「fd を監視して、読めたらコールバック」の骨組みだけを抜き出すと、こうなります。標準入力(これも立派な fd=0 です)で見せると一番シンプルです。

// 標準入力(fd=0)も監視対象のひとつ
process.stdin.on("data", (data) => {
  // ★これが登録簿に入るコールバック★
  //   「fd0が読めるようになったら(=キー入力があったら)これを実行」
  console.log("入力コールバックが起動:", data.toString().trim());
});
console.log("入力待ち(でもプログラムは固まっていない)");

これを実行すると、.on("data", ...) の関数がカーネルの epoll 経由で「fd0 が読めるようになったら呼ばれる」よう登録され、キーを打つたびに起動します。待っている間もプログラムは固まらない——これこそ、イベントループが背後で回っている何よりの証拠です。

まとめ:登録簿の中身は「返事が来たら動く係」

3つのレベルを通して見えてくるのは、登録簿に入るものの正体です。

  • 入るのは「API アクセス処理(これから叩く)」ではなく、受信コールバック(返事が来たら処理する係)
  • その実体は .on("data", ...) に渡す関数で、「fd が読めたら実行して」と libuv/epoll に登録される
  • await 可能にするには、その受信コールバックを new Promise で包み、データが揃ったら resolve する
  • 普段使う fetchaxios・DBドライバは、この「受信コールバック + new Promise」を、実運用に耐えるよう堅牢に書き上げたもの

普段のアプリ開発で、この低レベルコードを自分で書くことはまずありません。それでも「ライブラリの await の裏では new Promise とソケットの受信コールバックが動いている」と知っていれば、タイムアウトやコネクション管理でトラブルが起きたとき、どの層を見ればいいかを切り分けられるようになります。ライブラリをブラックボックスにしないための、確かな足場になるはずです。

おわりに

かなり色々説明をはしょったような気もしますが、おおまかな概念は伝えきれたようにお思います。これでasync/await怖くなくなれば幸いです。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

3人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です