プロセスやスレッド、そして並行処理や並列処理といったプログラムの処理方式について理解が浅かったため整理してみました。
並行処理や並列処理を正しく理解するためには、プロセスやスレッドに関する理解が不可欠です。本記事では、これらをひとつの流れの中でまとめています。
プロセスとスレッドを理解する
参考
プロセス
プロセスとは、OSレベルで見た際の実行中のプログラムのインスタンスを指します。
よく、「実行中のプログラムのインスタンス」という解説がされる場合も多いですが、
例えばRuby等でインスタンスをnewした時に生成されるようなインスタンスとは異なり、プロセスとして扱われる単位には以下のようなリソースが紐づきます。
-
メモリ空間:
- コードセグメント: 実行されるプログラムの命令コードを含む領域
- データセグメント: 静的変数やグローバル変数を含む領域
-
ヒープ(Heap):
malloc
やnew
などで確保される動的メモリ領域 - スタック(Stack): 関数呼び出し時のローカル変数や戻りアドレスなどを格納し、呼び出しに応じて伸縮する
-
CPUリソース:
- プログラムカウンタ: 現在実行中の命令アドレス
- レジスタ: 汎用レジスタやスタックポインタなど、CPUの実行状態を保持
-
ファイルディスクリプタ:
- オープンファイル: ファイルの読み書きなどに使われるファイルディスクリプタ
- ソケット: ネットワーク通信のためのディスクリプタ
-
入出力リソース:
- 標準入力/出力/エラー(stdin, stdout, stderr): デフォルトのIOストリーム
直感的には「アプリケーション(プログラム)の実行単位」と言ってもよいかもしれません。
スレッド
マルチスレッドと並行処理をわかりやすく説明します - フラミナル
** スレッド(Thread)は、プロセス内部で「CPU上で実行される制御の流れ(実行の文脈)」 **を指す単位です。
OSのスケジューラが「このスレッドを何ミリ秒間CPUで動かして、次は別のスレッドを動かす」…というふうに制御し、並行(あるいは並列)に処理を進めます。
スレッドが持つ典型的なリソースには、以下のようなものがあります。
- プログラムカウンタ(命令ポインタ)
- どの命令を実行中かを示すレジスタの値
- CPUレジスタ
- 汎用レジスタ、スタックポインタ、フラグレジスタなど
- スタック
- 関数呼び出し時のローカル変数や戻りアドレス等を保持
- スレッド固有のID
- OSやランタイム上で「この実行の流れ」を識別するID
これらは同じプロセス内の他のスレッドと “コード領域やグローバル変数などは共有” しつつ、スレッドごとに保持する形になります。
(プロセスの場合は他のプロセスとメモリ空間を基本的に共有しません。)
RubyやGo等のアプリケーションレイヤで実装したプログラムが、スレッドによって実行されるような認識でいるとよいかと思われます。
例えばGoやRubyでHTTPリクエストを受け付けてレスポンスを返すようなアプリケーションを実行する際、実装されたプログラムは言語ランタイムやOSによってスレッド上で実行されます。
単一のスレッドで複数のリクエストを同期的に処理するようなシナリオでは、FIFO的にリクエストを順番に処理します。複数スレッドを使うと、リクエストを同時並行に処理することもできます。
単一スレッドで動作するのか複数スレッドで処理を行うのかは、アプリケーションやフレームワークの実装に依存します。
同じ言語・環境でも「ワーカースレッド」や「マルチプロセス」、「スレッドプール」を使うかどうかは、アプリケーション開発者がどう並行/並列処理を構築したいかによって変わります。
コンテキストスイッチ
コンテキストスイッチは、現在実行しているプロセス(またはスレッド)から、別のプロセス(またはスレッド)へ実行主体を切り替えるOSの動作を指します。
切り替え時には以下のような情報(コンテキスト)を保存・読み込みして、続きから実行できるようにします。
- プログラムカウンタ
- スタックポインタ
- フラグレジスタ
非同期、並行、並列を理解する。
参考
その並列処理待った! 「Python 並列処理」でググったあなたに捧ぐasync, threading, multiprocessingのざっくりとした説明 - Qiita
非同期処理 / Asynchronise
非同期処理とは、ある処理の完了を待つことなく、別の処理を先に進められる手法全般を指します。
特にI/O処理(ファイル読み書き、ネットワークアクセス、DBアクセスなど)で待ち時間が長い場合、同期的(ブロッキング)に書いてしまうと他の処理がストップしてしまうため、非同期I/Oを用いて待ち時間中に他のタスクを進められる利点があります。
I/O操作の例
-
ファイルI/O
- ファイル読み込み、書き込み
- ファイルの削除、移動、コピー
-
ネットワークI/O
- HTTPリクエストとレスポンス
- ソケット通信(TCP/UDP)
- データのストリーミング
-
データベースI/O
- クエリの実行
- データの挿入、更新、削除
- トランザクション管理
-
デバイスI/O
- キーボード入力
- マウスの操作
- スキャナーやカメラからのデータ取得
- プリンターへのデータ送信
-
メモリI/O
- メモリマップトファイルの読み書き
- 共有メモリの操作
何かしらの Input を与えて何かしらの Output が返却されるまでの時間が長い場合、同期的に処理を行ってしまうとOutputが返ってくるまで処理が止まってしまいます。
待ち時間が秒単位で発生する場合は、I/Oの処理を待っている間に別の操作を非同期で実行することで、処理を素早く行うことが可能になります。
例えば、外部のAPIにリクエストを投げて、レスポンスが返ってくるまでの間に別の処理を進めるようなケースを再現してみましょう。
Pythonのコード例(外部APIにリクエストする)
クライアント側
import aiohttp
import asyncio
import time
async def fetch(session, url):
async with session.get(url) as response:
result = await response.json()
return result
async def main():
async with aiohttp.ClientSession() as session:
task1 = asyncio.create_task(fetch(session, 'http://127.0.0.1:5001/task1'))
task2 = asyncio.create_task(fetch(session, 'http://127.0.0.1:5002/task2'))
tasks = [task1, task2]
start_time = time.time()
end_time = start_time + 5 # カウントを5秒間続ける
async def monitor_tasks():
while tasks:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
tasks.remove(task)
result = await task
print(f"Task completed with result: {result}")
async def count_work():
count = 0
while time.time() < end_time:
print(f"Doing other work: {count}")
count += 1
await asyncio.sleep(0.5)
await asyncio.gather(monitor_tasks(), count_work())
print(f"Total time taken: {time.time() - start_time:.2f} seconds")
asyncio.run(main())seconds")
asyncio.run(main())
サーバー側
# 同じようなサーバーをポートを変えていくつか用意する。
from flask import Flask, jsonify
import time
import random
app = Flask(__name__)
@app.route('/task1')
def task1():
delay = random.uniform(0.1, 5.0) # 0.1秒から5.0秒の間でランダムな遅延
print(f"Task 1 delay: {delay:.1f} seconds") # デバッグメッセージ
time.sleep(delay)
return jsonify({"message": f"Task 1 completed in {delay:.1f} seconds"})
if __name__ == '__main__':
app.run(port=5001)
上記のプログラムでは、リクエストを投げてレスポンスを待っている間に標準出力を行っているだけなのでいまいちメリットが感じづらいですが、例えばこれを非同期を使わず同期的に処理してみると以下のようになります。
クライアント側
import requests
import time
def fetch(url):
response = requests.get(url)
result = response.json()
print(result) # タスクが完了したら即座に結果を出力
return result
def main():
urls = [
'http://127.0.0.1:5001/task1',
'http://127.0.0.1:5002/task2'
]
start_time = time.time()
for url in urls:
fetch(url)
for i in range(10):
print(f"Doing other work: {i}")
time.sleep(0.5)
print(f"Total time taken: {time.time() - start_time:.2f} seconds")
if __name__ == '__main__':
main()
もし同期的に実装すると、レスポンス待ちの間は他の処理が行えず、処理効率が下がることがわかりますね。
今回は小さな処理ですが、例えばこれがリクエスト先サーバーが100件ある場合等であれば、非常に非効率であることが想像できます。
並行処理 / Concurrency
**並行処理(Concurrency)**とは、複数のタスク(スレッドやコルーチンなど)を同時進行しているかのように扱う手法全般を指します。
シングルコア環境でも、OSやランタイムが高速にコンテキストスイッチを行うことで“同時に処理している”ように見えます。
複数のタスクを同時に処理する、という点においては先述の非同期処理と似ている側面もありますね。
そもそも、非同期処理とはある処理の結果を待たずに別の処理を行うプロセスを指しているため、解釈の仕方によっては並行処理とイコールであるとも言えます。
では、非同期処理と並行処理を明確に隔てるものは何かというと、以下のような差分が存在します。
- 非同期は「ある処理の完了を待たずに他の処理を進める」ことを主に指します。
- 並行は「タスクを同時進行で扱う(インターリーブさせる)設計」であり、CPUのコア数やスケジューリングが大きく絡みます。
- イメージ: 非同期は「待ち時間中にブロックしない」概念、並行は「複数タスクを同時に走らせる(あるいは切り替える)」概念です。
非同期処理では**「ある処理の完了を待つか否か」という部分に焦点が当てられているのに対して、並行処理では「処理のコンテキストが切り替わるか否か」という部分に焦点があたります。
並行処理の本質は「複数のコンテキスト(処理)を高速に切り替えて実行する」**という点にあり、シングルコアでも並行処理を行う場合は、ある一点で実行されているタスクは一つですが、時間を細かく区切って切り替えているため、マルチタスク感が得られます。
コンテキストの中には、処理(プロセス)の実行状態を保存するためのデータ(プログラムカウンタやスタックポインタなど)が格納されています。
これら一まとまりの情報(コンテキスト)を高速に切り替える事で、複数の処理を同時(に実行しているかのよう)に処理するのが、並行処理の実態です。
並列処理 / Parallelism
並列処理(Parallelism)は、物理的に複数のプロセッサコア(またはCPU)を使って、本当に同時実行をすることを指します。
並行処理の場合、時間軸のある一点において実行されているタスクは一つです。
高速でコンテキストを切り替えているため、人間の感覚からすると同時に複数の処理が実行されているように見えますが、本質的にはある一つの時点において一つの処理しか実行されていません。
それに対して、並列処理では複数のプロセッサー(マルチコア)を使用し、全く同じタイミングで複数の処理実行を実現します。
よくある例ですが、
一人の人間が二つのタスクを高速に切り替えて実行しているのか、二人の人間が二つのタスクを実行しているのか、という認知モデルで整理すれば間違いはないでしょう。
つまり、並列処理は真の意味でマルチタスクということです。
もちろん、その分消費するプロセッサーの数が多くなるため、処理を実行するためのコストは増加します。
必ずしも全てのユースケースにおいて、並列処理が優位性を持つわけではないことをご理解ください。
まとめ
- プロセス: OSが管理する実行単位(メモリ空間やファイルディスクリプタなどを保持)
- スレッド: プロセス内部での処理単位(プログラムカウンタやスタックを持つ)
- 非同期処理: 処理完了を待たずに先に進める概念(特にI/O待ちが長い時に有効)
- 並行処理: 複数のタスクを同時進行で扱う手法(コンテキストスイッチで1コア上でも実現可能)
- 並列処理: 複数コアを使い、同時に実行する手法(物理的に真正のマルチタスク)
これらの概念は、しばしば混同されやすいですが、**「非同期=待ち時間をブロックしない」「並行=タスクを同時進行」「並列=物理コアが複数」**と切り分けることで、それぞれの特徴を正しく理解することができます。
その他の参考
並行処理と並列処理の違いをコンテキストから考える - コネヒト開発者ブログ
プロセスよりもスレッドのほうが高速にコンテキストスイッチできることを検証する
並行・並列とマルチスレッド・マルチプロセスの関係を整理する - Qiita