Skip to content

Многопоточность и асинхронность

Чем многопоточное приложение отличается от многопроцессорного?

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

В Python для реализации многопроцессорного подхода используют библиотеки concurrent.futures и multiprocessing, а для многопоточности — threading. В многопоточном приложении в Python задачи могут выполняться конкурентно (не параллельно!) в нескольких потоках, но все эти потоки совместно используют ресурсы одного процесса. Потоки в многопоточном приложении имеют общее пространство памяти, что делает передачу данных между ними относительно простой.

Однако в Python существует GIL (Global Interpreter Lock), который ограничивает выполнение только одного потока Python-кода в каждый момент времени, что может приводить к проблемам с производительностью при использовании многопоточности для CPU-интенсивных задач. Начиная с версии Python 3.13 началась реализация концепции возможного отказа от использования GIL. Версия Python 3.14 представляет собой сборку Python без GIL, то есть реализует истинную параллельность (free-threaded Python, no-GIL). В более старых версиях решить проблему обхода GIL можно за счет запуска дополнительных потоков в отдельных процессах, что подразумевает возможность запуска отдельных интерпретаторов.

С другой стороны, в многопроцессном приложении каждая задача выполняется в собственном процессе, что обеспечивает полную изоляцию между процессами и позволяет использовать несколько ядер процессора для параллельной обработки данных. Поскольку каждый процесс имеет свое собственное пространство памяти, передача данных между процессами обычно осуществляется с использованием механизмов межпроцессного взаимодействия, таких как очереди или сокеты. Например, рассмотрим следующий код, который демонстрирует многопроцессное выполнение с использованием модуля multiprocessing:

import multiprocessing

def worker(num):
    print('Worker:', num)

if __name__ == '__main__':
    for i in range(5):
        p = multiprocessing.Process(target=worker, args=(i,))
        p.start()

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


Понятие асинхронности

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

Ключевой механизм: когда одна задача выполняет операцию ввода-вывода (например, ожидает ответ от сети, чтение файла или запрос к базе данных) она освобождает управление, позволяя работать другой задаче. Таким образом, сокращается время, которое поток мог бы проводить в ожидании результатов операции ввода-вывода.

Основное отличие от многопоточности: хотя в один момент времени выполняется только одна задача (как и при работе с GIL), переключение между задачами происходит не произвольно по решению операционной системы, а предсказуемо — только в местах, помеченных await, то есть в точках ожидания результатов IO операций, специально отмечаемых программистом.

Это позволяет одному потоку обрабатывать тысячи одновременных соединений, делая программу чрезвычайно эффективной для I/O-bound операций (задач, связанных с операциями ввода-вывода).

Механизм возврата к выполнению задачи:

  • Задача встречает await и приостанавливается, сообщая event loop: "Я жду I/O-операцию, займись другими задачами"
  • Event loop продолжает выполнять другие готовые задачи
  • Когда I/O-операция завершается, система оповещает event loop, что результат готов
  • Event loop помещает приостановленную задачу обратно в очередь готовых к выполнению
  • Когда доходит очередь, задача возобновляется именно с того места, где остановилась, получая результат операции

Ключевые особенности:

  • Запомнилось состояние стека и локальные переменные
  • Возобновление происходит точно после await
  • Не нужно заново запускать функцию с начала

Аналогия: Как закладка в книге — закрыли на интересном месте, потом открыли и продолжили читать с того же момента.

Примером использования асинхронного кода является библиотека asyncio в Python. Например, вот простой пример кода, который использует asyncio для запуска нескольких задач одновременно и ожидания их завершения: import asyncio

async def hello():
    await asyncio.sleep(1)
    print("Hello")

async def world():
    await asyncio.sleep(2)
    print("World")

async def main():
    await asyncio.gather(hello(), world())

if __name__ == '__main__':
    asyncio.run(main())

В этом примере мы определяем 3 асинхронные функции: hello(), world() и main(). Функции hello() и world() печатают соответствующие сообщения и ждут 1 и 2 секунды соответственно. Функция main() запускает эти две функции одновременно с помощью asyncio.gather() и ждет, пока они завершат свою работу. Затем мы запускаем функцию main() с помощью asyncio.run(). В результате мы получим сообщения "Hello" и "World", каждое через 1 и 2 секунды соответственно, при этом результаты двух задач были получены почти одновременно.

