MEASUREMENT
bench/async-vs-sync/executor.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Асинхронность против синхронности: замеры для статьи
Проверяется утверждение: перевод проекта с синхронного кода на асинхронный может не ускорить работу, а замедлить — в первую очередь там, где приложение работает с базой.
Все замеры ходят в один Postgres и в одну таблицу; параметры собраны в
env.py, чтобы «прочие равные» были действительно равными.
Как запустить
# 1. Поднять Postgres
/usr/lib/postgresql/16/bin/postgres -D <каталог данных> -p 5433 -k /tmp
# 2. Завести базу и таблицу
psql -h /tmp -p 5433 -U postgres -c "create database bench_async"
psql -h /tmp -p 5433 -U postgres -d bench_async -c "
create table items (id int primary key, payload text not null);
insert into items select g, repeat('x', 64) from generate_series(1, 100000) g;
analyze items;"
# 3. Драйверы
python3.13 -m pip install 'psycopg[binary]' psycopg_pool asyncpg
# 4. Замеры
python3.13 pool.py # потолок пула
python3.13 latency.py # цена одной операции
python3.13 executor.py # синхронный драйвер внутри async
python3.13 waiting.py # цена одновременного ожидания
python3.13 blocking.py # счёт рядом с запросами и хвост задержек
Адрес базы переопределяется переменной BENCH_DSN.
| Скрипт | Что меряет |
|---|---|
env.py |
общие параметры и печать версий — не замер, а гарантия сравнимости |
pool.py |
одна и та же работа при пуле 1…32 в трёх моделях: потоки + psycopg, async psycopg, asyncpg |
latency.py |
время одного короткого запроса по одному соединению, без одновременности |
executor.py |
синхронный драйвер через run_in_executor против трёх остальных моделей |
waiting.py |
цена держать N одновременных ожиданий: потоки против корутин, время и память |
blocking.py |
распределение задержки, когда рядом с запросами выполняется счётный код |
Что показали замеры (машина замеров: 2 vCPU, Postgres 16.13 локально)
- Потолок ставит пул соединений, а не модель исполнения. При одном и том же размере пула три модели дают одинаковое время в пределах шума — на пуле 8 это 0,43 / 0,45 / 0,43 с при «идеале» 0,38 с.
- На коротком запросе без одновременности async медленнее. Запрос по первичному ключу: 59 мкс синхронно против 70 мкс на async psycopg и 68 мкс на asyncpg — то есть на 16–19 % дороже. Ускорять здесь нечего: ждать параллельно нечего, остаётся только накладной расход.
run_in_executorне хуже и не лучше остальных, потому что и он упирается в тот же пул: 0,434 с против 0,428 с у чистых потоков.- Где async выигрывает по-настоящему — держать много ожиданий сразу. Четыре тысячи одновременных ожиданий: 0,31 с и +0 МБ RSS у корутин против 2,03 с и +61 МБ у потоков.
- Счётный код рядом с запросами портит обе модели. Ожидаемого «цикл событий встал, а потоки живут» не видно: GIL делает счёт последовательным в любой модели. Один кусок на 191 мс даёт максимум 184 мс у async и 212 мс у потоков.
Числа привязаны к этой машине и к локальной базе. Воспроизводить на своей — скрипты печатают все версии и параметры, без которых числа не значат ничего.
Script
131 lines"""Синхронный драйвер внутри асинхронного кода: `run_in_executor`.
ЧТО ПРОВЕРЯЕТСЯ. Самый частый способ «перейти на async, не переписывая доступ к
базе»: оставить синхронный драйвер и заворачивать каждый запрос в
`loop.run_in_executor`. Снаружи получается `await`, внутри — тот же поток.
ЧЕТЫРЕ УЧАСТНИКА, ОДНА И ТА ЖЕ РАБОТА (одинаковый пул соединений):
1. потоки без цикла событий — синхронный psycopg в ThreadPoolExecutor;
2. тот же psycopg, но через `run_in_executor` из корутины;
3. асинхронный psycopg;
4. asyncpg.
Разница между (1) и (2) — цена самой обёртки: работа одна и та же, потоки те же,
добавлен только цикл событий между ними.
"""
import asyncio
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import asyncpg
import psycopg
from psycopg_pool import AsyncConnectionPool, ConnectionPool
sys.path.insert(0, __file__.rsplit("/", 1)[0])
import env # noqa: E402
QUERIES = 600
SLEEP = 0.005
POOL = 8
REPEATS = 3
def threads_only() -> float:
with ConnectionPool(env.DSN, min_size=POOL, max_size=POOL) as pool:
pool.wait()
def one(_: int) -> None:
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute("select pg_sleep(%s)", (SLEEP,))
cur.fetchone()
with ThreadPoolExecutor(max_workers=POOL) as ex:
started = time.perf_counter()
list(ex.map(one, range(QUERIES)))
return time.perf_counter() - started
async def _executor() -> float:
with ConnectionPool(env.DSN, min_size=POOL, max_size=POOL) as pool:
pool.wait()
def blocking(_: int) -> None:
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute("select pg_sleep(%s)", (SLEEP,))
cur.fetchone()
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=POOL) as ex:
started = time.perf_counter()
await asyncio.gather(
*(loop.run_in_executor(ex, blocking, i) for i in range(QUERIES))
)
return time.perf_counter() - started
async def _async_psycopg() -> float:
pool = AsyncConnectionPool(env.DSN, min_size=POOL, max_size=POOL, open=False)
await pool.open(wait=True)
try:
async def one() -> None:
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute("select pg_sleep(%s)", (SLEEP,))
await cur.fetchone()
started = time.perf_counter()
await asyncio.gather(*(one() for _ in range(QUERIES)))
return time.perf_counter() - started
finally:
await pool.close()
async def _asyncpg() -> float:
pool = await asyncpg.create_pool(env.DSN, min_size=POOL, max_size=POOL)
try:
async def one() -> None:
async with pool.acquire() as conn:
await conn.fetchval("select pg_sleep($1)", SLEEP)
started = time.perf_counter()
await asyncio.gather(*(one() for _ in range(QUERIES)))
return time.perf_counter() - started
finally:
await pool.close()
def best(fn) -> float:
return min(fn() for _ in range(REPEATS))
def main() -> None:
env.describe()
print(
f"{QUERIES} запросов по {SLEEP * 1000:.0f} мс ожидания, пул {POOL} соединений, "
f"лучшее из {REPEATS}"
)
print(f"идеал при полном совмещении ожиданий: {QUERIES * SLEEP / POOL:.2f}с\n")
rows = [
("потоки, без цикла событий", best(threads_only)),
("потоки через run_in_executor", best(lambda: asyncio.run(_executor()))),
("psycopg async", best(lambda: asyncio.run(_async_psycopg()))),
("asyncpg", best(lambda: asyncio.run(_asyncpg()))),
]
base = rows[0][1]
print(f"{'модель':>30} | {'время':>8} | {'к потокам':>10}")
print("-" * 54)
for label, seconds in rows:
print(f"{label:>30} | {seconds:>7.3f}с | {seconds / base:>9.2f}×")
if __name__ == "__main__":
main()