はじめに
アプリケーション開発をしていると、次のような言葉をよく目にします。
- CPU使用率が高い
- メモリ不足でアプリケーションが落ちた
- スレッドが詰まっている
- プロセスが再起動した
- ジョブの処理が遅れている
これらを個別の用語として覚えているだけでは、「なぜ処理が遅くなったのか」「CPUを増やせば解決するのか」「実装をどう直せばいいのか」を判断できません。
結論を先に書きます。「処理が重い」という同じ現象でも、内部で起きていることは4通りに分かれます。CPUが計算能力を使い切っている場合、メモリを使いすぎている場合、スレッドが外部からの応答を待っている場合、処理能力に対して仕事量が多すぎる場合です。この4つは打ち手がまったく違うため、切り分けずにCPUを増やしても解決しません。
CPU・メモリ・プロセス・スレッド・ジョブの関係を整理したうえで、処理が重くなったときに内部で何が起きているのか、そして実装や障害調査にどう活かせるのかを説明します。
全体像
5つの用語は、次の役割を持ちます。
| 用語 | 役割 |
|---|---|
| CPU | 命令を実際に実行する |
| メモリ | 処理中のデータを一時的に保持する |
| プロセス | 実行中のプログラム |
| スレッド | プロセス内で処理を進める実行単位 |
| ジョブ | アプリケーション上の「仕事」の単位 |
CPUとメモリという限られたリソースを、複数のプロセスやスレッドが分け合って使っています。この「限られたものを分け合っている」という構造が、後半の「処理が重い」の話につながります。
CPUは命令を実行する装置である
CPUは、プログラムの命令を実際に処理する装置です。次のようなコードを書いたとき、実際に計算しているのはCPUです。
numbers.map { |number| number * 2 }
CPUには複数のコアが搭載されていることがあります。コアは、命令を実行する処理装置の単位です。複数のコアがあれば、複数の処理を本当に同じ瞬間に進められます。
並行処理と並列処理は違う
CPUが1コアしかなくても、Chrome、Slack、VS Codeを同時に使えます。これはOSが高速に実行対象を切り替えているからです。同じ瞬間に動いているのは1つだけで、人間には同時に見えます。これを並行処理と呼びます。
並行処理(1コア) 時間 →
Core 1 │ Chrome │ Slack │ VS Code │ Chrome │ Slack │
└────────┴───────┴─────────┴────────┴───────┘
切り替えながら順番に実行する
一方、複数のCPUコアを使って、本当に同じ瞬間に複数の処理を進めるのが並列処理です。
並列処理(複数コア) 時間 →
Core 1 │ Chrome の処理 │
Core 2 │ Slack の処理 │
Core 3 │ VS Code の処理 │
同じ瞬間に、本当に複数の処理が進む
コンテキストスイッチにはコストがかかる
CPUが実行するスレッドを切り替えるとき、OSは現在の実行状態を保存し、次の実行状態を復元します。この切り替えをコンテキストスイッチと呼びます。
スレッドAを実行
↓
Aの状態を保存する
↓
Bの状態を復元する
↓
スレッドBを実行
次にどのスレッドをCPUで実行するかは、OSのスケジューラが決めます。
スレッドを増やせば処理枠は増えます。ただし、CPUコア数を大きく超えて増やすと、保存と復元の回数も増えます。そのため、スレッドを増やせば必ず速くなるわけではありません。
メモリは処理中のデータを置く場所である
メモリは、プログラムの実行中に必要なデータを一時的に置いておく場所です。CPUを作業する人にたとえるなら、メモリは作業机にあたります。机が広ければ多くの資料を広げられますが、広さには限りがあります。
大量のデータを一度に保持すると、利用可能なメモリが少なくなります。
プロセスは実行中のプログラムである
プロセスは、実行中のプログラムです。Chromeを起動すればChromeのプロセスが、Slackを起動すればSlackのプロセスが作られます。
ただし、アプリケーション1つに対してプロセスが1つとは限りません。Chromeのように、1つのアプリケーションが複数のプロセスを使うこともあります。
プロセスごとにメモリ空間が分かれる
物理メモリ自体はマシンに搭載された1つの資源です。しかしOSは、各プロセスに対して基本的に独立した仮想メモリ空間を提供します。仮想メモリ空間とは、そのプロセスから見える専用のメモリ領域のことです。
物理メモリ
├── Chrome プロセスが使う領域
├── Slack プロセスが使う領域
└── VS Code プロセスが使う領域
そのためSlackのプロセスが、Chromeプロセス内の変数を直接操作することは基本的にできません。プロセスが分かれていれば、一方が落ちても他方に直接の影響が及びにくくなります。
スレッドはプロセス内の実行単位である
スレッドは、プロセスの中で処理を進める実行単位です。Chromeプロセスの中でも、画面を描画するスレッド、通信を担当するスレッドというように、複数の処理を並行して進めます。
プロセスとの大きな違いは、メモリの扱いです。同じプロセス内のスレッドは、そのプロセスのメモリ空間を共有します。
Chrome プロセス
┌─────────────────────────────┐
│ 共有されるメモリ空間 │
│ │
│ スレッド A → 画面描画 │
│ スレッド B → 通信処理 │
│ スレッド C → その他 │
└─────────────────────────────┘
プロセス同士 → メモリ空間は分かれている
スレッド同士 → メモリ空間を共有している
CPUが実際に実行する対象は、ざっくり言えばスレッドです。OSのスケジューラが、実行可能なスレッドの中から、どのスレッドをどのCPUコアで動かすかを決めます。
ジョブはアプリケーション上の仕事である
ジョブは、CPUやメモリのような物理的なリソースではありません。一般的には、アプリケーション上の1つの仕事を表す言葉です。画像を1枚変換する、PDFを1ファイル生成する、メールを1通送る、データを100件同期する、といったものがジョブにあたります。
プロセスやスレッドは仕事を実行する側で、ジョブは実行される仕事です。
プロセス
└── スレッド
└── ジョブを実行する
ジョブはOSが定める厳密な共通概念ではなく、アプリケーション側で定義されることが多い概念です。
「処理が重い」とき、内部では4通りのことが起きている
同じ「処理が遅い」でも、内部で起きていることは違います。大きく分けると次の4つです。
パターン1: CPUが忙しい
大量の計算処理を実行していると、CPUの処理能力を使い切ります。CPUコアがすべて埋まり、他のスレッドはCPUの空きを待ちます。
1_000_000.times do
# 重い処理
end
この状態が続くと、画面表示が遅くなり、APIのレスポンスが遅くなり、バックグラウンド処理も進みにくくなります。
打ち手は、計算そのものを減らせないかを考えることです。
users.each do |user|
expensive_calculation(user)
end
このコードが遅いのであれば、不要なループがないか、同じ計算を何度もしていないか、DB側で処理できないか、キャッシュできないかを検討します。
パターン2: メモリを使いすぎている
大量のデータを一度に読み込むと、メモリの空きが減ります。
users = User.all.to_a
空きメモリが少なくなると、新しいメモリを確保しにくくなり、処理が不安定になります。最悪の場合、プロセスが終了します。
打ち手は、一度に持つデータを減らすことです。必要なカラムだけを取得し、分割して処理します。
User
.select(:id, :email)
.where(active: true)
.find_in_batches(batch_size: 1000) do |users|
# 1000件ずつ処理する
end
パターン3: CPUは暇なのに処理が遅い
これが最も分かりにくいパターンです。アプリケーションが外部APIを呼び出している間、CPUはほとんど計算をしていません。しかし、その処理が終わるまでスレッドは使われ続けます。
DBアクセス、ネットワーク通信、ファイルの読み書き、Redisへのアクセスなど、CPU以外とのデータのやり取りをI/Oと呼びます。I/O待ちの間、CPU使用率は低いままです。
そのため、CPU使用率が20%、メモリ使用率が50%でも、レスポンスに10秒かかるという状態が起こります。
ここで問題になるのが、スレッドが有限だという点です。5スレッドで動くアプリケーションで、5つとも待ち状態になったとします。
スレッド1 → DBの応答待ち
スレッド2 → DBの応答待ち
スレッド3 → 外部APIの応答待ち
スレッド4 → 外部APIの応答待ち
スレッド5 → ファイルI/O待ち
新しいリクエスト → 空きスレッドなし → 待機
この状態で新しいリクエストが来ても、受け付けるスレッドがありません。このときCPU使用率は高くありません。
パターン4: ジョブが大量に溜まる
ジョブを非同期で処理している場合、処理速度よりもジョブが追加される速度のほうが速いと、キューに仕事が溜まります。
1秒間に 10ジョブ 追加される
1秒間に 5ジョブ しか処理できない
↓
毎秒 5ジョブずつ溜まっていく
この場合は、1ジョブあたりの処理が遅いのか、処理能力に対して仕事量が多すぎるのかを切り分けます。前者なら処理の中身を改善し、後者なら並列数を増やします。
実装でどう活かせるか
リソースの知識は、障害対応だけでなく普段の実装判断にも使えます。
大量データを一度に読み込まない
大量データを扱うとき、すべてを一度にメモリへ展開する必要があるかを考えます。
User.find_each do |user|
# 1件ずつ処理する
end
判断の軸は「メモリにどれくらい載るのか」です。
不要な計算を減らす
全件に対して本当に必要な計算なのか、同じ値を何度も計算していないか、事前に計算できないかを考えます。判断の軸は「CPUをどれくらい使うのか」です。
DBアクセスの回数を意識する
ループの中で毎回DBへアクセスすると、Rubyの処理とDBの応答待ちが交互に発生します。
Rubyの処理 → DB待ち → Rubyの処理 → DB待ち → …
このときCPUではなくI/O待ちが増えます。N+1を避け、必要なデータをまとめて取得し、適切なインデックスを使うことで、待ち時間そのものを減らせます。
外部APIにはタイムアウトを設定する
外部APIからレスポンスが返ってこないと、そのスレッドは長時間使われ続けます。スレッドは有限な処理枠なので、1つの通信が枠を占有し続けると、他のリクエストを受け付けられなくなります。
そのため、接続のタイムアウトと読み取りのタイムアウトを設定し、応答がないときは一定時間で打ち切ります。
非同期処理でスレッドを解放する
時間のかかる処理をHTTPリクエストの中で実行すると、その間ずっとWebサーバーのスレッドを占有します。
同期で実行する場合
リクエスト → 重い処理 → 重い処理 → レスポンス
(この間ずっとスレッドを占有する)
非同期にする場合
リクエスト → ジョブ登録 → レスポンス
↓
別のワーカーがジョブを実行する
ジョブとして登録してすぐレスポンスを返せば、スレッドはすぐ次のリクエストを受け付けられます。非同期処理は「時間がかかるから裏でやる」だけでなく、「Webリクエストを処理するスレッドを長時間占有しない」という意味を持ちます。
障害調査でどう活かせるか
監視画面の数字は、単に高い・低いで見るのではなく、リソースの関係を踏まえて仮説を立てるために使います。
| 観測される値 | 立てられる仮説 |
|---|---|
| CPU 95% / メモリ 50% | CPUがボトルネックになっている |
| CPU 20% / メモリ 50% / 応答10秒 | CPU以外、つまりI/O待ちが起きている |
| メモリ 95% / プロセス再起動 | メモリ不足でプロセスが落ちている |
仮説を立てたら、次の順に確認します。
処理が遅い
│
├── CPUが高い
│ → 重い計算を繰り返していないか
│ → 同じ計算を何度もしていないか
│ → DB側で処理できないか
│
├── メモリが高い
│ → 大量データを一度に読んでいないか
│ → 必要なカラムだけ取得しているか
│ → プロセスが再起動していないか
│
└── どちらも高くない
→ DBのクエリが遅くないか
→ 外部APIの応答を待っていないか
→ スレッドが全部埋まっていないか
→ コネクションプール待ちでないか
この切り分けができるだけで、闇雲にログを見る時間を減らせます。
おわりに
CPU・メモリ・プロセス・スレッド・ジョブの関係を理解すると、「処理が重い」を4つのパターンに切り分けられるようになります。切り分けができれば、CPUを増やすという打ち手が有効なのか、それとも実装を直すべきなのかを判断できます。
普段の実装でも、このコードはCPUを使うのか、メモリに大量のデータを載せていないか、DBや外部APIの応答待ちでスレッドを長時間占有しないか、この処理は同期で実行する必要があるか、という視点を持てるようになります。
コンピューターリソースの理解は、インフラの知識を増やすためだけのものではなく、「なぜこの実装は重いのか」を考えるための土台になります。

