perf: await pending frames without idle worker

This commit is contained in:
kiyo9w 2026-09-28 14:06:57 +09:00
parent 1064e356de
commit b937be8e30
2 changed files with 99 additions and 5 deletions

View file

@ -13,6 +13,7 @@ lock so classification callbacks can land concurrently.
from __future__ import annotations
import asyncio
import collections
import os
import statistics
@ -169,6 +170,9 @@ class LiveSession:
self._pending: Optional[Tuple[int, bytes]] = None
self._frame_ready = threading.Event()
# One waiter, created on the caller's loop. None until the first async take.
self._waiter: Optional[asyncio.Event] = None
self._waiter_loop: Optional[asyncio.AbstractEventLoop] = None
self._closed = False
self._seq = 0
@ -213,23 +217,77 @@ class LiveSession:
self._frames_dropped += 1
self._pending = (int(frame_id), payload)
self._frame_ready.set()
self._wake_waiter_locked()
return True
def _pop_pending_locked(self) -> Optional[Tuple[int, bytes]]:
pending = self._pending
self._pending = None
self._frame_ready.clear()
return pending
def _wake_waiter_locked(self) -> None:
"""Set the async event from this thread. Caller holds self._lock."""
waiter = self._waiter
loop = self._waiter_loop
if waiter is None or loop is None:
return
try:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
if running is loop:
waiter.set()
return
try:
loop.call_soon_threadsafe(waiter.set)
except RuntimeError:
# Loop already closed. The sync event still wakes take_pending.
pass
def take_pending(self, timeout: float = 0.2) -> Optional[Tuple[int, bytes]]:
"""Blocking pop for the processing loop; None on timeout/close."""
if not self._frame_ready.wait(timeout):
return None
with self._lock:
pending = self._pending
self._pending = None
self._frame_ready.clear()
return pending
return self._pop_pending_locked()
async def take_pending_async(self) -> Optional[Tuple[int, bytes]]:
"""Awaitable pop. None means the session closed with nothing left.
Bind the waiter to the running loop on first use. A frame that arrives
before that bind is still visible: the event starts set when one is queued.
"""
loop = asyncio.get_running_loop()
with self._lock:
if self._waiter is not None and self._waiter_loop is not loop:
raise RuntimeError("take_pending_async is bound to another event loop")
if self._waiter is None:
self._waiter = asyncio.Event()
self._waiter_loop = loop
if self._pending is not None or self._closed:
self._waiter.set()
waiter = self._waiter
while True:
await waiter.wait()
with self._lock:
if self._pending is not None:
pending = self._pop_pending_locked()
if self._waiter is waiter and self._pending is None and not self._closed:
self._waiter.clear()
return pending
if self._closed:
return None
if self._waiter is waiter:
self._waiter.clear()
def close(self) -> None:
with self._lock:
self._closed = True
self._pending = None
self._frame_ready.set()
self._frame_ready.set()
self._wake_waiter_locked()
@property
def closed(self) -> bool:
@ -293,6 +351,8 @@ class LiveSession:
self._latest_frame_id = None
self._frame_origin = [0.0, 0.0]
self._shift_map = None
# ponytail: do not touch _waiter here. asyncio.Event is loop-affine;
# take_pending_async clears a stale wake on its own loop.
# ------------------------------------------------------------------ frame path
def process_frame(self, frame_id: int, payload: bytes) -> Dict[str, Any]: