Deep Engineering

ЗАМЕР

bench/async-vs-sync/latency.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/concurrency/async-vs-sync

Запись прогона

Асинхронность против синхронности: замеры для статьи

Проверяется утверждение: перевод проекта с синхронного кода на асинхронный может не ускорить работу, а замедлить — в первую очередь там, где приложение работает с базой.

Все замеры ходят в один 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 локально)

  1. Потолок ставит пул соединений, а не модель исполнения. При одном и том же размере пула три модели дают одинаковое время в пределах шума — на пуле 8 это 0,43 / 0,45 / 0,43 с при «идеале» 0,38 с.
  2. На коротком запросе без одновременности async медленнее. Запрос по первичному ключу: 59 мкс синхронно против 70 мкс на async psycopg и 68 мкс на asyncpg — то есть на 16–19 % дороже. Ускорять здесь нечего: ждать параллельно нечего, остаётся только накладной расход.
  3. run_in_executor не хуже и не лучше остальных, потому что и он упирается в тот же пул: 0,434 с против 0,428 с у чистых потоков.
  4. Где async выигрывает по-настоящему — держать много ожиданий сразу. Четыре тысячи одновременных ожиданий: 0,31 с и +0 МБ RSS у корутин против 2,03 с и +61 МБ у потоков.
  5. Счётный код рядом с запросами портит обе модели. Ожидаемого «цикл событий встал, а потоки живут» не видно: GIL делает счёт последовательным в любой модели. Один кусок на 191 мс даёт максимум 184 мс у async и 212 мс у потоков.

Числа привязаны к этой машине и к локальной базе. Воспроизводить на своей — скрипты печатают все версии и параметры, без которых числа не значат ничего.

Скрипт

117 строк
"""Цена одной операции: короткий запрос по одному соединению, без одновременности.

ЧТО ПРОВЕРЯЕТСЯ. Вторая половина утверждения — что переход на 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()