Это вторая часть цикла статей «Бесстрашная защита». В первой мы рассказывали про безопасность памяти
Современные приложения многопоточны: вместо последовательного выполнения задач программа использует потоки для одновременного выполнения нескольких задач. Все мы ежедневно наблюдаем одновременную работу и параллелизм:
- Веб-сайты одновременно обслуживают несколько пользователей.
- UI выполняет фоновую работу, которая не мешает пользователю (представьте, что каждый раз при вводе символа приложение подвисает для проверки орфографии).
- На компьютере может одновременно выполняться несколько приложений.
Параллельные потоки ускоряют работу, но привносят набор проблем синхронизации, а именно взаимные блокировки и состояние гонки. С точки зрения безопасности, почему мы заботимся о безопасности потоков? Потому что у безопасности памяти и потоков одна и та же основная проблема: недопустимое использование ресурсов. Здесь атаки приводят к таким же последствиям, как атаки на память, включая повышение привилегий, выполнение произвольного кода (ACE) и обход проверок безопасности.
Ошибки параллелизма, как и ошибки реализации, тесно связаны с корректностью программы. В то время как уязвимости памяти почти всегда опасны, ошибки реализации/логики не всегда указывают на проблему безопасности, если не происходят в части кода, связанной с соблюдением контрактов безопасности (например, разрешение обойти проверку безопасности). Но у багов параллелизма есть особенность. Если проблемы безопасности из-за логических ошибок часто появляются рядом с соответствующим кодом, то ошибки параллелизма часто возникают в других функциях, а не в той, где непосредственно допущена ошибка, что затрудняет их отслеживание и устранение. Ещё одна трудность — определённое перекрытие между неправильной обработкой памяти и ошибками параллелизма, что мы видим в гонках данных.
Языки программирования разработали различные стратегии параллелизма, чтобы помочь разработчикам управлять проблемами производительности и безопасности многопоточных приложений.
Проблемы с параллелизмом
Принято считать, что параллельное программирование труднее обычного: наш
Взаимоблокировка возникает, когда несколько потоков для продолжения работы ожидают друг от друга выполнения определённых действий. Хотя это нежелательное поведение может вызвать атаку типа «отказ в обслуживании», оно не вызовет таких уязвимостей, как ACE.
Состояние гонки — ситуация, в которой время или порядок задач могут повлиять на корректность программы. Гонка данных происходит, когда несколько потоков пытаются одновременно получить доступ к одному и тому же местоположению в памяти при как минимум одной попытке записи. Бывает, что состояние гонки (race condition) и гонка данных (data race) происходят независимо друг от друга. Но гонки данных всегда несут опасность.
Потенциальные последствия ошибок параллелизма
- Взаимная блокировка
- Потеря информации: другой поток перезаписывает информацию
- Потеря целостности: сплетается информация из нескольких потоков
- Потеря живучести: проблемы с производительностью из-за неравномерного доступа к общим ресурсам
Самый известный тип атаки с параллелизмом называется TOCTOU (time of check to time of use): по сути, состояние гонки между проверкой условия (например, учётных данных в системе безопасности) и использованием результатов. Результатом выполнения атаки TOCTOU становится потеря целостности.
Взаимные блокировки и потеря живучести считаются проблемами производительности, а не проблемами безопасности, в то время как потеря информации и потеря целостности, скорее всего, будут связаны с безопасностью. В статье от Red Balloon Security рассматриваются некоторые возможные эксплойты. Один из примеров — повреждение указателя с последующей эскалацией привилегий или удалённым выполнением кода. В эксплойте функция, загружающая общую библиотеку ELF (Executable and Linkable Format), корректно инициирует семафор только при первом вызове, а затем некорректно ограничивает количество потоков, что вызывает повреждение памяти ядра. Эта атака — пример потери информации.
Самая сложная часть параллельного программирования — тестирование и отладка, потому что ошибки параллелизма трудно воспроизвести. Тайминги событий, решения операционной системы, сетевой трафик и прочие факторы… всё это меняет поведение программы при каждом запуске.
Иногда действительно проще удалить всю программу, чем искать баг. Heisenbugs
Мало того, что поведение меняется при каждом запуске, но даже вставка операторов вывода или отладки может изменить поведение, в результате чего возникают и таинственно исчезают «баги Гейзенберга» (недетерминированные, трудновоспроизводимые ошибки, типичные для параллельного программирования).
Параллельное программирование сложно. Трудно предсказать, как параллельный код будет взаимодействует с другим параллельным кодом. Когда появляются ошибки, их трудно найти и исправить. Вместо того, чтобы полагаться на тестеров, давайте рассмотрим способы разработки программ и такое использование языков, какое упрощает написание параллельного кода.
Сначала сформулируем понятие «потокобезопасность»:
«Тип данных или статический метод считается потокобезопасным, если он правильно ведёт себя при вызове из нескольких потоков, независимо от того, как эти потоки выполняются, и не требует дополнительной координации от вызывающего кода». MIT
Как языки программирования работают с параллелизмом
В языках без статической потокобезопасности программистам приходится постоянно следить за памятью, которая используется совместно с другим потоком и может в любое время измениться. В последовательном программировании нас учат избегать глобальных переменных, если другая часть кода втихую их изменила. Невозможно требовать от программистов гарантированно безопасного изменения общих данных, как и ручного управления памятью.
«Постоянная бдительность!»
Как правило, языки программирования ограничиваются двумя подходами:
- Ограничение изменяемости или ограничение общего доступа
- Потокобезопасность вручную (например, блокировки, семафоры)
Языки с ограничением потоков либо ставят ограничение в 1 поток для изменяемых переменных, либо требуют, чтобы все общие переменные были неизменяемыми. Оба подхода устраняют основную проблему гонки данных — неправильно изменяемые общие данные — но ограничения слишком суровые. Для решения проблемы в языках сделали низкоуровневые примитивы синхронизации, такие как мьютексы. Их можно использовать для построения потокобезопасных структур данных.
Python и глобальная блокировка интерпретатором
В эталонной реализации в Python и Cpython есть своеобразный мьютекс под названием Global Interpreter Lock (GIL), который блокирует все остальные потоки, когда один поток обращается к объекту. Многопоточный Python печально известен своей неэффективностью из-за времени ожидания GIL. Поэтому большинство параллельных программ Python работают в несколько процессов, чтобы у каждого был свой GIL.
Java и исключения среды выполнения
Java поддерживает параллельное программирование через модель общей памяти. У каждого потока собственный путь выполнения, но он может получить доступ к любому объекту в программе: программист должен синхронизировать доступ между потоками с помощью встроенных примитивов Java.
Хотя в Java есть строительные блоки для создания потокобезопасных программ, но потокобезопасность не гарантируется компилятором (в отличие от безопасности памяти). Если происходит несинхронизированный доступ к памяти (то есть гонка данных), то Java выбросит исключение времени выполнения, но программисты должны правильно использовать примитивы параллелизма.
C++ и мозг программиста
В то время как Python избегает состояния гонки с помощью GIL, а Java выбрасывает исключения во время выполнения, язык C++ рассчитывает на то, что программист вручную синхронизирует доступ к памяти. До версии C++11 стандартная библиотека не включала примитивы параллелизма.
Большинство языков предоставляют инструменты для написания потокобезопасного кода, и существуют специальные методы для обнаружения гонки данных и состояния гонки; но это не даёт никаких гарантий потокобезопасности и не защищает от гонки данных.
Как решает проблему Rust?
Rust использует многосторонний подход к устранению состояния гонки, используя правила владения и безопасные типы, чтобы полностью защитить от состояния гонки на этапе компиляции.
В первой статье мы познакомились с концепцией владения, это одно из основных понятий Rust. У каждой переменной есть уникальный владелец, а право владения можно передать или заимствовать. Если другой поток хочет изменить ресурс, то мы передаём право собственности, переместив переменную в новый поток.
Перемещение вызывает исключение: несколько потоков могут писать в одну и ту же память, но никогда одновременно. Поскольку владелец всегда один, что произойдёт, если другой поток заимствует переменную?
В Rust у вас или одно изменяемое заимствование, или несколько неизменяемых. Невозможно одновременно ввести изменяемое и неизменяемое заимствования (или несколько изменяемых). В безопасности памяти важно, чтобы должным образом освобождались ресурсы, а в потокобезопасности важно, чтобы в каждый момент времени только один поток имел право изменять переменную. Кроме того, в такой ситуации никакие другие потоки не будут ссылаться на устаревшее заимствование: для него возможна или запись, или совместный доступ, но не оба.
Концепция владения разработана для устранения уязвимостей памяти. Оказалось, что она также предотвращает гонки данных.
Хотя во многих языках есть методы обеспечения безопасности памяти (например, подсчёт ссылок и сборка мусора), они обычно полагаются на ручную синхронизацию или запреты на параллельный общий доступ для предотвращения гонки данных. Подход Rust направлен на оба вида безопасности, пытаясь решить основную проблему определения допустимого использования ресурсов и обеспечения этой допустимости во время компиляции.
Но подождите! Это ещё не всё!
Правила владения не позволяют нескольким потокам записывать данные в одну ячейку памяти и запрещают одновременный обмен данными между потоками и изменяемость, но это не обязательно обеспечивает потокобезопасные структуры данных. Каждая структура данных в Rust либо потокобезопасна, либо нет. Это передаётся компилятору с помощью системы типов.
«Хорошо типизированная программа не может ошибиться». — Робин Милнер, 1978
В языках программирования системы типов описывают допустимое поведение. Другими словами, хорошо типизированная программа чётко определена. До тех пор, пока наши типы достаточно выразительны, чтобы уловить предполагаемый смысл, хорошо типизированная программа будет вести себя так, как задумано.
Rust — это типобезопасный язык, здесь компилятор проверяет согласованность всех типов. Например, следующий код не скомпилируется:
let mut x = "I am a string";
x = 6;
error[E0308]: mismatched types
--> src/main.rs:6:5
|
6 | x = 6; //
| ^ expected &str, found integral variable
|
= note: expected type `&str`
found type `{integer}`
Все переменные в Rust имеют тип, часто неявный. Мы также можем определить новые типы и описать возможности каждого типа, используя систему трейтов. Трейты обеспечивают абстракцию интерфейса. Два важных встроенных трейта — это Send
и Sync
, они по умолчанию предоставляются компилятором для каждого типа:
Send
указывает, что структуру можно безопасно передать между потоками (требуется для переноса права владения)Sync
указывает, что потоки могут безопасно использовать структуру
Пример ниже — упрощенная версия кода из стандартной библиотеки, который порождает потоки:
fn spawn<Closure: Fn() + Send>(closure: Closure){ ... }
let x = std::rc::Rc::new(6);
spawn(|| { x; });
Функция spawn
принимает единственный аргумент closure
и требует для последнего тип, реализующий трейты Send
и Fn
. При попытке создать поток и передать значение closure
с переменной x
компилятор выдаёт ошибку:
error[E0277]: `std::rc::Rc<i32>` cannot be sent between threads safely --> src/main.rs:8:1 | 8 | spawn(move || { x; }); | ^^^^^ `std::rc::Rc<i32>` cannot be sent between threads safely | = help: within `[closure@src/main.rs:8:7: 8:21 x:std::rc::Rc<i32>]`, the trait `std::marker::Send` is not implemented for `std::rc::Rc<i32>` = note: required because it appears within the type `[closure@src/main.rs:8:7: 8:21 x:std::rc::Rc<i32>]` note: required by `spawn`
Трейты Send
и Sync
позволяют системе типов Rust понимать, какие данные могут быть общими. Включив эту информацию в систему типов, потокобезопасность становится частью типобезопасности. Вместо документации потокобезопасность реализуется по закону компилятора.
Программисты чётко видят общие объекты между потоками, а компилятор гарантирует надёжность этой установки.
Хотя инструменты для параллельного программирования есть во многих языках, предотвратить состояние гонки непросто. Если требовать от программистов сложного чередования инструкций и взаимодействия между потоками, то ошибки неизбежны. Хотя нарушения безопасности потоков и памяти приводят к схожим последствиям, традиционные средства защиты памяти, такие как подсчёт ссылок и сборка мусора, не предотвращают состояние гонки. Кроме статической гарантии безопасности памяти, модель владения Rust также предотвращает небезопасное изменение данных и некорректное совместное использование объектов между потоками, в то время как система типов обеспечивает потокобезопасность во время компиляции.
Автор: m1rko