Смерть goroutine под контролем

в 11:10, , рубрики: golang, goroutine

Вступление

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

Перевод статьи

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

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

Не общайтесь с помощью разделяемой памяти, разделяйте память с помощью общения.

На эту тему есть запись в блоге, а также интерактивное упражнение (code walk).

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

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

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

type LineReader struct {
        Ch chan string
        r  *bufio.Reader
}

func NewLineReader(r io.Reader) *LineReader {
        lr := &LineReader{
                Ch: make(chan string),
                r:  bufio.NewReader(r),
        }
        go lr.loop()
        return lr
}

У структуры LineReader есть канал Ch, через который клиент может получать строчки, а также внутренний буфер r (недоступный извне), используемый для эффективного считывания этих строчек. Функция NewLineReader создает инициализированный LineReader, запускает цикл чтения и возвращает созданную структуру.

Теперь давайте посмотрим на сам цикл:

func (lr *LineReader) loop() {
        for {
                line, err := lr.r.ReadSlice('n')
                if err != nil {
                        close(lr.Ch)
                        return
                }
                lr.Ch <- string(line)
        }
}

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

Но есть две детали, связанные с завершением этой логики: во-первых, теряется информация об ошибке, а во-вторых, нет никакого чистого способа прервать процедуру извне. Конечно, ошибку можно легко залогировать, но что, если мы хотим сохранить ее в базе данных, или отправить по проводам, или даже обработать ее, принимая во внимание ее природу? Возможность чистой остановки также во многих случаях ценна, например, для запуска из под test-runner’а.

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

Модель работы простая: Tomb отслеживает жива ли goroutine, умирает или мертва, а также причину смерти.

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

type LineReader struct {
        Ch chan string
        r  *bufio.Reader
        t  tomb.Tomb
}
 
func NewLineReader(r io.Reader) *LineReader {
        lr := &LineReader{
                Ch: make(chan string),
                r:  bufio.NewReader(r),
        }
        go lr.loop()
        return lr
}

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

Далее, изменим функцию-цикл для поддержки отслеживания ошибок и прерывания:

func (lr *LineReader) loop() {
       defer lr.t.Done()
       for {
               line, err := lr.r.ReadSlice('n')
               if err != nil {
                       close(lr.Ch)
                       lr.t.Kill(err)
                       return
               }
               select {
               case lr.Ch <- string(line):
               case <-lr.t.Dying():
                       close(lr.Ch)
                       return
               }
       }
}

Отметим несколько интересных моментов: во-первых непосредственно перед тем, как завершится функция loop, вызывается Done, чтобы отследить завершение goroutine. Затем, ранее неиспользуемая ошибка теперь передается в метод Kill, что помечает goroutine как умирающую. Наконец, отсылка в канал была изменена таким образом, чтобы она не блокировалась в случае, если goroutine умирает по какой-либо причине.

У Tomb есть каналы Dying и Dead, возвращаемые одноименными методами, которые закрываются, когда Tomb меняет свое состояние соответствующим образом. Эти каналы позволяют организовать явную блокировку до тех пор, пока не изменится состояние, а также выборочно разблокировать выражение select в таких случаях, как показано выше.

Имея такой измененный цикл, как описано выше, легко реализовать метод Stop для запроса чистого синхронного завершения goroutine извне:

func (lr *LineReader) Stop() error {
       lr.t.Kill(nil)
       return lr.t.Wait()
}

В этом случае, метод Kill переведет tomb в умирающее состояние извне выполняющейся goroutine, и метод Wait приведет к блокировке до тех пор, пока goroutine не завершится и не сообщит об этом через метод Done, как было показано выше. Эта процедура ведет себя корректно даже если goroutine уже была мертва или в умирающем состоянии из-за внутренних ошибок, потому что только первый вызов метода Kill с настоящей ошибкой запоминается в качестве причины смерти goroutine. Значение nil, переданное в t.Kill используется как причина чистого завершения без фактической ошибки и приводит к тому, что Wait возвращает nil по завершении goroutine, что свидетельствует о чистой остановке по идиомам Go.

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

Пакет tomb и его тип Tomb — фактическое представление хорошей договоренности по завершению goroutine, со знакомыми именами методов, вдохновленное существующими идиомами. Если вы хотите воспользоваться этим, то пакет можно получить с помощью команды:

$ go get launchpad.net/tomb

Детальная документация API доступна по адресу:

gopkgdoc.appspot.com/pkg/launchpad.net/tomb

Удачи!

Автор: Kluyg

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js