База для работы
~20 мин

Python: runtime и инженерные основы

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

Python для ML: runtime, память и конкурентное выполнение

В ML-коде Python связывает загрузку данных, эксперименты и сервисы. Здесь важны модель объектов, протокол итерации и выбор способа конкурентного выполнения: именно они объясняют неожиданные изменения значений, расход памяти и поведение I/O- и CPU-bound задач.

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

Карта главы

Материал идёт от семантики объектов к выполнению программы: сначала ссылки и изменяемость, затем протокол итерации и управление ресурсами, после этого — потоки, процессы и asyncio. Для каждого инструмента важнее назвать ограничение и подходящую нагрузку, чем воспроизвести определение.

  1. 01

    Объекты и ссылки

    Понять aliasing, mutability и время жизни объекта.

  2. 02

    Итерация

    Разобрать iterable, iterator, generator и StopIteration.

  3. 03

    Управление ресурсами

    Использовать context manager и декоратор там, где они упрощают контракт.

  4. 04

    Конкурентность

    Выбрать поток, процесс или coroutine по типу ожидания и вычислений.

Порядок разбора Python runtime: каждый следующий шаг опирается на предыдущий.

GIL — почему Python «однопоточный»

В обычной сборке CPython GIL (Global Interpreter Lock) допускает выполнение Python-байткода только одним потоком интерпретатора в каждый момент времени. Потоки всё равно полезны для ожидания сети, диска и нативных операций, которые освобождают GIL. Поэтому выбор между потоками, процессами и asyncio начинается с профиля нагрузки, а не с общего правила «Python однопоточный».

Зачем GIL появился? Он упрощает защиту внутреннего состояния интерпретатора и совместимость C-расширений, в том числе операции с подсчётом ссылок. Это историческое архитектурное решение CPython, а не требование самого языка Python.

Как GIL влияет на ML-код? Для CPU-bound кода, написанного на чистом Python, несколько потоков обычной сборки CPython не выполняют байткод параллельно. Но NumPy, PyTorch и другие нативные библиотеки могут освобождать GIL и использовать собственные потоки. Вывод проверяют профилированием конкретной операции.

Три инструмента параллелизма

threading использует потоки ОС. В CPython выполнение Python-байткода ограничено GIL, однако блокирующий I/O и часть нативных библиотек отпускают его. Поэтому потоки подходят для многих I/O-bound задач; выигрыш на CPU-bound коде нужно измерять, а не предполагать.

multiprocessing запускает отдельные процессы с независимой памятью и интерпретаторами. Это позволяет задействовать несколько ядер для CPU-bound Python-кода, но добавляет сериализацию, передачу данных и управление жизненным циклом процессов.

asyncio кооперативно переключает корутины в event loop, когда они доходят до await. Подход полезен для большого числа совместимых неблокирующих I/O-операций. Блокирующая функция внутри event loop остановит остальные coroutine, поэтому границы библиотек нужно проверять.

# CPU-bound → multiprocessing
from multiprocessing import Pool

def process_chunk(chunk):
    return chunk.apply(heavy_transform)

with Pool(8) as pool:
    results = pool.map(process_chunk, data_chunks)

# I/O-bound → asyncio
import asyncio, aiohttp

async def fetch(session, url):
    async with session.get(url) as resp:
        return await resp.json()

async def main():
    async with aiohttp.ClientSession() as s:
        tasks = [fetch(s, url) for url in urls]
        return await asyncio.gather(*tasks)

Важный нюанс

Многие операции NumPy, PyTorch и scikit-learn реализованы в C/C++/CUDA и могут освобождать GIL. Число потоков и реальный параллелизм зависят от конкретной операции, сборки BLAS и настроек runtime, поэтому их измеряют профилировщиком.

Генераторы и итераторы — ленивые вычисления

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

Протокол итератора

В Python любой объект, у которого есть __iter__() и __next__(), является итератором. for x in obj — это просто синтаксический сахар: Python вызывает iter(obj), потом next() до StopIteration. Список создаёт итератор из себя, файл — итератор по строкам, range — итератор по числам.

