Python Lock

本記事では、Pythonのthreadingライブラリ内にあるLockクラスについて解説します。Lockは、複数のスレッドが同じリソースにアクセスする際の競合を防ぐために使用される同期メカニズムです。

概要

Lockオブジェクトは、あるスレッドがリソースを使用している間に他のスレッドのアクセスを制限することで、データの整合性を保ちます。Lockを取得(ロック)することで、リソースが他のスレッドからアクセスされるのを防ぎ、処理が終わった後に解放(アンロック)する仕組みになっています。この機能により、例えば複数スレッドが同じファイルやデータベースにアクセスする際に、データが破損するリスクを軽減できます。

例えばこんな時に使うことができる

  • 複数のスレッドが共有リソース(例:変数、ファイル、データベースなど)に同時アクセスする場合

  • スレッド間でカウンタなどの変数を安全に更新したい場合

  • データ処理の順序が重要なプログラムで、リソースの使用順序を管理したい場合

「複数のスレッドで同じデータにアクセスしたら、データが予期しない値に変更された」「同時にファイルを書き込むと、ファイル内容が破損することがある」などの問題でLockを使える!

Lockを使わない場合の例

とりあえず、よくある問題を再現してみる

以下は、Lockを使わずにスレッドを作成し、共有変数counterを更新するプログラムです。複数のスレッドが同時にcounterにアクセスするため、競合が発生し、最終的なカウンタの値が予期しないものになります。

import threading
import time
import random

# カウンタを初期化
counter = 0

def increment_counter():
    global counter
    # 1000回ループしてカウンタをインクリメント
    for _ in range(10):
        current = counter
        # 意図的にスリープを入れて、タイミングをずらす
        time.sleep(random.uniform(0.00001, 0.00002))
        counter = current + 1

# スレッドを作成
threads = []
for i in range(10):
    thread = threading.Thread(target=increment_counter)
    threads.append(thread)
    thread.start()

# 全てのスレッドが終了するまで待機
for thread in threads:
    thread.join()

print(f"カウンタの最終値: {counter}")

実行結果の例

理論的には、counterは10回 × 10スレッド = 100になるはずです。しかし、このコードを実行すると、めちゃくちゃズレます。

なぜこのような問題が発生するのか?

この問題は、複数のスレッドが同時にcounterにアクセスしていることが原因です。counter += 1という操作は、以下の3つのステップからなります。

  1. 現在のcounterの値を読み取る

  2. 読み取った値に1を加える

  3. 新しい値をcounterに書き込む

複数のスレッドが同時にアクセスすると、例えば次のような順番で操作が行われる場合があります。

  • スレッドAが現在のcounterの値(例:5)を読み取る

  • スレッドBも同じタイミングで現在のcounterの値(5)を読み取る

  • スレッドAがcounterを6に更新

  • スレッドBがcounterを6に更新

このように、スレッド間で値の更新が競合し、実際には2回インクリメントされたはずのcounterが1しか増えていない結果になります。これが、最終的に期待する値とズレが生じる原因です。

Lockを使うとどう改善するか?

Lockを使って共有変数counterをスレッドセーフに更新します。

import threading

# カウンタを初期化
counter = 0
# Lockオブジェクトを作成
lock = threading.Lock()

def increment_counter():
    global counter
    # Lockを取得
    lock.acquire()
    try:
        for _ in range(10):
            counter += 1
    finally:
        # Lockを解放
        lock.release()

# スレッドを作成
threads = []
for i in range(10):
    thread = threading.Thread(target=increment_counter)
    threads.append(thread)
    thread.start()

# 全てのスレッドが終了するまで待機
for thread in threads:
    thread.join()

print(f"カウンタの最終値: {counter}")

Lockを使った場合の実行結果

このコードでは、Lockによって一度に1つのスレッドだけがcounterにアクセスできるようになるため、必ず最終的に100という期待通りの結果が得られます。

活用場面

Lockは、並行処理が必要なさまざまな場面で利用できます。例えば、ウェブスクレイピングで複数のスレッドが同時にページからデータを収集する場合や、バックグラウンドで動作するログの書き込み処理などに役立ちます。

もう1個実用例を紹介

タスク

複数のスレッドでアクセスされる共有リストにアイテムを追加するプログラムを作成します。このプログラムでは、各スレッドがリストにアイテムを追加しますが、Lockを使ってデータ競合を防ぎます。

実装例

import threading
import time

