Всем привет, сегодня я хотел бы поговорить о некоторых сложностях и заблуждениях, которые встречаются у многих соискателей. Наша компания активно растет, и я часто провожу или участвую в проведении собеседований. В итоге я выделил несколько вопросов, которые многих кандидатов ставят в сложное положение. Давайте вместе рассмотрим их. Я опишу специфические вопросы для 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. Подробнее можно почитать в этой статье.

В целом, как я и говорил, никаких сюрпризов тут нет, но эта статья может помочь вам понять, какие темы следует подтянуть. Не только для прохождения собеседований, но и для более глубокого понимания сути происходящих процессов.