Основные преимущества асинхронного программирования:

  • Улучшенная отзывчивость: Позволяет обрабатывать множество задач конкурентно без блокировки основного потока исполнения (для I/O-bound операций). Параллелизм — одновременное выполнение нескольких задач (на разных ядрах/процессорах). Конкурентность — управление несколькими задачами, которые выполняются в перекрывающиеся промежутки времени, но не обязательно одновременно (Asyncio).
  • Эффективное использование ресурсов: Позволяет эффективно использовать процессорное время, так как задачи выполняются в моменты ожидания операций ввода/вывода или других блокирующих операций.
  • Простота масштабирования: Позволяет легко создавать множество параллельных (конкурентных) задач без создания большого количества потоков или процессов.

Что такое async/await, для чего они нужны и как их использовать

async/await - это ключевые слова, представленные в Python 3.5 для определения асинхронных функций и вызовов. Они позволяют объявлять асинхронные функции и делать вызовы асинхронных функций в местах, где обычно используется блокирующий вызов.

Кратко:

  • async — объявляет асинхронную функцию (корутину)
  • await — приостанавливает выполнение корутины до завершения асинхронной операции
  • event loop — управляет выполнением асинхронных задач

Корутина — это специальный тип функции, которая может приостанавливать свое выполнение и возобновлять его позже.

В Python корутины создаются с помощью ключевого слова async def

async def my_coroutine():
    print("Начало корутины")
    await asyncio.sleep(1)
    print("Корутина завершена")

Особенности корутин:

  • При вызове возвращают объект корутины, а не результат
  • Для выполнения требуют наличия event loop
  • Могут содержать выражения await
  • Не выполняются до тех пор, пока не будут запланированы в event loop

Event Loop (Цикл событий) — это ядро асинхронного программирования в Python.

Он отвечает за:

  • Выполнение корутин и обратных вызовов
  • Выполнение сетевых операций ввода-вывода
  • Запуск подпроцессов
      # Создание и управление event loop вручную
      loop = asyncio.new_event_loop()
      asyncio.set_event_loop(loop)
    
      try:
          loop.run_until_complete(my_coroutine())
      finally:
          loop.close()
    

Ключевое слово Await

Выражение await приостанавливает выполнение корутины до тех пор, пока ожидаемый объект (awaitable) не вернет результат. Во время этой паузы event loop может выполнять другие задачи.

Что можно использовать с await:

  • Другие корутины
  • Задачи (Tasks)
  • Футуры (Futures)
  • Объекты, реализующие метод await

🚀 Жизненный цикл асинхронной операции:

  • Создание корутины — функция объявлена как async def
  • Вызов корутины — создается объект корутины, но выполнение не начинается
  • Планирование в event loop — корутина помещается в очередь выполнения
  • Выполнение до первого await — выполняется код до первого выражения await
  • Приостановка и переключение — при встрече await корутина приостанавливается
  • Возобновление выполнения — когда ожидаемая операция завершена
  • Завершение — возврат результата или исключение

Пример детального выполнения:

async def complex_operation():
    print("Шаг 1: Начало")
    await asyncio.sleep(1)  # Приостановка здесь
    print("Шаг 2: После сна")
    result = await fetch_data()  # Еще одна приостановка
    print("Шаг 3: Получены данные")
    return result

Объявление асинхронных функций:

async def my_async_function():
    # Асинхронная функция (корутина)
    return "Результат"

Вызов асинхронных функций:

async def main():
    # Правильно: используем await
    result = await my_async_function()

# Неправильно: my_async_function() без await вернет корутину, а не результат
wrong_result = my_async_function()  # Это объект корутины!

Запуск асинхронного кода:

# Python 3.7+
asyncio.run(main())

# Старые версии Python
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

Роль GIL в асинхронке

GIL — это мьютекс, который защищает доступ к объектам Python, предотвращая одновременное выполнение байт-кодов Python несколькими потоками. Это означает, что даже в многоядерных системах программа Python может не полностью использовать все доступные ядра, если она ограничена процессором и сильно зависит от потоков.

Влияние GIL

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

Обходные пути и решения

