Блог компании Mail.Ru Group
Go
Анализ и проектирование систем
Параллельное программирование
Программирование
20 апреля

Go: Хороший, плохой, злой

Перевод
Оригинал:
Sylvain Wallez

У Go есть некоторые замечательные свойства, которым посвящён раздел «Хороший». Но когда речь заходит о применении этого языка не для создания API или сетевых серверов (для чего он и был разработан), а для реализации бизнес-логики, то я считаю Gо слишком неуклюжим и неудобным. Хотя даже в рамках сетевого программирования найдётся немало подводных камней как в архитектуре языка, так и в реализации, что делает Go опасным, несмотря на его кажущуюся простоту.


Я решил написать эту статью после применения Go в одном из второстепенных проектов. Я активно использовал этот язык в предыдущем проекте при написании прокси (HTTP и TCP) для SaaS-сервиса. Работа над сетевой частью мне понравилась (я попутно изучал язык), но бухгалтерская и биллинговые части дались мне тяжело. Мой второстепенный проект представлял собой простой API, и мне казалось, что с помощью Go я смогу быстро его написать. Но, как вы знаете, многие проекты в результате оказываются сложнее, чем предполагалось. Мне пришлось реализовать обработку данных для обсчёта статистики, и я снова столкнулся с недостатками Go. Эта статья — рассказ об испытанных мной неприятностях.


Немного о себе: мне нравятся статически типизированные языки. Мои первые значимые программы были написаны на Pascal. В начале 1990-х использовал Ada и C/C++, затем перешёл на Java, потом на Scala (между ними было немного Go), и недавно начал изучать Rust. Также я написал большое количество кода на JavaScript, потому что до недавнего времени только этот язык был доступен в браузерах. Я чувствую себя неуютно при работе с динамически типизированными языками и стараюсь ограничить их использование простыми скриптами. Мне нравятся императивный, функциональный и объектно-ориентированный подходы.


Статья длинная, так что можете ориентироваться по содержанию:



Хороший


Go прост в изучении


Это факт: если вам знакомы все виды языков программирования, вы можете с помощью "Tour of Go" изучить синтаксис Go за пару часов, а через пару дней начать писать настоящие программы. Почитайте Effective Go, изучите стандартную библиотеку, поиграйтесь с веб-инструментами Gorilla или Go kit, и станете весьма приличным разработчиком на Go.


Всё дело во всеобъемлющей простоте языка. Когда я начал изучать Go, это напомнило мне времена моего знакомства с Java: тоже простой и богатый язык, стандартная библиотека без излишеств. Изучение Go стало приятным опытом на фоне современной тяжёлой среды Java. Благодаря простоте языка, код на Go очень легко читается, даже если блоки обработки ошибок несколько усложняют листинг (об этом ниже).


Но эта простота может оказаться ложной. Как сказал Роб Пайк: «простота сложна», и ниже мы увидим, что вас ожидает большое количество подводных камней, и что простота и минимализм препятствуют написанию DRY-кода.


Простое многопоточное программирование с помощью горутин и каналов


Пожалуй, горутины — лучшая особенность Go. Это небольшие потоки вычисления, отделённые от потоков вычисления ОС.


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


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


Однажды я столкнулся с утечкой горутин в приложении: прежде чем завершиться, они ожидали закрытия канала, а тот не закрывался (стандартная проблема дедлока). Процесс безо всяких причин потреблял 90 % ресурсов процессора, а при изучении expvars выяснилось, что сейчас простаивает 600 тысяч горутин! Полагаю, процессор занимал их диспетчер.


Конечно, система акторов наподобие Akka может безо всяких усилий обрабатывать миллионы акторов, отчасти потому, что у них нет стека. Но зато с помощью горутин гораздо легче создавать сильно распараллеленные приложения, действующие по схеме запрос-ответ (например, HTTP API).


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


Однако использовать каналы нужно обдуманно, поскольку при неправильном выборе размера (каналы по определению не буферизуются) могут возникать дедлоки. Ниже мы увидим, что из-за отсутствия неизменяемости в Go использование каналов не предотвращает состояния гонки.


Прекрасная стандартная библиотека


Стандартная библиотека Go действительно великолепна, особенно применительно к разработке сетевых протоколов или API: в ней есть HTTP-клиент и сервер, шифрование, форматы архивирования, сжатие, отправка писем и так далее. Есть даже парсер HTML и довольно мощный движок шаблонов, что позволяет создавать текст и HTML с автоматическим экранированием (automatic escaping) для защиты от XSS (к примеру, используется в Hugo).


Различные API в целом просты и легки для понимания. Хотя иногда они могут выглядеть чрезмерно упрощёнными: отчасти из-за модели горутин, то есть нам нужно заботиться об операциях, «кажущихся синхронными», а отчасти потому, что несколько универсальных функций могут заменить много специализированных, как я недавно обнаружил при вычислениях времени.


