Стандартный asyncio.Semaphore не учитывает приоритеты задач и не имеет time‑to‑live. В production‑кэшах это приводит к тому, что чтение с высоким приоритетом блокируется фоновой записью, а зависшая задача может навсегда захватить ресурс. Решение — семафор на PriorityQueue с TTL.
Архитектура на heapq и TTL
Внутри используется heapq — вставка и извлечение за O(log n). Каждая задача помещается в очередь с приоритетом, а TTL отсчитывается от момента постановки. Если время истекло — выбрасывается TimeoutError. Освобождается слот — подхватывается самая приоритетная задача. Context manager гарантирует вызов release.
Типичные грабли
Если TTL истёк, будущее (Future) нужно явно вычищать из очереди, иначе оно остаётся мёртвым грузом и накапливает память. Также приоритеты инвертированы: heapq считает минимальное значение наивысшим приоритетом. Поэтому в acquire передаётся отрицательное число, если нужно обратное поведение.
Пример из production
В in‑memory кэше расставлены приоритеты: запись от пользователя — 0, фоновое обновление — 5, чтение — 10. Для чтения установлен короткий TTL — при зависании можно отдать stale данные вместо полной блокировки. Это trade‑off между свежестью и доступностью.
@asynccontextmanager
async def acquire(self, priority: int, ttl: float):
future = self._loop.create_future()
heapq.heappush(self._queue, (priority, future))
try:
result = await asyncio.wait_for(future, timeout=ttl)
yield result
except asyncio.TimeoutError:
self._cleanup_expired()
raise
finally: ... # release and pop next
Недостаток и решение
Если очередь большая и приоритеты постоянно высокие, низкоприоритетные задачи могут умирать по TTL повторно. В таких сценариев стоит внедрить dynamic priority aging — со временем повышать приоритет ожидающей задачи. Но для большинства кэшей описанного подхода достаточно.
Вывод: Кастомный семафор с приоритетами и TTL решает конкретные проблемы конкурентного доступа в асинхронных кэшах, но требует аккуратной очистки и осознания trade‑offs при голодании низкого приоритета.