Главное меню
Мы солидарны с Украиной. Узнайте здесь, как можно поддержать Украину.

Доменная инфраструктура в ГриньСкрипте

Автор Алексей Гринь, ноября 23, 2014, 06:43

0 Пользователи и 1 гость просматривают эту тему.

Алексей Гринь

Мой скриптовый хобби-язык, идеологически создаваемый для эмбеддинга в другие программы/системы (скриптинг в играх, для интернет-серверов, для архитектуры плагинов в текстовом редакторе и т.д.), назовём его ГриньСкрипт (реальное название – другое), основывается на понятии «домен».

Домен приложения – это по сути изолированный поток. Что изолируется? Всё: память, код, метаданные. Благодаря этому полностью отсутствует глобальное состояние: домены абсолютно независимы (за исключением пары моментов, которые я опишу ниже). В одном процессе ОС (приложении-хосте) может «жить» хоть тысяча доменов.

Преимущество заключается в простоте написания рантайма, а также полном упрощении работы с многопоточностью с точки зрения пользователя VM. Возьмём, например, сборку мусора. В обычных VM алгоритм для сборки мусора довольно сложен, даже если брать простейший stop-the-world, все потоки должны войти в специально сгенерированные участки кода под названием "safe points" и затем приостановиться, прежде чем GC начнёт работу. А ведь есть ещё параллельные алгоритмы, полные матана, осуществляющие сборку мусора во время работы «мутаторов» (т. е. потоков, которые что-то делают с RAM в то же самое время).

У нас же у каждого домена, т. е. потока, собственный менеджер памяти с собственной кучей. Сборка мусора может осуществляться параллельно без каких-либо преград. Нагрузку на память можно распределять по доменам вручную (поэтому GC у меня реализован примитивнейшим образом).

Домены нужны не только для упрощения работы с многопоточным кодом, но и для изолирования кода вообще. В эмбеддинге это полезно, например, кривой плагин не должен уронить всю программу в целом. Также безопасность: мы можем запретить непонятным плагинам/пользовательскому коду (и т. д.) вызывать определённые небезопасные функции (обращение к файловой системе, например), при этом разрешая эти небезопасные функции другим доменам (системным, например). Ошибки в одном домене не влияют на работу в других доменах (исключение опишу ниже).
肏! Τίς πέπορδε;

Алексей Гринь

Междоменное общение.

Домены в текущей реализации могут обращаться друг к другу только в иерархии типа «дерево». То есть только домен-создатель может получить ссылку на новосозданный домен. Я делаю это, чтобы подчеркнуть изолированность доменов: нельзя допустить рандомным доменам получать ссылку на любые другие рандомные домены (что если есть домен-злодей?)

Память доменов изолирована, что значит объект одного домена несовместим с другим доменом – то есть мы не можем просто взять и вызывать метод в контексте другого домена (например, мы создали новый домен и хотим попросить его параллельно выполнить какие-то функции). Чтобы позволить доменам общаться между собой, вводится понятие междоменного общения. Домен-сервер должен «экспортировать» именованный объект (Domain::exportObject); домен-клиент должен по имени импортировать такой объект (DomainHandle::importObject). Импортированные объекты называются «чужими», то есть их видно в нашем домене, но реально они происходят из другого домена и отношение к ним немного другое.

При импортировании автоматически создаётся прокси-класс T*, являющейся обёрткой вокруг оригинального класса T в чужом домене. Когда вызывается метод у такого прокси-класса (то есть, у «чужого» объекта), все аргументы пакуются в бинарное сообщение и отправляются в очередь сообщений в домене-сервере. Таким образом осуществляется синхронизация между доменами: вместо того, чтобы изменять память напрямую из нескольких потоков, мы _просим_ чужой домен это сделать, через синхронизированную очередь. Сгенерированный прокси-класс наследует оригинальный класс, то есть он автоматически подходит в любые обычные функции, которые ожидают не-«чужие» объекты. Интересная деталь: прокси-класс наследует vtable локального класса, однако родительские поля игнорируются, потому что любые данные, ассоциированные с чужим объектом, хранятся в чужом домене, а потому иметь поля прокси-классу совершенно бессмысленно.

Это отличается от, например, CLR/C#, где нужно наследоваться от MarshalByRefObject. В ГриньСкрипте обёртки создаются автоматом, поэтому можно экспортировать объект любого существующего класса (кроме valuetypes, см. ниже): например, захотелось опубликовать объект класса StringBuilder – пожалуйста. Этот объект будет виден в других доменах как StringBuilder* (читать как «чужой StringBuilder»).

При создании доменов заново считываются скрипты с диска, заново происходит парсинг, компиляция и т. д. Есть один важный момент: временной промежуток между доменами может быть настолько длинный, что пользователь может успеть что-то изменить в файлах скриптов – таким образом, layout'ы классов в разных доменах будут отличаться, что приведёт к вылетам, если клиент будет полагать, что у класса layout одинаковый на сервере и клиенте. Поэтому при импортировании объектов мы верифицируем идентичность vtable'ов (больше и не надо): все ли методы присутствуют, такие же сигнатуры? И так далее.

Чтобы домен-сервер мог читать сообщения и отвечать другим доменам на их запросы, нужно вызвать специальный метод Domain::listen(Predicate), переводящий текущий домен в режим «слушания» (единственный аргумент – предикат останова, так что процесс этот прерываемый).

Чтобы домен-сервер мог корректно распаковывать бинарные сообщения в аргументы в машинном стеке, должны быть сгенерированы специальные server stub'ы, вспомогательные функции. Идеология ГриньСкрипта есть «никогда не генерируй того, чего не нужно», а также ГриньСкрипт – предкомпилируемый JIT, т. е. в текущей реализацией весь код предкомпилируется сразу, статически. Генерировать прокси-классы/server-stub'ы или нет – ГриньСкрипт может знать только из простейшего статического анализа кода. Если встречается хотя бы одно упоминание T*, то stub'ы генерируются. Если упоминания нет,  stub'ы не генерируются, и домен-сервер откажется обслуживать запросы с таким типом. Поэтому (и не только поэтому) введена force-конструкция. Она форсирует генерацию машинного кода, даже если композитный тип ни разу не был упомянут напрямую:
force StringBuilder*;
肏! Τίς πέπορδε;

Алексей Гринь

Синхронные vs. асинхронные вызовы.

Методы чужих объектов можно вызывать как сихронно, так и асинхронно. В синхронном случае текущий домен ждёт ответа чужого домена (с определённым таймаутом – тут я ещё не определился), и не двигается с места, пока ответа не будет. Ошибки в чужом домене пропагируются в домен-клиент (абортят его). Всё это (пропагация ошибок, блокирование вызовов, идентичность vtable'ов между T и T*) сделаны для того, чтобы чужие объекты можно было подсовывать в обычный код так, чтобы с точки зрения кода не было отличия: объект существует в этом домене или в чужом?

Также прокси-классы генерируют ещё одну обёртку для каждого метода: асинхронную обёртку с суффиксом Async. У таких обёрток сигнатуры изменены: последним аргументом добавляется замыкание, которое вызывается при завершении асинхронного вызова. Тип возвращённого значения перемещается из сигнатуры метода в сигнатуру этого замыкания. Например, если оригинальный метод возвращал int, то асинхронная обёртка ничего не возвращает, вместо этого добавляется замыкание, которое вызывается при завершении асинхронного вызова и в которое передаётся значения типа "int?" Тип "int?" – это алгебраический тип данных, который может содержать или int, или объект класса Error (этот тип используется повсеместно в ГриньСкрипте вместо исключений). Таким образом, асинхронные вызовы никогда не крэшат домен-клиент, и являются наиболее безопасным способом общения доменов.

В случае асинхронных вызовов, чтобы домент-клиент мог получить сообщение о завершении метода, тот должен войти в Domain::listen так же, как и сервер.

Таким образом мы можем писать многопоточный код, не прибегая к каким-либо специальным примитивам синхронизации; код пишется так, как будто он однопоточный.
肏! Τίς πέπορδε;

Алексей Гринь

Сериализация

Объекты не могут делиться между доменами, так как принадлежат разным кучам, поэтому вводится понятие сериализации, т. е. преобразования объектов одного домена в объекты другого домена во время вызова метода чужого объекта.
Используется собственный, довольно простой бинарный формат.

Маршаллинг примитивных типов типа int, float и т. д. довольно прост – копируется побитовое представление, и всё тут.

В ГриньСкрипте есть понятие valuetype-классов, как в C# – эти классы используются по значению, а не по ссылке (в отличие от reference types), например, Vector2D. То есть любое использование такого класса приводит к копированию значения. Маршаллинг таких классов довольно прост, ведь семантика valuetype-классов просто благоволит маршаллингу: просто копируй поля одно за другим – и всё. На той стороне маршаллинга (в целевом домене) будет копия значения, как и предполагалось. Valuetype-классы могут содержать ссылки на интерфейсы, чужие объекты, на вложенные valuetype-значения и т. д. – это всё в текущей реализацией поддерживается и успешно сериализуется.
В текущей реализации все valuetype-ы автоматически сериализуемы.

Что касается reference types – классов по значению – то здесь сериализуемы только чужие объекты. Рандомные локальные объекты невозможно сериализовать – они не экспортированы по имени, а также присутствует проблема жизненного цикла (долго объяснять). Что касается чужих объектов, то домен-клиент может передавать в методы чужого объекта только те чужие объекты, которые были созданы в том же домене-сервере. Текущая реализация запрещает одному доменту импортировать чужой объект и ре-импортировать его в третий.

Строки – особый случай. Наши строки а) иммутабельны б) внутренне реализованы как легковесные обёртки вокруг типа TString (принадлежит C++-фреймворку за моим авторством, на котором основан весь рантайм ГриньСкрипт). Этот TString, как и всё, наследуемое от TObject (не забываем, это уже классы на уровне C++) – reference-counted, т. е. управление памяти реализуется путём подсчёта ссылок. Что всё это значит? Это значит, что мы можем переиспользовать внутренние TString при сериализации/маршаллинге строк между доменами. ГриньСкрипт-обёртки приходится создавать заново в каждом домене, но ссылаться они будут на один и тот же TString. Деструктор ГриньСкрипт-обёртки вызывает TString::Unref(), поэтому время жизни детерменировано в независимости от того, какой домент уничтожен последним. Мы называем такой маршаллинг «marshal by bleed».

