Python. Итераторы, гинераторы и асинхронность
Файл-дескриптор
Файл-дескриптор — это уникальный числовой идентификатор, который операционная система (ОС) присваивает каждому открытому файлу или ресурсу ввода-вывода (например, сокету, каналу, устройству). Благодаря файловым дескрипторам программы могут работать с файлами и другими потоками данных абстрактно, не заботясь о деталях реализации на уровне ОС: вместо непосредственного взаимодействия с физическим файлом программа оперирует этим числом, а все детали работы с ресурсом скрыты внутри ядра ОС.
Когда программа (процесс) запрашивает у ОС открыть файл или другой ресурс ввода-вывода, ОС сначала проверяет, обладает ли процесс необходимыми правами доступа (например, на чтение или запись). Если доступ разрешён, ядро ОС создаёт специальную структуру данных — запись в глобальной таблице открытых файлов. Эта запись содержит всю необходимую информацию о файле: путь к файлу, текущую позицию (смещение) для чтения/записи, режим доступа (только чтение, только запись, чтение и запись), а также дополнительные флаги и параметры. После этого процессу возвращается уникальный неотрицательный целочисленный идентификатор — файл-дескриптор (например, 0, 3, 42, 841). Этот номер уникален только в рамках данного процесса: разные процессы могут иметь одинаковые значения дескрипторов, но они будут указывать на разные записи в их собственных таблицах.
Хотя дескриптор — это просто число, за ним скрывается сложная инфраструктура ядра ОС. Для каждого открытого файла или другого ресурса ввода-вывода существует отдельный дескриптор. Внутри ядра дескриптор используется как индекс для поиска соответствующей записи в таблице открытых файлов. Запись в этой таблице содержит индексный дескриптор файла (inode) — уникальный идентификатор файла в файловой системе, текущее смещение (offset) — позицию в файле, с которой будет происходить следующее чтение или запись, а также ограничения доступа — права и режимы (например, только для чтения, только для записи, или оба варианта). Кроме того, запись может содержать ссылки на дополнительные структуры, такие как буферы, блокировки или параметры синхронизации.
Каждый процесс в ОС имеет свою собственную таблицу дескрипторов, которая отображает локальные номера дескрипторов на глобальные записи файлов. При открытии нового файла процесс получает следующий свободный номер дескриптора. После закрытия файла (например, с помощью системного вызова close или метода file.close() в Python) дескриптор освобождается и может быть использован повторно для других файлов. Помимо обычных файлов, дескрипторы используются для работы с сокетами, каналами (pipe), устройствами и другими объектами ввода-вывода.
Таким образом, файловый дескриптор —
это фундаментальный механизм взаимодействия программ с файлами и другими ресурсами
ввода-вывода в операционных системах семейства Unix и не только.

Он обеспечивает универсальный и эффективный способ управления доступом к различным потокам данных,
скрывая детали реализации и обеспечивая безопасность и изоляцию между процессами.
STDIN, STDOUT и STDERR
В Unix-подобной операционной системе первыми тремя дескрипторами файлов по умолчанию являются STDIN (стандартный ввод), STDOUT (стандартный вывод) и STDERR (стандартный вывод ошибок). Эти дескрипторы создаются для каждого процесса в ОС.
В Python вы можете получить данные из stdin, воспользовавшись встроенной функцией input. В stdout пишет любимая функция print. В stderr тоже пишет print, но ей нужно передать дополнительный аргумент file, значение которого — sys.stderr.
import sys

print('Получим данные из stdin:')
# В консоли нужно напечатать какое-нибудь сообщение
s = input()
print('Выведем данные в stdout: ', s)
print('Выведем данные в stderr: ', s, file=sys.stderr)
Чтобы убедиться, что данные действительно выводятся в поток ошибок (stderr), можно воспользоваться перенаправлением этого потока в специальное устройство /dev/null. Это устройство в Unix-подобных системах «поглощает» все поступающие в него данные, не сохраняя их и не выводя на экран. Таким образом, если вы перенаправите поток ошибок в /dev/null, все сообщения, отправленные в stderr, исчезнут и не будут отображаться в терминале.
Для перенаправления потока ошибок используется оператор 2>. Число 2 указывает на файловый дескриптор стандартного потока ошибок (stderr), а знак > — на направление перенаправления (в данном случае — в файл или устройство). Например, следующая команда запускает скрипт Python и отправляет все сообщения об ошибках (и любые другие данные, записанные в stderr) в /dev/null:
python3 foo.py 2> /dev/null

