プログラマのための IT 教科書

プロセスとメモリ

この部の 3 / 12 章 ・ 全体で 24 / 76 章 ・ 読了目安 45 分

この章を読むとできるようになること
  • OOMKilled と too many open files の原因を切り分けられる
  • SIGTERM を受けて安全に終了する必要性を説明できる
  • goroutine が軽い理由を説明できる

ここまでの章で、説明せずに使ってきた言葉があります。

これらは全部、OS がプログラムをどう動かしているかの話です。 知らなくてもコードは書けますが、障害の原因が分からなくなります。

この章は、そこだけを埋めます。OS の教科書ではないので、 実務で必要になるところだけを扱います。

プログラムとプロセス

ディスクにある実行ファイルは、ただのバイト列です。 それを OS が読み込んで動いている状態にしたものがプロセスです。

./server           ディスク上のファイル(静止している)
    ↓ 実行
プロセス(PID 4821)  メモリを持ち、CPU を割り当てられ、動いている

同じ実行ファイルから、プロセスは何個でも作れます。 それぞれが独立したメモリを持ちます。

仮想メモリ — 各プロセスは世界を独り占めしていると思っている

プロセスは「アドレス 0x00400000 に自分のコードがある」と認識していますが、 それは本物のメモリの番地ではありません。

プロセスA が見るアドレス  →  [OS + CPU の変換]  →  実際の物理メモリ
プロセスB が見るアドレス  →  [OS + CPU の変換]  →  別の物理メモリ

この仕組みのおかげで、

  • プロセス同士が互いのメモリを壊せない(隔離)
  • 物理メモリより大きなアドレス空間を扱える
  • 足りなければディスクに退避できる(スワップ)

「別のプロセスの変数を直接書き換える」ができないのは、これが理由です。 だからプロセス間で情報をやり取りするには、 ファイル・ソケット・パイプといった OS の仕組みを通す必要があります。

コマンドラインで生きるの章のパイプはこれ

ps aux | grep node の | は、 左のプロセスの標準出力を、右のプロセスの標準入力につなぐよう OS に頼んでいます。

プロセスが隔離されているからこそ、こういう明示的な接続が必要になります。

プロセスのメモリは4つに分かれている

高位アドレス
┌─────────────────┐
│  スタック        │  ← 関数呼び出しごとに積む。下に伸びる
│      ↓          │
│                 │
│      ↑          │
│  ヒープ          │  ← 実行中に確保する。上に伸びる
├─────────────────┤
│  データ領域      │  ← グローバル変数・定数
├─────────────────┤
│  コード領域      │  ← 機械語(読み取り専用)
└─────────────────┘
低位アドレス

実務で効いてくるのは、スタックとヒープの違いです。

スタックとヒープ

スタックヒープ
何を置くか関数のローカル変数・引数・戻り先実行時にサイズが決まるデータ
確保の速さ非常に速い(ポインタを動かすだけ)遅い(空き領域を探す)
解放関数を抜けたら自動GC か手動
サイズ小さい(数MB)大きい(メモリの許す限り)
func f() {
    x := 42              // スタック(関数を抜けたら消える)
    s := make([]int, 1e6) // 中身はヒープ(大きい・寿命が読めない)
    _ = s
}
スタックオーバーフロー

スタックは小さくて固定的なので、深すぎる再帰で溢れます。

function f(n) { return f(n + 1); }
f(0);   // RangeError: Maximum call stack size exceeded

「無限再帰でクラッシュ」の正体はこれです。 再帰の深さがデータ量に比例する処理(木の探索など)を書く時は、 入力の深さに上限があるかを確認してください。

GC(ガベージコレクション)

ヒープに置いたデータを、いつ解放するか。 Go・JavaScript・Java は GC が自動で回収します。

GC は「どこからも参照されていないもの」を探して解放します。 そのため、次のことが起きます。

  • メモリリークは「解放し忘れ」ではなく「参照し続けている」で起きる
  • GC が動く瞬間、わずかに処理が止まる(stop-the-world)
// これはリークする。listeners が増え続け、GC が回収できない
const listeners = [];
function onRequest(handler) { listeners.push(handler); }
Go の「エスケープ解析」

Go は、変数をスタックとヒープのどちらに置くかをコンパイラが判断します。 関数の外に参照が漏れるものだけがヒープに行きます(エスケープする、と言います)。

go build -gcflags='-m' ./...   # どれがヒープに逃げたかを表示する

性能を詰める段階で使う道具ですが、 「なぜこの変数がヒープに行くのか」を追えると、GC の負荷を減らせます。

スレッド — メモリを共有する実行の流れ

1つのプロセスの中に、複数の実行の流れを持てます。これがスレッドです。

