0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonのwait_forで同期ツールが止まらない理由

0
Posted at

タイムアウトを短くしても、裏で動く同期ツールの寿命は縮まらない。

自分はLLMエージェントの同期ツールを asyncio.to_thread に渡す形を確認していて、タスクの cancelled() が True なのに同期関数が残るところで引っかかった。同じ更新処理をすぐ再試行するのは危ない。先の呼び出しがまだ書き込みに進む可能性がある。

今回は外部APIを呼ばず、CPython 3.14.8で再現した。Python側で何を打ち切っているかの確認。

Pythonのwait_forで50msだけ待つ

次を timeout_thread.py に保存し、python3 timeout_thread.py で実行する。同期関数の開始通知を待ってから、50msのタイムアウトを設定した。

import asyncio
import threading

async def main():
    loop = asyncio.get_running_loop()
    started, finished = asyncio.Event(), asyncio.Event()
    release = threading.Event()

    def sync_tool():
        loop.call_soon_threadsafe(started.set)
        release.wait()
        loop.call_soon_threadsafe(finished.set)

    task = asyncio.create_task(asyncio.to_thread(sync_tool))
    await started.wait()
    try:
        await asyncio.wait_for(task, timeout=0.05)
    except TimeoutError:
        print('task.cancelled():', task.cancelled())
        print('同期処理は完了した?', finished.is_set())
    finally:
        release.set()
    await finished.wait()
    print('待機を打ち切った後に完了:', finished.is_set())

asyncio.run(main())

手元の出力。

task.cancelled(): True
同期処理は完了した? False
待機を打ち切った後に完了: True

release.wait() で同期関数を止めておき、タイムアウト後の release.set() で先へ進めている。最後の通知が届いたので、キャンセル後も同期関数が続いたと分かる。sleep の秒数を調整するより、順序を追いやすかった。

ここでの50msは待機に設定した値。実測した応答時間ではない。

キャンセルの行き先をCPythonで追う

手元のCPythonソースを読んだ。asyncio/threads.py の to_thread は run_in_executor を待ち、既定のスレッドプールへ同期関数を渡す。

wait_for のキャンセルは元のFutureにも伝わる。ただ、concurrent/futures/_base.py の Future.cancel() は、状態が RUNNING なら False を返す実装だ。

待つ側のキャンセルと、実行する側の停止は別々に扱う。未実行の仕事ならキャンセルできる場合もあるので、再現では開始通知を挟んだ。

wait_forの50msの期限で待機タスクはキャンセルされるが、開始済みの同期関数はスレッドで待ち続け、release.setの後に完了通知を送る流れ。

同時実行数の枠も早く空く

asyncio.Semaphore(1) の内側で to_thread を待つ構成も確認した。1件目の待機をタイムアウトさせて2件目を投入すると、同期関数が2件同時に動いた。

キャンセルで async with を抜け、セマフォの枠を返す。1件目のスレッドは残るので、制限値1と実際の稼働数がずれる。待機を早く切るほど、次の仕事が流れ込むんだよね。

対策は、止めたいものに合わせて置く。

対象 実装で確認するところ
呼び出し元の待機 wait_for の期限
同期SDKの通信 SDKやHTTPクライアントの通信タイムアウトと再試行設定
稼働中の同期処理数 同期関数の入口で枠を取り、関数が抜けるまで保持する

枠待ちの仕事は残り得るので、取得直後に締切を確認し、期限切れなら更新を始めずに戻る。通信タイムアウトで戻っても、送信済みの更新が相手側で完了する可能性は残る。

ツール呼び出しでは、待機を打ち切った時刻と実処理の終了時刻を別々に記録したい。更新を再試行するなら、相手側で重複を防ぐ仕組みも要る。同期ツールが増えるほど、この時刻の差が稼働数の見積もりに効いてくると見ている。まずは cancelled() の横に、実処理の完了通知を一つ置くところから。

0
0
0

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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?