Deep Engineering

MEASUREMENT

bench/async-vs-sync/pool.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

125 lines
"""Потолок пула: одна и та же работа при разных размерах пула, три модели.

ЧТО ПРОВЕРЯЕТСЯ. Утверждение «перевод на async ускорит работу с базой».
Нагрузка одна и та же — N запросов, каждый ждёт на сервере фиксированное время
(`pg_sleep`), процессор при этом почти не занят. Меняется только модель
исполнения и размер пула соединений:

  * потоки + синхронный psycopg (пул соединений, поток на соединение);
  * корутины + асинхронный psycopg;
  * корутины + asyncpg.

ПОЧЕМУ `pg_sleep`, А НЕ НАСТОЯЩИЙ ЗАПРОС. Настоящий запрос грузит процессор
сервера, а сервер здесь живёт на тех же двух ядрах, что и клиент: измерялась бы
не модель исполнения, а конкуренция за процессор. `pg_sleep` — это чистое
ожидание, то есть ровно тот случай, ради которого асинхронность и заводят.

ЧТО СЧИТАЕТСЯ. Время всей партии и «эффективная одновременность» — во сколько
запросов сервер был занят в среднем: N * sleep / время. Если модель исполнения
что-то решает, эти числа разойдутся.
"""

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_SIZES = [1, 2, 4, 8, 16, 32]
REPEATS = 3  # берётся лучшее: шум машины только добавляет время, не убавляет


def run_threads(pool_size: int) -> float:
    with ConnectionPool(env.DSN, min_size=pool_size, max_size=pool_size) 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_size) as ex:
            started = time.perf_counter()
            list(ex.map(one, range(QUERIES)))
            return time.perf_counter() - started


async def _psycopg_async(pool_size: int) -> float:
    pool = AsyncConnectionPool(env.DSN, min_size=pool_size, max_size=pool_size, 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()

        # ВСЕ задачи запускаются разом — именно так выглядит «переписали на
        # async»: одновременных операций столько, сколько их пришло, а не
        # столько, сколько система может выполнить.
        started = time.perf_counter()
        await asyncio.gather(*(one() for _ in range(QUERIES)))
        return time.perf_counter() - started
    finally:
        await pool.close()


async def _asyncpg(pool_size: int) -> float:
    pool = await asyncpg.create_pool(env.DSN, min_size=pool_size, max_size=pool_size)
    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, size: int) -> float:
    return min(fn(size) for _ in range(REPEATS))


def main() -> None:
    env.describe()
    print(f"{QUERIES} запросов, каждый ждёт на сервере {SLEEP * 1000:.0f} мс, лучшее из {REPEATS}")
    print("«идеал» = QUERIES * SLEEP / размер пула — время, если бы ожидания сложились полностью")
    print("«одновр.» = QUERIES * SLEEP / время — во скольких запросов сервер занят в среднем\n")
    print(
        f"{'пул':>4} | {'идеал':>7} | {'потоки':>8} {'одновр.':>8} | "
        f"{'async pg':>9} {'одновр.':>8} | {'asyncpg':>8} {'одновр.':>8}"
    )
    print("-" * 76)

    work = QUERIES * SLEEP
    for size in POOL_SIZES:
        t_threads = best(run_threads, size)
        t_async = best(lambda s: asyncio.run(_psycopg_async(s)), size)
        t_apg = best(lambda s: asyncio.run(_asyncpg(s)), size)
        print(
            f"{size:>4} | {work / size:>6.2f}с | "
            f"{t_threads:>7.2f}с {work / t_threads:>8.2f} | "
            f"{t_async:>8.2f}с {work / t_async:>8.2f} | "
            f"{t_apg:>7.2f}с {work / t_apg:>8.2f}"
        )


if __name__ == "__main__":
    main()