MEASUREMENT
bench/async-vs-sync/latency.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
117 lines"""Цена одной операции: короткий запрос по одному соединению, без одновременности.
ЧТО ПРОВЕРЯЕТСЯ. Вторая половина утверждения — что переход на async может не
ускорить, а замедлить. Ускорять там нечего: запросы идут по одному, ждать
параллельно нечего, и всё, что видно, — накладной расход самой модели и
драйвера.
ПОЧЕМУ ЗАПРОС ИМЕННО ТАКОЙ. `select payload from items where id = ...` по
первичному ключу — самый дешёвый осмысленный запрос: индексный доступ по одной
странице. Такие запросы и составляют большинство в обычном приложении, и именно
на них накладной расход виден, потому что сравнивать его не с чем.
ЧТО ЕЩЁ ИЗМЕРЯЕТСЯ. `select 1` — запрос без обращения к таблице вовсе. Разница
между ним и запросом по ключу показывает, сколько в измерении от базы, а
сколько от дороги до неё.
"""
import asyncio
import statistics
import sys
import time
import asyncpg
import psycopg
sys.path.insert(0, __file__.rsplit("/", 1)[0])
import env # noqa: E402
OPS = 3000
REPEATS = 5
QUERIES = [
("select 1", "select 1", "select 1"),
(
"по первичному ключу",
"select payload from items where id = %s",
"select payload from items where id = $1",
),
]
def sync_loop(sql: str) -> float:
with psycopg.connect(env.DSN) as conn:
with conn.cursor() as cur:
# Прогрев: первый запрос по соединению оплачивает разбор и план.
for i in range(50):
cur.execute(sql, (i + 1,) if "%s" in sql else None)
cur.fetchall()
started = time.perf_counter()
for i in range(OPS):
cur.execute(sql, (i + 1,) if "%s" in sql else None)
cur.fetchall()
return time.perf_counter() - started
async def _psycopg_async_loop(sql: str) -> float:
conn = await psycopg.AsyncConnection.connect(env.DSN)
try:
async with conn.cursor() as cur:
for i in range(50):
await cur.execute(sql, (i + 1,) if "%s" in sql else None)
await cur.fetchall()
started = time.perf_counter()
for i in range(OPS):
await cur.execute(sql, (i + 1,) if "%s" in sql else None)
await cur.fetchall()
return time.perf_counter() - started
finally:
await conn.close()
async def _asyncpg_loop(sql: str) -> float:
conn = await asyncpg.connect(env.DSN)
try:
args = (1,) if "$1" in sql else ()
for i in range(50):
await conn.fetch(sql, *((i + 1,) if args else ()))
started = time.perf_counter()
for i in range(OPS):
await conn.fetch(sql, *((i + 1,) if args else ()))
return time.perf_counter() - started
finally:
await conn.close()
def best(fn, *args) -> float:
return min(fn(*args) for _ in range(REPEATS))
def main() -> None:
env.describe()
print(f"{OPS} запросов подряд по одному соединению, лучшее из {REPEATS}\n")
print(f"{'запрос':>21} | {'модель':>16} | {'на запрос':>10} | {'запросов/с':>11}")
print("-" * 68)
for label, sql_pg, sql_apg in QUERIES:
rows = [
("psycopg, синхронный", best(sync_loop, sql_pg)),
("psycopg, async", best(lambda s: asyncio.run(_psycopg_async_loop(s)), sql_pg)),
("asyncpg", best(lambda s: asyncio.run(_asyncpg_loop(s)), sql_apg)),
]
for model, seconds in rows:
print(
f"{label:>21} | {model:>16} | {seconds / OPS * 1e6:>7.1f} мкс | "
f"{OPS / seconds:>11.0f}"
)
base = rows[0][1]
for model, seconds in rows[1:]:
ratio = seconds / base
sign = "медленнее" if ratio > 1 else "быстрее"
print(f"{'':>21} | {model:>16} | {'':>10} | ×{max(ratio, 1 / ratio):.2f} {sign}")
print("-" * 68)
if __name__ == "__main__":
main()