yield — создаём генератор

Функция с yield — это генератор. При вызове она не выполняется, а возвращает объект-генератор. Каждый вызов next() выполняет код до следующего yield, возвращает значение и замораживает состояние. При следующем next() — продолжает с того же места.

# Генератор батчей для обучения
def batch_generator(data, batch_size=32):
    for i in range(0, len(data), batch_size):
        yield data[i:i + batch_size]  # отдаём один батч, замораживаемся

# Использование — в памяти только один батч
for batch in batch_generator(huge_dataset, batch_size=64):
    model.train_on_batch(batch)

# Generator expression — аналог list comprehension, но ленивый
squares = (x**2 for x in range())  # значения создаются по запросу
squares_list = [x**2 for x in range()]  # все ссылки хранятся сразу

Когда использовать генераторы: обработка больших файлов построчно и пайплайны, где элементы нужны последовательно. PyTorch DataLoader следует iterable-протоколу и может выдавать батчи лениво, но его устройство не сводится к одному Python-генератору. Когда материализовать список: если нужен повторный обход, известная длина или произвольный доступ по индексу.

Декораторы — функции, которые меняют функции

Декоратор принимает callable и возвращает callable с изменённым или дополненным поведением. @decorator над определением эквивалентен присваиванию func = decorator(func). На интервью важно разобрать closure, передачу аргументов и сохранение metadata через functools.wraps.

Анатомия декоратора

Каждый декоратор — это три уровня: внешняя функция (принимает оригинал), wrapper (обёртка, которая вызывает оригинал + делает что-то ещё), возврат wrapper. Ключевой момент — @functools.wraps(func): без него обёрнутая функция теряет имя, docstring и другие метаданные.

import time
from functools import wraps

# 1. @timer — замер времени выполнения
def timer(func):
    @wraps(func)                     # сохраняет __name__, __doc__
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.3f}s")
        return result
    return wrapper

# 2. @retry — повторить при ошибке
def retry(attempts=3, delay=1):
    def decorator(func):             # декоратор с параметрами — ещё один уровень
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if i == attempts - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@timer
def train_model(epochs):
    time.sleep(2)  # имитация обучения

@retry(attempts=5, delay=2)
def fetch_data(url):
    ...  # HTTP-запрос, может упасть

Часто используемые встроенные декораторы:@property — предоставляет method через attribute access • @staticmethod, @classmethod — методы без instance argument / с class argument • @functools.lru_cache(maxsize=128) — кеширует вызовы; подходит для hashable arguments, если повторный вызов не должен выполнять side effects • @dataclass — генерирует выбранные методы для data-oriented class

Порядок декораторов

Декораторы применяются снизу вверх. @A @B def f()f = A(B(f)). Сначала B оборачивает f, потом A оборачивает результат. Это важно: @lru_cache должен быть ближе к функции (внизу), чтобы кешировать вызов, а не обёртку.

Context managers — гарантия очистки ресурсов

Файл, соединение с БД или блокировку нужно освобождать и при успешном выполнении, и при исключении. Конструкция with выражает этот lifecycle через context manager и обычно заменяет ручной try/finally вокруг ресурса.

Протокол: объект с методами __enter__() (вызывается при входе в with, возвращает ресурс) и __exit__() (вызывается при выходе — даже при ошибке). Если __exit__ возвращает True, исключение подавляется.

# Свой контекстный менеджер — класс
class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"Elapsed: {self.elapsed:.3f}s")
        return False  # не подавляем исключения

with Timer() as t:
    train_model(epochs=10)

# Тот же результат через contextlib — проще
from contextlib import contextmanager

@contextmanager
def timer(name="block"):
    start = time.perf_counter()
    yield                  # тут выполняется тело with-блока
    print(f"{name}: {time.perf_counter() - start:.3f}s")

with timer("training"):
    train_model(epochs=10)

Где используются в ML: torch.no_grad() — отключает вычисление градиентов при инференсе. open() — чтение данных. tempfile.NamedTemporaryFile() — временные файлы. torch.cuda.amp.autocast() — mixed precision. Все — контекстные менеджеры.

Гибкие сигнатуры, type hints и dataclasses

*args и kwargs — механизм передачи произвольного числа аргументов. *args собирает позиционные аргументы в tuple, kwargs — именованные в dict. Без этого невозможно написать декоратор, который работает с любой функцией.

def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):      # принимает ЧТО УГОДНО
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)   # передаёт оригиналу
    return wrapper

# Type hints — подсказки типов (не проверяются в рантайме!)
def train(
    model: nn.Module,
    data: DataLoader,
    epochs: int = 10,
    lr: float = 1e-3,
) -> dict[str, float]:  # возвращает словарь метрик
    ...

Type hints не влияют на выполнение — Python их игнорирует. Но они критичны для: IDE (автодополнение, предупреждения), mypy (статическая проверка типов), читаемости кода. В ML-проектах type hints экономят часы отладки.

dataclass — конфиги без шаблонного кода

@dataclass генерирует выбранные методы вроде __init__, __repr__ и __eq__ для класса с объявленными полями. frozen=True запрещает обычное присваивание атрибутов, но не делает вложенные mutable objects неизменяемыми. Сгенерированный hash пригоден только когда значения полей сами hashable и семантика класса допускает hash-based lookup.

from dataclasses import dataclass, field

@dataclass
class TrainConfig:
    model_name: str = "catboost"
    lr: float = 1e-3
    epochs: int = 10
    tags: list[str] = field(default_factory=list)  # мутабельный default!

config = TrainConfig(lr=0.01)
print(config)  # TrainConfig(model_name='catboost', lr=0.01, epochs=10, tags=[])

__slots__ — когда объектов миллионы

Экземпляр обычного пользовательского класса обычно хранит атрибуты в __dict__. На больших коллекциях объектов этот словарь может заметно увеличить расход памяти. Точное значение зависит от версии Python, числа атрибутов, наследования и allocator, поэтому его измеряют на целевой среде.

__slots__ объявляет допустимые атрибуты экземпляра и может убрать отдельный __dict__, если его не возвращает наследование. Это часто уменьшает память на экземпляр, но величину выигрыша нужно измерять; также меняются возможности динамически добавлять атрибуты и некоторые сценарии множественного наследования.

class Point:                    # обычный класс — с __dict__
    def __init__(self, x, y):
        self.x = x
        self.y = y

class SlotPoint:                # со __slots__ — без __dict__
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

# Сравните полную память большой коллекции в своей версии Python:
# pympler.asizeof.asizeof(points) или memory-profiler

Когда использовать: классы с миллионами экземпляров (DataPoint, Token, Node в графе). В dataclass: @dataclass(slots=True) (Python 3.10+). Когда НЕ нужны: обычные классы с десятками экземпляров — экономия незаметна, а гибкость теряется.

Comprehensions vs map/filter — pythonic code

Comprehension и map/filter выражают преобразование коллекции по-разному. Comprehension удобно объединяет преобразование и условие, а map хорошо читается с уже существующей именованной функцией. Производительность зависит от операции и версии Python; сначала выбирают ясный код, затем измеряют горячий участок.

# Comprehension — читается как текст
even_squares = [x**2 for x in range(100) if x % 2 == 0]

# Dict comprehension
name_to_len = {name: len(name) for name in names}

# Set comprehension
unique_words = {word.lower() for word in text.split()}

# Эквивалент через map/filter — менее читаемо
even_squares = list(map(lambda x: x**2, filter(lambda x: x % 2 == 0, range(100))))

# Когда map лучше: с готовой функцией (без lambda)
numbers = list(map(int, string_list))   # чище, чем [int(s) for s in string_list]

