ErrorHandling-патерн в golang

04d2e8c958dddbb8f59fc43f3af6b45a.jpg

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

Будем отталкиваться от примера, где мы ожидаем результатов выполнения n-горутин. Результат читаем из канала resultChannel:

workerNumber := 5
resultChannel := make(chan Result)

for i := 0; i < workerNumber; i++ {
   go func() {
      var result Result
      defer func() {
         resultChannel <- result
      }()

      data, err := getSomeThing()
      if err != nil {
         result.Error = err
         return
      }

      result.Data = data
   }()
}

for i := 0; i < workerNumber; i++ {
   result := <-resultChannel
   if result.Error != nil {
      fmt.Printf("Ошибка: %v\n", result.Error)
      continue
   }
}

Мы отправляем информацию в канал resultChannel, где помимо поля с результатом выполнения (Data), есть поле (Error), куда мы поместим информацию об ошибке в случае ее возникновения:

type Result struct {
   Error error
   Data  interface{}
}

Этот код не готов к использованию. Мы получим данные от горутины, но у нас нет канала обратной связи для управления запущенными горутинами. Как минимум нужен канал done. Освежить знания о канале Done и работе с контекстом можно здесь.

Код горутины:

...
select {
case <-done:
   return
default:
   data, err := getSomeThing()
   if err != nil {
      result.Error = err
      return
   }

   result.Data = data
}
...

Обработка результатов работы горутины тоже изменится:

for i := 0; i < workerNumber; i++ {
   select {
   case <-done:
      break
   default:
      result := <-resultChannel
      if result.Error != nil {
         fmt.Printf("Ошибка: %v\n", result.Error)
         continue
      }
   }
}

Помимо этого код может усложнится различными бизнес сценариями в том числе связанными с обработками ошибок.  Например, когда результат работы отдельной подзадачи (горутины) оказывает влияние на логику выполнения программы: в случае разбиения задачи на подзадачи, при котором не успешный результат выполнения хотя бы одной подзадачи лишает смысл выполнения других: например, сложный sql запрос разбит на подзапросы. Для выполнения каждого подзапроса запускаем горутину и в случае возникновения ошибки хотя бы в одной из подзадач нет смысла дожидаться выполнения остальных.

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

Код горутины

...
select {
case <-done:
   return
case <-cancelChannel:
   return
default:
   data, err := getSomeThing()
   if err != nil {
      result.Error = err
      return
   }

   result.Data = data
}
...

Код обработчика ошибок:

result := <-resultChannel
if result.Error != nil {
   fmt.Printf("Ошибка: %v\n", result.Error)
   close(cancelChannel) 	
   break
}

Код становится сложнее для понимания. Пакет errgroup помогает решить эту проблему для описанного класса задач:

resultChannel := make(chan interface{}, workerNumber)
// создаем группу для работы с горутинами
eGroup := errgroup.Group{}
// устанавливаем лимит на кол-во одновременно запущенных горутин в группе
eGroup.SetLimit(workerNumber)

for i := 0; i < workerNumber; i++ {
   //запускает функцию в отдельной горутине
   //если к-во горутин превышает установленный limit 
   //метод Go блокирует выполнение до тех пор пока горутина не будет запущена
   // ошибка будет подучена в методе Wait
   eGroup.Go(func() error {
      data, err := getSomeThing()
      if err != nil {
         return err
      }

      resultChannel <- data
      return nil
   })
}

go func() {
   defer close(resultChannel)
   if err := eGroup.Wait(); err != nil {
      fmt.Printf("Ошибка: %v\n", err)
   }
}()

for result := range resultChannel {
   fmt.Println(result)
}

fmt.Println("Все горутины завершили работу")

Говоря о пакете errorgroup также стоит упоминуть о двух методах:
func TryGo (f func () error) bool — запускает функцию в новой горутине, если количество запущенных горутин в группе меньше установленного лимита. Возвращаемое значение содержит результат запуска: запущена горутина или нет.
func WithContext (ctx context.Context) (*Group, context.Context) — производный контекст отменяется как только переданная в метод Go функция возвращает не нулевую ошибку или когда Wait вернет значение первый раз в зависимости от того, что произойдет раньше. Поскольку наши горутины могут порождать другие горутины последняя функция может быть очень полезной.

Заключение

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

© Habrahabr.ru