// python interview / concurrency

Вопросы на собеседовании: Асинхронность и многопоточность

GIL и free-threading, потоки, процессы, asyncio — где Python параллелится, а где только притворяется. Здесь — топ-15 по частоте на реальных собесах: у первых вопросов открыт полный разбор, у остальных — устный эталон. Весь банк темы (27 вопросов) с разборами — в приложении.

Открыть тему в приложении каждый день бесплатно: 3 эталона и 3 проверки арбитром

junior: база, с которой начинают

Что такое GIL и что он на самом деле гарантирует программисту?

как ответить В обычной сборке CPython GIL позволяет только одному потоку интерпретатора исполнять Python-байткод одновременно и защищает внутреннее состояние объектов CPython от повреждения. Он не гарантирует потокобезопасность пользовательских данных или атомарность произвольной операции: один опкод либо вызов может выполнить пользовательский код или C-код, который отпускает GIL. Для разделяемого изменяемого состояния всё равно нужны явные средства синхронизации; во free-threaded сборке CPython GIL можно отключить.

разбор

GIL упрощает защиту счётчиков ссылок и других внутренних структур CPython, но это не транзакция вокруг строки пользовательского кода. Выражение может вызвать __hash__, __eq__, дескриптор, финализатор или расширение на C — и тем самым выполнить произвольную работу либо отпустить GIL. Поэтому число опкодов в dis не образует публичную гарантию атомарности.

Интерпретатор ориентируется на sys.getswitchinterval() при переключении CPU-bound потоков, но это желаемая длительность кванта, а не точный таймер: фактический интервал может быть больше, следующий поток выбирает ОС. GIL также отпускается вокруг блокирующего I/O и внутри расширений, которые делают это явно.

Практический вывод: GIL защищает целостность самого интерпретатора, но инварианты общего кэша, счётчика или пары «проверить — записать» защищает Lock либо потокобезопасная структура с документированным контрактом. Free-threaded сборка делает эту границу ещё заметнее, но писать корректный код нужно без опоры на случайное поведение конкретной версии CPython.

чтобы прозвучать сильнее Объясни, почему один опкод не равен атомарной бизнес-операции, и назови три места уступки: switch interval, блокирующий I/O и расширение, которое явно отпускает GIL.

python-concurrency-001 · junior · high

Почему многопоточность в Python не ускоряет CPU-bound вычисления?

как ответить GIL допускает исполнение python-байткода только одним потоком одновременно, поэтому при чисто вычислительной нагрузке (математика, обработка данных в чистом Python) потоки не выполняются параллельно на разных ядрах — они по очереди перехватывают GIL. Итоговое время работы N потоков с CPU-bound кодом обычно не меньше однопоточного, а из-за накладных расходов на переключение контекста и конкуренцию за лок иногда даже больше.

разбор

ОС может реально запланировать потоки на разные ядра, но исполнять python-байткод в конкретный момент времени может только тот, кто держит GIL — остальные простаивают в ожидании. Добавление потоков в CPU-bound код лишь добавляет overhead на context-switch и на борьбу за захват GIL (thrashing), из-за чего суммарное время иногда растёт.

Правильный путь для CPU-bound — multiprocessing (отдельные процессы, отдельный интерпретатор и GIL у каждого, но нет общей памяти и есть цена IPC/pickle), либо вынос тяжёлых вычислений в C-расширения/numpy/numba, которые явно освобождают GIL на время своей работы в C-коде.

Частый follow-up — «а numpy тогда почему быстрый с потоками» — потому что BLAS-вызовы внутри numpy написаны на C и явно отпускают GIL (Py_BEGIN_ALLOW_THREADS), так что реальный параллелизм там есть, просто он живёт не в python-байткоде.

чтобы прозвучать сильнее Подними ответ до middle, явно назвав альтернативы: multiprocessing для чистого Python и C-расширения/numpy/numba для тяжёлых вычислений — обе стратегии обходят GIL по-разному.

python-concurrency-002 · junior · high

Как ты создаёшь и запускаешь поток через threading.Thread? Что делает join() и что будет, если про него забыть?

как ответить threading.Thread(target=fn, args=(...)) создаёт объект потока, t.start() запускает его выполнение параллельно с текущим кодом, не дожидаясь результата. t.join() блокирует вызывающий поток, пока t не завершится — без него код после start() побежит дальше, не дождавшись данных из потока, и это частый источник гонок. Сам процесс всё равно не завершится, пока живы обычные (non-daemon) потоки, даже без явного join() — а вот daemon-поток интерпретатор просто убьёт при выходе, не дожидаясь его завершения.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-003 · junior · high

Объясни, что такое race condition, и почему операцию вроде counter += 1 нельзя считать атомарной, хотя есть GIL?

как ответить Race condition возникает, когда результат зависит от взаимного порядка операций нескольких потоков над общим состоянием. counter += 1 семантически состоит из чтения, вычисления и записи; язык Python не обещает атомарность всей последовательности, а __iadd__() или __add__() могут выполнить произвольный пользовательский код. Поэтому общий счётчик защищают Lock, а не наблюдаемым поведением конкретной версии CPython; во free-threaded сборке потоки дополнительно могут исполнять Python-код параллельно.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-004 · junior · high

Чем принципиально отличается multiprocessing от threading в Python, и как ты решаешь, что использовать в конкретной задаче?

как ответить multiprocessing запускает отдельные процессы с раздельными адресными пространствами и обходит GIL, поэтому это обычный выбор для CPU-bound кода на чистом Python, если приемлемы запуск процессов, сериализация и IPC. Потоки обычного интерпретатора CPython разделяют память и GIL: они особенно удобны для I/O, но CPU-intensive код расширения тоже может отпустить GIL и выполняться параллельно. Во free-threaded CPython потоки могут параллельно исполнять Python-код. Правило «считаем — процессы, ждём — потоки» — только стартовая эвристика.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-005 · junior · high

Объясни своими словами: что такое корутина в Python и чем await отличается от обычного вызова функции?

как ответить Корутина — объект, который возвращает вызов async def функции; сам код внутри неё не выполняется, пока корутину не передадут в await, create_task или asyncio.run. await приостанавливает выполнение текущей корутины в этой точке, отдаёт управление обратно в событийный цикл и возобновляется, когда awaited-операция готова — при этом поток не блокируется, он может пойти выполнять что-то ещё.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-006 · junior · high

У тебя внутри async-хендлера нужно дёрнуть синхронную библиотеку — например, requests.get или тяжёлый чисто-питоновский расчёт. В лоб через await их не вызвать. Как поступишь?

как ответить Блокирующий вызов выносим из event loop в отдельный поток через loop.run_in_executor(None, func) — начиная с 3.9 для этого есть удобная обёртка asyncio.to_thread(func, *args), которая ещё и копирует contextvars. Loop продолжает крутить другие корутины, пока поток ждёт I/O; для настоящего CPU-bound кода, который держит GIL, нужен ProcessPoolExecutor, а не threads.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-007 · junior · high

Как в asyncio ограничить операцию по времени — например, запрос к внешнему API не должен идти дольше 3 секунд?

как ответить Оборачиваем корутину в asyncio.wait_for(coro, timeout=3) — по истечении времени она вызывает cancel() у внутренней задачи и поднимает TimeoutError. С Python 3.11 удобнее async with asyncio.timeout(3): — тот же механизм отмены под капотом, но таймаут можно продлевать на лету через .reschedule() и он корректно комбинируется с вложенными таймаутами и TaskGroup.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-008 · junior · high

А где тогда потоки в Python реально дают выигрыш?

как ответить На I/O-bound задачах: сетевые запросы, чтение файлов, работа с БД, ожидание в time.sleep. Перед блокирующим системным вызовом CPython явно освобождает GIL, поэтому пока один поток ждёт ответа от сервера или диска, другой может захватить GIL и исполнять python-код. Поэтому threading — рабочий инструмент для конкурентного I/O, хотя сегодня для этого чаще берут asyncio, у которого нет накладных расходов на переключение потоков ОС.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-009 · junior · medium

Как процессы в multiprocessing обмениваются данными и почему это дороже, чем в threading?