プロセス(メモリを1つ持つ)
 ├── スレッド1  ← スタックは別々
 ├── スレッド2  ← しかしヒープとグローバルは共有
 └── スレッド3

プロセスは隔離されるが、スレッドは共有する。 ここが決定的な違いです。

だから、Goのデータ競合が起きます。

counter := 0
// 2つの goroutine が同時に counter++ を実行すると、
// 読み → 足す → 書く の途中で割り込まれ、片方の結果が消える

共有しているものを同時に触るから壊れる。共有していなければ壊れません。 これが「メモリを共有して通信するのではなく、通信してメモリを共有せよ」という Go の標語の意味です。

コンテキストスイッチ

CPU のコア数より多くのスレッドは、切り替えながら動きます。 切り替えには、レジスタの保存・復元と、OS のスケジューラの処理が必要です。

これが goroutine が軽い理由につながります。

OS スレッドgoroutine
スタック最初から数MB確保2KB から始まり、必要に応じて伸びる
切り替えOS のスケジューラ(カーネルに入る)Go ランタイムが行う(ユーザー空間で完結)
現実的な数数千数十万

goroutine は OS スレッドの上に Go ランタイムが実装した軽量な実行単位です。 「goroutine を10万個作ってよい」のは、OS を経由しないからです。

システムコール — カーネルに頼む

プログラムは、自分ではファイルを読めませんし、ネットワークにも触れません。 ハードウェアを直接触れるのは OS(カーネル)だけです。

ユーザー空間     あなたのプログラム
                      │  read(fd, buf, n)  ← システムコール
─────────────────────┼──────────────────
カーネル空間      OS がディスクから読む

この境界をまたぐのはコストがかかります。だから、

  • 小さい read/write を大量に呼ぶと遅い(バッファリングする理由)
  • ログを1行ごとに flush すると重い
  • I/O が遅いのは、ディスクやネットワークが遅いだけでなく、この往復もある
# プロセスがどんなシステムコールを呼んでいるか見る
strace -p <PID>       # Linux
sudo dtruss -p <PID>  # macOS(SIP の解除が必要な場合がある)

「なぜか遅い」時に、実際に何を呼んでいるかを見られるのは強力です。

ファイルディスクリプタ — 番号で開いているものを指す

プロセスが開いているファイル・ソケットは、整数の番号で管理されます。

0  標準入力  (stdin)
1  標準出力  (stdout)
2  標準エラー (stderr)
3〜 開いたファイル・ソケット

コマンドラインで生きるの 2>&1(標準エラーを標準出力に流す)は、この番号のことでした。

`too many open files` の正体

ファイルや HTTP レスポンスを閉じ忘れると、番号が増え続けて上限に達します。

resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()   // これを忘れるとリークする

上限は ulimit -n で確認できます(開発機だと 256 や 1024 のことがある)。

症状が出るのが数時間後なので、原因にたどり着きにくいのが厄介です。 「しばらく動かすと繋がらなくなる」なら、まずこれを疑ってください。

lsof -p <PID> | wc -l    # そのプロセスが今いくつ開いているか

シグナル — プロセスへの通知

OS から、あるいは他のプロセスから、プロセスに送る短い通知です。

シグナル意味止められるか
SIGINT (2)Ctrl+Cできる
SIGTERM (15)「終了してください」できる
SIGKILL (9)「今すぐ死ね」できない(カーネルが強制終了)
SIGHUP (1)端末が切れた / 設定再読み込みの慣習できる

Kubernetes の終了フローはこうなっている

1. Pod の削除が決まる
2. Service のエンドポイントから外される(新しいリクエストが来なくなる)
3. コンテナに SIGTERM が送られる          ← ここで後始末をする
4. terminationGracePeriodSeconds(既定30秒)待つ
5. まだ生きていたら SIGKILL             ← 問答無用で殺される

3 で何もしないと、処理中のリクエストが途中で切られます。 だからグレースフルシャットダウンを書きます。

// SIGTERM を受けたら、新規受付を止め、処理中が終わるのを待ってから落ちる
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
 
go func() { _ = srv.ListenAndServe() }()
 
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)   // 処理中のリクエストの完了を待つ
待ち時間は grace period より短くする

上の例で 20*time.Second にしているのは、 Kubernetes の猶予(既定30秒)より短くするためです。

猶予より長く待つと、待っている最中に SIGKILL され、 グレースフルシャットダウンを書いた意味がなくなります。

コンテナの PID 1 問題

コンテナの中では、あなたのプロセスが PID 1 になることがあります。 PID 1 には特別扱いがあり、明示的にハンドラを登録していないシグナルが無視されます。

さらに、シェル経由で起動すると(CMD sh -c "./server")、 PID 1 はシェルになり、シェルは SIGTERM を子に転送しません。

