вторник, 12 июля 2011 г.

Переполнение стека

На первый взгляд у Питона очень простая и прозрачная работа со стеком. Каждый новый вложенный вызов функции (на самом деле исполнение code block, но кому нужны эти детали) увеличивает внутренний счетчик, каждый выход — уменьшает. Если счетчик доходит до 1000 (значение по умолчанию) — выбрасывается RuntimeError с текстом «maximin recursion depth exceeded».

Допустимая глубина регулируется sys.getrecursionlimit / sys.setrecursionlimit.

Очень простая и понятная схема, в которой тем не менее есть серьезная проблема. Рассмотрим такой код:

with zipfile.ZipFile('filename.zip', 'w') as f:
    f.writestr('file.txt', get_text_data())

Допустим, вызов get_text_data выбросил исключение. Тогда ZipFile.__exit__ должен закрыть архив, записав все нужные структуры. Это — довольно большой кусок кода с многочисленными вложенными вызовами.

А мы и так уже находимся у самого края, стек почти весь вышел. Скорее всего в таком случае ZipFile.__exit__ (который в свою очередь вызывает ZipFile.close и т.д.) вместо нормального закрытия файла сам вывалится с RuntimeError «maximin recursion depth». Обработчик ошибок сломался, породив новое исключение.

То же самое может произойти при использовании try/finally или try/except. В результате существующее поведение выглядит очень странным. На самом деле нет безопасного способа делать что-либо при переполнении стека — любое неловкое движение приведет к новому переполнению. То, как поступает Питон (выбрасывание исключения) абсолютно бесполезно и может только запутывать логику обработки ошибок. Проще, наглядней и надежней было бы просто завершать интерпретатор в аварийном режиме.

В python 3 ситауцию кардинально исправили. В случае переполнения стека все так же выбрасывается RuntimeError. Но питон гарантирует обработчикам (всему коду, который будет выполнен до выхода из frame, поймавшего исключение) запас в 50 уровней стека — а это более чем достаточно.

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

Если обработчики не вложились в добавочные 50 вызовов — Питон аварийно закрывается.

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

Таким образом, теперь можно смело писать достаточно сложный код, который будет работать даже если стек исчерпан.

