itmo_conspects

Платформы и среды выполнения языков программирования

На курсе будут рассматриваться

Лекция 1. Введение в компиляторы и виртуальные машины

Разработку ПО можно разделить на две части:

Существенной проблемой при разработке является трудность управления памятью в так называемых неуправляемых языках (например, C и C++). Поэтому появились

Позже в 90-ых начали появляться управляемые языки: Java, C#, JavaScript. Как следствие появились виртуальные машины, которые управляли памятью и исполняли байт-код

Теперь условно можно выделить два типа языков:

Каждый язык соответствовал одной из этих целей:

В компиляторе берется исходный код, он представляется в виде промежуточных структур, таких как абстрактное синтаксическое дерево (Abstract Syntax Tree, AST) и граф потока управления (Control Flow Graph, CFG), а потом превращается в нативный для процессора/ОС код, который исполняется процессором

Термин AOT-компиляция (Ahead-of-Time) предполагает, что код обрабатывается до непосредственного выполнения. Напротив, JIT-компиляция (Just-in-Time) - это компиляция байт-кода в машинный код прямо во время выполнения программы. Это позволяет применять оптимизации на основе данных о реальном выполнении программы (профилирование)

В случае с виртуальной машиной исходный код превращается в промежуточный код, которой после исполняется виртуальной машиной


За последние два десятка лет очень изменилось то, как мы используем наши устройства - сейчас мы работаем с большой комбинацией устройств, и это создает множество совместных сценариев работы с устройствами

В индустрии стремятся сделать так, чтобы приложения разрабатывались под разные устройства

Так как бизнес хочет наилучшее решение за наименьшие деньги и время, нужно, чтобы устройства имели одну кодовую базу. Для запуска приложения на разных устройствах нужны виртуальные машины, фреймворки и библиотеки, реализованные для каждой из целевых платформ

Оказывается, что количество выручки с продаж потребительской электроники растет, а люди ценят наличие экосистемы среди своих устройств

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

Появилась альтернатива - миниприложения. Такие миниприложения, написанные на JavaScript, встроены в мессенджер WeChat. Оказалось, что миниприложения начали преобладать над этими 400 маркетами, так как они были намного удобнее

Для разработчика мобильных приложений это означает:

Маленькому бизнесу становится дорого это поддерживать, поэтому нам нужен инструмент, который позволит решить эту проблему более эффективно. Проще всего сделать одну виртуальную машину под разные устройства

Многие производители телефонов в первую очередь зарабатывают с продажи аппаратного обеспечения. Они имеют широкую линейку продуктов и данные использования от реальных пользователей.

Разработчикам приложений важно минимизировать усилия на разработку приложений, поэтому для их привлечения нужна знакомая экосистема разработки и простой доступ к заработку

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

Не всегда разработка кроссплатформенных решений облегчает создание новых продуктов, например Oracle с агрессивным лицензированием Java и появившийся в ответ на это C#. Потребность в большинстве языках программирования появилась с ростом рынка потребительской электроники, такие как Go, Swift, Kotlin, Dart. Например, Kotlin появился как ответ на медленные обновления языка Java. Swift - как современная альтернатива Objective-C. React - как попытка сделать кроссплатформенное решение для JavaScript

Кроссплатформенные решения поддерживаются ограниченным набором компании (Google, Apple, Microsoft и так далее). Такие компании заинтересованы, потому что они получают основную выгоду

Разберем примеры кроссплатформенных решений:

Лекция 2. История, цели и сложности

Разработка языков программирования началась с создания языка для машины ЭНИАК (ENIAC, Electronic Numerical Integrator and Computer)

Программирование на ЭНИАК осуществлялось соединением нужных разъемов на панели проводами и зачастую занимало недели

ЭНИАК использовалась для расчета вычисления, связанных с ядерным оружием, прогнозирования погоды, инженерных расчетов

Первым широкоиспользуемым языком стал FORTRAN, созданный в 1950-ых Джоном Бэкусом в IBM. Далее в 1956-1958 создается LISP при участии Джона Маккарти

Большинство популярных языков, таких как Go, Rust, Typescript, Kotlin, используемых в индустрии, были разработаны в течение нескольких лет (4-6 лет), что подтверждает, что создание промышленного языка программирования - задача нетривиальная

Целями создания языка программирования могут быть разными. Например,

Поэтому у конкретного языка может быть ограниченная область применения

Так что же нужно для создания языка:

Разработка языка программирования - огромная работа, которая может не прекращаться. Разработка начинается с формулировки требований и приоритетов:

Также нужно выбрать:

Что бы разработчик не выбрал, кому-то это можно не понравится. Нельзя уверенно сказать, какая комбинация решений приведет к наилучшему исходу без прототипирования, а функции языка могут взаимодействовать друг с другом

Пример взаимодействия функций языка из языка Go:

package main

import "fmt"

type IM interface {
    Method()
}

type SM struct {i int}

fune (x *SM) Method() {
    x.i = 1
}

func check(im IM) {
    if im != nil {
        im.Method()
    }
    fmt.Println("checked")
}
    
func main() {
    check(nil) // checked

    var S_nil *SM = nil
    check(S_nil) // segfault
}

