Библиотека сайта rus-linux.net
Среда времени выполнения динамических языков и языки Iron
Глава 8 из книги "Архитектура приложений с открытым исходным кодом", том 2.
Оригинал: The Dynamic Language Runtime and the Iron Languages
Автор: Jeff Hardy
Перевод: Н.Ромоданов
8.7. Точки динамических вызовов
В статическом языке платформы .NET все решения о том, какой код должен быть вызван, делаются на этапе компиляции. Например, рассмотрим следующую строку кода на языке C#:
var z = x + y;
Компилятору известны типы переменных `x' и `y' и то, можно ли их складывать. Компилятор, основываясь исключительно на статической информации, в которой есть все о типах переменных, может создать правильный код, необходимый для обработки перегружаемых операторов, преобразования типов и всего того, что может понадобиться для создания кода, который будет работать должным образом. Теперь рассмотрим следующую строку кода на языке Python:
z = x + y
Компилятор языка IronPython, когда он столкнется с такой строкой, понятия не имеет, что можно делать, поскольку ему не известны типы у x и y, и даже если они ему известны, то во время выполнения возможность складывать x и y может так или иначе измениться. В принципе, это узнать можно, но ни в языке IronRuby, ни в языке IronPython нет наследования типов. Вместо того, чтобы создавать код IL для сложения чисел, IronPython создает точку вызова (call site), для которой во время выполнения будет определена вся информация.
Точка вызова является местом выполнения операции, которая будет определена на этапе исполнения программы; точки вызова реализуются как экземпляры класса System.Runtime.CompilerServices.CallSite. В динамических языках, например, в Python или Ruby, почти для каждой операции есть динамический компонент; такие динамические операции представлены в деревьях выражений как узлы DynamicExpression и компилятор дерева выражений знает, как их преобразовывать в точки вызова. Когда создается точка вызова, то еще не известно, как требуется выполнять операцию; но точка вызова будет создана с экземпляром надлежащего средства привязки к точке вызова (call site binder), специального для каждого для используемого языка и содержащего всю необходимую информацию о том, как выполнять операцию.
Рис.8.1: Диаграмма класса CallSite
В каждом языке для каждой операции будут свои собственные средства привязки к точкам вызова; причем этим средствам часто известно много различных способов выполнения операции в зависимости от того, какие в точке вызова используются аргументы. Однако, генерация таких правил дорогостоящая (в частности, компиляция их в виде делегата выполнения, в котором есть обращения к компилятору .NET JIT), поэтому в точке вызова есть многоуровневый кэш точки вызова (call site cache), где уже созданные правила сохраняются для последующего использования.
Рис.8.2: Блок-схема CallSite
Первым уровнем L0 является свойство CallSite.Target
самого экземпляра точки вызова. В нем хранятся правила, которые использовались для этой точки вызова совсем недавно; для подавляющего количества точек вызова это все, что когда-либо потребуется, поскольку они всегда вызываются только с одним набором типов аргументов. Точка вызова также имеет еще один кэш L1, в котором хранятся еще 10 правил. Если свойство Target
не подходит для данного вызова (например, если типы аргументов разные), то в точке вызова сначала проверяется кэш с правилами для того, чтобы выяснить, может ли надлежащий делегат создан из предыдущего вызова, и можно ли повторно использовать это правило, а не создавать новое.
Сохранение правил в кэше экономит время, поскольку время, необходимое для компиляции нового правила, значительно больше по сравнению со временем, которое нужно для проверки существующих правил. Конкретно говоря, чтобы выполнить проверку типа переменной, который является наиболее распространенным типом предиката правил, на платформе .NET затрачивается около 10 нс (проверка бинарной функция занимает 20 нсек, и т.д.). С другой стороны компиляция простого метода, который складывает пару чисел, занимает приблизительно 80 мксек или на три порядка больше. Размер кэша ограничивается с тем, чтобы предотвратить излишнюю трату памяти на запоминание каждого правила, которое применяется в точке вызова; для простого сложения на каждый вариант требуется около 1 Кб памяти. Однако, профилирование показало, что для очень небольшого количества точек вызова когда-либо используется более 10 вариантов.
Наконец, есть кэш L2, в котором хранится экземпляр самого средства привязки. Экземпляр привязки, который ассоциирован с точкой вызова, может хранить некоторую дополнительную информацию о том, что делает эта конкретная точка вызова, но, в любом случае, большая часть точек вызовов не будут уникальными и для них можно совместно использовать один и тот же экземпляр средства привязки. Например, в языке Python, базовые правила, используемые для сложения, будут во всей программе одинаковыми; они зависят от двух типов данных, находящихся по обе стороны операции + - и все. Все операции сложения в программе могут пользоваться одним и тем же средством привязки, и, если не удается воспользоваться обоими кэшами L0 и L1, то в кэше L2 можно обнаружить гораздо больше правил (128), которые использовались последними во всей программе. Даже если это первое выполнение точки вызова, есть достаточно большой шанс найти соответствующее правило в кэше L2. Для того, чтобы этот метод работал наиболее эффективно, и в IronPython и в IronRuby предлагается набор канонических экземпляров средств привязки, которые применяются для обычных операций, таких как сложение.
Если кэш L2 использован не будет, то средство привязки запросит создать реализацию (implementation) для точки вызова, в которой учтены типы аргументов (и, возможно, даже их значения). Если в приведенном выше примере x и y имеют тип double (или другой нативный тип), то реализацией будет просто приведение их к типу double и вызов инструкции сложения add на языке IL. Средство привязки также создает тест, который проверяет аргументы и гарантирует, что они допустимы для этой реализации. Реализация и тест вместе образуют правило. В большинстве случаев, как реализация, так и тест создаются и хранятся в виде деревьев выражений. Однако инфраструктура точек вызовов не зависит от деревьев выражений; ее можно использовать отдельно с делегатами.
Если деревья выражений записать на языке C#, то код будет похож на следующий:
if(x is double && y is double) { // проверятся, является ли тип переменных типом double return (double)x + (double)y; // если тип double, то происходит выполнение } return site.Update(site, x, y); // если тип не double, то ищется/создается еще одно // правило для типов этих переменных
Затем средство привязки создает из деревьев выражений делегата (delegate), что означает, что правило компилируется в язык IL, а затем - в машинный код. В случае сложения двух чисел, это, вероятно, будет быстрая проверка типов, а затем - машинная команда сложения чисел. Даже с учетом всех используемых приемов, конечный результат будет лишь чуть-чуть медленнее, чем статический код. В языках IronPython и IronRuby также есть набор предварительно скомпилированных правил для обычных операций, таких как сложение примитивных типов, что позволяет экономить время, поскольку их не требуется создавать во время выполнения, но за это приходится заплатить некоторым дополнительным пространством на диске.
Продолжение статьи: Протокол метаобъектов