Конкурентное программирование в Python. Часть 1. Что такое конкурентность

Это первая статья из серии о конкурентном программировании на Python. Разберём, почему синхронное выполнение иногда становится узким местом и что в этом случае делать. Выясним, что такое конкурентность и чем она отличается от параллелизма. Введём понятия вытесняющей и кооперативной многозадачности.

Что не так с синхронными приложениями

В синхронном приложении код исполняется последовательно. Следующая строка кода выполняется после завершения предыдущей, и в каждый момент времени происходит что-то одно. Но что, если какая-то одна строка кода занимает слишком много времени? В таком случае весь последующий код должен будет замереть в ожидании, пока строка не завершится.

Любая достаточно длительная операция может блокировать приложение, но особенно часто это бывает, когда приложение ждёт завершения ввода-вывода.

import requests

response = requests.get('https://dmitbud.tech/')

headers = [
    f'{key}: {header}'
    for key, header in response.headers.items()
]

formatted_headers = '\n'.join(headers)

with open('headers.txt', 'w') as file:
    file.write(formatted_headers)

В коде выше есть две операции, ограниченные производительностью ввода-вывода:

  • response = requests.get('https://dmitbud.tech/') — запрос по сети;
  • file.write(formatted_headers) — запись на диск;

В обоих случаях код будет ждать завершения операции и до тех пор ничего не сможет сделать.

Чтобы не быть голословными, добавим замеры времени в этом коде:

import time
import requests

total_start = time.perf_counter()
io_time = 0.0

request_start = time.perf_counter()
response = requests.get('https://dmitbud.tech/', timeout=30)
request_time = time.perf_counter() - request_start
io_time += request_time

headers = [
    f'{key}: {header}'
    for key, header in response.headers.items()
]

formatted_headers = '\n'.join(headers)

disk_start = time.perf_counter()
with open('headers.txt', 'w') as file:
    file.write(formatted_headers)
disk_time = time.perf_counter() - disk_start
io_time += disk_time

total_time = time.perf_counter() - total_start

print(f'Total time: {total_time:.4f}')
print(f'IO time: {io_time:.4f}')
Total time: 0.4762
IO time: 0.4761

Практически всё время ушло на операции ввода-вывода! Программа ждала ответа от сети и работала с файловой системой. Выполняющий эту операцию поток в это время заблокирован и не может выполнять другую полезную работу. Вторым негативным фактором является то, что в момент ожидания ввода-вывода пользователю может казаться, что программа зависла. Например, синхронное приложение с GUI не будет реагировать на действия, отвечать на команды.

Чтобы решить эти проблемы, в программу вводят конкурентность. Говоря по-простому, конкурентность позволяет одновременно выполнять более одной задачи.

Конкурентность и параллелизм

Разберём подробнее, что такое конкурентность.

Говоря, что две задачи выполняются конкурентно, мы имеем в виду, что они работают в одно и то же время: либо их выполнение чередуется на одном ядре, либо они выполняются в один и тот же момент времени на разных ядрах.

Если задачи работают в один момент времени на разных ядрах процессора, то говорят, что они работают параллельно. Параллелизм является частным случаем конкурентности.

То есть в случае конкурентности несколько задач работают в течение одного промежутка времени, но только одна активна в каждый момент. В случае параллелизма несколько задач активно одновременно.

В конкурентной системе мы можем переключаться между приложениями, дав немного поработать сначала одному, а потом другому. Если делать это достаточно быстро, то создаётся впечатление, что две задачи работают одновременно. В параллельной системе два приложения работают действительно одновременно, так как оба активны в одно и то же время.

Конкурентность можно организовать, имея процессор всего с одним ядром, применив вытесняющую или кооперативную многозадачность для переключения между задачами.

Параллелизм же означает, что мы должны выполнять две или более задачи строго одновременно. На машине с одним ядром это невозможно. Необходимо иметь процессор с несколькими ядрами.

Параллелизм подразумевает конкурентность, но обратное верно не всегда. Многопоточное приложение, работающее на многоядерной машине, может быть и конкурентным, и параллельным. А благодаря многозадачности можно создать несколько задач, работающих конкурентно, но так, что в каждый момент времени выполняется только одна.

Возвращаясь к исходному примеру, отметим: конкурентность не ускорит сам сетевой запрос или взаимодействие с файловой системой, но позволит выполнить другую работу во время ожидания.

Многозадачность

Существует два основных вида многозадачности:

  • вытесняющая многозадачность;
  • невытесняющая (кооперативная) многозадачность.

Вытесняющая многозадачность

В этой модели операционная система решает, как переключаться между выполняемыми задачами с помощью процедуры квантования времени. Когда операционная система переключает задачи, говорят, что имеет место вытеснение.

Как устроен этот механизм, зависит от операционной системы. Обычно для этого используется планировщик, который распределяет процессорное время между конкурирующими за него потоками.

Кооперативная многозадачность

В этой модели для переключения между задачами мы не полагаемся на операционную систему, а явно определяем в коде приложения точки, где можно уступить управление другой задаче.

Сравнение кооперативной и вытесняющей многозадачности

У кооперативной многозадачности есть ряд преимуществ перед вытесняющей:

  • она потребляет меньше ресурсов, так как переключение контекста между корутинами гораздо дешевле, чем переключение между потоками или процессами ОС;
  • гранулярность. ОС приостанавливает поток или процесс в соответствии со своими алгоритмами планирования, но выбранный для этого момент не всегда оптимален. В случае кооперативной многозадачности мы явно помечаем точки, в которых приостановить задачу наиболее выгодно.

Однако есть и существенный недостаток:

  • если корутина долго не уступает управление или выполняет блокирующую операцию, она задерживает все остальные задачи в том же потоке.

Для реализации конкурентности Python имеет в арсенале несколько инструментов:

  • многопроцессность;
  • многопоточность;
  • asyncio.

Выбор инструмента зависит от типа решаемых задач. Как выбрать нужный инструмент, разберём в следующих статьях.