Вот вам небольшая история о таинственных сбоях сервера, которые мне пришлось отлаживать год назад (статья от 05 декабря 2018, прим.пер). Сервера некоторое время работали нормально, а затем в какой-то момент начинали аварийно завершаться. После этого попытки запустить практически любую программу, что были на серверах, терпели неудачу с ошибками «На устройстве нет места», хотя файловая система сообщала только о нескольких занятых гигабайтах на ~20 ГБ дисках.
Оказалось, что проблема была вызвана системой логгирования. Это было приложением на Ruby, которое берет лог-файлы, отсылает данные на удаленный сервер и удаляет старые файлы. Ошибка заключалась в том, что открытые лог-файлы не были закрыты явным образом. Вместо этого приложение позволяло автоматическому сборщику мусора Ruby очищать Файловые объекты. Проблема в том, что Файловые объекты не используют много памяти, так что система логгирования теоретически могла держать миллионы логов открытыми до того, как потребуется сборка мусора.
Файловые системы в *nix разделяют имена файлов и данные в файлах. Данные на диске могут иметь несколько имён файлов, указывающих на них (т.е. жёсткие ссылки), и данные удаляются только тогда, когда удалена последняя ссылка. Открытый дескриптор файла считается ссылкой, поэтому если файл удаляется во время чтения программой, то имя файла исчезает из каталога, но данные файла остаются живы до тех пор, пока программа не закроет его. Вот что и происходило с логгером. Команда du («disk usage») ищет файлы, используя листинг каталогов, поэтому она не видела гигабайты файловых данных для тысяч лог-файлов, которые еще были открыты. Эти файлы были обнаружены только после запуска lsof («list open files»).
Конечно, подобная ошибка возникает и в других подобных случаях. Пару месяцев назад мне пришлось столкнуться с Java-приложением, которое через несколько дней ломалось из-за утечки сетевых соединений.
Когда-то я писал большую часть своего кода на Си, а затем на Си++. В те времена я думал, что ручного управления ресурсами достаточно. Насколько сложным это было? Каждому malloc() нужна функция free(), а каждому open() нужна close(). Просто. За исключением того, что не все программы просты, поэтому ручное управление ресурсами со временем стало смирительной рубашкой. Потом однажды я открыл для себя подсчет ссылок и сборку мусора. Я подумал, что это решает все мои проблемы, и полностью перестал заботиться об управлении ресурсами. Опять же, для простых программ это было нормально, но не все программы просты.
Рассчитывать на сбор мусора не получается, потому что это решает только проблему управления памятью, а сложным программам приходится иметь дело с гораздо большим, чем просто с памятью. Есть популярный мем, который отвечает на это тем, что память — это 95% проблем с ресурсами. Можно даже сказать, что все ресурсы — это 0% ваших проблем — до тех пор, пока у вас не закончится один из них. Тогда этот ресурс становится 100% ваших проблем.
Но такое мышление все равно воспринимает ресурсы как особый случай. Более глубокая проблема заключается в том, что по мере усложнения программ, все стремится к тому, чтобы стать ресурсом. Например, возьмите программу-календарь. Сложная программа календаря позволяет нескольким пользователям управлять несколькими календарями с общим доступом, и с событиями, которые могут быть общими для нескольких календарей. Любая часть данных в итоге будет влиять на несколько частей программы, и должна быть актуальной и корректной. Поэтому для всех динамических данных нужен владелец, а не только для управления памятью. По мере добавления новых функций, все больше частей программы будут нуждаться в обновлении данных. Если вы в здравом уме, вы позволите обновлять данные только из одной части программы за раз, так что право и ответственность за обновление данных становится само по себе ограниченным ресурсом. Моделирование мутируемых данных с помощью иммутабельных структур не приводит к исчезновению этих проблем, а лишь переводит их в другую парадигму.
Планирование владения и срока жизни ресурсов является неизбежной частью проектирования сложного программного обеспечения. Это проще, если вы используете некоторые общие паттерны. Один из паттернов — это взаимозаменяемые ресурсы. Примером является иммутабельная строка «foo», которая семантически такая же, как и любая другая иммутабельная «foo». Этот вид ресурсов не нуждается в заранее заданном сроке жизни или владении. На самом деле, для того, чтобы система была как можно проще, лучше не иметь заранее установленного срока жизни или права владения (привет Rust, прим.пер). Другой паттерн — это ресурсы, которые не являются взаимозаменяемыми, но имеют детерминированную продолжительность жизни. Это включает в себя сетевые подключения, а также более абстрактные понятия, такие как право управления частью данных. Самым разумным является явное обеспечение продолжительности жизни таких вещей при кодировании.
Обратите внимание, что автоматическая сборка мусора действительно хороша для реализации первого шаблона, но не второго, в то время как техники ручного управления ресурсами (такие как RAII) отлично подходят для реализации второго паттерна, но ужасны для первого. Эти два подхода становятся взаимодополняющими в сложных программах.
Оказалось, что проблема была вызвана системой логгирования. Это было приложением на Ruby, которое берет лог-файлы, отсылает данные на удаленный сервер и удаляет старые файлы. Ошибка заключалась в том, что открытые лог-файлы не были закрыты явным образом. Вместо этого приложение позволяло автоматическому сборщику мусора Ruby очищать Файловые объекты. Проблема в том, что Файловые объекты не используют много памяти, так что система логгирования теоретически могла держать миллионы логов открытыми до того, как потребуется сборка мусора.
Файловые системы в *nix разделяют имена файлов и данные в файлах. Данные на диске могут иметь несколько имён файлов, указывающих на них (т.е. жёсткие ссылки), и данные удаляются только тогда, когда удалена последняя ссылка. Открытый дескриптор файла считается ссылкой, поэтому если файл удаляется во время чтения программой, то имя файла исчезает из каталога, но данные файла остаются живы до тех пор, пока программа не закроет его. Вот что и происходило с логгером. Команда du («disk usage») ищет файлы, используя листинг каталогов, поэтому она не видела гигабайты файловых данных для тысяч лог-файлов, которые еще были открыты. Эти файлы были обнаружены только после запуска lsof («list open files»).
Конечно, подобная ошибка возникает и в других подобных случаях. Пару месяцев назад мне пришлось столкнуться с Java-приложением, которое через несколько дней ломалось из-за утечки сетевых соединений.
Когда-то я писал большую часть своего кода на Си, а затем на Си++. В те времена я думал, что ручного управления ресурсами достаточно. Насколько сложным это было? Каждому malloc() нужна функция free(), а каждому open() нужна close(). Просто. За исключением того, что не все программы просты, поэтому ручное управление ресурсами со временем стало смирительной рубашкой. Потом однажды я открыл для себя подсчет ссылок и сборку мусора. Я подумал, что это решает все мои проблемы, и полностью перестал заботиться об управлении ресурсами. Опять же, для простых программ это было нормально, но не все программы просты.
Рассчитывать на сбор мусора не получается, потому что это решает только проблему управления памятью, а сложным программам приходится иметь дело с гораздо большим, чем просто с памятью. Есть популярный мем, который отвечает на это тем, что память — это 95% проблем с ресурсами. Можно даже сказать, что все ресурсы — это 0% ваших проблем — до тех пор, пока у вас не закончится один из них. Тогда этот ресурс становится 100% ваших проблем.
Но такое мышление все равно воспринимает ресурсы как особый случай. Более глубокая проблема заключается в том, что по мере усложнения программ, все стремится к тому, чтобы стать ресурсом. Например, возьмите программу-календарь. Сложная программа календаря позволяет нескольким пользователям управлять несколькими календарями с общим доступом, и с событиями, которые могут быть общими для нескольких календарей. Любая часть данных в итоге будет влиять на несколько частей программы, и должна быть актуальной и корректной. Поэтому для всех динамических данных нужен владелец, а не только для управления памятью. По мере добавления новых функций, все больше частей программы будут нуждаться в обновлении данных. Если вы в здравом уме, вы позволите обновлять данные только из одной части программы за раз, так что право и ответственность за обновление данных становится само по себе ограниченным ресурсом. Моделирование мутируемых данных с помощью иммутабельных структур не приводит к исчезновению этих проблем, а лишь переводит их в другую парадигму.
Планирование владения и срока жизни ресурсов является неизбежной частью проектирования сложного программного обеспечения. Это проще, если вы используете некоторые общие паттерны. Один из паттернов — это взаимозаменяемые ресурсы. Примером является иммутабельная строка «foo», которая семантически такая же, как и любая другая иммутабельная «foo». Этот вид ресурсов не нуждается в заранее заданном сроке жизни или владении. На самом деле, для того, чтобы система была как можно проще, лучше не иметь заранее установленного срока жизни или права владения (привет Rust, прим.пер). Другой паттерн — это ресурсы, которые не являются взаимозаменяемыми, но имеют детерминированную продолжительность жизни. Это включает в себя сетевые подключения, а также более абстрактные понятия, такие как право управления частью данных. Самым разумным является явное обеспечение продолжительности жизни таких вещей при кодировании.
Обратите внимание, что автоматическая сборка мусора действительно хороша для реализации первого шаблона, но не второго, в то время как техники ручного управления ресурсами (такие как RAII) отлично подходят для реализации второго паттерна, но ужасны для первого. Эти два подхода становятся взаимодополняющими в сложных программах.