Опыт работы с Golang: путь проб и ошибок

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

Но теперь есть, чем похвастаться: система работает в производственной среде и стала одной из основ сервиса ClimaCell. 

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

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

Изменяемость for-range 

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

package main

import (
	"fmt"
	"sync"
)

type A struct {
	id int
}

func main() {
	channel := make(chan A, 5)
	
	var wg sync.WaitGroup
	
	wg.Add(1)
	go func() {
		defer wg.Done()
		for a := range channel {
			wg.Add(1)
			go func() {
				defer wg.Done()
				fmt.Println(a.id)
			}()
		}

	}()
	
	for i := 0; i < 10; i++ {
		channel <- A{id:i}
	}
	close(channel)
	
	wg.Wait()
}

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

6
6
6
6
6
9
9
9
9
9

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

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

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

Решение 

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

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

go func() {
	defer wg.Done()
	for a := range channel {
		wg.Add(1)
		go func(item A) {
			defer wg.Done()
			fmt.Println(item.id)
		}(a) // Здесь происходит перехват 
	}
}()

В этом примере с помощью вызова функции внутренней горутины мы перехватываем a, успешно ее копируя. Переменную также можно скопировать явно: 

for a := range channel {
	wg.Add(1)
	item := a // Здесь происходит перехват
	go func() {
		defer wg.Done()
		fmt.Println(item.id)
	}()
}

Примечания

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

Осторожнее с := 

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

var num int
num = 3

name := "yossi"

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

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

package main

import (
	"fmt"
)

func main() {
	var data []string
	
	data, err := getData()
	if err != nil {
		panic("ERROR!")
	}
	
	for _, item := range data {
		fmt.Println(item)
	}
}

func getData() ([]string, error) {
	// Имитация получения данных из какого-либо источника, например базы данных.
	return []string{"there","are","no","strings","on","me"}, nil
}

Здесь мы считываем массив строк из какого-либо источника данных и выводим его: 

there
are
no
strings
on
me

Обратите внимание на := :

data, err := getData()

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

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

func main() {
	var data []string
	
	killswitch := os.Getenv("KILLSWITCH")
	
	if killswitch == "" {
		fmt.Println("kill switch is off")
		data, err := getData()
	
		if err != nil {
			panic("ERROR!")
		}
		
		fmt.Printf("Data was fetched! %d\n", len(data))
	} 	
	
	for _, item := range data {
		fmt.Println(item)
	}
}

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

kill switch is off
Data was fetched! 6

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

Как вы уже догадались  —  причина в :=!

Область видимости в Golang, как и во многих современных языках, определяется с помощью {}. Смотрите, if создает новую область: 

if killswitch == "" {
	...		
}

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

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

Решение 

Быть на чеку  —  не это ли я вам уже говорил? 🙂 

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

if killswitch == "" {
 fmt.Println("kill switch is off")
 data, err := getData()

if err != nil {
  panic("ERROR!")
 }
}

// Выдаст ошибку:
data declared but not used

Поэтому обращайте внимание на предупреждения компилятора. 

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

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

Тогда мы получим следующий результат: 

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

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

Набор обработчиков (worker pool) 

Рассмотрим очередной пример: 

package main

import (
	"fmt"
	"sync"
	"time"
)

type A struct {
	id int
}

func main() {
	start := time.Now()

	channel := make(chan A, 100)
	
	var wg sync.WaitGroup
	
	wg.Add(1)
	go func() {
		defer wg.Done()
		for a := range channel {
			process(a)
		}

	}()
	
	for i := 0; i < 100; i++ {
		channel <- A{id:i}
	}
	close(channel)
	
	wg.Wait()

	elapsed := time.Since(start)
	fmt.Printf("Took %s\n", elapsed)
}

func process(a A) {
	fmt.Printf("Start processing %v\n", a)
	time.Sleep(100 * time.Millisecond)
	fmt.Printf("Finish processing %v\n", a)
}

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

package main

import (
	"fmt"
	"sync"
	"time"
)

type A struct {
	id int
}

func main() {
	start := time.Now()
	
	channel := make(chan A, 100)
	
	var wg sync.WaitGroup
	
	wg.Add(1)
	go func() {
		defer wg.Done()
		for a := range channel {
			wg.Add(1)
			go func(a A) {
				defer wg.Done()
				process(a)
			}(a)
		}

	}()
	
	for i := 0; i < 100; i++ {
		channel <- A{id:i}
	}
	close(channel)
	
	wg.Wait()
	
	elapsed := time.Since(start)
	fmt.Printf("Took %s\n", elapsed)
}

func process(a A) {
	fmt.Printf("Start processing %v\n", a)
	time.Sleep(100 * time.Millisecond)
	fmt.Printf("Finish processing %v\n", a)
}

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

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

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

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

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

В некоторых средах, таких как лямбда-функции или поды K8s, присутствует ограничение на число CPU и объем памяти, которую может перегрузить образец кода, выполняющий обработку всего 100к горутин, опять же в зависимости от доступного для экземпляра объема памяти. В нашем случае с помощью облачной функции, располагающей 128мб, нам удалось обработать ~100к элементов до того, как произошел сбой.

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

Решение 

Набор обработчиков! 

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

package main

import (
	"fmt"
	"sync"
	"time"
)

type A struct {
	id int
}

func main() {
	start := time.Now()
	
	workerPoolSize := 100
	
	channel := make(chan A, 100)
	
	var wg sync.WaitGroup
	
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0;i < workerPoolSize;i++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				
				for a := range channel {
					process(a)
				}
			}()
		}

	}()
	
	// Передача данных в канал 
	for i := 0; i < 100000; i++ {
		channel <- A{id:i}
	}
	close(channel)
	
	wg.Wait()
	
	elapsed := time.Since(start)
	fmt.Printf("Took %s\n", elapsed)
}

func process(a A) {
	fmt.Printf("Start processing %v\n", a)
	time.Sleep(100 * time.Millisecond)
	fmt.Printf("Finish processing %v\n", a)
}

Количество наборов обработчиков ограничено до 100, и для каждого из них создается горутина: 

go func() {
	defer wg.Done()
	for i := 0;i < workerPoolSize;i++ {
		wg.Add(1)
		go func() { // Go routine per worker
			defer wg.Done()
				
			for a := range channel {
				process(a)
			}
		}()
	}
}()

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

Преимущество

Смело планируем среду, поскольку теперь мы можем измерить ожидаемый объем потребляемой памяти:

размер набора обработчиков * ожидаемый размер go-рутины (мин 2K)

Недостаток 

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

Выводы 

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

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

Примечания 

  • Число обработчиков должно быть настраиваемым (например, переменная env), чтобы обеспечить возможность экспериментировать с их количеством и получать желаемый результат на каждой используемой платформе. 
  • Приравняйте размер канала к минимальному количеству обработчиков в наборе. Это позволит поставщику данных пополнить очередь и предотвратить бездеятельное томление обработчиков в ожидании создаваемых данных. Он также должен быть настраиваемым. 

Заключение 

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

Признателен всем, кто дочитал до конца! 

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

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

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Yossi Shmueli: 3 Pitfalls in Golang I Wish I Had Known Earlier

Предыдущая статьяGitHub Actions: начало
Следующая статьяПринципы минимализма в цифровом дизайне