понедельник, 4 ноября 2019 г.

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

Rob Pike, 2012

Язык программирования Go был задуман в конце 2007 года как ответ на некоторые проблемы, с которыми мы сталкивались при разработке программной инфраструктуры в Google. Сегодняшний вычислительный ландшафт практически не связан с той средой, в которой были созданы используемые языки, в основном C++, Java и Python. Проблемы, создаваемые многоядерными процессорами, сетевыми системами, массивными вычислительными кластерами и моделью веб-программирования, скорее были решены как обходные пути, чем решены как задуманные изначально. Более того, масштаб изменился: современные серверные программы состоят из десятков миллионов строк кода, работают с сотнями или даже тысячами программистов и обновляются буквально каждый день. Что еще хуже, время сборки, даже на больших кластерах компиляции, увеличилось до многих минут, даже часов.

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

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

1. Введение

Go - это компилируемый, конкурентный, имеющий сборщик мусора, статически типизированный язык, разработанный в Google. Это проект с открытым исходным кодом: Google импортирует публичный репозиторий, а не наоборот.

Go эффективен, масштабируем и продуктивен. Некоторым программистам интересно работать сним; другие находят его лишенным воображения, даже скучным. В этой статье мы объясним, почему это не противоречивые позиции. Go был разработан для решения проблем, возникающих при разработке программного обеспечения в Google, что привело к языку, который не является прорывным исследовательским языком, но тем не менее является отличным инструментом для разработки больших программных проектов.

2. Go в Google

Go - это язык программирования, разработанный Google для решения проблем Google, и у Google большие проблемы.

Аппаратное обеспечение большое, а программное обеспечение большое. Есть много миллионов строк программного обеспечения, с серверами в основном на C++ и множеством Java и Python для других частей. Тысячи инженеров работают над кодом, находящимся в "голове" одного дерева, включающего все программное обеспечение, поэтому со дня на день происходят значительные изменения на всех уровнях дерева. Большая специализированная распределенная система сборки делает разработку в таком масштабе осуществимой, но она все еще медлительная.

И, конечно же, все это программное обеспечение работает на миллионах компьютеров, которые рассматриваются как небольшое количество независимых сетевых вычислительных кластеров.

Кратко, разработка в Google велика, может быть медленной и часто неуклюжей. Но это эффективно.

Цели проекта Go состояли в том, чтобы устранить медлительность и неуклюжесть разработки программного обеспечения в Google и тем самым сделать процесс более продуктивным и масштабируемым. Язык был разработан для людей, которые пишут, читают, отлаживают и поддерживают большие программные системы.

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

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

3. Болевые точки

Когда Go запускался, некоторые утверждали, что ему не хватает определенных функций или методологий, которые считались "обязательными" для современного языка. Как Go может быть полезным в отсутствие этих средств? Наш ответ на этот вопрос заключается в том, что свойства Go действительно решают проблемы, которые затрудняют крупномасштабную разработку программного обеспечения. Эти проблемы включают в себя:

  • медленные сборки
  • неконтролируемые зависимости
  • каждый программист использует разные подмножества языка
  • плохое понимание программы (плохо читаемый код, плохо документированный и т.д.)
  • дублирование усилий
  • стоимость обновлений
  • перекос версий
  • сложность написания автоматических инструментов
  • кросс-языковые сборки

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

В качестве простого, автономного примера рассмотрим представление структуры программы. Некоторые наблюдатели возражали против C-образной блочной структуры Go с фигурными скобками, предпочитая использовать пробелы для отступов в стиле Python или Haskell. Тем не менее, у нас был большой опыт отслеживания сбоев сборки и тестирования, вызванных межъязыковыми сборками, где фрагмент Python, встроенный в другой язык, например, посредством вызова SWIG, тонко и незаметно нарушается изменением отступа окружающего кода. Поэтому наша позиция такова: хотя места для отступов хороши для небольших программ, они плохо масштабируются, и чем больше и разнороднее кодовая база, тем больше проблем это может вызвать. Лучше отказаться от удобства из-за безопасности и надежности, поэтому у Go есть ограниченные скобками блоки.

4. Зависимости в C и C++

Более существенная иллюстрация масштабирования и других проблем возникает при обработке зависимостей пакетов. Мы начнем обсуждение с обзора того, как они работают в C и C++.

Стандарт ANSI C, впервые принятый в 1989 году, продвигал идею #ifndef "охраняет" ("guards") в стандартных заголовочных файлах. Идея, которая сейчас распространена повсеместно, заключается в том, что каждый заголовочный файл заключен в скобки с условным блоком компиляции, чтобы файл мог быть включен несколько раз без ошибок. Например, заголовочный файл Unix <sys/stat.h> схематически выглядит так:

