3 ловушки Golang, о которых должен знать каждый разработчик

3 ловушки Golang, о которых должен знать каждый разработчик

12 мая 2022 г.

В течение последнего года мы разрабатывали сложную систему полуреального времени в производстве. Мы решили написать его с помощью Golang. У нас практически не было опыта работы с Go, так что, как вы понимаете, это было не так уж и просто.


Перенесемся на год вперед: система запущена в производство и стала одной из основных составляющих предложения ClimaCell.


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


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


Изменчивость для диапазона


Рассмотрим следующий пример:


```иди


основной пакет


импорт (


"ФМТ"


"синхронизировать"


тип А структура {


идентификатор внутр.


основная функция () {


канал := сделать(чан А, 5)


var wg sync.WaitGroup


WG.Добавить(1)


иди функ () {


отложить wg.Done()


для канала := range {


WG.Добавить(1)


иди функ () {


отложить wg.Done()


fmt.Println(a.id)


для я := 0; я < 10; я++ {


канал <- A{id:i}


закрыть (канал)


wg.Подождите()


У нас есть канал, содержащий экземпляры структур. Мы перебираем канал с оператором `range. Как вы думаете, что будет на выходе этого фрагмента кода?


``` ударить


Странно, не так ли? Мы ожидали увидеть цифры 1-9 (конечно, не заказанные).


На самом деле мы видим результат изменчивости переменной цикла:


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


Замыкания — это другая часть уравнения: замыкание в Go (как и в большинстве языков) содержит ссылку на объекты в замыкании (не копируя данные), поэтому внутренняя процедура go берет ссылку на итерируемый объект. , что означает, что все подпрограммы go получают одну и ту же ссылку на один и тот же экземпляр.


Решение


Прежде всего, знайте, что это происходит. Это не тривиально, так как его поведение полностью отличается от других языков («для каждого» в C#, «для-из» в JS — в них переменная цикла неизменяемая)


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


```иди


