PythonのAWSライブラリBoto3のSessionはスレッドセーフではないよという話

こんにちは。サービスグループの武田です。PythonのAWSライブラリであるBoto3をマルチスレッドで使用したところ少しはまりましたので回避策とともに紹介します。
2021.04.20

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。サービスグループの武田です。

AWSのリソースを操作する方法としてマネジメントコンソールやAWS CLI、そして各プログラミング言語に用意されているSDKが利用できます。人気の高いPythonでは、Boto3というライブラリが提供されています。

さてそのBoto3ですが、基本的な使い方は次のような流れになります。

first_test.py

import boto3


session = boto3.Session()

client = session.client("sts")
r = client.get_caller_identity()
print(r)
  1. セッション作成
  2. 各サービス用のクライアント作成
  3. API呼び出し

通常であれば、これ以上言及することは特にないのですが、マルチスレッドと組み合わせる際に少し注意が必要です。一連の流れで作成しているSessionオブジェクト(および未登場ですがResourceオブジェクト)は スレッドセーフではありません 。これはドキュメントにも明記されています。

試しにエラーを発生させてみましょう。次のようなプログラムを用意してみます。

main.py

import concurrent.futures
import boto3


def task(session):
    client = session.client("sts")
    client.get_caller_identity()


session = boto3.Session()
with concurrent.futures.ThreadPoolExecutor() as executor:
    a = executor.submit(task, session)
    b = executor.submit(task, session)

    a.result()
    b.result()

実行すると次のようなエラーになります。

Traceback (most recent call last):
  File "/python_boto3_multithread/main.py", line 16, in <module>
    b.result()
  File "/usr/local/Cellar/python@3.9/3.9.1_5/Frameworks/Python.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 433, in result
    return self.__get_result()
  File "/usr/local/Cellar/python@3.9/3.9.1_5/Frameworks/Python.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 389, in __get_result
    raise self._exception
  File "/usr/local/Cellar/python@3.9/3.9.1_5/Frameworks/Python.framework/Versions/3.9/lib/python3.9/concurrent/futures/thread.py", line 52, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/python_boto3_multithread/main.py", line 6, in task
    client = session.client("sts")
  File "/python_boto3_multithread/.venv/lib/python3.9/site-packages/boto3/session.py", line 258, in client
    return self._session.create_client(
  File "/python_boto3_multithread/.venv/lib/python3.9/site-packages/botocore/session.py", line 839, in create_client
    credentials = self.get_credentials()
  File "/python_boto3_multithread/.venv/lib/python3.9/site-packages/botocore/session.py", line 441, in get_credentials
    self._credentials = self._components.get_component(
  File "/python_boto3_multithread/.venv/lib/python3.9/site-packages/botocore/session.py", line 941, in get_component
    del self._deferred[name]
KeyError: 'credential_provider'

問題の回避策

この問題を解決する一番簡単な方法は、ドキュメントにも書かれているように各スレッドでセッションを生成することです。

@@ -2,15 +2,15 @@
 import boto3


-def task(session):
+def task():
+    session = boto3.Session()
     client = session.client("sts")
     client.get_caller_identity()


-session = boto3.Session()
 with concurrent.futures.ThreadPoolExecutor() as executor:
-    a = executor.submit(task, session)
-    b = executor.submit(task, session)
+    a = executor.submit(task)
+    b = executor.submit(task)

     a.result()
     b.result()

これであればエラーは発生しません。

2023-05-30追記。
次のdeepcopyを使用した方法は、Python 3.10.9、boto3 1.26.51で dictionary changed size during iteration のエラーが出ることがあり、正常に動作しないことがあるようです。素直にスレッドごとにセッションを作成するのがお勧めです。

一方で、使用するSessionオブジェクトが一時クレデンシャルを利用する等複雑な構築をしている場合などはどうでしょうか。そのようなケースではSessionオブジェクトをコピーすることでエラーを回避できました。プログラムは次のようになります。

@@ -1,5 +1,6 @@
 import concurrent.futures
 import boto3
+import copy


 def task(session):
@@ -9,8 +10,8 @@

 session = boto3.Session()
 with concurrent.futures.ThreadPoolExecutor() as executor:
-    a = executor.submit(task, session)
-    b = executor.submit(task, session)
+    a = executor.submit(task, copy.deepcopy(session))
+    b = executor.submit(task, copy.deepcopy(session))

     a.result()
     b.result()

copy.deepcopy()がミソで、copy.copy()(シャローコピー)だとダメでした。

まとめ

マルチスレッドプログラミングはスレッドセーフ性や排他制御、競合状態など特有の問題に対処しなければいけませんが、コストに対するリターンは大きいです。ぜひみなさんもたくさん落とし穴にはまって、いいシステムを作っていってください。