Что касается интерфейсов, тут немного посложнее. Интерфейсы скрывают реальный тип объекта и сервер не может запросто определить, что конкретно нужно десериализовать, просто глядя на сигнатуру метода. Поэтому здесь сериализация происходит немного динамически: сначала в бинарное сообщение записываем название типа (тип объекта извлечён из первого слота vtable), а затем уже – данные по стандартному шаблону. При десериализации домен-сервер видит название типа, ищет его в своих метаданных (не забываем, что у каждого домена – собственная копия метаданных) и, если поиск успешен, перенаправляет десериализацию в тот тип (метод ClassPrivate::deserialize). И вот тут-то, опять, небольшая проблема с boxed классами. Boxed-класс – это автоматически сгенерированная обёртка, оборачивающия тип valuetype в reference type. При работе с интерфейсами должна быть возможность трактовать valuetypes и reference types идентично, поэтому приходится трактовать valuetypes как reference types. Как и в случае с прокси-классами, boxed-классы генерируются только если это видно статически. Сервер может просто не иметь предкомпилированного кода для boxed-классов, десерилиазируемых из интерфейсов при междоменных вызовах. Поэтому здесь опять выручает конструкция "force boxed T", чтобы заставить компилятор сделать это.

Есть ещё куча деталей и ограничений в текущей реализации, например:
- нельзя импортировать из себя в себя
- междоменному вызову нельзя возвратить чужой объект
- boxed-значения в текущей реализации не должны иметь ссылок на reference types (лень было реализовывать), но при этом обычные valuetypes – могут
- тип intptr (opaque-указатель) и объекты с нативными layout'ами запрещены к сериализации по значению (включая binary blobs). Про intptr я думаю, всё же, возможно отменить ограничение.
- нельзя передавать замыкания из домена в домен
- пока не реализована сериализация массивов
- и т. п. и т.д.
肏! Τίς πέπορδε;

Алексей Гринь

Безопасность

Домены также делятся на «домены полного доверия» (fulltrust domains) и безопасные домены (secure domains). Что это значит? Fulltrust-домены – обычные домены, в которых разрешено делать всё, что угодно. Безопасные домены же – по умолчанию не могут делать вообще никаких небезопасных операций. Чтобы разрешить безопасному домену, например, доступ к жёсткому диску, нужно это явно указать при создании безопасного домена: Domain runPathSecure pathToScript ["FileIOPermission"]; Таким образом, используется whitelisting вместо blacklisting.

Допустим, мы не доверяем коду и хотим запустить его в режиме sandbox. Для этого мы создаём для него отдельный безопасный домен (runPathSecure вместо runPath), не давая ему никаких разрешений. Если такой нехороший код захочет что-то сделать с файлами на диске, то домен моментально вылетит с ошибкой «Code access denied». Но как указать системе, какие методы безопасны, а какие – нет?

Для этого есть специальный класс Permission. Чтобы создать новый тип разрешения, нужно наследовать от этого класса – и всё. Чтобы запросить наличие разрешения у текущего домена, потенциально небезопасный код, которым может воспользоваться домен-злодей, должен создать объект нужного разрешения и вызвать у него метод "demand". Специальный объект создаётся для того, чтобы система сравнивала разрешения по имени типа объекта, извлечённого из vtable. Я сначала хотел ввести более простой метод типа demand(string), но при написании литералов могут быть опечатки, так что лучше сравнивать по типам, чем по строковым литералам.

Но это ещё не всё. Злой домен всё равно мог бы обойти эту систему очень простым образом: создать новый fulltrust-домен и делегировать все свои злые функции туда. Поэтому, чтобы закрыть такую брешь, создано новое правило: «безопасность заразна». То есть, безопасные домены могут создавать только безопасные домены. При этом злой домен мог бы попытаться добавить новые разрешения в runPathSecure и обойти нашу защиту – из-за чего введено ещё одно правило: разрешения наследуются от secure-домена к secure-домену и менять их список нельзя.