Производительность


Go компилируется в нативные исполняемые файлы. Многие программисты приходят в Go из Python, Ruby или Node.js. Им просто сносит крышу от такой возможности, поскольку сервер способен обрабатывать огромное количество одновременных запросов. То же самое можно сказать про тех, кто переходит с интерпретируемых языков без распараллеливания (Node.js) или с глобальной блокировкой интерпретатора. В сочетании с простотой языка это способствует популярности Go.


Но по сравнению с Java ситуация в бенчмарках производительности не столь однозначна. Зато Go лучше Java по использованию памяти и сборке мусора.


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


По сравнению с Java, сборщик мусора в Go выполняет меньше работы: слайсы структур представляют собой смежные массивы структур, а не массивы указателей, как в Java. Также maps в Go используют маленькие массивы в качестве блоков памяти (bucket). В результате сборщику мусора приходится выполнять меньше работы, что улучшает локальность кэша процессора.


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


Формат исходного кода определяется языком


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


Нравится вам это или нет, gofmt решает, как должен быть отформатирован код на Go, и эта проблема решена для всех раз и навсегда!


Стандартизированный тестовый фреймворк


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


Программы на Go очень удобны в эксплуатации


По сравнению с Python, Ruby или Node.js, установка единственного исполняемого файла — мечта инженеров по эксплуатации. Конечно, это вовсе не такая большая проблема с учётом всё более широкого использования Docker, но отдельные исполняемые файлы ещё и уменьшают размер контейнеров.


Также в Go есть некоторые встроенные возможности по наблюдению с помощью пакета expvar, позволяющего публиковать внутренние статусы и метрики, и облегчающего их добавление. Но будьте внимательны, потому что статусы и метрики автоматически отображаются — незащищённые — в обработчике HTTP-запросов по умолчанию. В Java для тех же целей есть JMX, но они гораздо сложнее в использовании.


Выражение defer помогает не забыть об очистке


Выражение defer играет ту же роль, что и finally в Java: в конце текущей функции исполняет код очистки, вне зависимости от того, как эта функция вышла. Любопытно, что defer не связано с блоком кода и может появляться в любое время. Это позволяет писать код очистки как можно ближе к коду, который создаёт то, что нужно вычистить:


file, err := os.Open(fileName)
if err != nil {
    return
}
defer file.Close()

// use file, we don't have to think about closing it anymore

Конечно, try-with-resource в Java получается менее многословно, а в Rust ресурсы автоматически забираются, когда их владелец дропается, но поскольку Go требует явной очистки ресурсов, то и наличие соответствующего кода рядом с выделением ресурсов идёт на пользу.


Новые типы


Мне нравятся типы, и меня раздражает и пугает, когда, к примеру, мы где угодно передаём идентификаторы сохранённых объектов (persisted object identifiers) в виде string или long. Обычно мы кодируем тип идентификатора в имени параметра, но когда в функции в качестве параметров есть несколько идентификаторов, это становится причиной мелких багов, а некоторые вызовы путают порядок параметров.


В Go превосходная поддержка новых типов — то есть типов, которые берут существующий тип и наделяют его иными признаками, отличными от исходных. В отличие от обёртывания, новые типы не увеличивают потребление ресурсов в ходе исполнения. Это позволяет компилятору ловить подобную ошибку:


type UserId string // <-- new type
type ProductId string

func AddProduct(userId UserId, productId ProductId) {}

func main() {
    userId := UserId("some-user-id")
    productId := ProductId("some-product-id")

    // Right order: all fine
    AddProduct(userId, productId)

    // Wrong order: would compile with raw strings
    AddProduct(productId, userId)
    // Compilation errors:
    // cannot use productId (type ProductId) as type UserId in argument to AddProduct
    // cannot use userId (type UserId) as type ProductId in argument to AddProduct
}

К сожалению, отсутствие обобщённых типов усложняет использование новых типов, поскольку написание многократно используемого кода для них требует приводить значения к/из исходного типа.


Плохой


Go игнорирует достижения современного проектирования языков


В статье Less is exponentially more Роб Пайк объясняет, что Google создавал Go в качестве замены для С и С++, и его предшественником был язык Newsqueak, написанный в 1980-х. Также в Go есть много отсылок к Plan9, распределённой ОС, которую авторы Go создали в Bell Labs в 1980-х.


Даже ассемблер Go создавался под впечатлением от Plan9. Почему нельзя было использовать LLVM, который из коробки предоставляет большое количество целевых архитектур? Возможно, я что-то упускаю, но зачем нужно было так делать? Если тебе нужно написать ассемблер, чтобы воспользоваться всеми возможностями процессора, то разве ты не будешь напрямую использовать процессорный ассемблер?