Получим данные из stdin:
  hello
Выведем данные в stdout:
  hello
С его же помощью можно перенаправить все ошибки в отдельный файл.
python3 foo.py 2> errors.txt
Получим данные из stdin:
  hello
Выведем данные в stdout:
  hello

# Выведем файл с ошибками
cat errors.txt
Выведем данные в stderr:
  hello
Рекомендуем прочитать интересную статью на Хабре про использование дескрипторов помимо процессов ввода/вывода.
Межпроцессная передача данных (IPC)
IPC (Inter-Process Communication или межпроцессная передача данных) — организация передачи данных между процессами.
Само определение IPC (межпроцессного взаимодействия) довольно абстрактно и не раскрывает, как именно процессы могут обмениваться данными, какими способами это реализуется на практике и зачем вообще нужен этот механизм. Давайте разберёмся подробнее.
Зачем нужен IPC и как он появился
Изначально IPC возник из-за необходимости создавать связанные процессы, которые должны работать совместно для решения общих задач. Например, в операционных системах часто требуется запускать несколько процессов, которые обрабатывают разные части одной задачи или обслуживают разные компоненты приложения. Поскольку у каждого процесса в современных ОС своё собственное адресное пространство (то есть память одного процесса недоступна другому напрямую), для их взаимодействия требуется специальный механизм передачи данных.
Для чего нужен IPC: основные сценарии
IPC используется в следующих случаях:
Обмен данными между процессами
Например, один процесс собирает данные, а другой их анализирует или визуализирует.
Ускорение вычислений
Задача может быть разбита на несколько частей, которые обрабатываются параллельно разными процессами, а затем результаты объединяются.
Разделение программы на независимые модули
Это позволяет повысить надёжность и масштабируемость: сбой одного процесса не приводит к сбою всей системы, а отдельные модули можно обновлять или заменять независимо друг от друга.
Организация клиент-серверных архитектур
Например, серверный процесс обслуживает множество
клиентских процессов,
обмениваясь с ними сообщениями.
Организация клиент-серверных архитектур
Безопасность и изоляция. Разделение на процессы
позволяет ограничить доступ к данным и ресурсам, что важно для защиты информации.
Почему процессы не могут просто обмениваться данными напрямую?
В отличие от потоков, которые могут работать с общей памятью внутри одного процесса, процессы изолированы друг от друга. Это сделано для безопасности и стабильности: если один процесс «упадёт» или начнёт вести себя некорректно, остальные процессы не пострадают. Поэтому для передачи данных между процессами приходится использовать специальные механизмы, которые контролируются операционной системой.
Основные модели обмена данными между процессами
Для обеспечения всех этих потребностей существует две основных модели обмена данными между процессами:
Общая память (shared memory)
Процессы получают доступ к одной и той же области памяти, выделенной ОС. Это самый быстрый способ обмена данными, но требует сложной синхронизации, чтобы избежать конфликтов при одновременной записи/чтении.
Обмен сообщениями (message passing)
Процессы обмениваются данными с помощью сообщений, которые передаются через специальные каналы (очереди, пайпы, сокеты и т. д.). Этот способ проще с точки зрения синхронизации и часто используется для взаимодействия между независимыми процессами, в том числе на разных компьютерах.
В следующих разделах мы подробнее рассмотрим, как реализуются эти модели, какие у них есть плюсы и минусы, и как их использовать на практике в Python.
Общая память (shared memory) —
это модель межпроцессного взаимодействия, при которой несколько процессов могут обращаться к одной и той же области оперативной памяти, выделенной операционной системой.

Для этого ОС предоставляет специальные механизмы: обычно создаётся сегмент памяти, к которому процессы получают доступ через файловый дескриптор или уникальное имя. Каждый процесс может подключиться к этому сегменту и читать или изменять данные напрямую, минуя дополнительные обращения к ядру.
Главное преимущество общей памяти — высокая скорость обмена данными между процессами, поскольку не требуется пересылать данные через ядро или использовать промежуточные буферы. Такой подход особенно эффективен при передаче больших объёмов информации или при необходимости частого взаимодействия между процессами.
Однако использование общей памяти требует аккуратной синхронизации: если несколько процессов одновременно попытаются изменить одни и те же данные, возможны конфликты и повреждение информации. Для предотвращения таких ситуаций применяют механизмы блокировок (mutex, семафоры и др.), что усложняет архитектуру приложения. Кроме того, важно обеспечить безопасность доступа, чтобы один процесс не мог случайно или намеренно повредить данные другого.
В Python для работы с общей памятью можно использовать модуль multiprocessing.shared_memory, который позволяет создавать и подключаться к сегментам памяти, а также управлять их жизненным циклом. Подробные примеры и описание интерфейса доступны в официальной документации.
В последнее время развивается подход, при котором общая память используется не только для взаимодействия процессов на одном компьютере, но и для обмена данными между процессами, запущенными на разных физических серверах. Это расширяет возможности масштабирования и построения распределённых систем, хотя требует дополнительных средств для организации защищённого и быстрого доступа к общей памяти по сети.
Обмен сообщениями —
это фундаментальная модель IPC, позволяющая процессам обмениваться данными без прямого доступа к памяти друг друга.

Существует два основных класса обмена сообщениями: прямой и косвенный.
Прямой обмен сообщениями
Предполагает, что отправитель точно знает, какому процессу адресовано сообщение: в коде явно указывается идентификатор или адрес получателя. Такой подход прост, но сильно ограничивает масштабируемость и гибкость архитектуры. Например, если потребуется добавить новый процесс или изменить схему взаимодействия, придётся модифицировать код отправителя. Кроме того, прямой обмен затрудняет построение динамических или распределённых систем, где состав участников может меняться во времени.
Косвенный обмен сообщениями 
Решает эти проблемы за счёт введения промежуточных сущностей — так называемых «портов», «каналов». Процессы отправляют сообщения не напрямую друг другу, а в определённый порт или очередь, откуда их может забрать любой заинтересованный процесс. Таким образом, отправитель не обязан знать, кто именно получит сообщение, а получатель — кто его отправил. Это позволяет строить более гибкие, масштабируемые и отказоустойчивые системы, где процессы могут динамически подключаться и отключаться, а сообщения — маршрутизироваться по разным сценариям.
Косвенный обмен сообщениями, в свою очередь, подразделяется на два типа по способу синхронизации:
Блокирующий (синхронный)
Отправитель или получатель может быть вынужден ждать, пока другая сторона не примет или не отправит сообщение. Например, если очередь пуста, получатель блокируется до появления данных; если очередь переполнена, отправитель ждёт освобождения места.
Неблокирующий (асинхронный)
Операции отправки и получения не заставляют процессы ждать друг друга. Сообщения помещаются в буфер (очередь), и процессы продолжают работу независимо. Такой подход повышает производительность и отзывчивость системы, но требует продуманной обработки ошибок и переполнения буфера.
В теории вычислений и формальной верификации подробно изучаются свойства различных моделей обмена сообщениями (например, гарантии доставки, порядок сообщений, отсутствие гонок и взаимоблокировок). Для практики важно знать основные механизмы косвенного обмена сообщениями, которые реализованы в современных ОС и языках программирования:
Очереди сообщений
Позволяют буферизовать сообщения между процессами, обеспечивая асинхронность и независимость отправителя и получателя.
Сокеты
Универсальный механизм для обмена данными между процессами как на компьютере, так и по сети; поддерживают как потоковую, так и пакетную передачу.
Пайпы (каналы)
Простейший способ передачи данных между двумя процессами, обычно используются для однонаправленного обмена.
Сигналы
Механизм уведомления процессов о наступлении определённых событий, чаще применяются для управления, чем для передачи данных.
RPC (Remote Procedure Call)
Позволяет процессу вызывать функцию или процедуру в другом процессе (даже на другом компьютере), как будто она локальная.
RMI (Remote Method Invocation)
Аналог RPC для объектно-ориентированных языков, позволяет вызывать методы удалённых объектов.
Немного об очередях
Очереди позволяют обмениваться сообщениями сразу между группами процессов. На очередях строится достаточно много систем, при этом их применение в процессах достаточно простое.
import multiprocessing as mp

