Перевод статьи The Top 10 Most Common Mistakes I’ve Seen in Go Projects от Teiva Harsanyi о наиболее часто встречающихся ошибках в проектах на Golang.

Содержание

  1. Значение по умолчанию для перечисляемых(enum) типов
  2. Тестирование производительности (benchmarking)
  3. Указатели! Указатели везде!
  4. Прерывание for/switch или for/select
  5. Управление ошибками
  6. Инициализация слайсов(slice)
  7. Управление контекстом
  8. Неиспользование опции -race
  9. Использование имени файла в качестве параметра функций
  10. Горутины и переменные в циклах

1. Значение по умолчанию для перечисляемых(enum) типов

Взглянем на простой кусочек кода

type Status uint32

const (
	StatusOpen Status = iota
	StatusClosed
	StatusUnknown
)

В нём мы создаём последовательность enum-переменных, содержащих следующие значения:

StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2

Представим, что наш тип Status является частью запроса и будет кодироваться или декодироваться, например, в JSON. Для этого будет использована такая структура:

type Request struct {
	ID        int    `json:"Id"`
	Timestamp int    `json:"Timestamp"`
	Status    Status `json:"Status"`
}

Представим, что обрабатываем вот такой JSON-запрос:

{
  "Id": 1234,
  "Timestamp": 1563362390,
  "Status": 0
}

Казалось бы, ничего особенного? Статус, который мы получим, соответствует StatusOpen, так?

Но взглянем на другой запрос, в котором значение статуса не определено (неважно по какой причине):

{
  "Id": 1234,
  "Timestamp": 1563362390
}

В этом случае поле Status нашей структуры типа Request будет проинициализировано нулевым значением (для uint32 типа это 0). Таким образом, вместо StatusUnknown в структуре, мы получим StatusOpen.

Правильным решением будет начать нашу enum-последовательность как раз со StatusUnknown:

type Status uint32

const (
	StatusUnknown Status = iota
	StatusOpen
	StatusClosed
)

Тогда, даже если поле Status будет отсутствовать в JSON-запросе, то поле Status в структуре будет иметь ожидаемое значение – 0, соответствующее StatusUnknown.

2. Тестирование производительности (benchmarking)

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

Одна из часто встречающихся ошибок связана с особенностями работы компилятора. Возьмём конкретный пример из teivah/bitvector:

func clear(n uint64, i, j uint8) uint64 {
	return (math.MaxUint64<<j | ((1 << i) - 1)) & n
}

Данная функция чистит биты в заданном диапазоне. Протестировать её производительность мы можем примерно так:

func BenchmarkWrong(b *testing.B) {
	for i := 0; i < b.N; i++ {
		clear(1221892080809121, 10, 63)
	}
}

Однако, в данном бенчмарке компилятор примет во внимание, что clear – это функция-лист (не вызывает никаких других функций) и будет выполнять её содержимое как inline-последовательность инструкций. После чего, компилятор определит, что clear не имеет никаких побочных эффектов(side-effects), т.е. никак не влияет на среду исполнения. После чего вызов clear будет просто удалён.

Один из вариантов избежать сценария выше – присваивать результат выполнения функции переменной уровня пакета. Примерно так:

var result uint64

func BenchmarkCorrect(b *testing.B) {
	var r uint64
	for i := 0; i < b.N; i++ {
		r = clear(1221892080809121, 10, 63)
	}
	result = r
}

Здесь компилятор не будет знать, есть ли у функции side-effect и бенчмарк будет точен.

3. Указатели! Указатели везде!

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

Кажется, что передача указателя всегда будет работать быстрее, не так ли?

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

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

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

Память под переменную может быть выделена в куче(heap) или стеке(stack).

Очень приблизительно:

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

Давайте рассмотрим простой пример, в котором вы возвращаем значение:

func getFooValue() foo {
	var result foo
	// Do something
	return result
}

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

Теперь тот же пример, но с указателем:

func getFooPointer() *foo {
	var result foo
	// Do something
	return &result
}

Переменная result также создаётся текущей горутиной, но клиент получает указатель (копию адреса переменной). Если result вытеснена из стека, клиент функции не сможет получить доступ к переменной.

В подобном сценарии компилятор Go вынужден переместить переменную result туда, где она может быть доступна(shared) – в кучу(heap).

Хотя есть и исключение. Для примера:

func main()  {
	p := &foo{}
	f(p)
}

Поскольку мы вызываем функцию f() в той же горутине, что и функцию main(), переменную p не нужно перемещать. Она просто находится в стеке и вложенная функция f() будет иметь к ней доступ.

По этим причинам метод Read интерфейса io.Reader принимает слайс в качестве параметра, а не возвращает его. Возврат слайса (а слайс всегда – указатель) приведёт к необходимости перемещать данные в кучу(heap).

Но почему стек так быстр? Основных причин две:

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

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

Таким образом, если мы страдаем от проблем с производительностью, один из возможных способов оптимизации – убедиться, что, в каждом конкретном случае, использование указателей обосновано. Узнать, в каких случаях компилятор вынужден перемещать данные в кучу, можно командой go build -gcflags "-m -m".

Но, ещё раз, для большинства повседневных случаев, передача по значению – лучший выбор.

4. Прерывание for/switch или for/select

Что произойдёт в следующем примере, если f() вернёт true?

for {
  switch f() {
  case true:
    break
  case false:
    // Do something
  }
}

Очевидно, будет вызван break. Вот только прерван будет switch, а не цикл for.

Та же проблема с селектом:

for {
  select {
  case <-ch:
  // Do something
  case <-ctx.Done():
    break
  }
}

Инструкция break прервёт select, а не цикл.

Простое решение проблемы – использовать именованный(labeled) цикл и вызывать break c этой меткой, как в примере ниже:

loop:
	for {
		select {
		case <-ch:
		// Do something
		case <-ctx.Done():
			break loop
		}
	}

5. Управление ошибками

Go ещё относительно молод на пути обработки ошибок. Одна из наиболее обсуждаемых комьюнити проблем в грядущем Go 2 – как раз эта.

Нынешняя стандартная библиотека(до Go 1.13) предлагает пользователям самостоятельно конструировать обработчики ошибок. Но, в Go есть ряд пакетов, которые пока не являются частью ядра, но, вероятно, в будущем станут таковыми. Возможно вы уже используете пакет pkg/errors. Возможно, на момент, когда вы это читаете – библиотека уже вошла в ядро, как когда-то вошла библиотека context.

Эта библиотека – хороший способ соблюсти правило, которое не всегда соблюдается:

Ошибка должна обрабатываться единожды. Логирование ошибки – это обработка ошибки. Ошибка должна быть залогирована или поднята на уровень выше(propagated).

Данное правило сложно соблюсти используя только стандартную библиотеку Go – сложно добавить информацию о контексте, в котором возникла ошибка, сложно отстроить иерархию ошибок.

Представим REST-вызов, привёдший к ошибке базы:

unable to server HTTP POST request for customer 1234
 |_ unable to insert customer contract abcd
     |_ unable to commit transaction

При использовании pkg/errors мы можем обработать ситуацию так:

func postHandler(customer Customer) Status {
	err := insert(customer.Contract)
	if err != nil {
		log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
		return Status{ok: false}
	}
	return Status{ok: true}
}

func insert(contract Contract) error {
	err := dbQuery(contract)
	if err != nil {
		return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
	}
	return nil
}

func dbQuery(contract Contract) error {
	// Do something then fail
	return errors.New("unable to commit transaction")
}

Изначальная ошибка создана вызовом errors.New(), на среднем уровне, в функции insert выполнено обёртывание ошибки, добавлен контекст выполнения. На верхнем уровне, в функции postHandler ошибка обработана(записана в лог). Каждый уровень, таким образом, или возвращает или обрабатывает ошибку.

Мы также можем захотеть проверить причину возникновения ошибки, чтобы, например, организовать повторное выполнение кода. Представим, что у нас есть пакет db из внешней библиотеки, отвечающий за доступ к базе данных. Библиотека может, к примеру, возвращать информацию о временной(transient) проблеме с базой db.DBError. Чтобы определить – нужно ли нам пробовать отправить запрос заново, мы должны проверить тип возвращаемой ошибки:

func postHandler(customer Customer) Status {
	err := insert(customer.Contract)
	if err != nil {
		switch errors.Cause(err).(type) {
		default:
			log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
			return Status{ok: false}
		case *db.DBError:
			return retry(customer)
		}

	}
	return Status{ok: true}
}

func insert(contract Contract) error {
	err := db.dbQuery(contract)
	if err != nil {
		return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
	}
	return nil
}

Это стало возможным, при использовании errors.Cause, также из пакета pkg/errors. И обычная ошибка здесь, которую я часто вижу – частичное использование пакета. Например так:

switch err.(type) {
default:
  log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
  return Status{ok: false}
case *db.DBError:
  return retry(customer)
}

В данном примере, если db.DBError обёрнут(errors.Wrapf), условие(switch case) никогда не будет выполнено.

6. Инициализация слайсов(slice)

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

Я часто вижу слайсы, инициализированные так:

var bars []Bar
bars := make([]Bar, 0)

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

Теперь, представим мы должны повторять эту операцию по масштабированию многократно, причём наш []Foo содержит тысячи элементов. Сложность подобной операции будет оставаться линейной O(1), но на практике производительность будет страдать сильнее.

Таким образом, если мы знаем финальную длину, мы можем:

  • или инициализировать слайс заранее заданной длины
    func convert(foos []Foo) []Bar {
      bars := make([]Bar, len(foos))
      for i, foo := range foos {
          bars[i] = fooToBar(foo)
      }
      return bars
    }
    
  • или инициализировать слайс нулевой длины, но заданной ёмкости
    func convert(foos []Foo) []Bar {
      bars := make([]Bar, 0, len(foos))
      for _, foo := range foos {
          bars = append(bars, fooToBar(foo))
      }
      return bars
    }
    

Какой вариант лучше? Первый – несколько быстрее. Но вы можете предпочесть второй, т.к. это делает процесс более последовательным(consistent) и очевидным, добавление элементов в конец происходит с использованием инструкции append.

7. Управление контекстом

Довольно часто разработчики не совсем ясно понимают, как правильно использовать пакет context.Context. Согласно официальной документации, контекст:

Контекст служит для обработки дедлайнов, сигналов отмены и других параметров, передаваемых в рамках API

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

Попробуем разобраться. Контекст содержит механизмы:

  • Дедлайна(deadline). Позволяющий задать период времени (250нс) или дату-время(2019-08-01 01:00:00) по достижению которых мы должны прекратить выполняющиеся активности, такие как операции ввода-вывода, ожидание информации из канала, запросы в удалённые системы).
  • Отмены(cancelation signal), обычно это <-chan struct{}. Поведение схоже с дедлайном. Как только мы получаем сигнал, мы должны прекратить выполняющиеся операции. Представим, что мы получили два запроса. Один, долго выполняющийся, например, обрабатывающий некие данные. Второй запрос, отменяющий первый, т.к. данные уже не нужны по какой-то причине. Достичь подобного прерывания можно использованием контекста с отменой.
  • Хранения списка ключ-значение, где и ключ и значение – оба интерфейсного типа.

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

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

Приложение было основано на пакете urfave/cli (неплохая библиотека для создания приложений командной строки). Разработчик наследовал контекст от некоего общего контекста приложения. Что означало, что при остановке приложения, все горутины получат сигнал отмены.

И, самое главное, приложение напрямую передавало этот же контекст в момент запроса к gRPC-эндпоинту. Так делать не следует.

Вместо этого, следует проинструктировать gRPC-библиотеку: Отменить запрос, когда приложение останавливается или после 100мс(для примера)

Чтобы этого добиться, достаточно просто создать дочерний контекст и передать его gRPC-клиенту.

ctx, cancel := context.WithTimeout(parentContext, 100 * time.Millisecond)
response, err := grpcClient.Send(ctx, request)

Контекст - не такая сложная концепция для понимания и одна из ключевых особенностей языка Go.

примечание Александра:
Применяя вышеуказанный код, не следует забывать, что функция отмены дочернего контекста cancel() должна быть вызвана. Либо явно, либо в отложенном варианте с defer cancel(). Это правило следует соблюдать, чтобы избежать утечки памяти. Даже если уже отменен контекст-родитель parentContext, вызов функции cancel() абсолютно безопасен. В том числе безопасен и её многократный вызов.

8. Неиспользование опции -race

Ошибка, которую я встречаю очень часто – запуск тестов для Go-приложений без ключа -race.

Как было отмечено в этом отчёте, несмотря на то, что “Go был спроектирован, чтобы сделать многопоточное программирование более лёгким и менее подверженным ошибкам”, мы всё ещё страдаем от проблем, связанных с многопоточными операциями.

Очевидно, среда испольнения Go-кода, а именно детектор гонки(race detector) помогает далеко не всегда. Тем не менее, это очень ценный механизм и нам следует выполнять тесты с этим ключом.

9. Использование имени файла в качестве параметра функций

Ещё одна распространённая ошибка – передача имени файла в функцию в качестве входящего параметра.

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

func count(filename string) (int, error) {
	file, err := os.Open(filename)
	if err != nil {
		return 0, errors.Wrapf(err, "unable to open %s", filename)
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	count := 0
	for scanner.Scan() {
		if scanner.Text() == "" {
			count++
		}
	}
	return count, nil
}

filename передан в качестве входящего параметра, мы его(файл) открываем и дальше обрабатываем – что может пойти не так?

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

Также, если мы хотим реализовать ту же логику, но для тела HTTP-запроса – придётся повторять ту же логику и дублировать функцию обработки.

Для корректной реализации нашей логики в Go есть две отличных абстракции. Это интерфейсы io.Reader и io.Writer. И, вместо передачи в функцию имени файла, мы можем передавать io.Reader, который абстрагирует источник данных, а логика останется единой для любых источников.

Файл, тело HTTP-запроса, байтовый буфер(byte buffer) – не важно что пришло, мы будем использовать для вычитывания данных один и тот же метод Read().

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

func count(reader *bufio.Reader) (int, error) {
	count := 0
	for {
		line, _, err := reader.ReadLine()
		if err != nil {
			switch err {
			default:
				return 0, errors.Wrapf(err, "unable to read")
			case io.EOF:
				return count, nil
			}
		}
		if len(line) == 0 {
			count++
		}
	}
}

А ответственность за открытие файла будет возложена на функцию, вызывающую функцию count():

file, err := os.Open(filename)
if err != nil {
  return errors.Wrapf(err, "unable to open %s", filename)
}
defer file.Close()
count, err := count(bufio.NewReader(file))

И функцию count() можно использовать с любым типом данных, не меняя ничего! Что, также, значительно облегчит тестирование, поскольку мы можем создать bufio.Reader из строки.

count, err := count(bufio.NewReader(strings.NewReader("input")))

10. Горутины и переменные в циклах

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

Что напечатает код в этом примере?

ints := []int{1, 2, 3}
for _, i := range ints {
  go func() {
    fmt.Printf("%v\n", i)
  }()
}

1 2 3 в случайном порядке? Нет.

В этом примере, каждая горутина получит одинаковое значение i. В результате мы получим (вероятнее всего) 3 3 3.

Есть пара вариантов решения проблемы. Первый – передать значение i замыканию(вложенной функции, запускаемой в горутине):

ints := []int{1, 2, 3}
for _, i := range ints {
  go func(i int) {
    fmt.Printf("%v\n", i)
  }(i)
}

Второй – создать дополнительную переменную внутри цикла:

ints := []int{1, 2, 3}
for _, i := range ints {
  i := i
  go func() {
    fmt.Printf("%v\n", i)
  }()
}

Конструкция i := i может выглядеть немного странно, но она абсолютно валидна с точки зрения Go. У цикла – своя область видимости. И i := i создаёт новую(другую) переменную, которая также называется i, как i в инструкции for. Естественно, для улучшения читаемости кода, можно назвать вложенную переменную как-нибудь иначе.

Конец