Статьи

Go — это ущербный недостаток, вот почему мы его используем

Го решительно поляризован. В то время как многие рекламируют свой переход на Go, стало одинаково модно критиковать  и издеваться  над языком. Как красноречиво сказал Бьярн Страуструп: «Есть только два вида языков программирования: те, кто всегда ссорится, и те, которые никто не использует». Эта пословица не может быть более правдивой. Я заранее прошу прощения за то, что, кажется, просто еще один в длинном ряду диатрибов. Я не очень сожалею, хотя.

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

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

12 мая 2015

Сегодня я собираюсь быть лицемером. Правда в том, что мы  должны заботиться о том, какой язык и технологии мы используем для построения и стандартизации, но эти решения должны быть локальными для организации. Мы не должны выбирать технологию, потому что она работает для кого-то другого. Скорее всего, у них была совсем другая проблема, другой набор требований, другая инженерная культура. Есть так много факторов, которые входят в «успех» — технология, вероятно, наименее  эффективна. Чей-то успех не означает твоего успеха. Это не технология, которая делает или разрушает нас, это то, как технология присваивается среди многих других взаимосвязанных элементов.

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

Простота через унижение

Перейти очень просто. Это то, что привлекло меня к языку в первую очередь, и я подозреваю, что другие чувствуют то же самое. Есть популярная цитата от Роба Пайка, которую, я думаю, стоит повторить:

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

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

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

статический против динамического 2

Конечно, это не относится к Go или Python. Как указано выше, при рассмотрении такого перехода вы должны задать множество вопросов. Как я уже говорил, языки — это инструмент для работы. Тогда можно спорить, почему компания выбирает один язык? Используйте правильный инструмент для работы! Это в принципе верно, но реальность такова, что есть и другие факторы, крупнейшим из которых является импульс. Когда вы фиксируете язык, вы создаете многократно используемые библиотеки, API, инструменты и знания. Если вы «используете правильный инструмент для работы», вы в конечном итоге тянете себя в разные стороны и выбрасываете эти вещи. Если вы Google масштаба, это не проблема. Большинство организаций не масштабируются Google. Это тонкий баланс при выборе технологии.

Go облегчает написание кода, который понятен. Нет такой «магии», как у многих корпоративных Java-фреймворков, и никаких изящных уловок, которые вы найдете в большинстве кодовых баз Python или Ruby. Кодекс многословен, но читабелен, не сложен, но понятен, утомителен, но предсказуем. Но маятник качается слишком далеко. До сих пор, фактически, он жертвует одной из самых священных доктрин разработки программного обеспечения, «  Не повторяй себя» , и делает это без причины.

Система Untype

Мягко говоря, система типов Go нарушена. Он не пригоден для написания качественного, поддерживаемого кода в большом масштабе, что, кажется, резко контрастирует с амбициями языка. Система типов благородна в теории, но на практике она разваливается довольно быстро. Без обобщений программисты вынуждены либо копировать и вставлять код для каждого типа, полагаться на генерацию кода, которая часто неуклюжа и трудоемка , либо полностью разрушать систему типов посредством отражения. Передача интерфейса {} возвращает нас к дням, предшествовавшим Java, чтобы сделать то же самое с Object. Код становится просто глупым, если вы хотите написать библиотеку многократного использования.

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

func isInt64InSlice(i int64, slice []int64) bool {
	for _, j := range slice {
		if j == i {
			return true
		}
	}
	return false
}

Теперь повторите для uint32, uint64, int32 и т. Д. На любом другом современном языке программирования это рассмешит вас из обзора кода. В Go никто, похоже, не видит глаз, и альтернативы не намного лучше.

Интерфейсы в Go интересны тем, что неявно реализованы. Есть преимущества, такие как реализация макетов и вообще работа с кодом, который вам не принадлежит. Они также могут вызвать некоторые тонкие проблемы, такие как случайная реализация. Тот факт, что тип соответствует сигнатуре интерфейса, не означает, что он был предназначен для реализации своего контракта. Не говоря уже о путанице, вызванной хранением nil в интерфейсе :

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

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

Правила должны нарушаться, но не вами

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

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

Существуют и другие специфические особенности. Обработка ошибок обычно выполняется путем возврата значений ошибок. Это хорошо, и я, конечно, вижу мотивацию, исходящую из мерзости исключений C ++, но есть случаи, когда Go не следует своему собственному правилу. Например, поиск по карте возвращает два значения: само значение (или нулевое значение / nil, если оно не существует) и логическое значение, указывающее, был ли ключ на карте. Интересно, что мы можем вообще игнорировать логическое значение — синтаксис, зарезервированный для определенных благословенных типов в стандартной библиотеке. Утверждения типа и получение канала имеют одинаково любопытное поведение.

intMap := make(map[string]int)
 
// Valid
value, ok := intMap["foo"]
println(value, ok)
 
// Also valid
value := intMap["foo"]
println(value)

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