иди функ () {


отложить wg.Done()


для канала := range {


WG.Добавить(1)


go func (элемент A) {


отложить wg.Done()


fmt.Println(item.id)


}(a) // Здесь происходит захват


Здесь мы используем вызов функции внутренней подпрограммы go, чтобы захватить a, т. е. фактически скопировав его. Его также можно скопировать явно:


```иди


для канала := range {


WG.Добавить(1)


item := a // Здесь происходит захват


иди функ () {


отложить wg.Done()


fmt.Println(item.id)


Примечания


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

  • for-range как дополнительное проявление для массивов. Он также создает переменную цикла индекса. Обратите внимание, что переменная цикла index также является изменяемой. чтобы использовать его в подпрограмме go, захватите его так же, как вы делаете это с переменной цикла значений

  • В текущей версии Go (1.15) исходный код, который мы видели, на самом деле выдает ошибку! Помогая нам избежать этой проблемы и заставляя нас собирать данные, которые нам нужны

Остерегайтесь:=


В GoLang есть два оператора присваивания: = и :=:


```иди


номер переменной целое


число = 3


имя := "Йосси"


:= довольно полезен, поскольку позволяет избежать объявления переменных перед присваиванием. Сегодня это обычная практика во многих типизированных языках (например, var в C#). Это довольно удобно и делает код чище (по моему скромному мнению).


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


```иди


основной пакет


импорт (


"ФМТ"


основная функция () {


переменная данных [] строка


данные, ошибка := getData()


если ошибка != ноль {


паника("ОШИБКА!")


для _, элемент := диапазон данных {


fmt.Println(элемент)


функция getData() ([]строка, ошибка) {


// Моделирование получения данных из источника данных — скажем, из БД.


return []string{"есть","есть","нет","строки","на","я"}, ноль


В этом примере мы откуда-то читаем массив строк и печатаем его:


```иди


там


находятся


нет


струны


на


меня


Обратите внимание на использование := :


данные, ошибка := getData()


Обратите внимание, что хотя data уже объявлено, мы все еще можем использовать :=, так как err не является - хорошее сокращение, которое создает более чистый код.


Теперь давайте немного изменим код:


```иди


основная функция () {


переменная данных [] строка


выключатель := os.Getenv("KILLSWITCH")


если killswitch == "" {


fmt.Println("выключатель выключен")


данные, ошибка := getData()


если ошибка != ноль {


паника("ОШИБКА!")


fmt.Printf("Данные получены! %d
", len(data))


для _, элемент := диапазон данных {


fmt.Println(элемент)


Как вы думаете, что будет результатом этого фрагмента кода?


выключатель выключен


Данные получены! 6


Странно, не так ли? Поскольку переключатель уничтожения выключен, мы ДЕЙСТВИТЕЛЬНО загружаем данные — мы даже печатаем их длину. Так почему же код не печатает его, как раньше?


Как вы уже догадались, из-за :=!


Область действия в GoLang (как и в большинстве современных языков) определяется с помощью {}. Здесь это if создает новую область видимости:


```иди


если killswitch == "" {


Поскольку мы используем :=, Go будет рассматривать как data, так и err как новые переменные! т.е. data в предложении if на самом деле является новой переменной, которая отбрасывается, когда область видимости закрывается.


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


Решение


Осознание - я уже говорил это? :)


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


```иди


если killswitch == "" {


fmt.Println("выключатель выключен")


данные, ошибка := getData()


если ошибка != ноль {


паника("ОШИБКА!")


// Выдаст ошибку:


данные объявлены, но не используются


Так что помните о предупреждениях при компиляции.


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


В любом случае, лучше всего стараться избегать сокращения :=, особенно когда оно связано с несколькими возвращаемыми значениями и обработкой ошибок, и уделять особое внимание при принятии решения об его использовании:


```иди


основная функция () {


переменная данных [] строка


var err error // Объявление ошибки, чтобы убедиться, что мы можем использовать = вместо :=


выключатель := os.Getenv("KILLSWITCH")


если killswitch == "" {


fmt.Println("выключатель выключен")


данные, ошибка = getData()


если ошибка != ноль {


паника("ОШИБКА!")


fmt.Printf("Данные получены! %d
", len(data))


для _, элемент := диапазон данных {


fmt.Println(элемент)


Приведет к:


выключатель выключен


Данные получены! 6


там


находятся


нет


струны


на


меня


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


Рабочий пул. Капитан Рабочий Пул


Рассмотрим следующий пример:


```иди


основной пакет


импорт (


"ФМТ"


"синхронизировать"


"время"


тип А структура {


идентификатор внутр.


основная функция () {


начало := время.Сейчас()


канал := сделать(чан А, 100)


var wg sync.WaitGroup


WG.Добавить(1)


иди функ () {


отложить wg.Done()


для канала := range {


процесс (а)


для я := 0; я < 100; я++ {


канал <- A{id:i}


закрыть (канал)


wg.Подождите()


прошедшее: = время. С (начало)


fmt.Printf("Взял %s
", истек)


функциональный процесс (a A) {


fmt.Printf("Начать обработку %v
", а)


время.Сон(100 * время.Миллисекунда)


fmt.Printf("Завершить обработку %v
", а)


Как и раньше, у нас есть цикл for-range на канале. Предположим, что функция «процесс» содержит алгоритм, который нам нужно запустить, и не очень быстрый. Если мы обработаем, скажем, 100 000 элементов, приведенный выше код будет выполняться почти три часа (в примере process выполняется 100 мс). Итак, вместо этого давайте сделаем это:


```иди


основной пакет


импорт (


"ФМТ"


"синхронизировать"


"время"


тип А структура {


идентификатор внутр.


основная функция () {


начало := время.Сейчас()


канал := сделать(чан А, 100)


var wg sync.WaitGroup


WG.Добавить(1)


иди функ () {


отложить wg.Done()


для канала := range {


WG.Добавить(1)


go func(a A) {


отложить wg.Done()


процесс (а)


}(а)


для я := 0; я < 100; я++ {


канал <- A{id:i}


закрыть (канал)


wg.Подождите()


прошедшее: = время. С (начало)


fmt.Printf("Взял %s
", истек)


функциональный процесс (a A) {


fmt.Printf("Начать обработку %v
", а)


время.Сон(100 * время.Миллисекунда)


fmt.Printf("Завершить обработку %v
", а)


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


| предметы | Без подпрограмм Go | с Go-процедурами |


| 100 | 10 с | 100 мс |


| | | |


Теоретически это будет работать и для 100 000 предметов, верно?


К сожалению, ответ "это зависит".


Чтобы понять почему, нам нужно понять, что происходит, когда мы запускаем подпрограмму go. Я не буду вдаваться в подробности, так как это выходит за рамки данной статьи. Короче говоря, среда выполнения создает объект, который содержит все данные, относящиеся к подпрограмме go, и сохраняет их. Когда выполнение процедуры go завершено, она удаляется. Минимальный размер объекта подпрограммы go составляет 2 КБ, но может достигать 1 ГБ (на 64-разрядной машине).


К настоящему моменту вы, вероятно, знаете, куда мы идем — чем больше мы создаем go-процедур, тем больше объектов мы создаем, следовательно, увеличивается потребление памяти. Кроме того, подпрограммам go требуется время на выполнение от ЦП для фактического выполнения, поэтому, чем меньше у нас ядер, тем больше этих объектов останется в памяти в ожидании выполнения.


В средах с низким уровнем ресурсов (функции Lambda, модули K8s с ограниченными ограничениями) как ЦП, так и память ограничены, пример кода создаст нагрузку на память даже при 100 000 операций (опять же, в зависимости от того, сколько памяти доступно для экземпляра) . В нашем случае в облачной функции с 128 МБ памяти мы смогли обработать \~100 000 элементов до сбоя.


Обратите внимание, что фактические данные, которые нам нужны с точки зрения приложения, довольно малы — в данном случае это простой тип int. Большую часть памяти потребляет сама процедура go.


Решение


Рабочие бассейны!


Рабочий пул позволяет нам управлять количеством имеющихся у нас go-процедур, сохраняя низкий уровень печати памяти. Давайте посмотрим на тот же пример с рабочим пулом:


```иди


основной пакет


импорт (


"ФМТ"


"синхронизировать"


"время"


тип А структура {


идентификатор внутр.


основная функция () {


начало := время.Сейчас()


размер рабочего пула: = 100


канал := сделать(чан А, 100)


var wg sync.WaitGroup


WG.Добавить(1)


иди функ () {


отложить wg.Done()


для i := 0;i < workerPoolSize;i++ {


WG.Добавить(1)


иди функ () {


отложить wg.Done()


для канала := range {


процесс (а)


// Питаем канал


для я := 0; я < 100000; я++ {


канал <- A{id:i}


закрыть (канал)


wg.Подождите()


прошедшее: = время. С (начало)


fmt.Printf("Взял %s
", истек)


функциональный процесс (a A) {


fmt.Printf("Начать обработку %v
", а)


время.Сон(100 * время.Миллисекунда)


fmt.Printf("Завершить обработку %v
", а)


Мы ограничили количество рабочих пулов до 100 и для каждого создали процедуру запуска:


```иди


иди функ () {


отложить wg.Done()


для i := 0;i < workerPoolSize;i++ {


WG.Добавить(1)


go func() { // Подпрограмма Go для каждого работника


отложить wg.Done()


для канала := range {


процесс (а)


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


Положительная сторона


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


размер рабочего пула * ожидаемый размер одной подпрограммы (минимум 2 КБ)


Обратная сторона


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


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


Подвести итоги


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


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


Примечания


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

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

Вывод


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


Если вы дойдете до этого места - Спасибо!


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


Также опубликовано на https://blog.house-of-code.com



Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE