「その処理、本当に並列ですか?」Node.js, Python, Ruby, Goで踏み抜くCPUバウンドの罠
この記事は「Hacobell Developers Advent Calendar」ー 8日目の記事です。
はじめに
「あの言語の並行処理って、結局どう動くんだっけ?」
日々の開発業務に追われる中で、ふと立ち止まってしまうことはありませんか?現代のアプリケーション開発において、マルチコアCPUの性能を最大限に引き出し、ユーザーに快適なレスポンスを返すために並行処理の理解は不可欠です。
しかし、使用する言語によって、そのアプローチや内部的な挙動は驚くほど異なります。
本記事では、Node.js, Python, Ruby, Goをピックアップし、それぞれの並行処理モデルが「CPUバウンドなタスク」と「I/Oバウンドなタスク」に対してどのように振る舞うのか、具体的なコード例を交えながらその違いを再整理します。
対象読者
本記事は以下のような方を想定しています。
- 複数の言語を日常的に使用している開発者:異なる言語の並行処理モデルの違いを理解したい方
- パフォーマンスチューニングを検討している方:I/OバウンドなタスクとCPUバウンドなタスクの違いを理解し、言語選択やアーキテクチャ設計に活かしたい方
本記事では、マルチスレッド・マルチプロセスの体系的な解説は割愛し、各言語で実行した際の挙動に焦点を当てます。詳細な理論的背景については、各言語の公式ドキュメントや専門書を参照してください。
Node.js: シングルスレッドとノンブロッキングI/O
Node.jsの並行処理モデルを理解する鍵は、シングルスレッドとノンブロッキングI/Oです。
I/Oバウンドなタスク
Node.jsが真価を発揮するのは、I/Oバウンドなタスクです。データベースへのクエリや外部APIの呼び出し(fetchなど)が発生すると、Node.jsはスレッドをブロックしません。
Promise.allを使えば、これらのI/O処理は効率的に並行実行されます。
const fetchData = async (url) => {
const response = await fetch(url);
return response.json();
};
const main = async () => {
console.log("処理開始");
const startTime = Date.now();
await Promise.all([
fetchData("https://api.example.com/data1"),
fetchData("https://api.example.com/data2"),
]);
const endTime = Date.now();
console.log(`全処理終了 (${endTime - startTime}ms)`);
};
このコードを実行すると、2つのAPI呼び出しがほぼ同時に開始され、全体の処理時間は約1回分の待ち時間で済みます。
CPUバウンドなタスク
では、I/Oではなく、CPUバウンドな重い計算ではどうでしょう?
Promise.allとasync/awaitを使えば、I/Oのように並行処理できそうに見えます。
例えば、この約1秒かかる処理を2回同時に実行してみましょう。
// 重い計算処理(CPUバウンドなタスク)
const cpuHeavyTask = async (taskName) => {
console.log(`${taskName}: 開始`);
const startTime = Date.now();
// 非常に大きなループでCPUを占有する(1秒程度)
for (let i = 0; i < 5_000_000_000; i++) {
}
const endTime = Date.now();
console.log(`${taskName}: 終了 (${endTime - startTime}ms)`);
return endTime - startTime;
};
const main = async () => {
console.log("処理開始");
const startTime = Date.now();
await Promise.all([
cpuHeavyTask("タスクA"),
cpuHeavyTask("タスクB"),
]);
const endTime = Date.now();
console.log(`全処理終了 (${endTime - startTime}ms)`);
};
main();
このコードを実行するとどうなるでしょう?
Promise.allで2つのタスクを同時に実行依頼しているので、1秒かかるタスクが2つあっても、全体の処理時間は約1秒(+α)になるでしょうか?
実は、、、なりません。 実際の実行結果(環境によりますが)は、このようになります。
処理開始
タスクA: 開始
タスクA: 終了 (約1000ms)
タスクB: 開始
タスクB: 終了 (約1000ms)
全処理終了 (約2000ms)
Node.jsはシングルスレッドで動作しています。Promiseやasync/awaitは、I/O待ちのようなノンブロッキングな処理でこそ真価を発揮し、待機中に他の処理を実行できます。
しかし、cpuHeavyTaskのような処理はCPUを占有するため、ノンブロッキングにはなりません。「タスクA」がforループでCPUを占有している間、Node.jsは他の処理(「タスクB」の開始)に進むことができません。「タスクA」が完了して初めて、次の「タスクB」が実行されます。
Promise.allを使ったとしても、CPUバウンドな処理は並行ではなく逐次的に実行されてしまうのです。
Worker Threads: CPUバウンドなタスクの解決策
では、Node.jsでCPUバウンドなタスクを並列実行する方法はないのでしょうか?
実は、Node.js v10.5.0以降ではWorker Threadsが利用できます。これは別のOSスレッドでJavaScriptコードを実行する機能で、CPUバウンドなタスクを並列実行できます。
Worker Threadsを使ったCPUバウンドなタスクの例
まず、ワーカースレッドで実行する処理を別ファイルに分離します。
const { parentPort, workerData } = require('worker_threads');
// 重い計算処理(CPUバウンドなタスク)
const cpuHeavyTask = (taskName) => {
console.log(`${taskName}: 開始`);
const startTime = Date.now();
// 非常に大きなループでCPUを占有する(1秒程度)
for (let i = 0; i < 5_000_000_000; i++) {}
const endTime = Date.now();
console.log(`${taskName}: 終了 (${endTime - startTime}ms)`);
return endTime - startTime;
};
const result = cpuHeavyTask(workerData.taskName);
parentPort.postMessage(result);
次に、メインスレッドから複数のWorkerを起動します。
const { Worker } = require('worker_threads');
const runWorker = (taskName) => {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', {
workerData: { taskName }
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
};
const main = async () => {
console.log("処理開始");
const startTime = Date.now();
await Promise.all([
runWorker("タスクA"),
runWorker("タスクB"),
]);
const endTime = Date.now();
console.log(`全処理終了 (${endTime - startTime}ms)`);
};
main();
このコードを実行すると、以下のような結果になります。
処理開始
タスクA: 開始
タスクB: 開始
タスクA: 終了 (約1000ms)
タスクB: 終了 (約1000ms)
全処理終了 (約1000ms)
# 1タスク分の時間で全処理が完了
Worker Threadsを使うことで、CPUバウンドなタスクが並列実行され、全体の処理時間は約1秒(1タスク分)で済むようになります。
Python: マルチスレッドとGIL (Global Interpreter Lock)
Python(CPython)は、マルチスレッドをサポートしています。しかし、その挙動にはGIL (Global Interpreter Lock) という大きな制約が伴います。
I/Oバウンドなタスク
先に結論を言うと、PythonのスレッドはI/Oバウンドなタスクでは有効です。
Pythonの threading モジュールを使って、I/Oバウンドなタスクをマルチスレッドで実行する例を見てみましょう。
import threading
import time
# I/O待ちを伴う処理(I/Oバウンドなタスク)
def io_heavy_task(task_name, sleep_time):
time.sleep(sleep_time) # I/O待ちをシミュレート
print("処理開始")
start_total_time = time.time()
# 2つのスレッドを作成
t1 = threading.Thread(target=io_heavy_task, args=("タスクA", 1))
t2 = threading.Thread(target=io_heavy_task, args=("タスクB", 1))
# スレッド開始
t1.start()
t2.start()
# スレッドの終了を待つ
t1.join()
t2.join()
end_total_time = time.time()
print(f"全処理終了 ({end_total_time - start_total_time:.2f}s)")
このコードを実行すると、2つのI/O待ち処理がほぼ同時に開始され、全体の処理時間は約1回分の待ち時間で済みます。
CPUバウンドなタスク
では、Node.jsと同じCPUバウンドなタスクを、Pythonの threading モジュールで実行してみましょう。
import threading
import time
# 重い計算処理(CPUバウンドなタスク)
def cpu_heavy_task(task_name):
print(f"{task_name}: 開始")
start_time = time.time()
# 非常に大きなループでCPUを占有する(1秒程度)
for i in range(5_000_000_000):
pass
end_time = time.time()
print(f"{task_name}: 終了 ({end_time - start_time:.2f}s)")
print("処理開始")
start_total_time = time.time()
# 2つのスレッドを作成
t1 = threading.Thread(target=cpu_heavy_task, args=("タスクA",))
t2 = threading.Thread(target=cpu_heavy_task, args=("タスクB",))
# スレッド開始
t1.start()
t2.start()
# スレッドの終了を待つ
t1.join()
t2.join()
end_total_time = time.time()
print(f"全処理終了 ({end_total_time - start_total_time:.2f}s)")
2つのスレッド(t1, t2)で処理を分けています。マルチコアCPU環境であれば、2つのタスクが並列に実行され、全体の処理時間は約1秒(1タスク分)になるでしょうか?
実は、、、なりません。 実際の実行結果は、このようになります。
処理開始
タスクA: 開始
タスクB: 開始
タスクA: 終了 (2.01s)
タスクB: 終了 (2.02s)
全処理終了 (2.03s)
# 2タスク分の時間がかかる
Pythonでこのような結果になるのはGIL (Global Interpreter Lock) の存在です。
GILは一種の排他制御で、複数のスレッドが同時にPythonオブジェクトにアクセスすることを防ぎます。CPUバウンドなタスクでは、各スレッドがこのGILを取得・解放を繰り返しながら実行されるため、実質的には1つのスレッドずつ順番に処理されることになります。
Pythonでは(Node.jsと異なり)スレッド自体は即座に開始されますが、このGILの制約により、マルチコア環境であっても同一時刻に実行できるのは常に1スレッドのみです。そのため、CPUバウンドなタスクにおいては並列処理にはならず、そのオーバーヘッドによって逐次実行と同等、あるいはそれ以上に処理時間がかかる可能性があります。
multiprocessing: CPUバウンドなタスクの解決策
PythonでCPUバウンドなタスクを並列実行する方法としてmultiprocessingモジュールを使うことが挙げられます。これは別のプロセスを起動するため、各プロセスが独立したGILを持ち、並列実行が可能になります。
multiprocessingを使ったCPUバウンドなタスクの例
import multiprocessing
import time
# 重い計算処理(CPUバウンドなタスク)
def cpu_heavy_task(task_name):
print(f"{task_name}: 開始")
start_time = time.time()
# 非常に大きなループでCPUを占有する(1秒程度)
for i in range(5_000_000_000):
pass
end_time = time.time()
print(f"{task_name}: 終了 ({end_time - start_time:.2f}s)")
return end_time - start_time
if __name__ == '__main__':
print("処理開始")
start_total_time = time.time()
# 2つのプロセスを作成
with multiprocessing.Pool(processes=2) as pool:
results = pool.map(cpu_heavy_task, ["タスクA", "タスクB"])
end_total_time = time.time()
print(f"全処理終了 ({end_total_time - start_total_time:.2f}s)")
このコードを実行すると、以下のような結果になります。
処理開始
タスクA: 開始
タスクB: 開始
タスクA: 終了 (1.02s)
タスクB: 終了 (1.03s)
全処理終了 (1.10s)
# 1タスク分の時間で全処理が完了
multiprocessingを使うことで、CPUバウンドなタスクが並列実行され、全体の処理時間が1タスク分で済むようになります。
Ruby: マルチスレッドとGVL (Global VM Lock)
Ruby(MRI/CRuby)もPythonと同様、マルチスレッドをサポートしており、PythonのGILと非常によく似たGVL (Global VM Lock) を持っています。
I/Oバウンドなタスク
RubyもPythonと同様で、スレッドがI/O待ち(ブロッキングI/O)に入るとGVLを解放します。そのため、I/Oバウンドな処理ではマルチスレッドが有効に機能します。
CPUバウンドなタスク
では、Rubyの Thread でCPUバウンドなタスクを実行してみましょう。
# 重い計算処理(CPUバウンドなタスク)
def cpu_heavy_task(task_name)
puts "#{task_name}: 開始"
start_time = Time.now
# 非常に大きなループでCPUを占有する(1秒程度)
5_000_000_000.times do |i|
end
end_time = Time.now
puts "#{task_name}: 終了 (#{end_time - start_time}s)"
end
puts "処理開始"
start_total_time = Time.now
# 2つのスレッドを作成
t1 = Thread.new { cpu_heavy_task("タスクA") }
t2 = Thread.new { cpu_heavy_task("タスクB") }
# スレッドの終了を待つ
t1.join
t2.join
end_total_time = Time.now
puts "全処理終了 (#{end_total_time - start_total_time}s)"
Rubyではどうでしょう? 2つのスレッド(t1, t2)で処理を分けています。処理時間は半分になるでしょうか?
もうお分かりですね。実行結果はPythonとほぼ同様、2つのタスクが並列には処理されず、逐次実行と変わらない(か、遅くなる)時間がかかります。
処理開始
タスクA: 開始
タスクB: 開始
タスクA: 終了 (約2.00s)
タスクB: 終了 (約2.01s)
全処理終了 (約2.01s)
# 2タスク分の時間がかかる
RubyのGVLもPythonのGILと同様の役割を果たします。そのため、CPUバウンドなタスクにおいては、複数のスレッドが同時にRubyのVMでコードを実行することができません。結果として、Pythonと同じく、マルチスレッドを使ってもCPUバウンドな処理は並列化されないのです。
Ractor: CPUバウンドなタスクの解決策
Ruby 3.0以降ではRactorという並列実行機構が導入されました。各Ractorオブジェクトが独立したGVLを持つため、CPUバウンドなタスクを並列実行できます。
Ractorを使ったCPUバウンドなタスクの例
# 重い計算処理(CPUバウンドなタスク)
def cpu_heavy_task(task_name)
puts "#{task_name}: 開始"
start_time = Time.now
# 非常に大きなループでCPUを占有する(1秒程度)
5_000_000_000.times do |i|
end
end_time = Time.now
puts "#{task_name}: 終了 (#{end_time - start_time}s)"
end_time - start_time
end
puts "処理開始"
start_total_time = Time.now
# 2つのRactorを作成
r1 = Ractor.new { cpu_heavy_task("タスクA") }
r2 = Ractor.new { cpu_heavy_task("タスクB") }
# Ractorの終了を待つ
result1 = r1.take
result2 = r2.take
end_total_time = Time.now
puts "全処理終了 (#{end_total_time - start_total_time}s)"
このコードを実行すると、以下のような結果になります。
処理開始
タスクA: 開始
タスクB: 開始
タスクA: 終了 (約1.00s)
タスクB: 終了 (約1.01s)
全処理終了 (約1.02s)
# 1タスク分の時間で全処理が完了
Ractorを使うことで、CPUバウンドなタスクが並列実行され、全体の処理時間が1タスク分で済むようになります。
Go: 軽量スレッドGoroutine
Goの並行処理モデルを理解する鍵は、Goroutine です。GoはNode.js、Python、Rubyとは異なり、並行処理・並列処理を言語レベルで強力にサポートしています。
I/Oバウンドなタスク
GoもNode.jsやPython、Rubyと同様に、I/Oバウンドなタスクを効率的に処理できます。
Goroutineを使えば、複数のHTTPリクエストや外部API呼び出しを並行実行できます。I/O待機中はGoroutineがブロックされても、Goのランタイムが他のGoroutineを実行するため、全体として効率的に処理が進みます。
コード例
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
// I/Oバウンドなタスク(外部APIへのHTTPリクエスト)
func fetchData(url string, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("リクエスト開始: %s\n", url)
startTime := time.Now()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("エラー: %v\n", err)
return
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
endTime := time.Now()
fmt.Printf("リクエスト完了: %s (%v)\n", url, endTime.Sub(startTime))
}
func main() {
fmt.Println("処理開始")
startTime := time.Now()
var wg sync.WaitGroup
urls := []string{
"https://api.example.com/data1",
"https://api.example.com/data2",
"https://api.example.com/data3",
}
wg.Add(len(urls))
// 3つのGoroutineを開始
for _, url := range urls {
go fetchData(url, &wg)
}
// すべてのGoroutineが終了するのを待つ
wg.Wait()
endTime := time.Now()
fmt.Printf("全処理終了 (%v)\n", endTime.Sub(startTime))
}
このコードを実行すると、3つのHTTPリクエストがほぼ同時に開始され、全体の処理時間は約1回分の待ち時間で済みます。Node.jsと似た結果が得られます。
CPUバウンドなタスク
では、Node.js、Python、Rubyでは並列化されなかったCPUバウンドなタスクは、Goではどうでしょう?
GoにはGIL/GVLのようなグローバルなロックは存在しません。go キーワードで処理をGoroutineとして起動し、sync.WaitGroup で全Goroutineの終了を待つことで、同じCPUバウンドなタスクを試してみましょう。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
// 重い計算処理(CPUバウンドなタスク)
func cpuHeavyTask(taskName string, wg *sync.WaitGroup) {
defer wg.Done() // タスク完了時にWaitGroupに通知
fmt.Printf("%s: 開始\n", taskName)
startTime := time.Now()
// 非常に大きなループでCPUを占有する(1秒程度)
for i := 0; i < 10_000_000_000; i++ {
}
endTime := time.Now()
fmt.Printf("%s: 終了 (%v)\n", taskName, endTime.Sub(startTime))
}
func main() {
fmt.Println("処理開始")
startTime := time.Now()
var wg sync.WaitGroup // 終了待ちのためのWaitGroup
// 2つのGoroutineを開始
wg.Add(2)
go cpuHeavyTask("タスクA", &wg)
go cpuHeavyTask("タスクB", &wg)
// すべてのGoroutineが終了するのを待つ
wg.Wait()
endTime := time.Now()
fmt.Printf("全処理終了 (%v)\n", endTime.Sub(startTime))
}
このコードを実行するとどうなるでしょう?
実は、期待通りに並列実行されます! マルチコアCPU環境で実行すると、以下のような結果になります。
処理開始
タスクB: 開始
タスクA: 開始
タスクA: 終了 (約1.01s)
タスクB: 終了 (約1.02s)
全処理終了 (約1.03s)
# 1タスク分の時間で全処理が完了
なぜこのような結果になるのでしょうか?
Goのランタイムは、Goroutineを複数のOSスレッド(=異なるCPUコア)に柔軟に割り当てて実行します。
Node.jsのようなシングルスレッドの制約も、Python/RubyのようなGIL/GVLも存在しないため、CPUバウンドなタスクが並列実行されます。結果として、タスクが2つあっても、全処理時間は1タスク分の時間とほぼ同じになります。
まとめ
本記事では、Node.js、Python、Ruby、Goという4つの言語で、同じCPUバウンドなタスクとI/Oバウンドなタスクを実行し、その挙動の違いを確認しました。
各言語の並行処理モデルは、言語が設計された時代背景や解決すべき課題によって大きく異なります。重要なのはどの言語が優れているかではなく、どのタスクに、どの言語が適しているかを理解することです。
この記事が、読者の皆さんの知識の整理や、新しい言語を学ぶきっかけになれば幸いです。
各言語の並行処理モデル比較表
| 言語 | 並行処理モデル | I/Oバウンドなタスク | CPUバウンドなタスク | 制約 |
|---|---|---|---|---|
| Node.js | シングルスレッド ノンブロッキングI/O |
⭕ 効率的に並行実行 | ❌ 逐次実行になる | メインスレッドをブロックすると他の処理も停止 |
| Python | マルチスレッド GIL |
⭕ 並行実行可能 | ❌ GILにより逐次実行 | GILが1スレッドずつの実行を強制 ※Python 3.14以降はfree-threaded buildで改善 |
| Ruby | マルチスレッド GVL |
⭕ 並行実行可能 | ❌ GVLにより逐次実行 | GVLが1スレッドずつの実行を強制 |
| Go | 軽量スレッド Goroutine |
⭕ 効率的に並行実行 | ⭕ 並列実行 | マルチコアを活用可能 |
「物流の次を発明する」をミッションに物流のシェアリングプラットフォームを運営する、ハコベル株式会社 開発チームのテックブログです! 【エンジニア積極採用中】t.hacobell.com//blog/engineer-entrancebook
Discussion
Ruby の「CPUバウンドなタスク」の結果間違っていますか?2 タスク分の時間になっていないように思います
@Masashi Nishiwakiさん
コメントありがとうございます!
おっしゃる通り、所要時間の記載に誤りがありました。
早速ですが、先ほど修正作業を完了いたしましたので、現在は正しい時間になっているかと思います。