Конкурентность и параллелизм
| Конкурентность | работа с несколькими задачами, чередующимися во времени |
|---|---|
| Параллелизм | одновременное выполнение на нескольких ядрах или машинах |
| Механизмы | процессы, потоки, корутины (async/await), зелёные потоки, акторы |
| Задачи с ожиданием ввода-вывода | асинхронность, потоки |
| Вычислительные задачи | процессы, потоки без глобальной блокировки, GPU |
| Ограничение ускорения | закон Амдала (1967) |
| Проблемы | гонки данных, взаимоблокировки, голодание |
Конкурентность (англ. concurrency) — свойство программы состоять из нескольких независимых задач, выполнение которых перекрывается во времени: пока одна ждёт ответа сети, другая обрабатывает данные. Параллелизм (parallelism) — физически одновременное выполнение вычислений на нескольких ядрах процессора, нескольких машинах или GPU. Роб Пайк сформулировал разницу так: конкурентность — это работа со многими вещами сразу, параллелизм — выполнение многих вещей сразу.[1]
Конкурентная программа может работать на одном ядре, чередуя задачи; параллельная обязательно использует несколько вычислителей. Повар, который ставит воду и, пока она закипает, режет овощи, работает конкурентно; два повара на одной кухне — параллельно.
Зачем это нужно
С середины 2000-х тактовая частота процессоров почти перестала расти, а количество ядер — нет. Герб Саттер назвал это концом «бесплатного обеда»: программы перестали ускоряться сами собой с каждым новым поколением процессоров, и для использования всех ядер их приходится писать параллельными.[2] Одновременно серверные приложения обслуживают тысячи соединений, большую часть времени ожидающих сети или диска, — это задача конкурентности.
Два класса задач
| Ограничены вводом-выводом (I/O-bound) | Ограничены процессором (CPU-bound) | |
|---|---|---|
| Пример | веб-сервер, парсер сайтов, вызовы API, запросы к БД | обработка изображений, численные расчёты, сжатие, обучение моделей |
| Узкое место | ожидание сети и диска | вычисления |
| Что помогает | конкурентность: async/await, потоки | параллелизм: процессы, потоки на разных ядрах, векторизация, GPU |
Механизмы
- Процессы — изолированная память, связь через каналы, сокеты или общую память. Надёжно, но дороже по ресурсам.
- Потоки (threads) — общая память внутри процесса; управляет ими операционная система. Дешевле процессов, но общий доступ к данным требует синхронизации.
- Корутины и async/await — задачи сами уступают управление в точках ожидания, а цикл событий переключается между ними в одном потоке. Тысячи одновременных соединений почти без накладных расходов — так работают asyncio в Python, JavaScript и асинхронный Rust.
- Зелёные потоки — лёгкие потоки, которыми управляет среда выполнения языка: горутины в Go, виртуальные потоки в Java. Совмещают простоту обычного кода с масштабом асинхронности.
- Акторы и передача сообщений — задачи не делят память, а обмениваются сообщениями (Erlang, Akka). Принцип Go: «не общайтесь через общую память — делитесь памятью через общение».[6]
Пример в Python
import asyncio, httpx
from concurrent.futures import ProcessPoolExecutor
# I/O-bound: 100 запросов конкурентно в одном потоке
async def fetch_all(urls):
async with httpx.AsyncClient() as client:
return await asyncio.gather(*(client.get(u) for u in urls))
# CPU-bound: вычисления параллельно на всех ядрах
def heavy(n: int) -> int:
return sum(i * i for i in range(n))
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
results = list(pool.map(heavy, [10**7] * 8))
В CPython глобальная блокировка интерпретатора (GIL) не даёт потокам выполнять Python-код параллельно, поэтому для вычислений используют процессы.[3] PEP 703 сделал GIL необязательным: начиная с Python 3.13 существует экспериментальная сборка без GIL, а в 3.14 она получила официальную поддержку (см. GIL и free-threading (PEP 703)).[4]
Проблемы
- Состояние гонки. Результат зависит от того, в каком порядке потоки обратились к общим данным: два потока одновременно читают счётчик, прибавляют единицу и записывают — одно увеличение теряется (см. Состояние гонки и блокировки).
- Взаимоблокировка (deadlock). Два потока ждут блокировки, захваченные друг другом, и стоят вечно.
- Голодание и инверсия приоритетов. Задача бесконечно не получает ресурс.
- Недетерминированность. Ошибки проявляются редко и не воспроизводятся в отладчике.
Средства защиты — мьютексы, атомарные операции, неизменяемые данные, очереди и каналы вместо общей памяти. Rust проверяет отсутствие гонок данных на этапе компиляции с помощью системы владения и типажей Send и Sync.[5]
Закон Амдала
Ускорение от параллелизма ограничено долей программы, которая выполняется последовательно. Если 10% работы нельзя распараллелить, то даже на бесконечном числе ядер программа ускорится не больше чем в 10 раз. Отсюда практический вывод: прежде чем добавлять ядра, найдите и сократите последовательную часть — блокировки, общий ресурс, однопоточный этап.
Параллелизм за пределами одной машины
На уровне систем те же идеи масштабируются: очереди задач и пулы обработчиков, горизонтальное масштабирование сервисов в Kubernetes, распределённые вычисления. Там к гонкам добавляются сетевые задержки и частичные отказы — это предмет распределённых систем.
См. также
- Состояние гонки и блокировки
- asyncio
- Горутины и каналы
- GIL и free-threading (PEP 703)
- Распределённые системы