https://habr.com/ru/company/domclick/blog/531674/- Блог компании ДомКлик
- Python
Всем привет, сегодня я хотел бы поговорить о некоторых сложностях и заблуждениях, которые встречаются у многих соискателей. Наша компания активно растет, и я часто провожу или участвую в проведении собеседований. В итоге я выделил несколько вопросов, которые многих кандидатов ставят в сложное положение. Давайте вместе рассмотрим их. Я опишу специфические вопросы для Python, но в целом статья подойдет для любого собеседования. Для опытных разработчиков никаких истин тут открыто не будет, но тем, кто только начинает свой путь, будет легче определиться с темами на ближайшие несколько дней.
Отличие процессов от потоков в Linux
Ну вы знаете, такой типичный и, в целом, несложный вопрос, чисто на понимание, без копания в деталях и тонкостях. Конечно, большинство соискателей расскажет, что потоки более легковесны, между ними быстрее переключается контекст, и вообще они живут внутри процесса. И всё это правильно и замечательно, когда мы говорим не о Linux. В ядре Linux потоки реализованы так же, как и обычные процессы. Поток— это просто процесс, который использует некоторые ресурсы совместно с другими процессами.
Для создания процессов в Linux можно использовать два системных вызова:
clone()
. Это основная функция для создания дочерних процессов. С помощью флагов разработчик указывает, какие структуры родительского процесса должны быть общими с дочерним. Базово используется для создания потоков (имеют общее адресное пространство, файловые дескрипторы, обработчики сигналов).
fork()
. Эта функция используется для создания процессов (которые имеют собственное адресное пространство), но под капотом вызывает clone()
с определенным набором флагов.
Я бы обратил внимание на следующее: когда вы сделаете
fork()
процесса, вы не сразу получите копию памяти родительского процесса. Ваши процессы будут работать с единым экземпляром в памяти. Поэтому, если суммарно у вас должно было случиться переполнение памяти, то всё продолжит работать. Ядро пометит дескрипторы страниц памяти родительского процесса как «только для чтения», а при попытке записи в них (дочерним или родительским процессом) будет вызвано и обработано исключение, которое вызовет создание полной копии. Этот механизм называется Copy-on-Write.
Отличной книгой об устройстве Линукса я считаю «Linux. Системное программирование» за авторством Роберта Лава.
Проблемы с Event Loop
В нашей компании повсеместно распространены асинхронные сервисы и воркеры на Python или Go. Поэтому мы считаем важным общее понимание асинхронности и работы Event Loop. Многие кандидаты уже довольно неплохо отвечают на вопросы о плюсах асинхронного подхода и правильно представляют Event Loop как некий бесконечный цикл, который позволяет понять, не пришло ли определенное событие от операционной системы (например, запись данных в сокет). Но не хватает связующего элемента: как программа получает эту информацию от операционной системы?
Конечно, самое простое, что можно вспомнить — это
Select
. С его помощью формируется список файловых дескрипторов, за которыми планируется наблюдать. В клиентском коде придется проверять все переданные дескрипторы на наличие событий (и их количество ограничено 1024), что делает его медленным и неудобным.
Ответа про
Select
более чем достаточно, но если вы вспомните про
Poll
или
Epoll
, и расскажете о проблемах, которые они решают, то это будет большим плюсом к вашему ответу. Чтобы не вызывать лишних волнений: код на C и детальную спецификацию у нас не спрашивают, мы говорим лишь о базовом понимании происходящего. Прочитать про различия
Select
,
Poll
и
Epoll
можно в
этой статье.
Еще советую посмотреть на тему асинхронности в
Python Девида Бизли.
GIL защищает, но не вас
Еще одно распространенное заблуждение заключается в том, что GIL придумали, чтобы защитить разработчиков от проблем с конкурентным доступом к данным. Но это не так. GIL, конечно, не даст вам распараллелить программу с помощью потоков (но не процессов). Проще говоря, GIL — это блокировка, которая должна быть взята перед любым обращением к Python (не так важно. исполняется Python-код или вызовы Python C API). Поэтому GIL защитит внутренние структуры от неконсистентных состояний, но вам, как и в любом другом языке, придется использовать примитивы синхронизации.
Также говорят, что GIL нужен только для корректной работы GC. Для неё он, конечно, нужен, но этим дела не ограничиваются.
С точки зрения исполнения даже самая простая функция будет разбита на несколько шагов:
import dis
def sum_2(a, b):
return a + b
dis.dis(sum_2)
4 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 RETURN_VALUE
С точки зрения процессора каждая из этих операций не является атомарной. Python выполнит очень много процессорных инструкций на каждую строчку байт-кода. При этом нельзя давать другим потокам изменять состояние стека или производить любую другую модификацию памяти, это приведет к Segmentation Fault или некорректному поведению. Поэтому интерпретатор запрашивает глобальную блокировку на выполнение каждой инструкции байт-кода. Однако между отдельными инструкциями контекст может быть изменен, и тут GIL нас никак не спасает. Подробнее про байт-код и как с этим работать можно почитать в
документации.
На тему защиты GIL посмотрите простой пример:
import threading
a = 0
def x():
global a
for i in range(100000):
a += 1
threads = []
for j in range(10):
thread = threading.Thread(target=x)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
assert a == 1000000
На моей машине ошибка вылетает стабильно. Если вдруг у вас оно не отработает, то запустите несколько раз или добавьте тредов. При небольшом количестве тредов вы получите плавающую проблему (ошибка то появляется, то не появляется). То есть помимо некорректности данных у таких ситуаций есть еще проблема в виде ее плавающего характера. Также это подводит нас к следующей проблеме: примитивам синхронизации.
И опять не могу не сослаться на
Девида Бизли.
Примитивы синхронизации
В целом, примитивы синхронизации — не самый лучший вопрос для Python, но они показывают общее понимание проблемы и то, насколько глубоко вы копали в эту сторону. Тема многопоточности, по крайней мере у нас, спрашивается как бонусная, и будет только плюсом (если вы ответите). Но ничего страшного, если вы с ней еще не сталкивались. Можно сказать, что этот вопрос не привязан к конкретному языку.
Многие начинающие питонисты, как я уже писал выше, надеются на чудотворную силу GIL, поэтому в тему примитивов синхронизации не заглядывают. А зря, это может пригодится при выполнении фоновых операций и задач. Тема примитивов синхронизации большая и хорошо разобранная, в частности, рекомендую почитать об этом в книге «Core Python Applications Programming» автора Wesley J. Chun.
И раз мы уже посмотрели пример, где нам нам не помог GIL в работе с потоками, то рассмотрим самый простой пример, как защититься от подобной проблемы.
import threading
lock = threading.Lock()
a = 0
def x():
global a
lock.acquire()
try:
for i in range(100000):
a += 1
finally:
lock.release()
threads = []
for j in range(10):
thread = threading.Thread(target=x)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
assert a == 1000000
Retry всему голова
Никогда нельзя полагаться на то, что инфраструктура будет всегда стабильно работать. На собеседованиях мы часто просим спроектировать простой микросервис, взаимодействующий с другими (например, по HTTP). Вопрос стабильности сервиса иногда сбивает кандидатов с толку. Я бы хотел обратить внимание на несколько проблем, которые кандидаты не учитывают, когда предлагают делать retry по HTTP.
Первая проблема: сервис может просто не работать продолжительное время. Повторные запросы в реальном времени будут бессмысленны.
Retry, сделанные неаккуратно, могут добить сервис, который начал замедляться под нагрузкой. Меньшее, что ему нужно, это увеличение нагрузки, которая за счет повторных запросов может вырасти в разы. Нам всегда интересно обсудить методы сохранения состояния и осуществления досылки после того, как сервис начнет работать штатно.
Как вариант, можно попытаться сменить протокол с HTTP на что-то с гарантированной доставкой (AMQP и т. д.).
Еще задачу retry может взять на себя service mesh. Подробнее можно почитать в
этой статье.
В целом, как я и говорил, никаких сюрпризов тут нет, но эта статья может помочь вам понять, какие темы следует подтянуть. Не только для прохождения собеседований, но и для более глубокого понимания сути происходящих процессов.