Создатели Go заслуживают уважения, но выглядит так, словно архитектура языка создавалась в параллельной вселенной (или в лаборатории Plan9?), где не было ничего из того, что реализовали в компиляторах и архитектурах языков в 1990-х и 2000-х. Или словно Go создавался системными программистами, которые ещё и компилятор смогли написать.


Функциональное программирование? Даже не вспоминайте. Обобщённые типы? Они вам не нужны, посмотрите, какой из-за них бардак в С++! И это несмотря на то, что слайсы, map и каналы являются обобщёнными типами, как мы увидим ниже.


Go создавался как замена для С и С++, и очевидно, что его авторы не слишком смотрели по сторонам. Их надежды не сбылись. Думаю, что главная причина заключается в сборщике мусора. Низкоуровневые С-разработчики яростно отвергают управляемую память, поскольку не контролируют, что и когда в ней происходит. Им нравится контролировать ситуацию, даже если это всё усложняет и открывает возможность утечек памяти и переполнений буфера. Любопытно, что в Rust применён совершенно иной подход с автоматическим управлением памяти без сборщика мусора.


С точки зрения эксплуатационных инструментов, Go нравится пользователям скриптовых языков вроде Python и Ruby. Они получили высокую производительность и небольшое потребление ресурсов памяти/процессора/диска. И заодно больше статичной типизации, что для них было в новинку. Убойным приложением для Go стал Docker, обеспечивший широкое распространение этого языка в мире DevOps. А расцвет Kubernetes усилил эту тенденцию.


Интерфейсы и структурные типы


Интерфейсы Go похожи на интерфейсы Java или трейты Scala и Rust: они определяют поведение, которое позднее реализуется типом (не буду называть здесь это «классом»).


Но, в отличие от интерфейсов Java и трейтов Scala и Rust, типу не нужно явно определять, что он реализует интерфейс: он просто обязан реализовывать все функции, определённые в интерфейсе. Так что интерфейсы Go фактически относятся к структурной типизации.


Вы можете подумать, что это нужно для реализации интерфейсов в других пакетах, а не в типе, к которому они относятся, по аналогии с расширениями классов в Scala и Kotlin, или трейтами в Rust. Но это не так: все методы, относящиеся к типу, должны определяться в пакете этого типа.


Go — не единственный язык, использующий структурную типизацию, но я нашёл у него несколько недостатков:


  • Трудно понять, какие типы реализуют конкретный интерфейс, поскольку это зависит от соответствия определения функции (function definition matching). В Java и Scala я часто встречаю интересные реализации, когда ищу классы, реализующие интерфейс.
  • Добавляя метод в интерфейс, находишь типы, которые нужно обновить, только когда они используются в качестве значения этого интерфейсного типа. И довольно долго о них просто не вспоминаешь. Чтобы избежать такой ситуации, рекомендуется использовать маленькие интерфейсы с очень небольшим количеством методов.
  • Тип может случайно реализовать интерфейс из-за соответствующих методов. Однако случайность этого события может привести к тому, что семантика реализации будет отличаться от того, что вы ожидаете от контракта интерфейса.

Дополнение: что касается недостатков интерфейсов, почитайте главу «Интерфейсные nil-значения».


Отсутствие перечислений


В Go нет перечислений, и я считаю это упущенной возможностью.


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


Это также означает, что компилятор не может проверить, является ли выражение switch исчерпывающим, и нет возможности описать разрешённые в типе значения.


Дилемма := / var


В Go есть два способа объявления переменной и присвоения ей значения: var x = "foo" и x := "foo". Зачем?


Главное отличие в том, что var позволяет объявлять без инициализации (потом приходится объявлять тип), как в случае с var x string, а := требует присваивания и позволяет смешивать существующие и новые переменные. Думаю, что := изобрели для существенного упрощения обработки ошибок:


С var:
var x, err1 = SomeFunction()
if (err1 != nil) {
  return nil
}

var y, err2 = SomeOtherFunction()
if (err2 != nil) {
  return nil
}
C:=:
x, err := SomeFunction()
if (err != nil) {
  return nil
}

y, err := SomeOtherFunction()
if (err != nil) {
  return nil
}

Синтаксис := позволяет случайно «затенить» переменную. Я несколько раз попадался на этом, поскольку := (объявить и присвоить) слишком похоже на = (присвоить):


foo := "bar"
if someCondition {
  foo := "baz"
  doSomething(foo)
}
// foo == "bar" even if "someCondition" is true

Нулевые значения приводят к панике


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


На практике, многие типы не могут быть полезны без соответствующей инициализации. Давайте рассмотрим объект io.File, который взят из Effective Go:


type File struct {
    *file // os specific
}

func (f *File) Name() string {
    return f.name
}

