MEASUREMENT
bench/async-vs-sync/waiting.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
116 lines"""Цена ОДНОВРЕМЕННОГО ОЖИДАНИЯ: N ждущих сразу, потоками и корутинами.
ЗАЧЕМ ЭТОТ ЗАМЕР ПОСЛЕ ОСТАЛЬНЫХ. Предыдущие показали, что на работе с базой
модель исполнения не решает почти ничего: потолок ставит пул соединений. Отсюда
легко сделать вывод «async не нужен», и он был бы неверным. Этот замер про то,
где разница настоящая, — не в скорости одного ожидания, а в цене того, чтобы
ДЕРЖАТЬ много ожиданий сразу.
ЧТО ИЗМЕРЯЕТСЯ. N единиц исполнения, каждая ждёт одно и то же время. База здесь
ни при чём намеренно: ожидание берётся самое дешёвое, какое бывает
(`time.sleep` / `asyncio.sleep`), чтобы в числах не осталось ничего, кроме цены
самой единицы исполнения.
* время: сколько занимает завести N ждущих и дождаться всех;
* память: прирост RSS процесса и, отдельно, прирост по `tracemalloc`.
ПОЧЕМУ ДВА СЧЁТЧИКА ПАМЯТИ, А НЕ ОДИН. Они меряют разное, и в этом всё дело.
Стек потока выделяет операционная система, а не аллокатор Python: `tracemalloc`
его не видит вовсе, и по нему тысяча потоков «ничего не стоит». RSS видит.
Обратная сторона: RSS растёт страницами и не уменьшается сразу, поэтому мелкие
объекты — задачи — в нём могут не проявиться, если место в куче уже было. Одно
число тут соврало бы в любую сторону; два показывают, какая память чья.
"""
import asyncio
import gc
import os
import sys
import threading
import time
import tracemalloc
sys.path.insert(0, __file__.rsplit("/", 1)[0])
WAIT = 0.2
COUNTS = [100, 500, 1000, 2000, 4000]
def rss_kb() -> int:
"""Резидентная память процесса. /proc — потому что стеки потоков видны только там."""
with open("/proc/self/status", encoding="ascii") as fh:
for line in fh:
if line.startswith("VmRSS:"):
return int(line.split()[1])
return 0
def threads(n: int) -> tuple[float, int, int]:
gc.collect()
tracemalloc.start()
base = rss_kb()
peak = base
started_all = threading.Event()
def hold() -> None:
started_all.wait()
time.sleep(WAIT)
started = time.perf_counter()
pool = [threading.Thread(target=hold) for _ in range(n)]
for t in pool:
t.start()
# Все потоки уже созданы и стоят на барьере — здесь и снимается память:
# именно в этот момент они существуют одновременно.
peak = max(peak, rss_kb())
traced = tracemalloc.get_traced_memory()[1]
started_all.set()
for t in pool:
t.join()
tracemalloc.stop()
return time.perf_counter() - started, peak - base, traced
async def _tasks(n: int) -> tuple[float, int, int]:
gc.collect()
tracemalloc.start()
base = rss_kb()
gate = asyncio.Event()
async def hold() -> None:
await gate.wait()
await asyncio.sleep(WAIT)
started = time.perf_counter()
pool = [asyncio.create_task(hold()) for _ in range(n)]
await asyncio.sleep(0) # дать задачам дойти до ожидания
peak = rss_kb()
traced = tracemalloc.get_traced_memory()[1]
gate.set()
await asyncio.gather(*pool)
tracemalloc.stop()
return time.perf_counter() - started, peak - base, traced
def main() -> None:
print(f"python {sys.version.split()[0]}")
print(f"cpu {os.cpu_count()}")
print(f"каждый ждёт {WAIT * 1000:.0f} мс; память снята, пока все существуют сразу\n")
print(
f"{'сколько':>8} | {'потоки':>8} {'RSS':>8} {'traced':>8} | "
f"{'корутины':>9} {'RSS':>8} {'traced':>8}"
)
print("-" * 70)
for n in COUNTS:
t_thr, rss_thr, tr_thr = threads(n)
t_task, rss_task, tr_task = asyncio.run(_tasks(n))
print(
f"{n:>8} | {t_thr:>7.3f}с {rss_thr / 1024:>6.1f}МБ {tr_thr / 1048576:>6.1f}МБ | "
f"{t_task:>8.3f}с {rss_task / 1024:>6.1f}МБ {tr_task / 1048576:>6.1f}МБ"
)
if __name__ == "__main__":
main()