Существует несколько стратегий смягчения ограничений, налагаемых GIL:

  • Используйте многопроцессорность: Вместо использования потоков вы можете использовать модуль multiprocessing, который создает отдельные процессы, каждый со своим собственным интерпретатором Python и пространством памяти. Этот подход обходит GIL и может в полной мере использовать преимущества нескольких ядер ЦП.
  • Используйте внешние библиотеки: Некоторые библиотеки, такие как NumPy, используют собственные расширения, которые освобождают GIL во время вычислительно-интенсивных операций. Это позволяет базовому коду C выполнять многопоточные операции более эффективно.
  • Оптимизация кода: Оптимизируйте свой код, чтобы минимизировать время, проведенное в интерпретаторе Python. Уменьшая необходимость в конкуренции потоков, вы можете улучшить производительность многопоточных приложений.
  • Асинхронное программирование: Для задач, связанных с вводом-выводом, рассмотрите возможность использования асинхронного программирования с библиотекой asyncio. Этот подход позволяет реализовать параллелизм, не полагаясь на несколько потоков.

Статья на хабре Как устроен GIL (Global Interpreter Lock) в Python: влияние на многозадачность и производительность


Модуль threading

В Python многопоточность обычно реализуется с использованием модуля threading, который предоставляет высокоуровневый интерфейс для создания и управления потоками выполнения. Этот модуль позволяет создавать потоки, которые могут выполняться параллельно в пределах одного процесса. При использовании threading, каждый поток работает в рамках общего пространства памяти процесса, что позволяет им легко обмениваться данными между собой. Однако, из-за использования GIL (Global Interpreter Lock) в Python, многопоточные программы могут испытывать проблемы с параллельным выполнением задач на многоядерных системах. Это ограничение делает многопоточность в Python более подходящей для операций ввода/вывода (I/O-bound) или для приложений, где потоки часто блокируются на ожидании ввода/вывода. Ниже приведен пример использования модуля threading для создания двух потоков, которые выполняются параллельно: import threading

def print_numbers(start, end):
    for i in range(start, end):
        print(i)

Создание и запуск первого потока

thread1 = threading.Thread(target=print_numbers, args=(1, 6))
thread1.start()

Создание и запуск второго потока

thread2 = threading.Thread(target=print_numbers, args=(6, 11))
thread2.start()

В этом примере создаются два потока: первый выводит числа от 1 до 5, а второй - числа от 6 до 10. Оба потока запускаются параллельно, и каждый из них выполняет свою функцию. Модуль threading предоставляет механизмы для синхронизации доступа к общим ресурсам и ожидания завершения выполнения потоков, что позволяет эффективно управлять многопоточными приложениями. Однако стоит помнить о возможных проблемах с синхронизацией и безопасностью при работе с общими ресурсами из нескольких потоков.

Статья Многопоточность в Python

Python: как сделать многопоточную программу


Отличие task от future

В асинхронном программировании на Python, объекты Task и Future представляют собой разные аспекты выполнения асинхронных операций. Future является базовым объектом, представляющим обещание результата асинхронной операции, в то время как Task является конкретной реализацией Future, предназначенной для запуска и управления сопрограммами (coroutines) в цикле событий.

Future:

  • Представляет собой обещание результата, который будет доступен в будущем.
  • Не является исполняемым объектом сам по себе.
  • Используется для ожидания завершения асинхронной операции и получения её результата.
  • В основном используется для низкоуровневого взаимодействия с асинхронным кодом.
  • Может быть создан напрямую, но чаще создается в рамках Task.

Task:

  • Является подклассом Future и наследует его функциональность.
  • Представляет собой конкретную задачу (сопрограмму), запланированную на выполнение в цикле событий.
  • Запускает сопрограмму и управляет её жизненным циклом.
  • Оборачивает сопрограмму в объект, который можно добавить в цикл событий.
  • Позволяет отслеживать состояние выполнения задачи (например, была ли она выполнена, отменена, произошла ли ошибка).
  • Обычно создается с помощью функции asyncio.create_task().

Future - это более общий концепт, представляющий обещание результата. Task - это конкретный тип Future, предназначенный для управления асинхронными задачами (сопрограммами) в цикле событий. Task оборачивает сопрограмму и делает её доступной для цикла событий, в то время как Future просто представляет собой ожидаемый результат


Как запустить несколько tasks (gather, taskgroup)

Для запуска нескольких асинхронных задач в Python, в частности, с использованием gather и taskgroup, можно применить следующие подходы: Использование asyncio.gather:

import asyncio