# 共有リストとLockを作成
shared_list = []
lock = threading.Lock()

def add_to_list(item):
    # Lockを取得してリストにアイテムを追加
    lock.acquire()
    try:
        shared_list.append(item)
        print(f"{item} をリストに追加しました。")
    finally:
        lock.release()

# スレッドを作成してアイテムをリストに追加
threads = []
for i in range(5):
    thread = threading.Thread(target=add_to_list, args=(i,))
    threads.append(thread)
    thread.start()

# 全てのスレッドが終了するまで待機
for thread in threads:
    thread.join()

print("最終的なリストの内容:", shared_list)

実行結果

リストへの追加が安全に行われ、競合が発生しません。

注意点

  • Lockの取得・解放忘れ: lock.acquire()後に必ずlock.release()を呼ぶようにしましょう。これを怠るとデッドロックが発生し、プログラムが停止する恐れがあります。

  • withステートメントを活用: lock.acquire()とlock.release()の代わりに、Pythonのwithステートメントを使うとより安全に管理できます。

# with文を使用した例
def safe_increment():
    global counter
    with lock:
        for _ in range(1000):
            counter += 1

デッドロックの発生に注意

Lockの取得と解放の順序に問題があるとデッドロックが発生します。複数のロックがある場合には、必ず同じ順序でロックを取得すること。このルールを守らないと、複数のスレッドが互いにロックの取得を待ち続ける「デッドロック」が発生する可能性があります。

悪い例

以下は、複数のLock(lock1とlock2)を異なる順序で取得しようとしてデッドロックが発生するケースです。

import threading
import time

# 2つのLockオブジェクトを作成
lock1 = threading.Lock()
lock2 = threading.Lock()

# スレッドA
def thread_a():
    with lock1:  # lock1を先に取得
        print("スレッドAがlock1を取得しました")
        time.sleep(1)  # 他のスレッドがlock2を取得するまで待つ
        with lock2:  # 次にlock2を取得
            print("スレッドAがlock2を取得しました")

# スレッドB
def thread_b():
    with lock2:  # lock2を先に取得
        print("スレッドBがlock2を取得しました")
        time.sleep(1)  # 他のスレッドがlock1を取得するまで待つ
        with lock1:  # 次にlock1を取得
            print("スレッドBがlock1を取得しました")

# スレッドを作成して開始
thread_a = threading.Thread(target=thread_a)
thread_b = threading.Thread(target=thread_b)

thread_a.start()
thread_b.start()

thread_a.join()
thread_b.join()

このコードの問題点

  1. スレッドAがlock1を取得後、lock2を待機しています。

  2. 一方、スレッドBはlock2を取得後、lock1を待機しています。

この状況では、スレッドAがlock2を解放するまでスレッドBは動けず、スレッドBがlock1を解放するまでスレッドAも動けません。このように、両スレッドが互いに相手のロックの解放を待ち続ける状況が「デッドロック」です。

良い例

デッドロックを防ぐためには、すべてのスレッドが同じ順序でロックを取得するようにします。以下では、すべてのスレッドがlock1を先に取得し、その後にlock2を取得するように順序を統一しています。

import threading
import time

# 2つのLockオブジェクトを作成
lock1 = threading.Lock()
lock2 = threading.Lock()

# スレッドA
def thread_a():
    with lock1:  # lock1を先に取得
        print("スレッドAがlock1を取得しました")
        time.sleep(1)  # 他のスレッドがlock2を取得するまで待つ
        with lock2:  # 次にlock2を取得
            print("スレッドAがlock2を取得しました")

# スレッドB
def thread_b():
    with lock1:  # lock1を先に取得
        print("スレッドBがlock1を取得しました")
        time.sleep(1)  # 他のスレッドがlock2を取得するまで待つ
        with lock2:  # 次にlock2を取得
            print("スレッドBがlock2を取得しました")

# スレッドを作成して開始
thread_a = threading.Thread(target=thread_a)
thread_b = threading.Thread(target=thread_b)

thread_a.start()
thread_b.start()

thread_a.join()
thread_b.join()

このコードの良い点

  1. スレッドAスレッドBも、まずlock1を取得し、その後にlock2を取得するようにしています。

  2. 同じ順序でロックを取得することで、デッドロックが発生するリスクを減らせます。


まとめ

複数のロックを使う場合、どのスレッドも「ロックの取得順序を同じ」にする!!

いいなと思ったら応援しよう!