Здесь функция check правильно обработает значение nil, однако при передачи указателя на объект SM интерфейса IM, равного nil, выбрасывается ошибка доступа к адресу, так как по адресу nil нет объекта с методом Method()

Лекция 3. Требования и решения

Современная система программирования или приложение содержит компоненты, написанные на нескольких языках, так как ни один язык не удовлетворяет все потребности. Части системы, такие как бизнес-логика, фреймворки, UI, требуют разных языков, которые отличаются в управлении памятью, параллелизмом, системе типом и так далее

Тогда появилась идея создания семейства языков, которые будут хорошо вместе взаимодействовать, вместо языка, который будет встраиваться с другими

Тогда для этих языков нужно писать компилятор, значит, нужно выбрать язык, на котором нужно написать этот компилятор

При этом, в нашем семействе должен быть язык, на котором будет приятно писать следующие версии нашего компилятора, чтобы снизить зависимость и влияние от сторонних языков

В начале этот первый язык должен быть легко читаемым, легко понимаемым, иметь все нужные простые функции и механизмы и ничего того, что выходит за нашу область. Также в нем должно быть уменьшено число ошибок времени исполнения, безопасность null-типов, системы типов, сборщик мусора и модульность

Производительность первого языка не должна быть приоритетной для нас

Название языка должно быть простым для понимания. После этого можно начать предварительный дизайн: дизайн грамматики и система типов

Шаг второй - разработка компилятора на другом языке и исполнения скомпилированного кода. Далее компилятор пишется на самом языке, что позволяет протестировать наш язык в прикладной задаче

Грамматика и синтаксис языка должна быть простой, а семантика - очевидной, не должно быть скрытой семантики и “злой магии”

Среди базовых типов можно выбрать байт (8-битное целое число), 64-битное целое число, 64-битное вещественное число, логический тип, символ, строка. Для создания компилятора могут также понадобиться вектор (динамический массив), опциональный тип (вместо null) и классы

Другие продвинутые механизмы могут вызвать дополнительные трудности в разработке:

Лекция 4. Конвейер компиляции на примере JavaScript

Сейчас большинство веб-приложений написаны на JavaScript - динамически типизируемом языке для скриптов с похожим на язык C синтаксисом

Код на JavaScript могут исполнять множество движков (виртуальных машин)

Хотя в JavaScript есть примитивные типы, многие операции заставляют их вести себя как объекты через автоматическую упаковку в объекты. У объекта в JavaScript есть свойства, у свойства - имя и значения, а сами свойства могут добавляться и удаляться во время исполнения

В JavaScript вместо наследование использованы прототипы: каждый объект хранит в себе ссылку на прототип другого объекта, тем самым наследуя его свойства. Объекты могут представлять собой цепочку прототипов, которую можно изменять во время исполнения

Конвертация типов в JavaScript позволяют выполнять такие операции:

var x = 1 + 2;  // 3
x = 1 + "2";    // "12"
x = 1 + {};     // "1[object Object]"
x = +"23";      // 23

Из-за прототипов, свойств и динамической типизации JavaScript очень динамический, что представляет трудность для реализации виртуальной машины. Из-за этого появляются проблемы в разработке: приходящий тип объекта не соответствует ожидаемому

Далее появился TypeScript, позволяющий аннотировать типы и проверять их перед исполнением программы

Движок JavaScript работает так:

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

Внутри компилятора:

После этого интерпретатор исполняет байткод

Из-за того, что JavaScript - динамически типизированный, выражение x + y может представлять из себя:

Из-за этого JavaScript обладает низкой производительностью

Главная цель для JavaScript - мгновенно исполнять код, чтобы веб-страницы загружались быстро. Поэтому парсер и интерпретатор не должны тратить ресурсы на оптимизации. Другой путь - компиляция после парсера, что замедляет время запуска

Сейчас в браузерах байткод после парсера сразу исполняется интерпретатором, а в другом потоке горячие регионы - часто вызывающиеся участки кода - компилируются и исполняются нативно

Всего можно выделить 4 уровня:

  1. Уровень 0, интерпретатор - нет оптимизации
  2. Уровень 1, базовые частичные оптимизации без сбора профилей
  3. Уровень 2, базовые частичные оптимизации со сбором профилей
  4. Уровень 3, полностью оптимизированная JIT-компиляция

Если кусок кода вызывается больше какого-то выбранного числа, то далее выбирается нужные оптимизации исходя из информации о выполнении кода

Большая часть веб-приложений и мобильных приложений используют такую гибридную модель: часть кода исполняется в интерпретаторе, часть кода нативно как машинные инструкции

Старая версия движка JavaScriptCore использовала три уровня исполнения

В новой версии добавился новый уровень:

Позднее поняли, что с таким конвейером тяжело работать, поэтому упростили инфраструктуру


Интерпретатор имеет ряд достоинств

И недостатков: большое потребление памяти и низкая скорость по сравнению с машинным кодом

Интерпретацию можно ускорить с помощью AOT- и JIT-компиляций, скрытых классов (структур, с помощью которых можно быстрее обрабатывать сложные объекты). Также можно применить такие оптимизации: