Deep Engineering

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.

Cited in
/en/python/concurrency/async-vs-sync

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 локально)

  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 мс у потоков.

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

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()