/* Крупная заметка об авторском праве и лицензии */
#ifndef _SYS_STAT_H_
#define _SYS_STAT_H_
/* Типы и другие определения */
#endif

Предполагается, что препроцессор C читает файл, но игнорирует содержимое во втором и последующих чтениях файла. Символ _SYS_STAT_H_, определяемый при первом чтении файла, "охраняет" ("guards") последующие вызовы.

У этого дизайна есть несколько приятных свойств, наиболее важно, чтобы каждый заголовочный файл мог безопасно включать все свои зависимости, даже если другие заголовочные файлы также будут включать их. Если следовать этому правилу, дизайн разрешает упорядоченный код, который, например, сортирует предложения #include в алфавитном порядке.

Но это очень плохо масштабируется.

В 1984 году компиляция ps.c, исходного кода команды Unix ps, #include <sys/stat.h> наблюдалась 37 раз к тому времени, когда была выполнена вся предварительная обработка. Хотя при этом содержимое отбрасывается 36 раз, большинство реализаций C открывают файл, читают его и сканируют все 37 раз. Фактически, без большой хитрости это поведение требуется потенциально сложной макросемантикой препроцессора С.

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

Технически говоря, так не должно быть. Понимая долгосрочные проблемы с использованием средств защиты #ifndef, разработчики библиотек Plan 9 выбрали другой, не-ANSI подход. В Plan 9 было запрещено содержать в заголовочных файлах дополнительные пункты #include; все #include должны были находиться в файле C верхнего уровня. Конечно, это требовало некоторой дисциплины - программист должен был перечислять необходимые зависимости ровно один раз, в правильном порядке - но документация помогла, и на практике это сработало очень хорошо. В результате, независимо от того, сколько зависимостей имел исходный файл C, каждый файл #include читался ровно один раз при компиляции этого файла. И, конечно же, было легко увидеть, нужен ли был #include, убрав его: отредактированная программа скомпилируется только тогда, когда зависимость не понадобится.

Наиболее важным результатом подхода Plan 9 была гораздо более быстрая компиляция: объем ввода-вывода, который требуется для компиляции, может быть значительно меньше, чем при компиляции программы с использованием библиотек с защитой #ifndef.

Однако вне Plan 9 "guards" подход является общепринятой практикой для C и C++. Фактически, C++ усугубляет проблему, используя тот же подход с более высокой степенью детализации. По соглашению, программы на C++ обычно структурированы с одним заголовочным файлом на класс или, возможно, небольшим набором связанных классов, группировка намного меньше, чем, скажем, <stdio.h>. Поэтому дерево зависимостей намного сложнее, отражая не библиотечные зависимости, а полную иерархию типов. Более того, заголовочные файлы C++ обычно содержат реальный код - объявления типа, метода и шаблона, а не только простые константы и сигнатуры функций, типичные для заголовочного файла C. Таким образом, C++ не только отправляет больше компилятору, но и то, что он отравляет, сложнее компилировать, и каждый вызов компилятора должен повторно обрабатывать эту информацию. При создании большого бинарного файла C++, компилятор мог бы тысячи раз научиться представлять строку, обрабатывая заголовочный файл <string>. (Для сведения, около 1984 года Том Каргилл заметил, что использование препроцессора C для управления зависимостями будет долгосрочным обязательством для C++ и должно быть решено.)

Создание одного бинарного файла C++ в Google может открывать и читать сотни отдельных заголовочных файлов десятки тысяч раз. В 2007 году инженеры по сборке в Google разработали сборку основного бинарного файла Google. Файл содержал около двух тысяч файлов, которые, если их просто объединить, составили 4,2 мегабайта. Ко времени расширения #include на вход компилятора было доставлено более 8 гигабайт, что составляет 2000 байт на каждый исходный байт C++.

В качестве другой точки данных, в 2003 году система сборки Google была перемещена из одного файла Makefile в дизайн для каждого каталога с более управляемыми, более явными зависимостями. Типичный бинарный файл сократился примерно на 40% по размеру файла, просто из-за более точной записи зависимостей. Несмотря на это, свойства C++ (или C в этом отношении) делают непрактичным автоматическую проверку этих зависимостей, и сегодня у нас все еще нет точного понимания требований к зависимостям больших бинарных файлов Google C++.

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

Даже с распределенной системой сборки большая сборка Google может занять много минут. Этот бинарный файл 2007 года занял 45 минут с использованием системы распределенной сборки предшественника; сегодняшняя версия той же программы занимает 27 минут, но, конечно, программа и ее зависимости за это время выросли. Инженерные усилия, необходимые для масштабирования системы сборки, едва ли могли опередить рост создаваемого программного обеспечения.


Читайте также:


Комментариев нет:

Отправить комментарий