async def my_task(task_id):
   """
    Простая асинхронная задача.
    """
    print(f"Task {task_id}: Начало")
    await asyncio.sleep(1)
    print(f"Task {task_id}: Завершение")

    return f"Результат Task {task_id}"


async def main():
    """
    Запуск нескольких задач с использованием asyncio.gather.
    """
    tasks = [my_task(i) for i in range(3)]
    results = await asyncio.gather(*tasks)
    print("Все задачи завершены.")
    print(f"Результаты: {results}")


if __name__ == "__main__":
    asyncio.run(main())

В этом примере asyncio.gather(*tasks) одновременно запускает все задачи, переданные в списке tasks, и ждет их завершения. Результаты выполнения каждой задачи собираются в список results. asyncio.gather не обрабатывает ошибки, возникающие внутри задач. Если одна из задач бросит исключение, то остальным задачам будет позволено завершиться, а затем исключение будет переброшено в месте вызова gather.

Рекомендации:

  • Если требуется обработка ошибок, используйте asyncio.create_task и asyncio.wait или asyncio.gather с настройкой return_exceptions=True.
  • Для простого запуска и ожидания результатов, asyncio.gather является удобным инструментом.
  • Для более гибкого управления задачами, включая обработку исключений и отмену задач, используйте asyncio.create_task и asyncio.wait. asyncio.run следует использовать для запуска асинхронного кода из обычной (синхронной) функции. Она создает новый цикл событий и управляет его завершением.

Статья Группы задач TaskGroup() модуля asyncio в Python


Базовые примитивы синхронизации в Python

Общая концепция

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

Lock (Блокировка) - Гарантирует эксклюзивный доступ к ресурсу

Модули:

  • threading.Lock - для многопоточности
  • multiprocessing.Lock - для многопроцессности
  • asyncio.Lock - для асинхронности

Пример использования:

# Для многопоточности
from threading import Lock
lock = Lock()

with lock:
    # Критическая секция - только один поток одновременно
    shared_variable += 1

Event (Событие) - Координация через механизм оповещений

Модули:

  • threading.Event - для потоков
  • multiprocessing.Event - для процессов
  • asyncio.Event - для асинхронных задач

Принцип работы:

  • set() - установить флаг
  • clear() - сбросить флаг
  • wait() - ждать установки флага

Сценарий использования: Ожидание готовности ресурса, синхронизация начала операций

Queue (Очередь) - Безопасная передача данных

Модули:

  • queue.Queue - для многопоточности
  • multiprocessing.Queue - для многопроцессности
  • asyncio.Queue - для асинхронности

Особенности:

  • Потокобезопасные операции put() и get()
  • Возможность ограничения размера
  • Блокирующие и неблокирующие операции

Semaphore (Семафор) - Ограничение количества одновременных доступов

Модули:

  • threading.Semaphore - для потоков
  • multiprocessing.Semaphore - для процессов
  • asyncio.Semaphore - для асинхронных задач

Применение:

  • Ограничение подключений к БД
  • Контроль параллельных HTTP-запросов
  • Управление доступом к файлам

Важные отличия

  1. Не взаимозаменяемы:

    • threading.Lock нельзя использовать в asyncio
    • asyncio.Semaphore не подходит для multiprocessing
  2. Особенности реализации:

    • Threading примитивы - работают в рамках одного процесса
    • Multiprocessing примитивы - используют IPC для связи между процессами
    • Asyncio примитивы - неблокирующие, работают в event loop
  3. Выбор модуля зависит от:

    • Модели параллелизма (потоки/процессы/асинхронность)
    • Типа решаемой задачи (I/O-bound vs CPU-bound)
    • Требований к производительности

Семафоры

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

Как работают семафоры в asyncio:

  1. Создание: Семафор создается с указанием максимального количества задач, которые могут одновременно выполняться. Например, semaphore = asyncio.Semaphore(2) создаст семафор, который позволяет одновременно работать только двум задачам.
  2. Блокировка и освобождение: Перед выполнением асинхронной задачи, требующей доступа к ресурсу или имеющей ограничение по количеству одновременных запусков, необходимо "захватить" семафор, используя конструкцию async with semaphore:. Это заблокирует семафор, уменьшив его счетчик, и позволит задаче выполняться.
  3. Ожидание освобождения: Если семафор уже занят (счетчик равен нулю), задача будет ожидать, пока другая задача не освободит его (выполнит release(), увеличив счетчик). После освобождения семафора, задача сможет его захватить и продолжить выполнение.
  4. Освобождение после выполнения: После завершения выполнения асинхронной задачи, семафор автоматически освобождается (если использовалась конструкция async with), и счетчик семафора увеличивается.

