async/awaitの動きをイベントループ、fd、epoll、Promiseの仕組みからたどり、非同期処理がどう進むのかをコードと図の対応でわかりやすく解説します。
こんにちは、サイオステクノロジー武井です。今回はasync/awaitの動きをOSカーネルのレイヤーから追うことで理解を深めてみます。
async/awaitは、なかなか取っつきにくい概念だと思います。
「非同期処理にはasyncを付ける」「awaitしないと結果が取れない」——ルールとしては覚えられます。コードも動きます。でも、こんな疑問にぶつかった経験はないでしょうか。
awaitしている間、プログラムは「止まって」いるの? 止まっていないの?awaitを付け忘れると Promise が返ってくるのはなぜ?こうした疑問は、イベントループやepollの仕組みを知っていると、すっきり解決するのではないかと思い、今回筆を執りました。
鍵になるのは、イベントループという仕組みと、さらにその下で動くOSカーネルの epollという機構です。async/awaitはこの2つの上にかぶせられた「一番上の薄い皮」にすぎません。皮だけを眺めて中身を推測しようとするから難しいのであって、下の層から順に積み上げれば、実はかなり素直な仕組みです。
この記事では、その水面下を「イベントループと fd」→「実際のコードの動き」の順で見ていきます。
プログラムがデータベースや外部APIにアクセスすると、返事が来るまでの待ち時間が発生します。CPUの感覚では、この待ち時間は途方もなく長いものです。メモリアクセスを「1秒」とすると、ネットワークの応答待ちは「数週間〜数か月」に相当します。
つまりI/O待ちの間、CPUは膨大に暇をしています。「この暇な時間に別の仕事をさせたい」——これが非同期処理のすべての動機です。
古典的なやり方は、リクエストごとにスレッドを用意して、それぞれに待たせる方式でした。しかしスレッドは1本あたり数MBのメモリを消費するため、同時接続数が多くなると厳しいことになります。
そこで発想を逆転させます。1本のスレッドが、大量の「待ち」をまとめて見張り、準備ができたものから順に処理する。レストランでいえば、客1組ごとにウェイターを張り付かせるのではなく、1人のウェイターが全テーブルを担当し、「呼ばれたテーブルにだけ行く」方式です。
この「ぐるぐる回りながら、準備できた仕事を順にさばく」ループこそがイベントループです。
ここで2つの重要な部品が登場します。epollとfdです。
**fd(ファイルディスクリプタ)**は、OSが通信の窓口(ソケットなど)1つ1つに割り振る、ただの整数です。DBへの接続なら fd9、外部APIへの接続なら fd10、というように、「どの待ち先か」を番号で識別できます。
ポイントは、fd は単なる番号であって、中身のデータでも処理でもないことです。「9番の窓口」「10番の窓口」という札だと思ってください。
epollはLinuxカーネルの機能で、やることを一言でいうと:
大量の fd を登録しておくと、「データが届いた/送れる状態になった」fd だけをまとめて教えてくれる
イベントループのepoll.wait()は、カーネルへの「どれか準備できるまで寝ているから、できたら起こして」という依頼です。1万個の fd を登録していても、実際に用があるのが2個なら、2個分の番号だけが返ってきます。全部を1個ずつ見回る必要がない——ここが決定的に効率的です。
もう1つ、仕組みの肝になるのが情報の置き場所です。
カーネルからの通知は「fd9 が準備完了」という番号だけです。libuvはその番号を鍵にして、自分の登録簿から「fd9 → このコールバック」を引き当て、実行キューに積みます。番号で照合して、初めて「続きの処理」が動き出すわけです。
この分業を頭に入れたうえで、実際のコードを見てみましょう。
ユーザー情報を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者です。
getUserの番——awaitに到達し、fd9 を登録するgetUser()が呼ばれ、A-1 のログを出し、await db.findUser(9)に到達します。ここで登録フェーズが動きます。
① アプリケーションが libuv に「DBアクセス処理を頼む」と依頼します。DBへの問い合わせも、この依頼に乗って送り出されます。
② libuv がカーネルに「この接続を見張って」と依頼します。
③ カーネルの監視対象に、DB接続の窓口であるfd9が登録されます。以後カーネルは fd9 を見張り続けます。
④ libuv が自分の登録簿に「fd9 → コールバック処理(A-2 以降の続き)」を控えます。
そして決定的なのが次の一歩です。getUserはDBの返事を待ちません。①〜④の依頼と登録だけ済ませて、その場で中断し、制御をイベントループに返します。ウェイターが注文を厨房に通して、すぐテーブルを離れるのと同じです。
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消費はゼロです。
やがてDBの応答が届きます。ここからは完了フェーズです。
① 見張っていたカーネルが「fd9 にデータが届いた」ことを検知し、libuvに完了通知を送ります。流れるのは「fd9 が準備完了」という番号だけです。
② libuv がその番号を鍵に登録簿を引き、「fd9 → コールバック処理」を取得します。
③ そのコールバックがアプリケーション上で実行され、getUserが**awaitの次の行(A-2)から再開**します。
少し遅れてAPIの応答も届けば、今度は fd10 についてまったく同じ①〜③が走ります。
① カーネルが「fd10 が準備完了」を libuv に通知。
② libuv が登録簿から「fd10 → コールバック処理」を取得。
③ コールバックが実行され、getNewsがB-2 から再開します。
両方そろったところでPromise.allが解決し、Cのログが出て完了です。
ところで、ここまでawait db.findUser(9)と一息に書いてきましたが、この一行が裏で返しているPromiseという存在に、まだきちんと触れていませんでした。実はこの Promise こそ、今追った図の世界と、私たちが書く JavaScript のコードとをつなぐ「窓口」です。同じ図をもう一度、今度は 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の状態にあたります。まだ結果は無く、引換券は「保留中」のまま宙に浮いています。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> }(引換券に包まれている)
そしてもう1つ大事なルールがあります。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()の中身にあたる受信コールバックは、具体的に何なのでしょうか。次章では、それを自分の手で書いてみます。
ここまでで「fd の準備ができたら、対応する処理が実行される」と説明してきました。では、その「対応する処理」——図の登録簿に入っているものの正体は、具体的に何でしょうか。
この受信コールバックは、普段はfetchやaxios、DBドライバといったライブラリが内部で用意しているものです。「ライブラリが勝手にやってくれる」の正体は、ライブラリの中で誰かがこのコールバックを書いて登録している、というだけ。つまり、自分でも書けます。低レベルな姿から順に見ていきましょう。
タイマーも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 に包みます。
受信コールバックを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の内部も、煎じ詰めれば(エラー処理・タイムアウト・再接続などを足しつつ)これと同じことをやっています。
ライブラリも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つのレベルを通して見えてくるのは、登録簿に入るものの正体です。
.on("data", ...)に渡す関数で、「fd が読めたら実行して」と libuv/epoll に登録されるawait可能にするには、その受信コールバックをnew Promiseで包み、データがそろったらresolveするfetch・axios・DBドライバは、この「受信コールバック + new Promise」を、実運用に耐えるよう堅牢に書き上げたもの普段のアプリ開発で、この低レベルコードを自分で書くことはまずありません。それでも「ライブラリのawaitの裏ではnew Promiseとソケットの受信コールバックが動いている」と知っていれば、タイムアウトやコネクション管理でトラブルが起きたとき、どの層を見ればいいかを切り分けられるようになります。ライブラリをブラックボックスにしないための、確かな足場になるはずです。
かなりいろいろ説明をはしょった気もしますが、おおまかな概念は伝えきれたのではないかと思います。これでasync/awaitが怖くなくなれば幸いです。
ご覧いただきありがとうございます! この投稿はお役に立ちましたか?
4人がこの投稿は役に立ったと言っています。