ЗАМЕР
bench/async-vs-sync/pool.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 локально)
- Потолок ставит пул соединений, а не модель исполнения. При одном и том же размере пула три модели дают одинаковое время в пределах шума — на пуле 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 мс у потоков.
Числа привязаны к этой машине и к локальной базе. Воспроизводить на своей — скрипты печатают все версии и параметры, без которых числа не значат ничего.
Скрипт
125 строк"""Потолок пула: одна и та же работа при разных размерах пула, три модели.
ЧТО ПРОВЕРЯЕТСЯ. Утверждение «перевод на 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()