Практический критерий — читаемость выражения и требуемая семантика. Сложный вложенный comprehension лучше развернуть в именованные шаги или обычный цикл; компактность сама по себе не делает код понятнее.

Мутабельность и передача аргументов

Имена Python ссылаются на объекты. При вызове функции новое локальное имя связывается с тем же переданным объектом. Мутация списка внутри функции поэтому видна вызывающему коду, а переназначение локального имени на другой объект — нет. Такой контракт часто описывают как call by sharing.

Мутабельный аргумент по умолчанию

def add(x, lst=[]) создаёт список один раз при определении функции. Повторные вызовы без явного аргумента изменяют тот же объект. Безопасный шаблон: lst=None, затем if lst is None: lst = [] внутри функции.
# Один list создаётся при определении функции
def add_item(x, lst=[]):
    lst.append(x)
    return lst

add_item(1)  # [1]
add_item(2)  # [1, 2] — неожиданно!

# Вариант с отдельным list для каждого вызова
def add_item(x, lst=None):
    if lst is None:
        lst = []
    lst.append(x)
    return lst

На собеседовании

Junior

Mutable vs immutable — в чём разница? list, dict, set можно менять in-place. int, str и сами объекты tuple — нет; новый результат создаёт другой объект. Хешируемость tuple дополнительно зависит от его элементов. • Что будет с def f(x, lst=[])? lst создаётся один раз при определении. Повторные вызовы мутируют тот же list. • Генератор vs list comprehension? Генератор вычисляет элементы по запросу, а список материализует их сразу. Сравните память и время на входе нужного размера. • Что такое декоратор? Вызываемый объект, который получает функцию и возвращает замену или обёртку. @dec эквивалентно func = dec(func).

Middle

GIL — что это и как влияет на выбор? В обычной сборке CPython один поток исполняет Python-байткод за раз. Для I/O подходят потоки или asyncio; CPU-bound Python-код можно вынести в процессы или нативные операции. Решение подтверждают профилированием. • Как работает yield? Функция с yield возвращает генератор. next() выполняет код до следующего yield, сохраняет состояние и отдаёт значение. • __enter__ / __exit__ — зачем? Протокол контекстного менеджера гарантирует освобождение ресурса при выходе из with, включая исключение. • *args, kwargs — что это? *args собирает позиционные аргументы в tuple, kwargs — именованные в dict. • Зачем @functools.wraps? Он переносит метаданные исходной функции на wrapper и сохраняет __wrapped__ для интроспекции.

Senior

__slots__ — зачем? Может убрать __dict__ экземпляра и сократить память большой однородной коллекции. Выигрыш измеряют на целевой версии Python; наследование может изменить результат. • asyncio — event loop, как работает? Event loop запускает готовые корутины; await отдаёт управление, пока операция ожидает результат. CPU-bound работу нельзя надолго оставлять в том же loop. • GIL и free-threading (PEP 703)? Начиная с CPython 3.13 доступна отдельная free-threaded сборка, где GIL можно отключить. Она не является обычной сборкой, а несовместимое C-расширение может снова включить GIL. • Декоратор с аргументами — как устроен? Внешняя функция получает настройки, возвращает декоратор; тот получает функцию и возвращает wrapper. Пример: @retry(attempts=3).

Практический минимум Python для ML

Продвинутые механизмы Python помогают выбирать подходящую concurrency model, обрабатывать потоки данных лениво и явно управлять ресурсами. Декораторы полезны для повторяемого поведения вроде logging или cache при корректной семантике, а type hints и dataclasses делают контракты данных заметнее для инструментов и команды.

Практический вывод: сначала определите, где выполняется работа — в Python-байткоде, нативной библиотеке, I/O или отдельном процессе. После этого выбирайте способ конкурентности и измеряйте время, память и throughput на реальной нагрузке.

Проверьте себя

Ответьте на вопросы из реальных собеседований. Переходить в отдельный тренажёр не нужно.

Материал был полезен?

Прогресс сохранён в этом браузере · Войти, чтобы синхронизировать