В мире программирования высокопроизводительных и масштабируемых приложений принято использовать множество технологий и парадигм, призванных облегчить жизнь разработчикам и повысить эффективность работы программ. Однако, среди этих технологий существуют три, обладающие общим серьезным недостатком — они делают поведение программ не всегда повторяемым и предсказуемым. Речь идет о потоках (threads), сборке мусора (garbage collection) и недетерминированных деструкторах (non-deterministic destructors). Все три механизма несут в себе определенные сложности и подводные камни, которые отрицательно влияют на отладку и стабильность приложений. Ключевой проблемой потоков является именно непредсказуемость хода исполнения.
В многопоточных программах операции могут происходить в разном порядке при каждом запуске. Даже при условии корректного кода и синхронизации, результаты и промежуточные состояния могут меняться. Рассмотрим, например, этапы map/reduce, где параллельно обрабатываемые данные слегка отличаются по времени прихода к фазе свертки. Хотя по логике стадия reduce должна выдать одинаковый результат независимо от порядка получаемых данных, фактически на практике из-за неустойчивости порядка возникает риск ошибочного поведения. Еще хуже, когда в вычислениях накапливаются погрешности или происходит обращение к аппаратным ошибкам, иллюстрированным известной ошибкой FDIV в процессорах Pentium первого поколения.
В зависимости от порядка обработки данных результат может отличаться, что превращает отладку в крайне сложную задачу. Помимо изменений в логике выполнения, многопоточность несет в себе «бомбу замедленного действия» — проблему гонок данных и неправильных блокировок. Если разработчик ошибается и не ставит замки в правильных местах, программа может сработать безупречно 99,999% времени, а на редкой итерации выдаст неверные данные. Эти ошибки очень коварны — они воспроизводятся редко, не всегда повторимы, что значительно затрудняет их выявление. Подобные неисправности часто становятся причиной недовольства пользователей, потери доверия и увеличенных временных затрат на исправление.
Свой вклад в общую непредсказуемость вносят и системы сборки мусора, которые широко применяются в таких языках, как Java, C# и Python. Суть проблемности именно в том, что сборщик мусора работает в параллельном потоке и запускается в произвольные моменты времени. Это приводит к изменению времени задержек и этапов освобождения ресурсов, что больше всего заметно в реальных системах с критическими к времени откликами. Особенно сложно с тем моментом, что деструкторы в средах с сборкой мусора являются недетерминированными. Они могут быть вызваны системой в любой момент, зачастую отложено и случайно, что приводит к фундаментальным трудностям управления жизненным циклом объектов.
Недетерминированный деструктор — это когда гарантируется факт выполнения очистки объекта, но не гарантируется, когда именно это произойдет. В языках с классической сборкой мусора вся ответственность за освобождение внешних ресурсов ложится либо на пользователя, либо на вспомогательные механизмы, что часто приводит к устойчивым ошибкам и утечкам. Например, в некоторых приложениях приходится вручную вызывать методы dispose, особенно когда речь идет о работе с базами данных или сетевыми соединениями. В языке C# распространена практика использования конструкции using, которая вынуждает программиста заботиться о правильном и своевременном освобождении ресурсов. Но на практике это создает серьезный дополнительный слой сложности, нарушает принципы инкапсуляции и требует поддержки множества вспомогательных вызовов и зависимостей вручную.
Еще одна проблема связана с тем, что объекты часто включают в себя другие объекты, и если внутренний объект требует явного вызова dispose, внешний контейнер должен явно реализовать вызов dispose для своих полей. Это вызывает рост количества шаблонного кода и нарушает архитектурное разбиение приложения. Чтобы избежать возможных проблем в будущем, некоторые разработчики прибегают к тому, что начинают вызывать dispose для всех объектов без исключения, что возвращает ситуацию почти к ручному управлению памятью с сопутствующими неудобствами. Если говорить о решениях, которые уменьшали бы подобные неопределенности, стоит выделить механизм подсчета ссылок (reference counting) и детерминированный вызов деструкторов. В системах с детерминированными деструкторами, например в C++, объекты уничтожаются в строго предсказуемый момент — когда их время жизни заканчивается.
Это позволяет создавать умные указатели (smart pointers), которые автоматически управляют временем жизни объектов, освобождая ресурсы ровно тогда, когда они перестают быть нужны. Такой подход упрощает архитектуру и минимизирует утечки утечек и ошибок состояния. В языках с подсчетом ссылок автоматическое управление жизненным циклом несколько иначе. Для каждого объекта ведется счетчик ссылок, который увеличивается при добавлении новой ссылки и уменьшается при ее удалении. Когда счетчик достигает нуля — объект уничтожается немедленно.
Примером таких языков можно считать Python, Perl и Ruby, где управление памятью выполняется именно таким способом. Благодаря этому можно писать лаконичный и удобочитаемый код, в котором временно созданные объекты тут же уничтожаются, исключая неоправданное использование памяти. Однако подсчет ссылок обладает и своими недостатками. В многопоточной среде обновление счетчика ссылок требует дополнительных синхронизаций, что может существенно тормозить работу программы на многоядерных системах. В результате в языках, ориентированных на параллельность, таких как Java и C#, чаще отдают предпочтение именно сборке мусора и отказу от подсчета ссылок из-за сложностей с синхронизацией и производительностью.
В совокупности, современные тенденции развития аппаратного обеспечения продвигают идеи многоядерности и параллелизма, оказывая давление на языки программирования для адаптации к многопоточности. При этом сама концепция многопоточности, сборки мусора и недетерминированных деструкторов становится источником множества проблем, усложняющих разработку, отладку и сопровождение программных продуктов. Специалисты начинают задумываться о том, что скорость разработки и надежность все же важнее чистой производительности. Часто проще и эффективнее написать честно однопоточное приложение с ясной моделью управления ресурсами, чем разбираться с трудноуловимыми ошибками конкурентности и неявным поведением памяти. Примером служит экономия сил и времени, достигаемая при использовании языков с детерминированным временем жизни объектов.