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

http://blog.labix.org/2011/10/09/death-of-goroutines-under-control/
  • Перевод

Вступление


Недавно наткнулся на маленький полезный пакет и решил поделиться находкой. Для этого публикую перевод статьи, обсуждающей проблему корректного завершения 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

Удачи!
Поделиться публикацией

Похожие публикации

Комментарии 15
    0
    Пользуясь случаем спрошу как в Go обстоит дело с подключением одной и той же библиотеки, но разных версий? Например, есть два проекта, один проект нужно оставить со старой библиотекой, а другой обновить до последней версии. Как в Go разрешить эту проблему?
      0
      Разработчики языка на эту тему отписывались так:
      либо идите в ногу со временем, либо делайте fork и мучайтесь со старой версией сами.
      Лично я их мнение не разделяю :)
        0
        мдя, ну я примерно тоже самое читал в их ньюсгруппе, мол новые версии библиотек должны быть совместимы с предыдущими, я так сразу вспомнил hibernate и подумал, что ребята в каком-то идеальном мире живут :)
          0
          а нет ничего подобного типа bundler'а?
            0
            Нет, такого пока нет. У пакетов Go вообще нет версий на данный момент.*

            *Это не совсем так, стандартная утилита Go определяет версию Go (stable, weekly, tip) и пытается скачать соответствующую версию пакета. Но к проблеме версионирования самих пакетов это мало относится.
          0
          А нельзя сделать так?

          import newLib "lib/myLib"            //   newLib.func1()
          import oldLib "lib/old_myLib"        //   oldLib.func1()
            0
            Можно сделать по разному, вопрос о том как принято делать, чтобы каждый не городил велосипед. А еще желательно иметь официальную поддержку «как надо делать», прописанную в документации.
              +1
              Можно конечно, это же пример с локальными библиотеками.
              Проблема только с внешними библиотеками, у которых строка импорта == url адресу откуда пакет качается, поэтому нужно fork'ать и менять строку импорта, если хочешь определенную версию.
            0
            Я не утверждаю, что это что-то, что сложно сделать, каким-либо способом. Я хочу сказать, что на сегодняшний день нет общепринятого подхода для обработки этих моментов простым и последовательным образом.


            Общепринятый подход сейчас, как я понимаю, такой: tour.golang.org/#65
            + исходящий канал сделать структурой с ошибкой и строчкой
              0
              Да, но данный подход не охватывает обработку ошибок.
                0
                Невнимательно прочитал, извиняюсь. Если исходящий канал делать структурой с ошибкой и строчкой, то получается что у нас возвращается всегда (n раз) структура в которой строчка и nil и 0 или 1 раз структура в которой nil и ошибка.
                Подход из статьи хорош в случае, если ошибка — она один раз и все, завершение goroutine.
                Ваш подход хорош в случае, если ошибка не приводит к завершению goroutine.
                  0
                  Ну вот… А я было вернулся, чтобы запостить поясняющую портянку кода.

                  Да, конечно, методы для разных ситуаций. Стандартый подход с ошибкой, возвращаемой всегда, универсальнее, но скорее всего даёт больший оверхед по объему переданного через канал.
                0
                Интересная статья. Но сразу с погружением. Думаю, пригодилась бы статья про горутины, каналы (как средство взаимодействия между ними) и интерфейсы.
                То есть те сущности языка, которые отличают Go от коллег по цеху.

                Написал бы сам, но пока опыта в Go не хватает настолько, чтобы научить других.

                PS: И спасибо за пост. Хаб обновляется почти раз в сутки, и нас тут уже 1000(!).
                  0
                  Да, и вот не совсем понятно: сам tomb где работает, в отдельной горутине? (не увидел создания) Если — да, то следит ли он за состоянием своей.
                    0
                    Он работает без отдельной горутины. Создается автоматически с нулевыми полями внутри (это же структура, не указатель), инициализируется лениво при первом обращении к какой-либо функции (Kill, Wait, Done и т.п.).

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

                  Самое читаемое