Готовиться к собеседованию по списку из StackOverflow — значит знать ровно то же, что знают все остальные. Интервьюеры это чувствуют. В этой статье — 10 вопросов, которые реально задают на Python backend собеседованиях, с разбором так, как это объяснили бы вам после интервью на обратной связи.
Вопрос 1. Позднее связывание в замыканиях
Смотрим на код:
funcs = [lambda: i for i in range(10)]print(funcs[0]()) # Что вернёт?print(funcs[3]()) # А это?
Большинство джунов уверенно отвечают: 0 и 3. Оба ответа неправильные. Оба вернут 9.
Почему так происходит?
Когда вы пишете lambda: i, Python не запоминает значение переменной i в момент создания функции. Он запоминает ссылку на переменную i. Это называется поздним связыванием (late binding).
К моменту, когда вы вызываете funcs[0](), цикл уже завершился, и i равно 9. Все десять лямбд смотрят на одну и ту же переменную — и видят одно и то же значение.
Как исправить?
Зафиксировать значение в момент создания через аргумент по умолчанию:
funcs = [lambda i=i: i for i in range(10)]print(funcs[0]()) # 0print(funcs[3]()) # 3
Аргумент по умолчанию вычисляется в момент определения функции, а не в момент вызова. Это и спасает.
Где это стреляет в продакшене? В любом месте, где вы динамически создаёте функции в цикле: обработчики событий, роуты, колбэки. Это одна из самых тихих и болезненных ошибок, потому что код выглядит правильно, а работает неправильно.
Вопрос 2. GIL — враг или друг?
Что такое GIL?
GIL (Global Interpreter Lock) — это мьютекс, который гарантирует, что в любой момент времени только один поток выполняет Python-байткод. Буквально: даже если у вас 16 ядер процессора и 16 потоков — Python-код в один момент времени выполняет только один из них.
Почему многопоточность на CPU-задачах работает медленнее?
Представьте: вы хотите посчитать факториал большого числа в нескольких потоках. Логика подсказывает — должно быть быстрее. Но нет:
import threadingimport timedef count(n): while n > 0: n -= 1# Один потокstart = time.time()count(50_000_000)print(f"Один поток: {time.time() - start:.2f}s")# Два потокаstart = time.time()t1 = threading.Thread(target=count, args=(25_000_000,))t2 = threading.Thread(target=count, args=(25_000_000,))t1.start(); t2.start()t1.join(); t2.join()print(f"Два потока: {time.time() - start:.2f}s")
Два потока окажутся медленнее одного. Потому что они постоянно борются за GIL, переключаются, тратят время на синхронизацию — и в итоге работают хуже, чем один поток, который просто спокойно делает своё дело.
Тогда зачем вообще threading?
GIL снимается во время I/O операций. Когда поток ждёт ответа от базы данных, читает файл или делает сетевой запрос — он отпускает GIL, и другой поток может работать. Именно поэтому threading отлично подходит для I/O-bound задач.
Простая шпаргалка:
-
CPU-bound (математика, обработка данных) →
multiprocessing -
I/O-bound (HTTP, БД, файлы) →
threadingилиasyncio
Что с этим делают? В Python 3.13 появился экспериментальный режим «no-GIL». Это долгожданное изменение, но пройдёт ещё несколько лет, прежде чем экосистема к нему адаптируется.
Вопрос 3. slots — ускоряем сервис в несколько раз
Как Python хранит атрибуты объекта по умолчанию?
У каждого экземпляра класса есть скрытый словарь dict, который хранит все атрибуты:
class User: def __init__(self, name, age): self.name = name self.age = ageu = User("Alice", 30)print(u.__dict__) # {'name': 'Alice', 'age': 30}
Словарь — удобная структура, но тяжёлая. Он занимает много памяти и работает медленнее, чем прямой доступ к полю.
Что такое slots?
slots говорит Python: «этот класс будет иметь только вот эти атрибуты — заранее зарезервируй под них память». Никакого словаря — только фиксированные слоты:
class UserWithSlots: __slots__ = ['name', 'age'] def __init__(self, name, age): self.name = name self.age = ageu = UserWithSlots("Alice", 30)# u.__dict__ → AttributeError! Словаря нет.
Насколько это важно?
import sysu1 = User("Alice", 30)u2 = UserWithSlots("Alice", 30)print(sys.getsizeof(u1)) # ~48 байт + словарь ~232 байт = ~280 байтprint(sys.getsizeof(u2)) # ~56 байт — всё!
Разница ~5x по памяти. Если вы создаёте миллион объектов — разница между 280 МБ и 56 МБ очень ощутима.
Когда применять? Когда у вас есть датакласс-объект, который создаётся в огромных количествах: строки из CSV, события аналитики, кэшируемые сущности. Особенно актуально для высоконагруженных сервисов.
Ограничение: вы не можете динамически добавлять новые атрибуты к объекту со slots. Это цена за скорость.
Хотите больше таких вопросов?
Это только 10 из огромного списка того, что реально спрашивают на собеседованиях. Если вы хотите системно подготовиться — есть бесплатный курс «101 вопрос с собеседований | Python».
В нём 101 вопрос с реальных интервью, каждый с подробным разбором и примерами кода. Курс охватывает типы данных, память, ООП, исключения, файлы и многое другое — всё то, что реально спрашивают от Intern до Junior уровня. Без воды, без платных стен, полностью бесплатно.
Вопрос 4. asyncio vs threading vs multiprocessing
Это один из самых популярных вопросов на собеседованиях — и один из тех, где большинство джунов дают поверхностный ответ.
Задача: нужно сделать 1000 запросов к внешнему API. Что выбрать?
threading
Создаём 1000 потоков, каждый делает свой запрос. Работает, но дорого: каждый поток — это ~8 МБ памяти. 1000 потоков = 8 ГБ. Плюс накладные расходы на переключение контекста.
import threadingimport requestsdef fetch(url): response = requests.get(url) return response.json()threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]for t in threads: t.start()for t in threads: t.join()
asyncio
Один поток, один event loop, тысячи корутин. Пока одна корутина ждёт ответа от сервера — event loop переключается на другую. Памяти уходит на порядок меньше:
import asyncioimport aiohttpasync def fetch(session, url): async with session.get(url) as response: return await response.json()async def main(): async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in urls] results = await asyncio.gather(*tasks)
multiprocessing
Обходит GIL, создавая отдельные процессы — каждый со своим интерпретатором. Подходит только для CPU-bound задач:
from multiprocessing import Pooldef heavy_computation(data): # Тяжёлые вычисления return resultwith Pool(processes=4) as pool: results = pool.map(heavy_computation, data_list)
Итоговая таблица:
|
Задача |
Инструмент |
|---|---|
|
1000 HTTP запросов |
asyncio + aiohttp |
|
Парсинг HTML (CPU) |
multiprocessing |
|
Работа с legacy-кодом без async |
threading |
|
Обучение ML-модели |
multiprocessing |
Вопрос 5. Почему async нельзя просто вызвать?
Смотрим на код:
async def get_data(): return 42result = get_data()print(result)
Что выведет print? Не 42. Вот что на самом деле произойдёт:
<coroutine object get_data at 0x7f8b1c2d3e80>RuntimeWarning: coroutine 'get_data' was never awaited
Почему так?
async def не возвращает результат при вызове — она возвращает объект корутины. Это как чертёж, а не здание. Корутина описывает что нужно сделать, но сама ничего не делает, пока кто-то не запустит её в event loop.
Три способа запустить корутину:
# 1. await внутри другой async-функцииasync def main(): result = await get_data() print(result) # 42# 2. asyncio.run() — точка входаasyncio.run(main())# 3. Создать задачу в event loopasync def main(): task = asyncio.create_task(get_data()) result = await task
Важная деталь: await можно писать только внутри async def. Если попробуете await в обычной функции — получите SyntaxError. Это одна из первых вещей, которая сбивает с толку тех, кто переходит с синхронного кода.
Вопрос 6. Event loop — что происходит под капотом
Большинство разработчиков используют asyncio, не понимая что происходит внутри. На собеседовании это сразу заметно.
Что такое event loop?
Event loop — это бесконечный цикл, который управляет очередью задач:
while True: events = poll_for_events() # Проверяем: что готово? for event in events: run_callback(event) # Запускаем нужный код
Что происходит когда вы пишете await asyncio.sleep(1)?
async def main(): print("Старт") await asyncio.sleep(1) print("Прошла 1 секунда")
Шаг за шагом:
-
Выполняется
print("Старт") -
Встречается
await asyncio.sleep(1)— корутина приостанавливается и передаёт управление обратно в event loop -
Event loop регистрирует таймер на 1 секунду
-
Event loop проверяет: есть ли другие задачи? Если есть — выполняет их
-
Через 1 секунду таймер срабатывает, event loop возобновляет корутину
-
Выполняется
print("Прошла 1 секунда")
Поток при этом не блокируется ни на секунду. Он либо выполняет полезную работу, либо ждёт событий от ОС.
Вот почему time.sleep() в async-коде — это катастрофа:
async def bad_handler(): time.sleep(5) # ВСЕ запросы к серверу зависнут на 5 секунд! return {"status": "ok"}async def good_handler(): await asyncio.sleep(5) # Только эта корутина ждёт, остальные работают return {"status": "ok"}
time.sleep() блокирует поток — и вместе с ним весь event loop. asyncio.sleep() только
приостанавливает корутину, позволяя event loop обрабатывать другие запросы.
Вопрос 7. N+1 проблема
Это классика, но удивительное количество джунов не могут объяснить её через SQL.
Смотрим на код:
users = User.objects.all()for user in users: print(user.profile.bio)
Выглядит невинно. На деле — это катастрофа при масштабировании.
Что происходит в базе:
-- Запрос 1: получаем всех пользователейSELECT * FROM users;-- Запрос 2: профиль первого пользователяSELECT * FROM profiles WHERE user_id = 1;-- Запрос 3: профиль второго пользователяSELECT * FROM profiles WHERE user_id = 2;-- ... и так для каждого пользователя-- Итого: 1 + N запросов
100 пользователей = 101 запрос. 1000 пользователей = 1001 запрос. Каждый запрос — это сетевой round-trip до базы данных.
Решение через select_related:
users = User.objects.select_related('profile').all()for user in users: print(user.profile.bio) # Данные уже загружены!
В SQL это превратится в один JOIN:
SELECT users.*, profiles.*FROM usersLEFT JOIN profiles ON profiles.user_id = users.id;
Когда select_related, а когда prefetch_related?
select_related — для связей ForeignKey и OneToOne (JOIN в одном запросе).
prefetch_related — для ManyToMany и обратных FK (два запроса + Python-склейка):
# У каждого поста много тегов — тут нужен prefetch_relatedposts = Post.objects.prefetch_related('tags').all()
Как обнаружить N+1 в проекте? Поставьте django-debug-toolbar или django-silk — они покажут все SQL-запросы на странице. Если видите 50+ одинаковых запросов — перед вами N+1.
Вопрос 8. Индексы — не серебряная пуля
Частая ошибка джуна: «запрос тормозит — добавлю индекс». Это далеко не всегда правильное решение.
Как работает индекс?
Индекс — это отдельная структура данных (обычно B-tree), которая хранит отсортированные значения колонки и указатели на строки. Вместо полного перебора таблицы (full table scan) база данных может найти нужные строки за O(log n).
Почему нельзя просто проиндексировать всё?
Потому что индекс нужно обновлять при каждой записи. За скорость чтения вы платите скоростью записи:
-- Без индекса:-- INSERT → просто добавить строку-- С 5 индексами:-- INSERT → добавить строку + обновить 5 индексов
На таблицах с большим количеством INSERT/UPDATE/DELETE — лишние индексы могут замедлить запись в разы.
Индексы с низкой селективностью бесполезны:
-- Плохой индекс: колонка is_active содержит только TRUE/FALSE-- Индекс найдёт 50% строк — это почти весь столCREATE INDEX idx_is_active ON users(is_active);-- Хороший индекс: email уникален у каждого пользователяCREATE INDEX idx_email ON users(email);
Правило: индекс полезен, если он отсекает 80-90% строк.
LIKE с wildcard в начале — индекс не помогает:
-- Индекс НЕ сработает:SELECT * FROM users WHERE name LIKE '%alice%';-- Индекс сработает:SELECT * FROM users WHERE name LIKE 'alice%';
Составные индексы — порядок колонок важен:
CREATE INDEX idx_name_age ON users(name, age);-- Этот запрос использует индекс:SELECT * FROM users WHERE name = 'Alice' AND age = 30;-- Этот НЕ использует (первой колонки нет):SELECT * FROM users WHERE age = 30;
Всегда начинайте с EXPLAIN ANALYZE перед добавлением индекса — посмотрите, что реально происходит с запросом.
Вопрос 9. Race condition при конкурентных запросах
Это один из тех вопросов, который мгновенно показывает — понимает ли кандидат как работают реальные системы.
Сценарий:
На складе 1 товар. Два пользователя одновременно нажали «купить». Код:
def buy_item(item_id, user_id): item = Item.objects.get(id=item_id) if item.quantity > 0: item.quantity -= 1 item.save() Order.objects.create(user_id=user_id, item_id=item_id) return "Успешно куплено" else: return "Товар закончился"
Что происходит:
Пользователь A: читает quantity = 1 ✓Пользователь B: читает quantity = 1 ✓ ← оба прочитали до того как кто-то записалПользователь A: 1 > 0, сохраняет quantity = 0Пользователь B: 1 > 0, сохраняет quantity = 0
Оба получили «Успешно куплено». Товар ушёл дважды. Склад в минусе.
Решение 1: SELECT FOR UPDATE (пессимистичная блокировка)
from django.db import transactiondef buy_item(item_id, user_id): with transaction.atomic(): # Блокируем строку до конца транзакции item = Item.objects.select_for_update().get(id=item_id) if item.quantity > 0: item.quantity -= 1 item.save() Order.objects.create(user_id=user_id, item_id=item_id) return "Успешно куплено" return "Товар закончился"
Пока пользователь A держит транзакцию — пользователь B будет ждать у заблокированной строки. Никакого race condition.
Решение 2: Атомарный UPDATE с проверкой (оптимистичная блокировка)
from django.db.models import Fdef buy_item(item_id, user_id): updated = Item.objects.filter( id=item_id, quantity__gt=0 # Проверка и обновление — одним запросом ).update(quantity=F('quantity') - 1) if updated: Order.objects.create(user_id=user_id, item_id=item_id) return "Успешно куплено" return "Товар закончился"
Один атомарный SQL UPDATE — база сама разберётся с конкурентностью. Второй запрос просто не найдёт подходящей строки, если quantity уже 0.
Вопрос 10. Connection pool — что будет если переполнится?
Зачем нужен connection pool?
Открытие нового соединения с базой данных — дорогая операция: TCP-handshake, аутентификация, выделение памяти на стороне БД. На это уходит 20-100 мс. Connection pool держит набор уже открытых соединений и раздаёт их по запросу.
Что происходит при переполнении?
Представьте: pool настроен на 10 соединений. Приходит 11-й запрос.
-
Он встаёт в очередь и ждёт пока освободится соединение
-
Если за
timeoutничего не освободилось — он получает ошибкуQueuePool limit of size X overflow Y reached
В логах вы увидите что-то вроде:
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30
Правильная конфигурация в SQLAlchemy:
from sqlalchemy import create_engineengine = create_engine( DATABASE_URL, pool_size=10, # Постоянные соединения max_overflow=20, # Дополнительные при пике нагрузки pool_timeout=30, # Сколько ждать свободного соединения pool_recycle=1800, # Пересоздавать соединения каждые 30 мин pool_pre_ping=True, # Проверять соединение перед использованием)
Типичная ошибка: разработчик видит ошибку переполнения пула и просто увеличивает pool_size. Но у PostgreSQL тоже есть лимит — max_connections (обычно 100). Если у вас 5 инстансов приложения с pool_size=25 — вы уже на лимите БД.
Правильное решение — PgBouncer:
Между приложением и PostgreSQL ставят PgBouncer — прокси, который умеет мультиплексировать тысячи клиентских соединений в десятки реальных соединений с БД. Это стандарт для продакшена.
[App Instance 1] ──┐[App Instance 2] ──┤── [PgBouncer] ── [PostgreSQL][App Instance 3] ──┘ (1000 conn) (10 conn)
Ещё одна частая причина исчерпания пула — утечка соединений. Код взял соединение из пула, упал с исключением и не вернул его обратно. Всегда используйте контекстные менеджеры:
# Правильно — соединение вернётся в пул даже при ошибкеasync with db.acquire() as connection: result = await connection.fetch(query)
ссылка на оригинал статьи https://habr.com/ru/articles/1044508/