func (f *File) Read(b []byte) (n int, err error) {
    if err := f.checkValid("read"); err != nil {
        return 0, err
    }
    n, e := f.read(b)
    return n, f.wrapErr("read", e)
}

func (f *File) checkValid(op string) error {
    if f == nil {
        return ErrInvalid
    }
    return nil
}

Что мы видим?


  • Вызов Name() применительно к нулевому значению File приведёт к панике, поскольку поле file содержит nil.
  • Функция Read, как и почти все остальные методы File, начинается с проверки инициализации файла.

Так что, по сути, File с нулевым значением не только бесполезен, но и может привести к панике. Вам придётся использовать одну из функций-конструкторов вроде Open или Create. А проверка правильной инициализации — это дополнительные расходы, на которые придётся идти при каждом вызове функции.


В стандартной библиотеке есть множество типов, подобных этому, и некоторые даже пытаются делать что-то полезное со своими нулевыми значениями. Вызовите любой метод применительно к нулевому значению html.Template: все будут паниковать.


Также есть серьёзная проблема с нулевым значением map: вы можете его запросить, но если попытаетесь в нём что-то сохранить, возникнет паника:


var m1 = map[string]string{} // empty map
var m0 map[string]string     // zero map (nil)

println(len(m1))   // outputs '0'
println(len(m0))   // outputs '0'
println(m1["foo"]) // outputs ''
println(m0["foo"]) // outputs ''
m1["foo"] = "bar"  // ok
m0["foo"] = "bar"  // panics!

Это требует осторожности при работе со структурой, в которой есть поле map, потому что его нужно инициализировать прежде, чем добавлять в него какие-то записи.


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


В Go нет исключений. Хотя погодите… они есть!


В статье "Why Go gets exceptions right" подробно рассказано, чем плохи исключения и в чём преимущество подхода Go, который требует возврата error. Я могу с этим согласиться, трудно работать с исключениями в условиях асинхронного программирования или функционального стиля, наподобие потоков Java (не будем уточнять, что в Go первое не нужно благодаря горутинам, а последнее просто невозможно). В статье верно говорится, что panic «всегда фатальна для вашей программы, это конец».


В статье "Defer, panic and recover" объясняется, что делать в случае паники (нужно её ловить), и говорится: «реальный пример паники и работы с ней можно посмотреть в JSON-пакете из стандартной библиотеки Go».


Действительно, в JSON-декодере есть стандартная функция обработки ошибок, которая просто паникует. Возникшая паника нейтрализуется с помощью верхнеуровневой функции unmarshal, которая проверяет тип паники и возвращает её как ошибку, если это «локальная паника», либо повторяет панику в случае ошибки иного рода (попутно теряя трассировку стека исходной паники).


Для любого Java-разработчика это выглядит как try / catch (DecodingException ex). Так что исключения в Go есть, он использует их внутри себя, но вам не разрешает.


Любопытный факт: недавно сторонний разработчик исправил JSON-декодер, чтобы тот использовал обычное информирование об ошибках.


Злой


Кошмар управления зависимостями


Сначала процитирую Джаану Доган (Jaana Dogan, aka JBD), известную гофершу из Google, которая недавно вылила своё разочарование в Twitter:


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


— JBD (@rakyll) March 21, 2018


Скажу просто: в Go нет управления зависимостями. Все текущие решения — это хаки и ухищрения.


Здесь нужно вспомнить о том, что язык создан в Google, которая для всех своих исходных кодов использует гигантский единый репозиторий. Им не нужно версионирование модулей, не нужны репозитории сторонних модулей, просто собирай всё подряд из своей текущей ветки. К сожалению, в остальном интернете такой подход не работает.


В Go добавление зависимости подразумевает клонирование репозитория исходного кода этой зависимости в ваш GOPATH. Какая ещё версия? Всё делается в текущую на момент клонирования мастер-ветку, вне зависимости от её содержимого. А если разным проектам нужные разные версии зависимости? Ничего не поделаешь. Отсутствует даже само понятие «версии».


Кроме того, ваш проект должен жить в GOPATH, иначе компилятор просто не найдёт его. Хотите, чтобы проект был аккуратно организован в отдельной директории? Придётся хакать предпроектный GOPATH или мошенничать с символьными ссылками.


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


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


Но всё же ситуация улучшается: недавно был представлен dep, официальный инструмент управления зависимостями для вендоринга. Он поддерживает версии (git-теги) и содержит средство разрешения версий (version solver), соблюдающее соглашения о семантическом версионировании. Работает пока нестабильно, но направление выбрано верное. Да, и проекты всё ещё должны находиться в GOPATH.


Однако dep может прожить недолго, поскольку инструмент vgo, тоже разработанный в Google, хочет самостоятельно привнести версионирование в Go и уже привлёк к себе внимание.


