Python: runtime и инженерные основы
Ссылки и изменяемость, итераторы, генераторы, декораторы, GIL и конкурентное выполнение.
Python для ML: runtime, память и конкурентное выполнение
В ML-коде Python связывает загрузку данных, эксперименты и сервисы. Здесь важны модель объектов, протокол итерации и выбор способа конкурентного выполнения: именно они объясняют неожиданные изменения значений, расход памяти и поведение I/O- и CPU-bound задач.
Глава предполагает, что вы уже умеете писать функции и циклы. Разбираем вопросы, которые можно проверить небольшим примером кода: ссылки на изменяемые объекты, итераторы и генераторы, декораторы и ограничения CPython при конкурентном выполнении.
Карта главы
Материал идёт от семантики объектов к выполнению программы: сначала ссылки и изменяемость, затем протокол итерации и управление ресурсами, после этого — потоки, процессы и asyncio. Для каждого инструмента важнее назвать ограничение и подходящую нагрузку, чем воспроизвести определение.
- 01
Объекты и ссылки
Понять aliasing, mutability и время жизни объекта.
- 02
Итерация
Разобрать iterable, iterator, generator и StopIteration.
- 03
Управление ресурсами
Использовать context manager и декоратор там, где они упрощают контракт.
- 04
Конкурентность
Выбрать поток, процесс или coroutine по типу ожидания и вычислений.
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)Важный нюанс
Генераторы и итераторы — ленивые вычисления
Список материализует все элементы, а генератор вычисляет очередное значение по запросу и хранит только своё состояние. Ленивый обход полезен для потоковой обработки файлов и батчей, когда полная последовательность не должна одновременно находиться в памяти.
Протокол итератора
В 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
def f(x, lst=[])? lst создаётся один раз при определении. Повторные вызовы мутируют тот же list.
• Генератор vs list comprehension? Генератор вычисляет элементы по запросу, а список материализует их сразу. Сравните память и время на входе нужного размера.
• Что такое декоратор? Вызываемый объект, который получает функцию и возвращает замену или обёртку. @dec эквивалентно func = dec(func).Middle
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 на реальной нагрузке.
Проверьте себя
Ответьте на вопросы из реальных собеседований. Переходить в отдельный тренажёр не нужно.
Материалы
Официальное руководство по отдельной free-threaded сборке CPython и совместимости расширений.
Детальный разбор GIL: как работает, зачем нужен, как обходить.
Официальная документация по корутинам, event loop и задачам.
Пошаговое руководство: от простого декоратора до декораторов с параметрами и классами.
Глубокий доклад: генераторы, декораторы, контекстные менеджеры, метаклассы.
Сложность всех операций для list, dict, set, deque — шпаргалка.
Практические рецепты по API-дизайну, итераторам, concurrency и устройству Python-кода.
Материал был полезен?
Прогресс сохранён в этом браузере · Войти, чтобы синхронизировать