Хочу поведать поучительную историю ошибки в реализации функции urandom из модуля os в CPython на UNIX-подобных ОС (Linux, Mac OS X, etc.).
Цитата из документации по тройке:
Return a string of n random bytes suitable for cryptographic use.Документация по двойке добавляет:
This function returns random bytes from an OS-specific randomness source. The returned data should be unpredictable enough for cryptographic applications, though its exact quality depends on the OS implementation. On a Unix-like system this will query /dev/urandom, and on Windows it will use CryptGenRandom().
New in version 2.4.Другими словами, к примеру, под Linux, urandom читает и возвращает байты из системного устройства /dev/urandom. Напомню, что в этой ОС существуют два типичных устройства-источника энтропии: /dev/random и /dev/urandom. Как известно, первое устройство «медленное» и блокирующее, а второе «быстрое», и вопреки распространенному мнению, оба они криптостойкие источники (псевдо-)случайных чисел. Сразу скажу, КДПВ к статье отношения не имеет и речь пойдёт совсем не о криптографии, безопасности
Казалось бы, как можно ошибиться в реализации столь простой рутины? Как это часто бывает, дооптимизировались…
2.4
Возвратимся в конец 2004, выходит
Выше уже писалось, что в том числе добавили os.urandom, имплементированную на самом Питоне. Давайте пофантазируем, как можно было бы написать urandom:
def urandom(n):Вот так, три строчки. Причём, это абсолютно корректная реализация без ошибок, если не считать обработку исключений и прочие детали, чтобы соответствовать спецификации работы функции по докам. И тут чья-то светлая голова предлагает ускорить этот код. Как это возможно, спросите вы. Закешировав файловый объект, отвечает светлая голова.
with open('/dev/urandom', 'rb') as rnd:
return rnd.read(n)
rnd = NoneКакие проблемы появляются с такой реализацией? Скрипты, которые становятся демонами, падают при первом же вызове urandom после смерти родителя.
def urandom(n):
if rnd is None:
rnd = open('/dev/urandom', 'rb')
return rnd.read(n)
fork()
Многие в курсе, что системная функция fork(), входящая в стандарт POSIX 2001 года и появившаяся в самой первой версии Unix, предназначена для порождения новых процессов методом «раздваивания», когда в системе появляется близнец процесса с идентичным окружением, но отдельным адресным пространством, и начинает работу он ровно с того самого места в коде, где был вызов fork(). Как правило, форки используют механизм copy-on-write, благодаря которому при создании процесса-близнеца («ребёнка») память физически не копируется. Вместо этого, из памяти родителя копируются страницы, в которые пишет близнец по мере своей работы. Это всё лирика, а нас же интересует следующая цитата из man fork:
The child inherits copies of the parent's set of open file descriptors. Each file descriptor in the child refers to the same open file description (see open(2)) as the corresponding file descriptor in the parent. This means that the two descriptors share open file status flags, current file offset, and signal-driven I/O attributesИначе говоря, файловые дескрипторы, принадлежащие питоновским file object-ам, после форка взаимосвязаны и ссылаются на один и тот же файл. Однако, если в одном процессе файл будет закрыт, то он не будет автоматически закрыт и в другом.
Ну fork и fork, скажете вы. Питон-то здесь причём? А при том что
- поверх него работает multiprocessing*
- через него происходит демонизация
Благодаря fork-анию в multiprocessing-е дети изначально находятся в состоянии, которое было у главного процесса перед размножением. Что касается процесса демонизации (превращением в сервис в терминах Windows) — см. PEP 3143. Где-то в самом разгаре там происходит вызов fork(). И если по лучшим традициям закрывать в новоиспечённом демоне все файловые дескрипторы напрямую, не через close() (например, так: os.closerange(3,256)), то os.urandom() рушится.
Примерно этими словами объясняли пользователи CPython в начале 2005-го его разработчикам ошибку. Впрочем, Гвидо сначала пытался
I recommend to close this as invalid. The daemonization code is clearly broken.К счастью, люди смогли убедить царя в обратном, и, наконец, в июле кеширование /dev/urandom убрали — прошло более полугода. Обращаю внимание на то, как это сделали: в коде нет ни ссылки на номер бага, ни указания на причины патча, ни, в конце концов, просто поясняющего комментария. Работает, и хорошо.
3.4
Проходит 9 лет. В марте 2014 выходит CPython 3.4. Он добавляет такие нужные фичи, как… wait, oh shi
No new syntax features were added in Python 3.4.Ладно-ладно, если серьёзно, прогресс большой: кучу библиотек приняли, к примеру, asyncio, о котором уже много писали на Хабре, безопасность улучшили, освобождение объектов подкрутили — не мне об этом рассказывать. Главное, что перед релизом нашлись люди, которые посчитали, что реализация /dev/urandom на Питоне адски медленная, и true performance может обеспечить только старый добрый C. В общем функцию переписали… и снова наступили на те же самые грабли. И никакой PEP 446 им не помог. Патч вышел 24 апреля и на этот раз уже содержал в изобилии комментарии, ссылку на баг и даже regression тесты.
Какое мне до этого дело
В качестве бонуса к статье, расскажу, как я споткнулся об эту ошибку. Рабочая система у меня Ubuntu 14.04 LTS, и, к сожалению, на ней
import platformУ меня работал демонизирующий код, закрывающий все файловые дескрипторы. И вот ведь беда,
platform.python_build()
('default', 'Apr 11 2014 13:05:11')
import osпечатает
print(os.listdir('/proc/self/fd'))
import random
print(os.listdir('/proc/self/fd'))
Эксперимент не совсем чистый, т.к. os.listdir создаёт свой дескриптор в обоих случаях под последним номером. После импорта random открылся номер 3. Какому файлу он соответствует?['0', '1', '2', '3']
['0', '1', '2', '3', '4']
print(os.readlink('/proc/self/fd/3'))
/dev/urandom
Та-дам! Я всегда плохо относился к работе при импорте модулей… В данном случае, привожу окончание random.py:from os import urandom as _urandomОстается заметить, что import random делают Tornado, Twisted, uuid, и целая куча других библиотек, стандартных и не очень.
class Random(_random.Random):
# ...
def __init__(self, x=None):
# ...
self.seed(x)
self.gauss_next = None
def seed(self, a=None, version=2):
# ...
if a is None:
try:
a = int.from_bytes(_urandom(32), 'big')
except NotImplementedError:
# ...
Надо заметить, что сначала я не совсем верно понял суть проблемы, необоснованно решив, что файловые дескрипторы ребёнка и родителя закрываются одновременно. Спасибо kekekeks за восстановление полной картины этого бага.
Выводы
Следует всегда думать об извечных проблемах fork() при разработке библиотек, всегда комментировать багфиксы в коде и внимательно читать сообщения о проблемах пользователей (по крайней мере, если они программисты).