Управление зависимостями в Go кошмарное. Его трудно настраивать, и о нём не вспоминаешь, пока ситуация не взорвётся при новом импортировании или когда просто захочешь запулить ветку коллеги в свой GOPATH...


Но вернёмся к коду.


Изменяемость жёстко прописана в языке


В Go нельзя определить неизменяемые структуры: поля struct являются изменяемыми, а ключевое слово const к ним не применяется. Однако в Go можно легко скопировать всю структуру с простым присваиванием, так что можно подумать, что для реализации неизменяемости достаточно передать аргументы по значениям, лишь потратив ресурсы на копирование.


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


Чтобы было понятнее:


type S struct {
    A string
    B []string
}

func main() {
    x := S{"x-A", []string{"x-B"}}
    y := x // copy the struct
    y.A = "y-A"
    y.B[0] = "y-B"

    fmt.Println(x, y)
    // Outputs "{x-A [y-B]} {y-A [y-B]}" -- x was modified!
}

Так что будьте очень аккуратны и не думайте, что получили неизменяемость, если передали параметр по значению.


Есть библиотеки глубокого копирования, которые пытаются решить эту проблему с помощью (медленной) рефлексии (reflection), но толку от этого не так много, поскольку обращаться к приватным полям с помощью рефлексии нельзя. Так что трудно будет организовать защитное копирование в надежде избежать состояния гонки, поскольку это потребует большого количества шаблонного кода. В Go даже нет интерфейса Clone, который позволил бы это стандартизировать.


Подвохи слайсов


Со слайсами вас поджидает несколько подводных камней. Как объясняется в "Go slices: usage and internals", если слайс перенарезать, то ради сохранения производительности массив скопирован не будет. Причина достойная, но это означает, что подслайсы какого-то слайса будут являться всего лишь представлениями (view), повторяющими изменения исходного слайса. Так что не забудьте применить к слайсу copy(), если хотите отделить его от оригинала.


Если вы забудете применить copy(), то ситуация станет опаснее в связи с функцией append: добавление значений к слайсу приведёт к изменению массива, если у того не хватит ёмкости для хранения новых значений. То есть в зависимости от исходной ёмкости результат append может указывать на исходный массив, а может и не указывать. В результате возможно появление трудно выявляемых, недетерминированных багов.


В этом коде показано, как влияние функции, добавляющей значения в подслайс, зависит от ёмкости исходного слайса:


func doStuff(value []string) {
    fmt.Printf("value=%v\n", value)

    value2 := value[:]
    value2 = append(value2, "b")
    fmt.Printf("value=%v, value2=%v\n", value, value2)

    value2[0] = "z"
    fmt.Printf("value=%v, value2=%v\n", value, value2)
}

func main() {
    slice1 := []string{"a"} // length 1, capacity 1

    doStuff(slice1)
    // Output:
    // value=[a] -- ok
    // value=[a], value2=[a b] -- ok: value unchanged, value2 updated
    // value=[a], value2=[z b] -- ok: value unchanged, value2 updated

    slice10 := make([]string, 1, 10) // length 1, capacity 10
    slice10[0] = "a"

    doStuff(slice10)
    // Output:
    // value=[a] -- ok
    // value=[a], value2=[a b] -- ok: value unchanged, value2 updated
    // value=[z], value2=[z b] -- WTF?!? value changed???
}

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


Согласованность в Go основана на CSP, использующих каналы, что делает координирование горутин гораздо проще и безопаснее по сравнению с синхронизацией общих данных. Здесь применяется мантра «Не взаимодействую с помощью общей памяти, делай память общей с помощью взаимодействия». Это желаемый подход, но в реальности его невозможно применять безопасно.


Как мы уже видели, в Go невозможно создать неизменяемые структуры данных. Поэтому когда мы отправляем указатель в канал, для него всё кончено: мы поделились изменяемыми данными между параллельными процессами. Конечно, канал структур (а не указателей) копирует отправленные в него значения, но, как мы видели, не выполняется глубокое копирование ссылок, включая слайсы и map, которые изменяемы по своей сути. То же самое касается полей struct интерфейсного типа: это указатели, и любой метод изменения, определённый интерфейсом, является приглашением к состоянию гонки.


Так что, хотя каналы и облегчают согласованное программирование, они не предотвращают состояние гонки применительно к общим данным. И её вероятность возрастает из-за принципиальной изменяемости слайсов и map.


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


Неудобное управление ошибками


В Go вы быстро столкнётесь с ошибкой шаблона обработки ошибок, которая повторяется до умопомрачения:


someData, err := SomeFunction()
if err != nil {
    return err;
}

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


Вы быстро привыкнете не замечать этот шаблон и называть его «ага, обработка ошибок», но всё же он создаёт неудобства, да и к тому же иногда трудно найти нужный код среди обработок ошибок.


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


len, err := reader.Read(bytes)
if err != nil {
    if err == io.EOF {
        // All good, end of file
    } else {
        return err
    }
}

В статье "Error has values" Роб Пайк предлагает несколько подходов к уменьшению многословности обработки ошибок. Я считаю их довольно опасными:


type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return // Write nothing if we already errored-out
    }
    _, ew.err = ew.w.Write(buf)
}

func doIt(fd io.Writer) {
    ew := &errWriter{w: fd}
    ew.write(p0[a:b])
    ew.write(p1[c:d])
    ew.write(p2[e:f])
    // and so on
    if ew.err != nil {
        return ew.err
    }
}

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


В Rust такая же проблема: поскольку в нём нет исключений (действительно нет, в отличие от Go), функции, которые могут сбоить, возвращают Result<T, Error> и требуют шаблонного сопоставления результата. Поэтому в Rust 1.0 внедрили макрос try!, и учитывая его востребованность, макрос стал одним из главных свойств языка. В результате получается лаконичный код с корректной обработкой ошибок.


Перенести этот подход из Rust в Go, к сожалению, невозможно, потому что в Go нет ни обобщённых типов, ни макросов.


Интерфейсные nil-значения


Один пользователь Reddit jmickeyd заметил странное поведение nil и интерфейсов, которое определённо можно считать недостатком языка. Поясню:


type Explodes interface {
    Bang()
    Boom()
}

// Type Bomb implements Explodes
type Bomb struct {}
func (*Bomb) Bang() {}
func (Bomb) Boom() {}

func main() {
    var bomb *Bomb = nil
    var explodes Explodes = bomb
    println(bomb, explodes) // '0x0 (0x10a7060,0x0)'
    if explodes != nil {
        explodes.Bang() // works fine
        explodes.Boom() // panic: value method main.Bomb.Boom called using nil *Bomb pointer
    }
}

Код проверяет, чтобы explodes не был nil, но паники возникают в Boom, а не в Bang. Почему? Всё дело в строке println: указатель bomb ссылается на 0x0, по сути — nil, однако explodes не является nil (0x10a7060,0x0).


Первый элементы этой пары — указатель на таблицу назначения методов (method dispatch table) для реализации интерфейса Bomb типом Explodes, второй элемент — адрес реального объекта Explodes, который является nil.


Вызов Bang успешен, потому что он применяется к указателям на Bomb: для вызова метода нет нужды разыменовывать указатель. Метод Boom применяется к значению, и поэтому вызов приводит к разыменованию указателей, что вызывает панику.


Обратите внимание, что если написать var explodes Explodes = nil, тогда != nil не будет успешно выполнено.


Как же написать безопасный тест? Нужно проверить на nil оба интерфейсных значения, и если они не nil, тогда… с помощью рефлексии проверить значение, на которое ссылается объект интерфейса!


if explodes != nil && !reflect.ValueOf(explodes).IsNil() {
    explodes.Bang() // works fine
    explodes.Boom() // works fine
}

Баг или фича? В Tour of Go целая страница посвящена объяснению этого поведения, и там ясно сказано: «Обратите внимание, что интерфейсное значение, содержащее конкретное nil-значение, само по себе не является nil».


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


Теги полей struct: runtime DSL в строковых


Если вы использовали JSON в Go, то наверняка сталкивались с чем-то подобным:


type User struct {
    Id string    `json:"id"`
    Email string `json:"email"`
    Name string  `json:"name,omitempty"`
}

Это теги struct, которые спецификация называет строковыми. Они «видимы через рефлексивный интерфейс (reflection interface) и участвуют в идентификации struct’ов, но в остальном игнорируются». Так что помещайте в эти строковые что угодно, и во время runtime парсите с помощью рефлексии. И паникуйте во время runtime, если синтаксис ошибочный.


Эта строковая представляет собой метаданные поля, которые в других языках десятилетиями известны в качестве «аннотаций» или «атрибутов». Благодаря поддержке языка, их синтаксис формально определён и проверяется при компилировании, при этом оставаясь расширяемым.


Почему в Go решили использовать обычную строковую, которую любая библиотека может использовать с любым DSL, парсящимся во время runtime?


Всё становится ещё сложнее, когда вы используете несколько библиотек. Вот пример из буфера протокола из документации Go:


type Test struct {
    Label         *string             `protobuf:"bytes,1,req,name=label" json:"label,omitempty"`
    Type          *int32              `protobuf:"varint,2,opt,name=type,def=77" json:"type,omitempty"`
    Reps          []int64             `protobuf:"varint,3,rep,name=reps" json:"reps,omitempty"`
    Optionalgroup *Test_OptionalGroup `protobuf:"group,4,opt,name=OptionalGroup" json:"optionalgroup,omitempty"`
}

