Что я узнал с нуля с помощью Go

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

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

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

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

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

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

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

6
6
6
6
6
9
9
9
9
9

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

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

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

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

Решение

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

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

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

Примечания

  • Для больших наборов данных обратите внимание, что при захвате переменной цикла будет создано большое количество объектов, каждый из которых будет сохранен до тех пор, пока не будет выполнена базовая процедура go. Поэтому, если объект содержит несколько полей, рассмотрите возможность захвата только необходимых полей для выполнения внутренней процедуры.
  • for-range как дополнительное проявление массивов. Он также создает переменную цикла индекса. Обратите внимание, что переменная цикла индекса также является изменяемой, т. Е. Чтобы использовать ее в подпрограмме go, зафиксируйте ее так же, как вы это делаете с переменной цикла значений.
  • В текущей версии Go (1.15) начальный код, который мы видели, фактически выдает ошибку, помогая нам избежать этой проблемы и заставляя нас собирать нужные нам данные.

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

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

var num int
num = 3

name := "yossi"

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

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

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

there
are
no
strings
on
me

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

data, err := getData()

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

Теперь немного изменим код:

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

kill switch is off
Data was fetched! 6

Странно, правда? Поскольку аварийный выключатель выключен, мы загружаем данные - мы даже печатаем их длину. Так почему же код не печатает его, как раньше?

Вы догадались - из-за :=!

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

if killswitch == "" {
	...		
}

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

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

Решение

Осведомленность - я уже сказал это? :)

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

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

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

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

Это приведет к следующему:

kill switch is off
Data was fetched! 6
there
are
no
strings
on
me

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

WorkerPool. Капитан Рабочий Бассейн

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

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

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

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

К сожалению, ответ - «это зависит от обстоятельств».

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

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

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

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

Решение

Рабочие пулы!

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

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

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

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

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

the size of the worker pool * expected size of a single go routine (min 2K)

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

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

Обобщить

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

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

Примечания

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

Заключение

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

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

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