from multiprocessing import Process
from multiprocessing.queues import Queue
from time import sleep


def producer(queue: Queue):
    while True:
        message = 'ping'
        queue.put(message)
        sleep(1)


def consumer(queue: Queue):
    while message := queue.get():
        print(message)


if __name__ == '__main__':
    ctx = mp.get_context('spawn')
    queue = Queue(ctx=ctx)
    producer_ = Process(target=producer, args=(queue,))
    consumer_ = Process(target=consumer, args=(queue,))
    producer_.start()
    consumer_.start()
    producer_.join()
    consumer_.join()
Один процесс пишет в очередь сообщение ping, а другой — вычитывает сообщение из очереди и печатает его в консоль. Каждому процессу отдаётся один и тот же объект queue, через который процессы могут взаимодействовать друг с другом и при этом не быть связанными.
Основной момент, который прибавился в современных версиях Python — необходимость указывать контекст для очереди. Всего три опции:
spawn
Основной процесс запускает новый процесс интерпретатора Python. Сравнительно медленный метод по сравнению с fork и forkserver. Доступен на всех популярных ОС.
fork
Использует системную команду fork() для создания дочерних процессов. Доступен для всех Unix-based систем, включая MacOS. Работает быстрее spawn, но для Windows недоступен.
forkserver
Используется специальный процесс-сервер, к которому обращается главный процесс программы, чтобы создать новый процесс. Такой способ делает безопасным вызов функции fork(). Доступен для Unix-based систем.
По умолчанию используется процесс spawn, так как он доступен на всех системах и удобен в использовании разработчиками.
Очереди также могут быть буферизированными, то есть в них можно записать более одного сообщения и при этом не блокировать работу процесса.
У очередей есть один важнейший недостаток для разработчика. При передаче данных по очереди их нужно сериализовать, то есть превратить в байтовое представление. Поэтому на плечи разработчика ложится забота о том, чтобы данные правильно упаковывались в байты. Поэтому сложные объекты желательно не посылать в очереди, иначе велик риск допустить ошибку и сломать приложение.
С сокетами вы познакомитесь в рамках этого спринта, а с RPC — в следующих модулях.
Все способы объединяет одно преимущество — процессами могут выступать программы, написанные на разных языках программирования. Это открывает возможности для обмена сообщениями между разными стеками технологий, чем активно пользуются в рамках микросервисной архитектуры.
Что нужно запомнить про процессы и потоки для собеса
Достоинства процессов в Python
  • У каждого процесса своя память.
  • Позволяют использовать все доступные ядра процессора.
  • GIL не накладывает ограничения.
  • Есть возможность прервать процесс.
  • Подходит для тяжёлых вычислений.
Недостатки процессов в Python
  • IPC более сложный и имеет большие накладные расходы.
  • Большое потребление памяти.
Достоинства потоков в Python
  • Более легковесные и требуют меньше памяти.
  • Общая память между потоками.
  • Можно запускать в потоках для параллельной работы модули, написанные на C, которые отпускают GIL.
  • Подходят для операций ввода/вывода.
Недостатки потоков в Python
  • GIL не позволяет потокам работать одновременно кроме C модулей, которые отпускают GIL.
  • Нет возможности прервать выполнение потока.
  • Необходимо использовать примитивы синхронизации для работы с общей памятью, чтобы предотвратить «гонку».