Примечание: почему эти теги столь часто применяются с JSON? Потому что в публичных полях в Go нужно использовать UpperCamelCase, или хотя бы начинать с заглавной буквы, в то время как соглашение по именованию полей в JSON подразумевает использование lowerCamelCase или snake_case. В результате приходится применять утомительное тегирование.


Стандартный кодировщик/декодер JSON не разрешает использовать стратегию именования для автоматизации преобразования, как это делает Jackson в Java. Вероятно, этим объясняется, почему все поля в Docker API именованы с помощью UpperCamelCase: его разработчикам не приходится писать громоздкие теги для своих больших API.


Обобщённых типов нет… по крайней мере, для вас


Трудно представить себе современный, статически типизированный язык без обобщённых типов, но именно таким и является Go: в нём нет обобщённых типов… или, точнее, почти нет. И как мы увидим, это ещё хуже, чем если бы их не было вовсе.


Встроенные слайсы, map, массивы и каналы являются обобщёнными типами. Объявление map [string]MyStruct ясно свидетельствует об использовании обобщённого типа с двумя параметрами. И это хорошо, потому что допускает типобезопасное программирование с поимкой ошибок всех видов.


Однако в Go отсутствуют определяемые пользователями обобщённые структуры данных. Это означает, что вы не можете типобезопасным способом определить многократно используемые абстракции, способные работать с любыми типами. Придётся использовать нетипизированный interface{} и приводить значения к соответствующему типу. Любая ошибка будет поймана только в runtime и приведёт к панике. Для Java-разработчиков эта ситуация аналогична JSE 5.0 2004 года.


В статье "Less is exponentially more" Роб Пайк почему-то относит обобщённые типы и наследование к «типизированному программированию» и говорит, что предпочитает композицию, а не наследование. Прекрасно, ты можешь не любить наследование (я пишу много кода на Scala и стараюсь избегать наследования), но обобщённые типы помогают решать другую задачу: многократное использование с сохранением типобезопасности.


Как мы увидим дальше, разделение на встроенные типы с обобщёнными и пользовательские без обобщённых влияет не только на «комфорт» разработчиков и типобезопасность при компилировании — это влияет на всю экосистему Go.


В Go мало структур данных помимо slice и map


В экосистеме Go мало структур данных, предоставляющих дополнительную или какую-то иную функциональность из встроенных slice и map. В свежих версиях Go добавлены пакеты контейнеров, которые чуть улучшили ситуацию. И у всех одно слабое место: они работают со значениями interface{}, поэтому вы теряете типобезопасность.


Разберём пример с sync.Map — это согласованная map с более низкой конкуренцией за поток исполнения (thread contention) по сравнению с защитой обычной map с помощью мьютекса:


type MetricValue struct {
    Value float64
    Time time.Time
}

func main() {
    metric := MetricValue{
        Value: 1.0,
        Time: time.Now(),
    }

    // Store a value

    m0 := map[string]MetricValue{}
    m0["foo"] = metric

    m1 := sync.Map{}
    m1.Store("foo", metric) // not type-checked

    // Load a value and print its square

    foo0 := m0["foo"].Value // rely on zero-value hack if not present
    fmt.Printf("Foo square = %f\n", math.Pow(foo0, 2))

    foo1 := 0.0
    if x, ok := m1.Load("foo"); ok { // have to make sure it's present (not bad, actually)
        foo1 = x.(MetricValue).Value // cast interface{} value
    }
    fmt.Printf("Foo square = %f\n", math.Pow(foo1, 2))

    // Sum all elements

    sum0 := 0.0
    for _, v := range m0 { // built-in range iteration on map
        sum0 += v.Value
    }
    fmt.Printf("Sum = %f\n", sum0)

    sum1 := 0.0
    m1.Range(func(key, value interface{}) bool { // no 'range' for you! Provide a function
        sum1 += value.(MetricValue).Value        // with untyped interface{} parameters
        return true // continue iteration
    })
    fmt.Printf("Sum = %f\n", sum1)
}

Прекрасная иллюстрация, почему в экосистеме Go так мало структур данных: их трудно использовать по сравнению со встроенными слайсами и map. И ещё одна причина — в Go есть две категории структур данных:


  • аристократия, встроенные слайсы, map, массивы и каналы: типобезопасные и обобщённые, удобные в использовании с range,
  • и весь остальной код на Go: нет типобезопасности, неудобно использовать из-за необходимости приведения значений (casts).

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


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


import "sort"

type Person struct {
    Name string
    Age  int
}

// ByAge implements sort.Interface for []Person based on the Age field.
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func SortPeople(people []Person) {
    sort.Sort(ByAge(people))
}