Есть ещё одно последнее разделение, которое я не указал: base domain vs. secondary domains. Base domain – это основной домен, занимающий главный поток процесса. Это корень всей иерархии доменов. По умолчанию именно он является fulltrust. При эмбеддинге именно он создаётся на стороне C/C++ и при использовании консольного интерпретатора именно он автоматически создаётся при запуске кода. Когда я говорил, что домены независимы, я врал. В текущей реализации при уничтожении base domain'а (например, если он вылетел с ошибкой) автоматически уничтожается весь процесс со всеми доменами внутри. Поэтому, например, если базовый домен обращается к нехорошему домену в синхронном режиме, то первая же ошибка в нехорошем домене будет пропагирована в базовый домен, что приведёт к полному падению всего процесса. Злой домен уронил весь процесс! Поэтому базовому домену не рекомендуется общаться с безопасными доменами в синхронном режиме. Я, может быть, это запрещу явно на уровне кода.

Такие вот дела.
肏! Τίς πέπορδε;

Алексей Гринь

Больше изоляции.

Потрудился давеча насчёт лучшей изоляции доменов друг от друга и от ОС в целом.

Если безопасному домену дано разрешение типа FileIOPermission, создаётся временная папка, в которую изолируется домен. Дело в том, что если домену дать полный доступ к диску, то вся суть безопасных доменов теряется: можно обойти всю систему изоляции, просто перезаписав скрипты на диске. Поэтому безопасные домены всегда могут писать только в свою собственную папку (название генерируется с помощью GUID). Когда домен завершает свою работу, эта папка удаляется вслед за ним. Каждый IO API в стандартной библиотеке должен вызывать специальную утилитную функцию DemandFileIOPermission, которая проверяет множество вещей:
а) разрешён ли доступ к диску вообще;
б) содержит ли путь небезопасные элементы;
в) находится ли путь в пределах изолированной папки.

В качестве небезопасных элементов рассматривается ".." для относительного доступа к родителям: для осторожности это запрещено вообще (функционал не критичен, заменяется ручной работой с путями; как оказалось, IIS 6.0 запрещает это тоже). Также запрещён "/" вместо "\" (для Windows).
Также путь в безопасном режиме проверяется на наличие 0 в середине строки, чтобы предотвратить атаки с помощью отправки некорректных строк с обрывом (строки у нас сохраняют в себе свою длину отдельным полем, поэтому обрыв можно зафиксировать).

Другая интересная проблема с путями заключается в понятии CurrentDirectory и GetFullPath. Дело в том, что домены у нас изолированы, однако они всё равно находятся в пределах одного процесса ОС.
В WinAPI функция SetCurrentDirectory изменяет путь для всего процесса. Если один рандомный домен изменит текущую папку процесса, то другой рандомный домен (напр. безопасный) неожиданно для себя будет пялиться в совершенно другие папки. Это непозволительно.

Поэтому CurrentDirectory у нас реализовано так: текущая папка запоминается раз и навсегда при запуске процесса (когда ещё только один главный поток есть) и больше не меняется никогда. Все последующие попытки будут обращаться к закешированному значению вместо WinAPI. Динамически изменять текущую папку для всего процесса вообще глупая затея, это всё запросто реализуется ручной манипуляцией путей, да и многопоточный код не ломается.

То же самое с GetFullPath, которое в WinAPI уповает на GetCurrentDirectory.
Что касается безопасных доменов, то для них CurrentDirectory установлена в автоматически созданную папку, уникальную для каждого домена.

Интересно, что эта проблема в .NET/CLR так и не была решена: AppDomain'ы там могут ломать логику друг друга, просто меняя текущую папку, несмотря на все заявления об изолированности AppDomain'ов (а ещё сборщик мусора един для всех).

Минус: в текущей версии я пока не решил проблему с внезапной остановкой безопасных доменов (непредвиденной). В таком случае временные папки не будут удалены. Это само по себе не проблема, так как мы изолируем код, а не данные, поэтому мусор безопасности не вредит. Однако мусор сам по себе не есть хорошо. Я сначала пытался сделать очистку через atexit, однако понял, что это потоконебезопасно и существует вероятность access violations.
肏! Τίς πέπορδε;

Быстрый ответ

Обратите внимание: данное сообщение не будет отображаться, пока модератор не одобрит его.

Имя:
Имейл:
Проверка:
Оставьте это поле пустым:
Наберите символы, которые изображены на картинке
Прослушать / Запросить другое изображение

Наберите символы, которые изображены на картинке:

√36:
ALT+S — отправить
ALT+P — предварительный просмотр