CMD ["./server"]          # 良い(exec 形式。server が PID 1 になる)
CMD ./server              # 危険(shell 形式。sh が PID 1 になる)

「Pod の削除にいつも30秒かかる」なら、SIGTERM が届いておらず SIGKILL を待っている可能性が高いです。

メモリ制限と OOMKilled

RSS と仮想メモリ

top や ps に出るメモリには2種類あります。

表記意味
VSZ / VIRTプロセスが確保した仮想アドレス空間。実際には使っていない分も含む
RSS / RES実際に物理メモリを使っている量

見るべきは RSS です。 VSZ は Go や JVM だと巨大な数字が出ますが、 それ自体は問題ではありません。

OOMKilled

コンテナには limits.memory でメモリ上限を設定します(コンテナと Kubernetes)。 上限を超えると、カーネルがそのプロセスを SIGKILL で殺します。

State:       Terminated
  Reason:    OOMKilled
  Exit Code: 137          ← 128 + 9(SIGKILL)

Exit Code 137 を見たら OOM と覚えてください。

原因は主に3つです。

  1. 本当に足りない — limit を上げる
  2. リークしている — 参照を持ち続けている箇所を探す
  3. 一度に全部読んでいる — 100万行のファイルを丸ごとメモリに載せている

3 が新人に最も多いパターンです。

// 危険: ファイル全体をメモリに載せる
data, _ := os.ReadFile(path)
 
// 安全: 1行ずつ処理する(メモリ使用量が入力サイズに依存しない)
sc := bufio.NewScanner(f)
for sc.Scan() { process(sc.Text()) }

DB のクエリも同じです。LIMIT の無い SELECT は、 本番のデータ量でプロセスを殺します。

Pod が Exit Code 137 で再起動を繰り返しています。まず何を確認しますか。

見るための道具

ps aux | head              # プロセス一覧(RSS・CPU・起動コマンド)
top / htop                 # リアルタイムの負荷
lsof -p <PID>              # そのプロセスが開いているファイル・ソケット
lsof -i :8080              # そのポートを掴んでいるプロセス
kill -TERM <PID>           # 終了を依頼する(まずこれ)
kill -KILL <PID>           # 強制終了(最後の手段)
pgrep -fl server           # 名前でプロセスを探す
`kill -9` を最初に打たない

kill -9(SIGKILL)は、プロセスに後始末をさせません。

  • 書きかけのファイルが壊れる
  • ロックが残る
  • 処理中のリクエストが失われる

まず kill(SIGTERM)を送り、それでも終わらない時だけ -9 を使います。 「-9 でないと止まらない」こと自体がバグの兆候です。

実務の落とし穴まとめ

  1. Exit Code 137 — OOMKilled。ログは残らない
  2. too many open files — Close 漏れ。数時間後に発症する
  3. CMD ./server(shell 形式) — SIGTERM が届かず、常に SIGKILL される
  4. grace period より長く待つ — 待っている途中で殺される
  5. ファイル・クエリ結果を全部メモリに載せる — 本番のデータ量で落ちる
  6. 深い再帰 — スタックオーバーフロー
  7. kill -9 を最初に打つ — 後始末が行われない
  8. VSZ を見て「メモリを食っている」と判断 — 見るべきは RSS

まとめ

  • プロセスはメモリを隔離され、スレッドはメモリを共有する。 だからデータ競合はスレッド(goroutine)でだけ起きる
  • スタックは速くて小さい・自動で消える、ヒープは大きくて GC が要る
  • goroutine が軽いのは、スタックが 2KB から可変で、 切り替えがユーザー空間で完結するから
  • I/O はシステムコールでカーネルに頼む。往復コストがあるからバッファリングする
  • 開いたものはファイルディスクリプタ。閉じ忘れると too many open files
  • SIGTERM は止められる、SIGKILL は止められない。 Kubernetes は SIGTERM → 猶予 → SIGKILL の順で来る
  • Exit Code 137 = OOMKilled。見るのは RSS

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
Linux man pages(signal(7) など)https://man7.org/linux/man-pages/
Go runtime パッケージhttps://pkg.go.dev/runtime
Kubernetes: Pod のライフサイクルhttps://kubernetes.io/ja/docs/concepts/workloads/pods/pod-lifecycle/

章末問題

デプロイのたびに、数リクエストが 502 エラーになります。最も可能性が高い原因は?

goroutine を10万個起動しても動くのに、OS スレッドを10万個作るとマシンが止まるのはなぜですか。

次の章では、これらのプロセスが実際に動いている場所—— Linux サーバーの中を歩きます。ログはどこにあり、サービスはどう管理されているのか。

読み終わったら記録しておくと、目次で進み具合が分かります。