ターミナルにタイムアウトが返った。今日、同期型LLMクライアントを別スレッドで呼ぶ構成で、裏に残る処理を数えた。
自分は待ち時間と実処理の同時実行数を別々に制限する。asyncio.Semaphore(1)を付けても、タイムアウトを挟むと実処理が3本まで増えた。
Semaphoreが1でも、スレッドは3本動く
CPython 3.14.8で実行した。標準ライブラリだけを使い、blocking()を同期クライアントの代役にする。API接続はない。
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
async def probe(workers):
loop = asyncio.get_running_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=workers))
gate = asyncio.Semaphore(1)
started = asyncio.Queue()
release = threading.Event()
lock = threading.Lock()
active = 0
def blocking():
nonlocal active
with lock:
active += 1
loop.call_soon_threadsafe(started.put_nowait, None)
release.wait()
with lock:
active -= 1
try:
for i in range(3):
async with gate:
task = asyncio.create_task(asyncio.to_thread(blocking))
if workers == 3 or i == 0:
await started.get()
try:
await asyncio.wait_for(task, timeout=0.02)
except TimeoutError:
pass
with lock:
print(f"workers={workers}, active={active}")
assert active == workers
finally:
release.set()
asyncio.run(probe(3))
asyncio.run(probe(1))
出力。
workers=3, active=3
workers=1, active=1
activeは実行中の本数。releaseを開けるまで終了しないので、処理時間の偶然に頼らず数えられる。開始通知も待った。1本のプールでは最初の関数が居座るため、2回目以降は開始通知を待たない。
待機をキャンセルすると、枠だけが空く
wait_for()の期限が来るとTaskがキャンセルされる。async with gateを抜けてSemaphoreの枠が戻り、次の呼び出しが入る。実行中のスレッドはrelease.wait()に残る。
枠は空いた。関数は終わっていない。
to_threadの実装はrun_in_executor()に関数を渡す。Future.cancelはRUNNINGのFutureをキャンセルできない。実行中の関数を強制終了する仕組みにはならない。
20msは待機の期限だ。
同期クライアントを使うなら
2回目はmax_workers=1に変えた。Semaphoreが戻っても、次の関数は並んで待つ。未着手の呼び出しはキャンセルできる。
アプリでは他の処理も既定プールを使うので、LLM専用プールをrun_in_executor()へ渡し、終了処理も用意する。asyncio.run()も既定プールの完了を待つため、このコードのfinallyを外すと終了待ちで止まる。
スレッド数の上限だけでは待ち行列は制限できない。受付件数にも上限を置き、同期クライアントには通信タイムアウトを設定する。サーバー側の生成や課金まで止まるかはAPI側の仕様を確認する。
タイムアウト件数だけでは残った処理を数え損ねる。クライアント呼び出しの開始と終了を、待機側の終了と分けて記録する。同期SDKが増えるほど、この差はリトライ時の負荷に出ると見ている。上のコードなら、APIを呼ばずに残存本数を確かめられる。