Одна из философий Го — «Делись памятью, общаясь; не общайтесь, делясь памятью ». Это еще одно правило, которое стандартная библиотека часто нарушает. В стандартной библиотеке создано около 60 каналов, исключая тесты. Если вы посмотрите код, вы увидите, что мьютексы имеют тенденцию быть предпочтительными и часто работают лучше — об этом чуть позже.

Кроме того, Go активно  препятствует  использованию пакетов sync / atomic и небезопасных. На самом деле, были  признаки, что sync / atomic будет удален, если бы не требования обратной совместимости:

Мы хотим, чтобы синхронизация была четко задокументирована и использовалась при необходимости. Как правило, мы вообще не хотим, чтобы sync / atomic использовался … Опыт показывает нам снова и снова, что очень немногие способны писать правильный код, использующий атомарные операции … Если бы мы думали о внутренних пакетах, когда добавляли синхронизацию / Атомный пакет, возможно, мы бы использовали это. Теперь мы не можем удалить пакет из-за гарантии Go 1.

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

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

Будучи одной из отличительных черт Go, каналы немного разочаровывают. Как я уже упоминал, поведение паники на путах на закрытый канал проблематично. А как насчет случаев, когда у нас заблокированы продюсеры на канале, и на него закрываются очередные вызовы? Они в панике. Другие неприятности включают в себя невозможность заглянуть в канал или получить из него более одного элемента, общие операции в большинстве блокирующих очередей. Я могу с этим смириться, но что хуже для желудка, так это последствия для производительности, о которых я намекал ранее. Для этого я обращаюсь к своему коллеге и нашему болтуну, Дастину Хайетту :

Редко разработчики Golang обсуждают производительность канала, хотя в прошлый раз, когда я был в Gophercon, я слышал грохот о том, что нельзя использовать задержку или каналы. Видите ли, когда Роб Пайк заявляет, что вы можете использовать каналы вместо замков, он не совсем честен. За кулисами каналы используют блокировки для сериализации доступа и обеспечения безопасности потоков. Таким образом, используя каналы для синхронизации доступа к памяти, вы фактически используете блокировки; блокировки, завернутые в потокобезопасную очередь. Так как же причудливые блокировки Go сравниваются с использованием мьютексов из стандартного пакета «sync» библиотеки? Следующие числа были получены с помощью встроенной функции сравнения Go для последовательного вызова Put для одного набора их соответствующих типов.

BenchmarkSimpleSet-8 3000000 391 нс / оп.
BenchmarkSimpleChannelSet-8 1000000 1699 нс / оп

Это с буферизованным каналом, что произойдет, если мы используем небуферизованный канал?

BenchmarkSimpleChannelSet-8 1000000 2252 нс / оп

Yikes, с легкой или без многопоточности, установка с использованием мьютекса происходит немного быстрее (версия go1.4 linux / amd64). Насколько хорошо это делается в многопоточной среде. Следующие числа были получены путем вставки одинакового количества элементов, но в 4 отдельных подпрограммах, чтобы проверить, насколько хорошо работают каналы в условиях конфликта.

BenchmarkSimpleSet-8 2000000 645 нс / оп.
BenchmarkChannelSimpleSet-8 2000000 913
нс / оп.

Лучше, но мьютекс все еще почти на 30% быстрее. Очевидно, что некоторая магия канала здесь стоит нам, и это без лишних умственных затрат для предотвращения утечек памяти. Думаю, Голанг чувствовал то же самое, и именно поэтому в их стандартных библиотеках, которые сравниваются, например, «net / http», вы почти никогда не найдете каналы, всегда мьютексы.

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

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

type IntContainer []int
 
func (i IntContainer) Iterator() <-chan int {
	ch := make(chan int)
	go func() {
		for _, val := range i {
			ch <- val
		}
		close(ch)
	}()
	return ch
}
 
func main() {
	c := IntContainer([]int{1, 2, 3, 4, 5})
	for x := range c.Iterator() {
		println(x)
	}
}

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

type IntContainer []int
 
func (i IntContainer) Iterator(cancel <-chan struct{}) <-chan int {
	ch := make(chan int)
	go func() {
		for _, val := range i {
			select {
			case ch <- val:
			case <-cancel:
				close(ch)
				return
			}
		}
		close(ch)
	}()
	return ch
}
 
func main() {
	c := IntContainer([]int{1, 2, 3, 4, 5})
	cancel := make(chan struct{})
	for x := range c.Iterator(cancel) {
		println(x)
		break
	}
	close(cancel)
}

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

Управление зависимостями на практике

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

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

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

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

Сообщество или Карусель

Go имеет все более энергичное сообщество, но оно глубоко упрямое. Мое самое большое недовольство связано не с самим языком, а с менталитетом, который кажется нам против нас. Ты либо с нами, либо против нас. Это почти комично, потому что кажется, что каждая критика языка, включая мой, имеет префикс «Мне действительно нравится Go, но…», чтобы якобы смягчить ситуацию. Части сообщества могут показаться религиозными, почти культовыми. Само упоминание  дженериков в настоящее время встречается с сердечным увольнением.  Это не путь Go.

Отношение к принятию решений в отношении языка вызывает сожаление, и я думаю, что Го действительно мог бы взять страницу из книги Руста относительно ее модели управления. Я полностью согласен с мнением о том, что « бедный мастер обвиняет свои инструменты », но еще более бедный мастер не выбирает лучшие инструменты в своем распоряжении. Я не пристрастен ни к одному из моих инструментов. Они являются средством для достижения цели, но мы должны стремиться улучшить их и сделать их более эффективными. Сообщество не должно порождать самодовольство. С Go я боюсь, что оба процветают.

Despite your hand wringing over the effrontery of Go’s designers to not include your prerequisite features, interest in Go is sky rocketing. Rather than finding new ways to hate a language for reasons that will not change, why not invest that time and join the growing number of programmers who are using the language to write real software today.

This is dangerous reasoning, and it hinders progress. Yes, programmers are using Go to write real software today. They were also writing real software with Java circa 2004. I write Go every day for a living. I work with smart people who do the same. Most of my open-source projects on GitHub are written in Go. I have invested countless hours into the language, so I feel qualified to point out its shortcomings. They are not irreparable, but let’s not just brush them off as people toying with Go and “finding ways to hate it”—it’s insulting and unproductive.

The Good Parts

Alas, Go is not beyond reproach. But at the same time, the language gets a lot of things right. The advantages of a single, self-contained binary are real, and compilation is fast. Coming from C or C++, the compilation speed is a big deal. Cross-compile allows you to target other platforms, and it’s getting even better with Go 1.5.

The garbage collector, while currently a pain point for performance-critical systems, is the focus of a lot of ongoing effort. Go 1.5 will bring about an improved garbage collector, and more enhancements—including generational techniques—are planned for the future. Compared to current cutting-edge garbage collectors like HotSpot, Go’s is still quite young—lots of room for improvement here.

Over the last couple of months, I dipped my toes back in Java. Along with C#, Java used to be my modus operandi. Going back gave me a newfound appreciation for Go’s composability. In Go, the language and libraries are designed to be composable, à la Unix. In Java, everyone brings their own walled garden of classes.

Java is really a ghastly language in retrospect. Even the simplest of tasks, like reading a file, require a wildly absurd amount of hoop-jumping. This is where Go’s simplicity nails it. Building a web application in Java generally requires an application server, which often puts you in J2EE-land. It’s not a place I recommend you visit. In contrast, building a web server in Go takes a couple lines of code using the standard library—no overhead whatsoever. I just wish Java shared some of its generics Kool-Aid. C# does generics even better, implementing them all the way down to the byte-code level without type erasure.

Beyond go get, Go’s toolchain is actually pretty good. Testing and benchmarking are built in, and the data-race detector is super handy for debugging race conditions in your myriad of goroutines. The gofmt command is brilliant—every language needs something like this—as are vet and godoc. Lastly, Go provides a solid set of profiling tools for analyzing memory, CPU utilization, and other runtime behavior. Sadly, CPU profiling doesn’t work on OSX due to a kernel bug.

Although channels and goroutines are not without their problems, Go is easily the best “concurrent” programming language I’ve used. Admittedly, I haven’t used Erlang, so I suspect that statement made some Erlangers groan. Combined with the select statement, channels allow you to solve some problems which would otherwise be solved in a much more crude manner.

Go fits into your stack as a language for backend services. With the work being done by Docker, CoreOS, HashiCorp, Google, and others, it clearly is becoming the language of Infrastructure as a Service, cloud orchestration, and DevOps as well. Go is not a replacement for C/C++ but a replacement for Java, Python, and the like—that much is clear.

Moving Forward

Ultimately, we use Go because it’s boring. We don’t use it because Google uses it. We don’t use it because it’s trendy. We use it because it’s no-frills and, hey, it usually gets the job done assuming you’ve found the right nail. But Go is still in its infancy and has a lot of room for growth and improvement.

I’m cautiously optimistic about Go’s future. I don’t consider myself a hater, I consider myself a hopeful. As it continues to gain a critical mass, I’m hopeful that the language will continue to improve but fearful of its relentless dogma. Go needs to let go of this attitude of “you don’t need that” or “it’s too complicated” or “programmers won’t know how to use it.” It’s toxic. It’s not all that different from your users requesting features after you release a product and telling those users they aren’t smart enough to use them. It’s not on your users, it’s on you to make the UX good.

A language can have considerable depth while still retaining its simplicity. I wish this were the ideal Go embraced, not one of negativity, of pessimism, of “no.” The question is not how can we protect developers from themselves, it’s how can we make them more productive? How can we enable them to solve problems? But just because people are solving problems with Go today does not mean we can’t do better. There is always room for improvement. There is never room for complacency.

My thanks to Dustin Hiatt for reviewing this and his efforts in benchmarking and profiling various parts of the Go runtime. It’s largely Dustin’s work that has helped pave the way for building performance-critical systems in Go.