この記事で伝えること
マルチスレッドプログラミングを学ぶと必ず出てくる「ミューテックス(Mutex)」と「セマフォ(Semaphore)」。どちらも「複数のスレッドの動きを制御する仕組み」という点では似ていますが、目的も使い方も実は異なります。
この記事では、
- ミューテックスとセマフォがそれぞれ何のために存在するのか
- 「排他制御」と「同期」という2つの異なる問題
- 具体的なコード例でどう使い分けるのか
を、丁寧に解説していきます。「なんとなく違いは知っているけど、いざ聞かれると説明できない」という状態から抜け出せる内容を目指します。
基礎知識の解説
そもそも何のためにあるのか
複数のスレッドが同時に動くプログラムでは、共有されているデータやリソースに複数のスレッドが同時にアクセスすると、意図しない結果(レースコンディション)が発生します。
例えば、2つのスレッドが同時に「カウンターを1増やす」処理をすると、以下のような競合が起きます。
スレッド1: カウンターを読む(0)
スレッド2: カウンターを読む(0)
スレッド1: 1を足して書き込む(1)
スレッド2: 1を足して書き込む(1) # 本来は2になるべきなのに1のまま
これを防ぐための仕組みが「排他制御」と「同期」です。ミューテックスは主に前者、セマフォは両方に使われますが、それぞれ得意な役割が異なります。
ミューテックス(Mutex)とは
Mutexは "Mutual Exclusion"(相互排他)の略で、その名の通り「一度に1つのスレッドしかアクセスできないようにする」ための仕組みです。
特徴は以下の通りです。
- 状態は「ロック中」か「アンロック中」の2値だけ
- ロックしたスレッドだけがアンロックできる(所有権の概念がある)
- 用途は「1つの共有リソースを1スレッドずつ順番に使わせたい」場合
イメージとしては「トイレの鍵」に近いです。入った人が鍵を閉め、出るときに自分で開ける。他の人は開けられません。
import threading
lock = threading.Lock()
counter = 0
def increment():
global counter
with lock: # ロック取得。処理が終わると自動でアンロック
counter += 1
セマフォ(Semaphore)とは
セマフォは「同時にアクセスできるスレッド数を、任意の数(N)まで許可する」仕組みです。内部的にはカウンタを持っていて、リソースを使うたびにカウンタを減らし、使い終わったら増やします。
特徴は以下の通りです。
- カウンタが0より大きい間は複数のスレッドが同時に進入できる
- 所有権の概念がない(ロックしたスレッド以外でもカウンタを操作できる)
- 用途は「同時接続数を制限したい」「あるイベントの発生をスレッド間で知らせたい」場合
イメージとしては「駐車場のゲート」です。空きが3台分あれば3台まで同時に入れますが、満車になると次の車は待たされます。
import threading
# 同時に3スレッドまでアクセスを許可
semaphore = threading.Semaphore(3)
def access_resource():
with semaphore:
# ここに最大3スレッドまで同時進入できる
do_something()
Nを1にすればミューテックスと似た挙動になりますが、「所有権がない」という点が本質的に異なります。つまりセマフォは、ロックしたスレッドとは別のスレッドが「解放(release)」を行うこともできます。この性質が、後述する「同期」の用途で活きてきます。
排他制御と同期という2つの問題
ここが混同されやすいポイントです。
- 排他制御(Mutual Exclusion): 「同時に1つだけ」を保証したい問題。ミューテックスが得意。
- 同期(Synchronization): 「あるスレッドの完了を、別のスレッドが待つ」問題。セマフォが得意。
例えば「生産者・消費者問題」(生産者スレッドがデータを作り、消費者スレッドがそれを処理する)では、単なる排他制御だけでなく「データがまだ無いときは消費者を待たせる」という同期の仕組みが必要です。これはミューテックスだけでは表現しづらく、セマフォ(カウンティングセマフォ)が自然にフィットします。
具体例・実際の動作
生産者・消費者問題をセマフォで表現すると、以下のようになります。
import threading
import queue
import time
buffer = queue.Queue(maxsize=5)
empty_slots = threading.Semaphore(5) # 空きスロット数
filled_slots = threading.Semaphore(0) # データが入っているスロット数
def producer():
for i in range(10):
empty_slots.acquire() # 空きが無ければ待つ
buffer.put(i)
print(f"生産: {i}")
filled_slots.release() # データが1つ増えたことを通知
time.sleep(0.1)
def consumer():
for _ in range(10):
filled_slots.acquire() # データが無ければ待つ
item = buffer.get()
print(f"消費: {item}")
empty_slots.release() # 空きが1つ増えたことを通知
time.sleep(0.2)
threading.Thread(target=producer).start()
threading.Thread(target=consumer).start()
ここでは empty_slots と filled_slots という2つのセマフォが、「バッファの空き状況」と「データの有無」という2つの状態をそれぞれ表現し、生産者と消費者の速度差を自動的に調整しています。これはミューテックスだけでは実現しづらい、セマフォならではの使い方です。
筆者の考え・所感
個人的には、ミューテックスとセマフォの違いを理解する一番のコツは「所有権があるかどうか」だと思っています。ミューテックスは「鍵をかけた本人しか開けられない」という強い制約があるからこそ、単純な排他制御に向いています。一方セマフォは所有権が緩い分、生産者・消費者のような「異なるスレッド間でのシグナリング」に自然に使えます。
実務では、単純な共有変数の保護にはほぼミューテックス(あるいはRWロックなどの派生形)を使い、「接続数の上限を決めたい」「キューの空き/詰まりを制御したい」といった場面でセマフォを選ぶ、という使い分けをしています。名前が似ているせいで最初は混同しがちですが、「排他か同期か」という視点で考えると、どちらを選ぶべきかが自然と見えてくると感じています。
また、Pythonの threading.Lock はGIL(Global Interpreter Lock)の存在もあって効果を実感しにくい場面もありますが、I/Oバウンドな処理やファイル・ネットワークリソースへのアクセス制御では依然として重要な仕組みです。他言語(Go・Java・Swiftなど)でも考え方はほぼ共通するので、一度きちんと理解しておくと応用が効きます。
まとめ
- ミューテックスは「1つのリソースを1スレッドずつ排他的に使う」ための仕組みで、所有権の概念がある
- セマフォは「N個まで同時アクセスを許可する」カウンタ式の仕組みで、所有権がなく、異なるスレッド間の同期(シグナリング)にも使える
- 「排他制御」なのか「同期」なのかを見極めることが、どちらを使うべきかの判断基準になる