13 комментариев:

  1. Спасибо за статью. У меня есть вопрос не совсем по теме, но он связан со стеком.

    Сейчас наблюдается бум всяких библиотек для осуществления асинхронного IO, которые не хотят быть похожими на твистед. Они делятся на два лагеря: те, что используют генераторы, и те, что используют гринлеты. И там и там есть свои минусы, гринлеты к примеру некоторые люди не решаются использовать, считая что там перебор с магией (замена стека при переключении между корутинами). Зато переписывать ничего не надо.

    Генераторы на мой взгляд хороши тем, что они являются частью языка, они естественно смотрятся и работают явно. Главное их свойство (для асинхронного IO) состоит конечно в их способности отдавать управление наружу, засыпать, принимать данные и просыпаться. Однако даже после возможных улучшений (pep-380 например) с генераторами будет не так просто работать, как например с gevent-ом. Большая часть кода приложения будет реализована на генераторах, API библиотек будет обязано возвращать deferred объекты, а получить результат можно будет только в самом теле генератора при помощи yield. И переписывать надо будет все или писать с нуля в свойственной манере.

    Все это навело меня на мысль о создании механизма восстановления работы кода после исключения, т.е. просходит исключение где-то в глубине кода, это исключение идет вверх до обработчика, который решает что с этим исключением делать. Обработчик может обработать исключение, отложить обработку и/или вернуть управление обратно, послав какие-то данные. Таким образом можно решить проблему с асинхронным IO раз и навсегда - передачу управления вверх из простой функции/метода с восстановлением работы и получением данных от обработчика. Я вот только не знаю насколько это будет полезно в других сферах применения питона и насколько трудно/оправданно это будет реализовать в питоне? Стек вызовов надо будет как-то хранить, наверное в самом исключении. По затратам должно быть дешевле/бесплатно чем gevent и будет более органично смотреться, плюс может найдутся другие применения. Сам с внутренностями питона не работал, поэтому и спрашиваю вашего мнения. С другой стороны хотелось просто поделиться идеей и узнать не бредовая ли она:)

    ОтветитьУдалить
  2. На исключениях попросту не выйдет. Любой вызов C Extension требует C стека, который восстановить невозможно.
    Именно поэтому greenlet использует черную магию stack slicing (которая в некоторых редких случаях тоже может ломаться на этих самых C Extensions).
    Я верю, что будущее асинхронного IO за продвинутыми генераторами.
    Кстати, отдельный синтаксис, требующий yield, можно рассматривать и как достоинство: это явное переключение управления. В отличие от greenlet, где нужно каждый раз смотреть на вызываемую функцию, чтобы понять, она будет асинхронна или нет. Проблемы примерно те же, что и в Python C API, Практически все вызовы возвращают PyObject*. Некоторые делают перед этим incref (стандартное поведение), а небольшое число возвращает так называемый borrowed reference в целях оптимизации. В результате программист должен смотреть в документацию или в код, чтобы понять — decref обязателен или нет. Конечно, с использованием greenlet ничего не ломается в любом случае — но ожидаемо асинхронный вызов может внезапно оказаться синхронным и при этом довольно долгим, что очень неприятно.

    ОтветитьУдалить
  3. Анонимный12 июля 2011 г., 22:44

    Vladimir Magamedov: по-моему ваше описание восстановления после ошибок отдаленно напоминает Erlang

    ОтветитьУдалить
  4. Я не знаю, как работает Erlang изнутри. И тем более не представляю, как в этом случае поступают модули Эрланга, написанные на С. Может, специалисты просветят? Дима Васильев, скажи свое веское слово...
    Подозреваю, что single assignement очень помогает — но в подробностях полный профан. Именно с точки зрения С расширений.
    Не забывайте — в Питоне этой чудес нет, и прикрутить их мешает совершенно отличная от Эрланга модель.

    ОтветитьУдалить
  5. Интересно, процесс начинает жрать 100% ресурсов, или как выглядит срыв стека на исключениях?

    ОтветитьУдалить
  6. Не понял вопроса. Выбрасывается исключение и стек раскручивается. Или вы имели в виду нечто совсем другое?

    ОтветитьУдалить
  7. ..исключение RuntimeError(), но и его достаточно чтобы всё порушить - теперь понял. Есть еще проблема под UNIX, когда системного стека не хватает под очередной вызов (особенно когда завышен через sys.setrecursionlimit) - она актуальна для Py3k?

    ОтветитьУдалить
  8. Да, если в ulimit поставлен маленький стек — Питону может быть очень плохо. Программа складывается без всяких внятных предупреждений.

    ОтветитьУдалить
  9. На счёт сравнения greenlet с Python C API не согласен

    1) Ошибки с reference count роняют программу, а blocking io нет
    2) Лично мне интуитивно понятно какие функции блокируют программу а какие нет. А вот C API приходится всё время смотреть
    3) Блокировки можно попытаться отслеживать. А вот reference counters толком не подебажишь, тут реально надо быть экспертом чтобы не посадить хитрых багов.

    ОтветитьУдалить
  10. 1. С первым пунктом согласен.
    2. Вот лично мне интуитивно совершенно непонятно, особенно для пользовательских функций. gevent API можно выучить наизусть, если голова большая. Для кода, написанного коллегой из соседней комнаты, каждый раз придется изучать исходник.
    3. Отслеживание блокировок по исходникам, занимающим пару десятков мегабайт — не самая простая штука.

    На gevent хорошо мелкие поделки писать...

    ОтветитьУдалить
  11. Не очень понял почему "хак" с исключениями из первого комментария сравнили с Erlang? В Erlang отдельный стек для каждого процесса (не путать с процессами ОС) и он может расти и уменьшаться динамически во время жизни процесса.

    В целом мне кажется, что основная проблема с новыми библиотеками для асинхронного IO в Python - это их сырость. Например, когда мы начинали использовать gevent, то практически сразу обнаружили проблему с утекающими исключениями. Денис Биленко ее достаточно оперативно исправил, но осадок остался. В тоже время Erlang разрабатывается уже практически 25 лет и такого рода косяки точно не попадаются.

    ОтветитьУдалить
  12. > Не очень понял почему "хак" с исключениями из первого комментария сравнили с Erlang?

    Согласен, что greenlet'ы это "хак", но задачи решаются похожие, поэтому, я думаю, сравнивать вполне можно.

    Действительно, в Erlang у каждого процесса есть свой, так называемый, managed stack. То есть, чтобы обеспечить N-M модель потоков исполнения не надо применять хаки типа stack slicing — значит нет проблем с профайлингом, пробросом исключений и т.д. (кстати coev частично решает это патчем thread state в CPython).

    Что касается C extensions для Erlang. Есть несколько способов интегрировать нативный код в Erlang приложение, и если интегрируемый кусок кода должен делать I/O то надо писать или port-driver (линкуется к VM и имеет доступ к event-loop'у) или port (вызывает внешний исполняемый файл и неблокирующе читает/пишет stdout/stdin). Дима Васильев меня поправит, если я не прав.

    Естественно, если использовать gevent/eventlet — можно тоже писать C extensions, которые использует event-loop самого gevent/eventlet.

    Поэтому, я думаю, что основная проблема async I/O в Python — это фрагментация всех библиотек на 1) использующие blocking I/O 2) использующие greenlet'ы, чтобы кооперировать при I/O 3) написанные для Twisted 4) написанные для Tornado 5) ...

    В Erlang, Node.js такой проблемы нет.

    ОтветитьУдалить
  13. И всё-таки мне кажется, что greenlet и все основанные на этой технологии библиотеки (eventlet, gevent) являются хаком.
    Чистым решением выглядит stackless python — но это всё же редко используемый диалект.

    ОтветитьУдалить