はじめに
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を実行してみます。
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 つの処理が実行されています。
- getattr() でクラス属性
count
の値を取得 - そこに offset を加える
- 結果を setattr() でクラス属性
count
の値を更新
インクリメント処理部分の確認
項目51「合成可能なクラス拡張のためにはメタクラスではなくクラスデコーレータを使う」において、特殊メソッドの呼び出しを出力させることを学びました。同じことをやってみましょう。
まず sample_01.py にクラスデコレータを追加します2。
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 デコレータで態とそれぞれのステップで時間がかかるようにしたことによって、アトミックな処理でないことの確認ができるようになったのでしょう。
このように、並行処理のプログラムを書いて、ローカルでテストして問題がないと思っても、実はスレッドセーフではないということがあり得るので、そのあたりの確認には注意深さが必要であることを示唆する事例だったのではないかと思います。