LoginSignup
0

More than 1 year has passed since last update.

単純なカウンターのインクリメントの並行処理がアトミックでないことの確認

Posted at

はじめに

Effective Python 第2版 ―Pythonプログラムを改良する90項目 の項目54「スレッドにおけるデータ競合を防ぐためにLockを使う」に掲載されているプログラムを試したところ、自分の Linux マシンでは期待される結果(スレッドセーフではない)が得られなかったので、Effective Python で学んだことを組み合わせて確認してみました。

環境

バージョン
Linux Ubuntu 20.04.3 LTS
Python 3.10.0
リソース名
メモリ 3GB
CPU Intel(R) Core(TM) i7-4510U
core数 2

※ ホストOSが Windows の環境で Oracle Virtual Box をインストールし、上記リソースを割り当てて Ubuntu を動かしています。

実験

227~228ページに記載されている以下のコード1を実行してみます。

sample_01.py
from threading import Thread

N_SENSOR = 5

class Counter:
    def __init__(self):
        self.count = 0

    def increment(self, offset):
        self.count += offset

def worker(_sensor_index, how_many, counter):
    for _ in range(how_many):
        # ...
        counter.increment(1)

def main():
    how_many = 10**5
    counter = Counter()
    threads = []
    for i in range(N_SENSOR):
        thread = Thread(target=worker,
                        args=(i, how_many, counter))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    expected = how_many * N_SENSOR
    found = counter.count
    print(f'Counter should be {expected}, got {found}')

main()
実行結果
$ python sample_01.py
Counter should be 500000, got 500000

結果だけ見ると、インクリメント処理がアトミックに処理されているように見えます。

本項目の説明によれば、self.count += offset は内部的には以下の 3 つの処理が実行されています。

  1. getattr() でクラス属性 count の値を取得
  2. そこに offset を加える
  3. 結果を setattr() でクラス属性 count の値を更新

インクリメント処理部分の確認

項目51「合成可能なクラス拡張のためにはメタクラスではなくクラスデコーレータを使う」において、特殊メソッドの呼び出しを出力させることを学びました。同じことをやってみましょう。

まず sample_01.py にクラスデコレータを追加します2

sample_02.py(追加分)
import types
from functools import wraps

trace_types = (
    types.MethodType,
    types.FunctionType,
    types.BuiltinFunctionType,
    types.BuiltinMethodType,
    types.MethodDescriptorType,
    types.ClassMethodDescriptorType,
    types.WrapperDescriptorType,
)

def trace_func(func):
    if hasattr(func, 'tracing'):
        return func

    @wraps(func)
    def wrapper(*args, **kwargs):
        result = None
        try:
            result = func(*args, **kwargs)
            return result
        except Exception as e:
            result = e
            raise
        finally:
            print(f'{func.__name__}({args!r}, {kwargs!r}) -> '
                  f'{result!r}')

    wrapper.tracing = True
    return wrapper

def trace(klass):
    for key in dir(klass):
        value = getattr(klass, key)
        if isinstance(value, trace_types) and key != '__repr__':
            wrapped = trace_func(value)
            setattr(klass, key, wrapped)
    return klass

そして Counter クラスをデコレートします。

@trace
class Counter:
    ...

このまま動かすと時間がかかるので、how_many の値を 10**2 に変更して試してみましょう。

実行結果
$ python sample_02.py
...(snip)...
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count'), {}) -> 351
__setattr__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count', 352), {}) -> None
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'increment'), {}) -> <bound method Counter.increment of <__main__.Counter object at 0x7fe020d4fcd0>>
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count'), {}) -> 352
__setattr__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count', 353), {}) -> None
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count'), {}) -> 353
Counter should be 500, got 353

書籍に記載されている例の通り、カウンターの値が期待値とは異なる値になりました。出力されたメッセージを遡ってみてみると、解説された通りのことが起きていることを確認できます(カウンターが 213 から 209 に巻き戻っています)。

__setattr__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count', 212), {}) -> None
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'increment'), {}) -> <bound method Counter.increment of <__main__.Counter object at 0x7fe020d4fcd0>>
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count'), {}) -> 212
__setattr__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count', 213), {}) -> None
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count'), {}) -> 208
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__setattr__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count', 209), {}) -> None
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'increment'), {}) -> <bound method Counter.increment of <__main__.Counter object at 0x7fe020d4fcd0>>
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'increment'), {}) -> <bound method Counter.increment of <__main__.Counter object at 0x7fe020d4fcd0>>
__getattribute__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count'), {}) -> 209
__setattr__((<__main__.Counter object at 0x7fe020d4fcd0>, 'count', 210), {}) -> None
increment((<__main__.Counter object at 0x7fe020d4fcd0>, 1), {}) -> None

まとめ

Effective Python で説明されているように、単純なカウンターのインクリメントの並行処理がアトミックでないことを確認することができました。

なぜ同じコードで同じ結果が得られなかったのでしょうか?確かに理論的には、(おおまかに) 3 ステップで行われているカウンターのインクリメント処理がコンテキストスイッチ時に分断されてしまう可能性はありますが、おそらく私の環境ではその 3 ステップが瞬時に終わってしまい、その結果、分断されることがなかったのではないかと考えられます。ところが trace デコレータで態とそれぞれのステップで時間がかかるようにしたことによって、アトミックな処理でないことの確認ができるようになったのでしょう。

このように、並行処理のプログラムを書いて、ローカルでテストして問題がないと思っても、実はスレッドセーフではないということがあり得るので、そのあたりの確認には注意深さが必要であることを示唆する事例だったのではないかと思います。


  1. プログラムコードは、書籍に掲載されているものから若干修正しています。 

  2. オリジナルのコードに types.WrapperDescriptorType を追加しています。そして無限再帰呼び出しされないように __repr__ だけは除外しています。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0