Погодите… Серьёзно? Мы вынуждены определять новый тип ByAge, который должен реализовывать три метода, чтобы соединить обобщённый (в смысле «многократно используемый») алгоритм сортировки и типизированный слайс.


Единственное, что должно заботить нас, разработчиков, — функция Less, которая сравнивает два объекта и предметно-зависима (domain-dependent). Всё остальное — шум и шаблонный код, необходимые лишь потому, что в Go нет обобщённых типов. И всё это приходится повторять для каждого типа, который мы хотим сортировать. И для каждого компаратора.


Обновление: мне указали на упущенный мной sort.Slice. Выглядит лучше, хотя под капотом использует рефлексию (ой!) и для сортировки требует наличия завершения слайса в виде функции-компаратора, что выглядит уродливо.


Когда утверждают, что Go не нуждается в обобщённых типах, это всегда объясняют «путём Go», который позволяет иметь многократно используемые алгоритмы, избегая приведения к дочернему типу (downcasting) interface{}...


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


go generate: неплохо, но...


В Go 1.4 появилась команда go:generate для запуска генерирования кода из аннотаций исходного листинга. Ну, под «аннотациями» подразумеваются волшебные комментарии //go:generate со строгими правилами: «комментарий должен начинаться в начале строки и не иметь пробелов между // и go:generate». Если вставите пробел, ни один инструмент вас об этом не предупредит.


Таким образом решаются две задачи:


  • Генерирование Go-кода из других источников: схем ProtoBuf / Thrift / Swagger, языковых грамматик (language grammars) и так далее.
  • Генерирование Go-кода, дополняющего существующий код, вроде stringer, который генерирует метод String() для ряда типизированных констант.

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


Что касается второго случая, то многие языки, в том числе Scala и Rust, поддерживают макросы (упомянутые в документации по архитектуре), которые обращаются к AST исходного кода во время компилирования. Stringer импортирует парсер компилятора Go для прохождения AST. В Java такого макроса нет, но ту же роль играют обработчики аннотаций.


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


Кстати, вы знали, что в компиляторе Go есть аннотации/прагмы и условное компилирование, использующие этот синтаксис комментариев?


Заключение


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


Мне нравится простота Go при написании эффективных API и сетевых приложений, и в том немалая заслуга горутин. Но при этом я ненавижу его ограниченную выразительность, когда нужно реализовать бизнес-логику, и ненавижу все эти ухищрения и подстерегающие тебя подводные камни.


До недавнего времени у нас не было реальных альтернатив там, где царит Go: в сфере разработки эффективных, нативных исполняемых файлов без мучений C или C++. Rust быстро развивается, и чем больше я с ним работаю, тем больше он мне кажется крайне интересным и тщательно продуманным. Я считаю, что Rust — один из тех друзей, с которыми сначала не так просто поладить, но потом хочется долго с ним общаться.


Что касается технических аспектов, то в сети есть статьи, утверждающие, что Rust и Go не конкурируют друг с другом, что Rust — это системный язык, поскольку в нём нет сборщика мусора, и тому подобное. Думаю, эти утверждения становятся всё менее верными. Rust поднимается всё выше в списке замечательных веб-фреймворков и хороших ORM’ов. Он наделяет приятной уверенностью, что «если код скомпилировался, то ошибки связаны с написанной мной логикой, а не особенностями языка, про которые я забыл».


В сфере контейнеров/service mesh сегодня наблюдаются интересные изменения, связанные с прокси Sozu, написанным на Rust. Компания Buoyant (разработчик Linkerd) создаёт новый Kubernetes-service mesh Conduit, в котором Go используется на уровне управления (вероятно, благодаря доступным Kubernetes-библиотекам), а Rust, благодаря своей эффективности и надёжности, — на уровне работы с данными.


Swift тоже начинает рассматриваться как альтернатива C и C++. Хотя его экосистема всё ещё слишком Apple-центрична, несмотря на доступность языка под Linux и на развитие серверных API и фреймворка Netty.


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


Несколько дней спустя...


Через три дня после публикации: реакция на статью оказалась невероятной. Она попала на главные страницы Hackernews (доходила до третьего места) и /r/programming (доходила до пятого места), а также получила поддержку в Twitter.


Комментарии, в целом, положительные (даже на /r/golang/), или хотя бы отмечают сбалансированность статьи и стремление к честности. Конечно, людям на /r/rust понравился мой интерес к Rust. Мне даже написал какой-то незнакомец: «Хочу лишь сказать, что ваш текст — самый лучший. Спасибо за все ваши усилия».


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


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


Я писал статью по вечерам две недели, но это было интересно. Делая серьёзную и честную работу, ты получаешь много хороших отзывов в интернете (если игнорировать несколько троллей и вечно недовольных). Очень мотивирует писать более глубокие статьи!

+105
35,4k 230
Комментарии 189
Похожие публикации
Популярное за сутки