Современные разработчики привыкли делить безопасность программ на несколько категорий: безопасность памяти (memory safety) и потоковая безопасность (thread safety). Однако на деле эти понятия куда теснее переплетены, чем кажется. Свежая статья эксперта по языкам программирования Ральфа Юнга заставляет по-новому взглянуть на то, как эти концепции влияют друг на друга, и почему даже популярные и «безопасные» языки, такие как Go, могут скрывать серьёзные риски.
🤔 Что такое безопасность памяти?
Под безопасностью памяти обычно понимается отсутствие проблем вроде:
- 🗑️ Использования уже освобождённой памяти (use-after-free)
- 📏 Выхода за пределы выделенного диапазона (out-of-bounds access)
При этом традиционно безопасность памяти отделяют от потоковой безопасности, которая подразумевает защиту программы от ошибок многопоточности и гонок данных (data races). Но эта грань оказалась тоньше, чем ожидалось.
🐞 Скрытая угроза в Go
В статье автор приводит яркий пример кода на Go, языке, который широко считается безопасным с точки зрения управления памятью. Вот упрощённый пересказ проблемы, вскрывающей неожиданные последствия гонки данных:
🔹 Как это происходит на практике:
- Создаётся глобальная переменная-интерфейс, которая постоянно переключается между двумя разными типами.
- В другом потоке метод интерфейса вызывается постоянно без какой-либо синхронизации.
- Возникает гонка данных, из-за которой интерфейс одновременно содержит указатель на объект одного типа, а методы от другого.
- Программа пытается обратиться к некорректному адресу памяти (0x2a, т.е. числу 42), что вызывает крах программы (segfault).
На этом примере ясно видно, что простая гонка данных способна нарушить даже базовые гарантии памяти.
📚 А как у других?
Сравним поведение Go с другими популярными языками:
- ☕ Java: Позволяет гонки данных, но поведение программы чётко определено, и подобные гонки не могут привести к критическому сбою.
- 🦀 Rust и 🕊️ Swift: Предотвращают гонки данных статически, благодаря строгой типовой системе и ограничениям компилятора.
Go же оказывается в подвешенном состоянии: он допускает гонки, но не гарантирует определённого поведения, тем самым открывая двери неопределённому поведению (Undefined Behavior).
⚙️ Технические детали: почему это происходит?
Внутренне интерфейсы в Go представлены парой указателей (на данные и таблицу виртуальных функций — vtable). Когда гонка данных приводит к одновременному обновлению этих указателей, возникает ситуация, при которой метод одного типа пытается интерпретировать данные другого типа. Это приводит к обращению по неверному адресу, что в лучшем случае заканчивается крахом приложения, а в худшем — уязвимостью безопасности.
Подобные проблемы возникают и со срезами (slices), так как их структура также хранится в нескольких отдельных словах памяти, что опять-таки создаёт почву для гонок и некорректного чтения памяти.
🧐 Мнение автора: что такое истинная безопасность?
На мой взгляд, автор статьи абсолютно прав в своих рассуждениях: разделять понятия безопасности памяти и потоков не имеет смысла, если программа допускает неопределённое поведение. Настоящая безопасность — это отсутствие неопределённого поведения вообще. Любая брешь в этом принципе ставит под вопрос безопасность всей программы.
🛠️ Go пытается смягчить проблему встроенными инструментами (например, детектором гонок данных), но полагаться на них полностью рискованно: инструмент способен найти лишь те гонки, которые возникли при выполнении тестов, и не может гарантировать отсутствие проблем в продакшене.
📢 Личное мнение и выводы
Go — прекрасный язык для быстрой и простой разработки, но его подход к параллелизму несёт в себе скрытую опасность. Язык, позиционируемый как «безопасный», не должен допускать ситуации, в которых базовые инварианты памяти оказываются под угрозой из-за гонок данных.
🔸 Что можно было бы улучшить?
- Введение строгой статической проверки гонок данных на уровне компиляции.
- Более прозрачное описание таких рисков в официальной документации.
🔸 Что стоит помнить разработчикам:
- Используйте средства синхронизации даже там, где это кажется избыточным.
- Не полагайтесь исключительно на встроенные детекторы гонок — покрытие тестами никогда не бывает полным.
В конечном счёте, будущее за языками, которые сочетают простоту и строгость гарантий безопасности. Rust и Swift уже показывают пример такого пути — возможно, и Go пора переосмыслить свой подход.
🌐 Источник новости и дополнительная информация:
- Оригинальная статья Ральфа Юнга: There is no memory safety without thread safety
📖 Полезные ссылки из оригинала статьи:
- Go Playground для экспериментов: https://go.dev/play/
- Документация Go о модели памяти: https://go.dev/ref/mem