プロセスとスレッド — 分けるか、共有するか
シェルスクリプトと環境変数 で、export した値は子へ渡るが、子で書き換えても親には戻らないと述べました。これは決まりごとではなく、そもそも子が別のプロセスで、渡っているのが値のコピーだからです。
一方、スレッドはその逆で、同じメモリを共有します。共有するから受け渡しの費用がかからず、共有するから壊れ方も変わります。この章では、その 2 つの分かれ目を見ます。
この章で学ぶこと
- プロセスとスレッドが何を分け、何を共有するか
- 子プロセスが親の複製として作られる仕組みと、環境変数のコピーが一方向な理由
- パイプでつないだ各段が同時に走っていること
- 共有メモリで競合状態が起きる仕組みと、その直し方
- CPU で詰まるのか待ちで詰まるのかの見分け
- シグナルによる終了と、取り残しを作らない止め方
シェルの基本 の標準入出力と、シェルスクリプトと環境変数 の親子の関係を先に読むと具体例がつながります。イベントループ は、1 本のスレッドの中で待ち時間を捌く側の話です。
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
export と実行方法の使い分けなど、シェルからの操作 | シェルスクリプトと環境変数 |
| 1 本のスレッドの中で待ち時間を捌く仕組み | イベントループ |
| キューのワーカーを何本動かし、どう監視するか | Laravel API 開発ガイド — キューの本番運用 |
| コンテナとして動かすときの台数・配置の設定 | AWS 実践ガイド — ECS サービス |
| 台数を増やす判断そのもの | スケールの方向 |
器と、その中を走る流れ
プロセスは資源の器で、スレッドはその中を走る実行の流れです。1 つのプロセスには最低 1 本のスレッドがあり、増やせます。
| プロセス | 同じプロセス内のスレッド | |
|---|---|---|
| メモリ空間 | 別々。互いの変数は見えない | 共有。同じ変数を読み書きできる |
| ファイルディスクリプタ | 別々 (作るときにコピーされる) | 共有 |
| コールスタック | 別々 | スレッドごとに別。ここは共有しない |
| 環境変数 | 別々 (作るときにコピーされる) | 同じプロセスの環境を指す。実行環境が複製することがある |
| 作る費用 | 高い | 低い |
| 1 つが異常終了したとき | 他のプロセスは生き残る | プロセス全体が落ちることがある。メモリを共有しているため |
コールスタックがスレッドごとに用意される点は、再帰と分割統治 で扱った再帰の深さの上限と直結します。積める段数はスレッドごとの持ち分で決まります。
環境変数の行に条件が付いているのは、言語やランタイムが表を上書きすることがあるからです。プロセスの環境そのものは 1 つですが、たとえば Node.js のワーカーは既定で process.env のコピーを受け取るので、ワーカー側で書き換えても親には戻りません。この表は OS の層の話で、使っている実行環境がどう見せるかは別に確かめてください。
最後の行が実務上いちばん効きます。**分けておけば巻き添えを避けられ、共有すれば受け渡しが要らない。**どちらを取るかがこの章の主題です。
子プロセスは複製から作られる
新しいプロセスは、無から作られるのではなくいまのプロセスを複製して作られます。POSIX の fork はこう定めています。
The new process (child process) shall be an exact copy of the calling process (parent process) except as detailed below
複製した直後に、別のプログラムへ置き換えるのが exec です。
The exec family of functions shall replace the current process image with a new process image. There shall be no return from a successful exec, because the calling process image is overlaid by the new process image.
環境変数が渡るのは、この置き換えのときです。exec の仕様は、新しいプロセスイメージの環境が呼び出し側の environ から取られる (あるいは envp として明示的に渡される) と定めています。渡るのはその時点の値のコピーです。
import {execFileSync} from 'node:child_process';
process.env.APP_LOG_LEVEL = 'DEBUG';
// 子は親の環境のコピーを受け取る
const inherited = execFileSync('sh', ['-c', 'echo $APP_LOG_LEVEL']).toString().trim();
console.log('子が見た値 ', inherited);
// 子が書き換えても親には戻らない
execFileSync('sh', ['-c', 'APP_LOG_LEVEL=WARN; export APP_LOG_LEVEL']);
console.log('親の値は変わらない', process.env.APP_LOG_LEVEL);
子が見た値 DEBUG
親の値は変わらない DEBUG
「子に渡す通り道はあるが、戻ってくる通り道がない」のが親子の関係です。子から親へ何かを返したいなら、標準出力・ファイル・ソケットなど明示的な経路を使います。
パイプの各段は同時に走っている
シェルの基本 で見たパイプは、別々のプロセスの間をつなぐ仕組みです。ここで重要なのは、| でつないだ各段が順番に実行されるのではなく、同時に走ることです。
3 つのプロセスが同時に起動し、左のプロセスが書いた分から順に右のプロセスが読みます。**中間のファイルもできず、全体をメモリに載せる必要もありません。**10 GB のログをパイプでつないでも詰まらないのはこのためです。
同時に走ることから、2 つの挙動が導けます。
- **右が遅いと左が待たされる。**パイプのバッファが埋まると、書き込もうとしたプロセスはそこで止まります。全体の速さは最も遅い段で決まります
- 右が先に終わると左は書けなくなる。
headのように途中で打ち切るコマンドを右に置くと、左のプロセスは書き込み先を失って終了します。無限に出力するコマンドをパイプの左に置いても止まるのは、この経路です
スレッドは共有するから壊れる
同じプロセス内のスレッドは同じメモリを見ます。受け渡しが要らないので速いのですが、同時に書き換えると壊れます。
import {Worker, isMainThread, workerData} from 'node:worker_threads';
const COUNT = 5_000_000;
if (isMainThread) {
for (const mode of ['そのまま足す', 'Atomics で足す'] as const) {
const shared = new SharedArrayBuffer(4);
const workers = [0, 1].map(
() => new Worker(new URL(import.meta.url), {workerData: {shared, mode}}),
);
await Promise.all(workers.map((w) => new Promise((r) => w.on('exit', r))));
const value = Atomics.load(new Int32Array(shared), 0);
console.log(`${mode}: 期待は ${COUNT * 2}、一致したか ${value === COUNT * 2}`);
}
} else {
const {shared, mode} = workerData as {shared: SharedArrayBuffer; mode: string};
const view = new Int32Array(shared);
for (let i = 0; i < COUNT; i++) {
if (mode === 'そのまま足す') view[0] = view[0] + 1; // 読む・足す・書く の 3 手
else Atomics.add(view, 0, 1); // 割り込まれない 1 手
}
}
そのまま足す: 期待は 10000000、一致したか false
Atomics で足す: 期待は 10000000、一致したか true
view[0] = view[0] + 1 は 1 行ですが、読む・足す・書く の 3 手でできています。片方のスレッドが読んだあと書く前に、もう片方が同じ値を読むと、2 回足したのに 1 しか増えません。
**これは ロックと分離レベル のロストアップデートとまったく同じ形です。**あちらは 2 つのトランザクション、こちらは 2 つのスレッドという違いだけで、「読んで、変えて、書く」が割り込まれる点は同じです。同じ理由で、直し方の系統も同じになります。
| 直し方 | スレッドでの例 | データベースでの対応 |
|---|---|---|
| 割り込まれない 1 手にする | Atomics.add | UPDATE ... SET n = n + 1 |
| 他を締め出す | ミューテックスなどの排他制御 | 排他ロック |
| そもそも共有しない | プロセスを分ける / メッセージで渡す | 行を分ける |
上の出力の false は「合わなかった」という意味ですが、必ず合わないわけではありません。片方のワーカーがもう片方の開始前に終わってしまえば、競合そのものが起きないので値は一致します。間欠的にしか壊れないのが競合状態の性質で、テストで捕まえにくい理由もここにあります。
CPU で詰まっているのか、待ちで詰まっているのか
「遅い」の原因は 2 系統あり、効く手当てが違います。
| 何をしている時間か | 効く手当て | |
|---|---|---|
| I/O バウンド | 通信・ディスク・データベースの応答を待っている | イベントループ。1 本のスレッドで何百も並行に待てる |
| CPU バウンド | 計算で演算器を使い切っている | 別のスレッド・別のプロセス。待ちではないので、譲っても総量は減らない |
Node.js のドキュメントはこの線引きをそのまま述べています。
Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work. The Node.js built-in asynchronous I/O operations are more efficient than Workers can be.
同じ計算を、メインスレッドで回した場合と別スレッドへ出した場合で比べます。
import {Worker, isMainThread} from 'node:worker_threads';
function heavy(): number {
let sum = 0;
for (let i = 0; i < 2_000_000_000; i++) sum += i;
return sum;
}
if (!isMainThread) {
heavy(); // ワーカーとして起動されたときは、これだけ実行して終わる
} else {
// 1) メインスレッドで回す
let ticks = 0;
let timer = setInterval(() => ticks++, 10);
heavy();
clearInterval(timer);
console.log('メインスレッドで回した間にタイマーが動いたか:', ticks > 0);
// 2) 同じ処理を別スレッドで回す
ticks = 0;
timer = setInterval(() => ticks++, 10);
const worker = new Worker(new URL(import.meta.url));
await new Promise((resolve) => worker.on('exit', resolve));
clearInterval(timer);
console.log('別スレッドで回した間にタイマーが動いたか :', ticks > 0);
}
メインスレッドで回した間にタイマーが動いたか: false
別スレッドで回した間にタイマーが動いたか : true
10 ミリ秒ごとのタイマーが 1 回も動かないのは、その間ずっと 1 つのタスクが回り続けていたからです。別スレッドへ出すと、メインスレッドは空いたままになります。
**別スレッドは魔法ではありません。**計算そのものにかかる時間は変わらず、変わるのは「その間に他のことができるか」だけです。使えるコアの数を超えてスレッドを増やせば、待ち行列が伸びるだけになります。
終わらせ方 — シグナル
動いているプロセスを止める合図がシグナルです。実務で効くのは 2 つの違いです。
| シグナル | 捕まえられるか | 使いどころ |
|---|---|---|
SIGTERM | できる | 通常の停止依頼。後始末の時間を与える |
SIGKILL | できない | 応答しなくなったプロセスの強制終了 |
POSIX は SIGKILL を「Kill (cannot be caught or ignored)」と定めています (IEEE Std 1003.1-2024 — signal.h)。捕まえられないということは、後始末のコードが動かないということです。
デプロイやコンテナの入れ替えでは、まず SIGTERM を送り、一定時間待ってから SIGKILL へ進む形が一般的です。この待ち時間の中で行うことが、取り残しを作らないための後始末です。
- 新しい仕事を受け付けるのをやめる — スケールの方向 のヘルスチェックを異常側に倒し、ロードバランサーの配り先から外れる
- 処理中の仕事を終わらせる — 進行中のリクエストや、取り出し済みのキューのジョブ
- 接続を閉じる — データベースへの接続や、開いているファイル
**待ち時間を超えると SIGKILL になり、途中の仕事は途中のまま消えます。そこで壊れないようにする設計が リトライと冪等性 の側の話で、「もう一度実行されても平気にしておく」**ことが答えになります。
よくある誤解
「スレッドを増やせば速くなる」 — 速くなるのは CPU バウンドの処理を、空いているコアへ配れたときだけです。待ちが原因なら、スレッドを増やしても待ち時間は減りません。コア数を超えて増やせば、切り替えの費用が乗るぶん遅くなります。
「子プロセスで環境変数を書き換えれば親にも伝わる」 — 伝わりません。渡るのは値のコピーで、方向は親から子への一方向です。戻したいなら標準出力やファイルなど、明示的な経路が要ります。
「パイプは左が終わってから右が動く」 — 同時に動きます。左が書いた分から右が読み始めるので、中間ファイルもできませんし、全体をメモリに載せる必要もありません。
「1 プロセス 1 スレッドなら競合状態は起きない」 — 同じメモリを触るスレッドが 1 本でも、同じデータを触るプロセスが複数あれば同じ壊れ方をします。ファイル・データベース・共有キャッシュはいずれも共有された状態です。
「kill -9 で確実に止められるから、それを既定にする」 — 確実に止まりますが、後始末のコードが一切動きません。処理中の仕事も、書きかけのファイルも、途中のまま残ります。既定は SIGTERM で、SIGKILL は応答しないときの最後の手段です。
確認問題
問 1. 画像を一括変換するバッチが、1 件ずつなら 3 秒で終わるのに、100 件を Promise.all で同時に走らせても 300 秒かかります。「同時に走らせたのだから速くなるはずだ」と言われました。どう説明しますか。
変換が CPU バウンドだからです。
Promise.all が並行にできるのは「待っている時間」だけです。画像の変換は待ちではなく計算なので、同じスレッドの上で順番に実行されるだけになります。同時に開始はしますが、同時に計算はしていません。
短くするには、計算を別の実行の流れへ配る必要があります。
- 別スレッドへ出す — ワーカーを CPU のコア数ぶん用意し、そこへ変換を振り分けます
- 別プロセスへ出す — キューに積んでワーカープロセスが処理します。1 件が失敗しても他が巻き添えにならない利点があります
**どちらにしても、上限はコア数です。**4 コアなら理屈のうえでも 4 倍までで、100 倍にはなりません。100 件を 4 コアで割ると 25 回ぶんの時間がかかります。
問 2. デプロイのたびに、注文確認メールが届かない注文が数件出ます。ログには「注文を保存した」まで残っていますが、その先がありません。何が起きていて、どう直しますか。
古いプロセスが、メール送信まで進む前に止められていると考えられます。
デプロイでは古いプロセスへ停止の合図が送られます。処理の途中で SIGKILL まで進むと、そこで実行が消えます。「保存した」までログがあって続きがないのは、その形と合います。
切り分けと対処は 2 段です。
- 停止の合図を受け取って後始末をしているか確認する。
SIGTERMを捕まえていなければ、既定の動作で即座に終了します。受け付けを止め、処理中の分を終わらせてから終了するようにします - **待ち時間が足りているか確認する。**後始末を書いても、
SIGKILLまでの猶予より処理が長ければ同じことが起きます
**そのうえで、消えても復旧できる形にしておきます。**メール送信を注文の保存と同じ処理の中で行わず、キューへ積んでおけば、プロセスが落ちてもジョブは残ります。再実行で二重送信にならないよう、リトライと冪等性 の考え方を併せます。
問 3. 訪問回数を数えるカウンタを、複数のワーカーから更新しています。テストでは正しく増えるのに、本番では時々数が合いません。原因の候補と、確かめ方を説明してください。
「読んで、変えて、書く」が割り込まれる競合状態が第一候補です。
カウンタの更新が「現在値を読む → 1 を足す → 書き戻す」の 3 手なら、2 つのワーカーが同じ値を読んだ時点で 1 回ぶん失われます。
**テストで再現しないのは、それが競合状態の性質だからです。**片方が終わってからもう片方が始まれば、重なりが起きないので正しい値になります。負荷が高いほど重なりやすくなるので、本番でだけ出るという症状と合います。
確かめ方は次のとおりです。
- **更新が 1 手になっているか読む。**アプリ側で読んでから書いているなら候補に当たります。データベースなら
UPDATE ... SET n = n + 1の形か、SELECTしてからのUPDATEかを見ます - **重なりを増やして再現させる。**同時実行数を上げると差が出ます。ずれる量が同時実行数に応じて増えるなら、この原因です
直し方は 3 つの系統があります。割り込まれない 1 手にする、排他制御で他を締め出す、そもそも共有しない (ワーカーごとに数えて後で合算する) です。カウンタなら 1 つ目が最も単純で速く、ロックと分離レベル で扱った選択と同じ判断になります。
まとめ
- プロセスは資源の器、スレッドはその中を走る流れです。プロセスはメモリを分け、スレッドは共有します。コールスタックはスレッドごとに別で、共有の見え方は実行環境が上書きすることがあります
- 新しいプロセスはいまのプロセスの複製として作られ、そこへ別のプログラムを重ねます。環境変数はこのときコピーで渡るので、子から親へは戻りません
- パイプの各段は同時に走ります。中間ファイルは要らず、右が遅ければ左が待ち、右が終われば左も終わります
- スレッドの共有メモリでは「読んで、変えて、書く」が割り込まれます。ロックと分離レベル のロストアップデートと同じ形で、直し方の系統も同じです
- **競合状態は間欠的にしか出ません。**合ってしまう回があることが、テストで捕まらない理由です
- 待ちで詰まっているならイベントループ、計算で詰まっているなら別の実行の流れが効きます。上限は使えるコアの数です
SIGTERMは捕まえられ、SIGKILLは捕まえられません。後始末を動かせるのは前者だけなので、止め方は「受け付けを止める → 処理中を終える → 閉じる」の順に設計します
- IEEE Std 1003.1-2024 — fork — 子プロセスが親の複製として作られること
- IEEE Std 1003.1-2024 — exec — プロセスイメージの置き換えと環境の受け渡し
- IEEE Std 1003.1-2024 — signal.h — シグナルの一覧と既定の動作
- Node.js — worker_threads — ワーカーが向く処理と、メモリを共有する手段
- Laravel API 開発ガイド — キューの本番運用 — ワーカープロセスの本数と監視