Пример использования:

import asyncio

async def worker(name, semaphore):
   print(f'Задача {name}: ожидает семафор')
    async with semaphore:
      print(f'Задача {name}: захватила семафор')
      await asyncio.sleep(1)
        print(f'Задача {name}: освобождает семафор')


async def main():
   semaphore = asyncio.Semaphore(2)
    tasks = [worker(i, semaphore) for i in range(5)]
   await asyncio.gather(*tasks)


if __name__ == "__main__":
 asyncio.run(main())

В этом примере, несмотря на то, что создано 5 задач, только 2 из них могут выполняться одновременно из-за установленного в семафоре лимита в 2. Остальные задачи будут ожидать, пока одна из двух работающих задач не завершится и не освободит семафор.

Преимущества использования семафоров:

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

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

Синхронизация задач: Семафоры могут использоваться для координации выполнения задач, например, для обеспечения того, чтобы определенная задача не начиналась, пока не завершится другая.

Простота использования: Semaphore предоставляет удобный и интуитивно понятный способ управления параллелизмом


Выполнение синхронных вызовов в асинхронном приложении

Проблема

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

Решение: 3 подхода

1. Использование run_in_executor() с ThreadPoolExecutor

Для сетевых запросов, работы с файлами, обращений к БД:

import asyncio
import requests

async def fetch_data(url):
    loop = asyncio.get_event_loop()
    response = await loop.run_in_executor(
        None,  # Стандартный пул потоков
        requests.get, url
    )
    return response.json()

Суть: Блокирующая операция выполняется в фоновом потоке, не блокируя основной event loop.

2. Явное управление пулом потоков

Когда нужно ограничить количество одновременных операций:

async def process_requests(urls):
    with ThreadPoolExecutor(max_workers=3) as executor:
        loop = asyncio.get_event_loop()
        tasks = []
        for url in urls:
            task = loop.run_in_executor(executor, requests.get, url)
            tasks.append(task)
        responses = await asyncio.gather(*tasks)
    return [r.json() for r in responses]

Суть: Создаем пул с ограничением потоков для контроля нагрузки.

3. ProcessPoolExecutor для CPU-bound задач

Для вычислений, обработки данных, ML:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def heavy_calculation(data):
    return sum(i * i for i in range(data))

async def main():
    with ProcessPoolExecutor() as executor:
        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(
            executor, heavy_calculation, 1000000
        )
    return result

Суть: Вычисления в отдельном процессе, обход GIL для истинной параллельности.

Ключевые правила

  • I/O операцииThreadPoolExecutor
  • ВычисленияProcessPoolExecutor
  • Всегда предпочитайте асинхронные библиотеки когда возможно
  • Никогда не делайте прямые синхронные вызовы в асинхронном коде

Эволюция от генераторов к корутинам

1. Обычные генераторы (Python 2.2+) - Пассивные последовательности

Изначально yield создавал только генераторы — функции, производящие последовательности значений, но не взаимодействующие с вызывающей стороной.

def number_generator(limit):
    """Простой генератор - только отдает значения"""
    for i in range(limit):
        yield i  # Только отдаем значение наружу

# Использование - только получение данных
gen = number_generator(3)
for num in gen:
    print(num)  # 0, 1, 2

Ограничение: генератор не мог получать данные извне, только производить их.

2. Генераторы становятся корутинами (Python 2.5+) - Двусторонняя связь

С введением методов .send(), .throw(), .close() генераторы превратились в корутины — функции, способные к двустороннему обмену данными.

Механизм работы .send():

def data_processor():
    """Корутина с двусторонней связью"""
    print("Корутина запущена")

    # Первый yield - инициализация, только отдаем значение
    initial = yield "Готов к работе"
    print(f"Получил начальное значение: {initial}")

    # Основной цикл обработки
    while True:
        data = yield f"Обработано: {initial}"  # Получаем И отдаем
        print(f"Обрабатываю: {data}")
        initial = data  # Сохраняем для следующей итерации

# Использование с двусторонней связью
coro = data_processor()

# Инициализация - обязательно send(None) первый раз
status = coro.send(None)  # Получаем: "Готов к работе"
print(status)

# Двусторонний обмен
result = coro.send(10)    # Отправляем 10, получаем "Обработано: 10"
print(result)

result = coro.send(20)    # Отправляем 20, получаем "Обработано: 20"
print(result)

Полный API корутин:

def advanced_coroutine():
    try:
        received = yield "Первое значение"
        while True:
            try:
                received = yield f"Получил: {received}"
            except ValueError as e:
                print(f"Обработана ошибка: {e}")
                received = yield "Продолжаю работу"
    finally:
        print("Корутина завершена")

coro = advanced_coroutine()
print(coro.send(None))        # "Первое значение"
print(coro.send("данные"))    # "Получил: данные"
coro.throw(ValueError("test")) # Обрабатываем исключение внутри корутины
coro.close()                  # Завершаем корутину

3. Делегирование с yield from (Python 3.3+) - Композиция корутин

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

def sub_generator():
    yield "из подгенератора 1"
    yield "из подгенератора 2"
    return "результат подгенератора"

def main_generator():
    """Делегирует выполнение подгенератору"""
    print("Основной генератор начал работу")

    # yield from автоматически передает управление и значения
    result = yield from sub_generator()

    print(f"Подгенератор вернул: {result}")
    yield "основной генератор завершен"

# Использование
for value in main_generator():
    print(value)

4. Современные корутины с async/await (Python 3.5+) - Явная асинхронность

Синтаксис async/await сделал асинхронное программирование более понятным и безопасным.

Эволюция синтаксиса:

Старый стиль (смешение генераторов и корутин):

def old_coroutine():
    # Непонятно - это генератор или корутина?
    data = yield
    yield process(data)  # Блокирующий вызов - проблема!

Новый стиль (явное разделение):

async def modern_coroutine():
    # Четко видно - это асинхронная функция
    data = await async_receive()  # Явное ожидание
    result = await async_process(data)  # Явная асинхронность
    return result

Преимущества async/await:

  1. Явность: сразу видно, где происходит ожидание
  2. Безопасность: нельзя случайно использовать yield неправильно
  3. Интеграция: встроенная поддержка в языковых инструментах
  4. Производительность: оптимизированная реализация в интерпретаторе

Ключевые различия в эволюции

Поколение Механизм Двусторонняя связь Композиция Читаемость
Генераторы yield ❌ Нет ❌ Сложно ✅ Хорошая
Корутины yield + .send() ✅ Есть ❌ Сложно ❌ Плохая
yield from yield from ✅ Есть ✅ Легко 🟡 Средняя
async/await async/await ✅ Есть ✅ Очень легко ✅ Отличная

Конструкция yield from стала фундаментальным механизмом для построения корутин — функций, которые могут приостанавливать и возобновлять своё выполнение.

В контексте корутин yield from обеспечивает:

  • Делегирование выполнения с сохранением состояния

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

  • Прозрачную передачу данных и управления

yield from создаёт невидимый "канал связи" между корутинами. Все значения, которые yield'ит подкорутина, автоматически передаются через основную корутину наружу, а все данные, отправляемые в основную корутину, автоматически перенаправляются в подкорутину. Это создаёт иллюзию, что внешний код работает напрямую с подкорутиной, хотя на самом деле между ними находится промежуточное звено.

  • Единую точку приостановки и возобновления

С точки зрения планировщика (event loop) вся цепочка корутин, соединённых через yield from, ведёт себя как единое целое. Когда любая корутина в цепочке приостанавливается на yield, приостанавливается вся цепочка. Когда планировщик возобновляет выполнение, оно продолжается ровно в той же корутине, где было прервано, независимо от глубины делегирования.

  • Автоматическую обработку завершения

Когда подкорутина завершает выполнение через return, её возвращаемое значение автоматически передаётся в основную корутину, которая продолжает выполнение с места после yield from. Это позволяет строить сложные workflows, где результаты работы одних корутин естественным образом передаются другим.


Статьи по асинхронному программированию

Асинхронный код на Python: синтаксис и особенности

Асинхронное программирование на Python: основы и примеры

Асинхронный python без головной боли (часть 1)

Несинхронный Python

Разбираемся в асинхронности: где полезно, а где — нет?

Цикл статей "Асинхронный python без головной боли"