как ответить Потоки в одном процессе просто делят память — передать объект между ними почти бесплатно. Процессы изолированы, поэтому любой объект, уходящий в другой процесс (аргументы Pool.map, элементы Queue, возвращаемое значение), сначала сериализуется через pickle, отправляется по каналу IPC (pipe/socket) и десериализуется на другом конце — это лишний CPU и лишняя копия данных в памяти. Примитивы почти бесплатны, а DataFrame на сотни мегабайт может свести на нет весь выигрыш от параллелизма.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-010 · junior · medium

middle: где отделяют уверенных

В чём разница между threading.Lock и threading.RLock? Что произойдёт в этом коде и почему?

import threading

lock = threading.Lock()

def recursive(n):
    with lock:
        if n > 0:
            recursive(n - 1)

recursive(3)

как ответить Lock — обычный мьютекс без понятия владельца: если поток, уже держащий его, попробует захватить снова, он заблокируется сам на себе. RLock (reentrant lock) хранит владельца и счётчик захватов — тот же поток может взять его повторно без блокировки, но обязан отпустить ровно столько раз, сколько взял. В этом коде recursive() внутри уже открытого with lock на втором уровне рекурсии пытается захватить тот же Lock ещё раз — поток зависает навсегда в self-deadlock; замени Lock на RLock — и код отработает.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-013 · middle · high

Когда есть смысл брать ThreadPoolExecutor вместо потоков вручную, и как GIL влияет на выигрыш от многопоточности в CPU-bound и I/O-bound задачах? Что выведет этот код?

import time
from concurrent.futures import ThreadPoolExecutor

def work(n):
    time.sleep(0.3 - n * 0.1)
    return n

with ThreadPoolExecutor(max_workers=3) as ex:
    for r in ex.map(work, [0, 1, 2]):
        print(r)

как ответить ThreadPoolExecutor управляет ограниченным пулом потоков и возвращает Future, поэтому обычно проще и безопаснее ручного создания потоков. В обычной сборке CPython он не ускоряет CPU-bound код на чистом Python из-за GIL, но подходит для I/O и вычислений в нативном коде, который отпускает GIL; во free-threaded сборке потоки могут параллельно исполнять Python-код. Пример напечатает 0, 1, 2: map() выдаёт результаты в порядке входных аргументов, а не завершения задач.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-014 · middle · high

Что выведет этот код, и что изменится, если поменять start method со spawn на fork? Объясни, почему.

import multiprocessing as mp

def worker():
    print(shared_value)

if __name__ == "__main__":
    shared_value = 42
    ctx = mp.get_context("spawn")
    p = ctx.Process(target=worker)
    p.start()
    p.join()

как ответить На spawn дочерний процесс не наследует память родителя: стартует новый интерпретатор, модуль __main__ импортируется заново, но код под if __name__ == '__main__': при этом импорте не выполняется — поэтому shared_value не определена, и worker падает с NameError. Spawn — дефолт на macOS и Windows; на Linux с 3.14 дефолт — forkserver с тем же эффектом (память родителя не наследуется). Если явно взять mp.get_context('fork'), дочерний процесс — copy-on-write снимок памяти родителя на момент fork: shared_value там уже есть, и напечатается 42.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-015 · middle · high

Чем asyncio.create_task отличается от простого await корутины, и когда без gather не обойтись?

как ответить await coro запускает корутину и ждёт её результата, не давая параллельно продвигаться ничему другому, инициированному тобой в этот момент — по сути последовательное выполнение. asyncio.create_task оборачивает корутину в Task и сразу планирует её в event loop, не дожидаясь результата — так можно запустить несколько операций параллельно, а потом дождаться каждой или собрать через gather, который ждёт весь набор awaitable'ов и возвращает список результатов в исходном порядке.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-016 · middle · high

Что случится, если внутри корутины сделать блокирующий синхронный вызов вроде time.sleep или requests.get? Чем это опасно?

как ответить Блокирующий вызов выполняется прямо в потоке event loop и не отдаёт управление обратно циклу — весь loop замирает на это время, и все остальные корутины и task'ы в приложении перестают продвигаться, даже если они не связаны с этим вызовом. В однопоточном asyncio-сервере это значит, что запросы всех пользователей встанут, пока один синхронный вызов не завершится.

полный разбор и проверка ответа арбитром — в приложении python-concurrency-017 · middle · high

Соседние темы того же собеса