Організація «чистого» завершення додатків на Go



Доброго дня, в даній замітці буде порушена тема організації «чистого» для завершення програм, написаних на мові Go.
Чистим виходом я називаю наявність гарантій того, що в момент завершення процесу (по сигналу або з будь-яких інших причин, крім system failure), будуть виконані певні процедури і вихід буде відкладено до закінчення їх виконання. Далі я наведу кілька типових прикладів, розповім про стандартному підході, а також продемонструю свій пакет для спрощеного застосування цього підходу у ваших програмах і сервісах.

TL;DR: github.com/xlab/closer GoDoc

1. Введення

Отже, напевно ви хоч раз помічали, як який-небудь сервер або утиліта ловить ваш кручений
Ctrl^C
і, дико вибачаючись звичайно, просить почекати, поки вона вирішуватиме справи, які ніяк не можна відкласти. Добре написані програми завершують справи і виходять, погані ж впадають в deadlock і здаються тільки при вигляді
SIGKILL
. Точніше, про
SIGKILL
програма дізнатися не встигає, докладно процес описаний тут: SIGTERM vs. SIGKILL і Unix Signal.

При переході на Go в якості основного мови розробки і після тривалого використання останнього для написання різних сервісів мені стало ясно, що додавати обробку сигналів потрібно буквально в кожен сервіс. В основному з-за того, що в
Go
багатопоточність є примітивом мови. Усередині одного процесу можуть одночасно працювати, наприклад, наступні потоки:

  • Connection pool БД клієнтів;
  • Consumer для pub/sub черги;
  • Publisher для pub/sub черги;
  • N потоків власне воркеров;
  • Кеш пам'яті;
  • Відкриті файли логів;
Тут немає нічого надприродного (вибачте, якщо образив), тим більше на ділі це являє собою кілька сутностей, які роблять свою роботу у фоні (go-рутини), і спілкуються між собою через go-канали (типізовані черзі). Звичайний такий сервіс микросервисной архітектури.

І з запуском все гранично просто: спочатку стартуємо пул клієнтів БД, якщо не стартував — виходимо з помилкою. Потім ініціалізуємо кеш пам'яті. Потім запускаємо publisher, якщо не стартував — виходимо з помилкою. Потім відкриваємо файли, наприклад логи. Потім запускаємо воркеров, та побільше, які будуть споживати дані через consumer, писати в БД і тримати в кеші, а результати складати в publisher. Ах так, ще події обробки будуть писатися в логи, не обов'язково з тих же потоків. І, нарешті, активуємо все це відкривши потік даних consumer, а якщо не отрылся — виходимо.

Ініціалізація відбувається послідовно, в одну нитку, у разі помилки на одному етапі відкочувати вже виконані етапи ініціалізації не обов'язково, так як система перебуває в нульовому положенні весь цей час, поки не відкриємо потік даних. І ось відкрили потік даних, а через 5 хвилин нам терміново треба було вийти, завершити всі, та так, щоб красиво і чисто.

Навіщо? А тому що не всі результати з буферизированного каналу могли встигнути бути отриманими процесом запису у БД, так і ті, що були зчитані з каналу, могли не встигнути дійти до БД по мережі. І не всі об'єкти могли встигнути опублікуватися у pub/sub чергу. Не всі воркеры могли встигнути здати свої результати у відповідні канали. Споживання черзі воркерами могло бути також буферизировано, а значить, невелика частина об'єктів могла виявитися зчитаної з сервера pub/sub черзі, але ще не обробленій воркерами. Кеш пам'яті, наприклад, повинен бути сдамплен на диск в момент завершення програми, а ще всі буфери з даними логів повинні бути очищені у відповідні файли. Все це тут перераховано з метою показати, що будь-примітивний сервіс з кількома фоновими завданнями приречений мати спосіб надійного відстеження виходу програми. І зовсім не заради красивого повідомлення «Bye bye...» в консолі, а як життєво необхідний механізм синхронізації багатопотокового комбайна.

2. Трохи практики

В Go є хороший інструмент — defer, це вираз, будучи застосованим до функції, додасть її в спеціальний список. Функції з цього списку будуть виконані в зворотному порядку перед поверненням з поточної функції. Такий механізм інший раз спрощує роботу з мьютексами та іншими ресурсами, які треба звільнити її при поверненні. Ефект
defer
діє навіть якщо трапляється паніка (=виняток), тобто, визначений у deferred-функції код отримує гарантію бути виконаним, а самі виключення таким способом можуть бути спіймані і оброблені.

func Checked() {
defer func() {
// перевірка, чи була паніка
if x := recover(); x != nil {
// можна написати в лог, а також прокинути виняток нагору
}
}()

// що-небудь робимо, трапляється паніка
}

Але є один злісний антипаттерн, чомусь часто
defer
починають використовувати функції
main
. Наприклад:

func main() {
defer doCleanup()

// трохи псевдоработи
fmt.Println("10 seconds to go...")
<-time.Tick(10 * time.Second)
}

Код відмінно відпрацює у випадку звичайного повернення і навіть паніки, але люди забули про те, що
defer
не спрацює у разі отримання процесом сигналу на завершення (виконується syscall exit, документації Go: «The program terminates immediately; deferred functions are not run.»).

Щоб грамотно обробити подібну ситуацію, сигнали слід ловити вручну «підписавшись на типи сигналів. Поширена практика (судячи з відповідей на StackOverflow) полягає у використанні signal.Notify, патерн виглядає приблизно так:

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT)
go func() {
s := <-sigChan
// зловили один з
}()

Для приховування зайвих деталей реалізації і був придуманий пакет xlab/closer, про нього піде мова далі.

3. Closer

Отже, пакет
closer
бере на себе обов'язок відстежувати сигнали, дозволяє прив'язати функції і автоматично виконає їх у зворотному порядку при завершенні. Пакет потокобезопасен, тим самим позбавляючи користувача від необхідності думати про можливі тут станах гонки при виклику closer.Close з декількох потоків одночасно. API на даний момент складається із 5 функцій: Init, Bind, Checked, Hold і Close. Init дозволяє користувачу змінити список сигналів та інші опції, використання інших функцій розглянемо на прикладах.

Стандартний список сигналів:
syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGABRT
.

Приклад звичайний

func main() {
closer.Bind(cleanup)

go func() {
// робимо роботу в окремому потоці
fmt.Println("10 seconds to go...")
<-time.Tick(10 * time.Second)
// по закінченні вимагаємо завершення процесу
closer.Close()
}()

// блокує, поки не буде відпрацьований вихід по сигналу або через closer.Close
closer.Hold()
}

func cleanup() {
fmt.Print("Hang on! I'm closing some DBs, wiping some trails..")
<-time.Tick(3 * time.Second)
fmt.Println(" Done.")
}

Приклад з помилкою

Функція closer.Checked дозволяє робити перевірку на помилки і ловити виключення. Тут код повернення буде відмінний від нуля, причому обробкою виходу займається раніше пакет
closer
.
func main() {
closer.Bind(cleanup)
closer.Checked(run, true)
}

func run() error {
fmt.Println("Will throw an error in 10 seconds...")
<-time.Tick(10 * time.Second)
return errors.New("KAWABANGA!")
}

func cleanup() {
fmt.Print("Hang on! I'm closing some DBs, wiping some trails...")
<-time.Tick(3 * time.Second)
fmt.Println(" Done.")
}

Приклад з панікою (виключенням)

func main() {
closer.Bind(cleanup)
closer.Checked(run, true)
}

func run() error {
fmt.Println("Will panic in 10 seconds...")
<-time.Tick(10 * time.Second)
panic("KAWABANGA!")
return nil
}

func cleanup() {
fmt.Print("Hang on! I'm closing some DBs, wiping some trails...")
<-time.Tick(3 * time.Second)
fmt.Println(" Done.")
}

Таблиця відповідності кодів завершення:

Подія | Код завершення
------------- | -------------
error = nil | 0 (успіх)
error != nil | 1 (помилка)
panic | 1 (помилка)

Висновок

Таким чином, незалежно від першопричини завершення процесу, ваш додаток на Go відпрацює необхідну процедуру «чистого» завершення. В Go прийнято для кожної сутності, що вимагає такої процедури, писати метод Close, який би финализировал всі внутрішні процеси цієї сутності. Отже, завершення вищеописаного сервісу з другої частини даної статті буде полягати у виклику методу
Close()
для всіх створених сутностей, у зворотному порядку.

Спочатку закривається потік даних consumer черзі pub/sub, нових завдань в систему надходити не буде, потім система дочекається, поки всі воркеры відпрацюють і завершаться, тільки після цього буде синхронізовано з диском кеш, закритий канал запису в БД, закритий канал publisher, синхронізовані і закриті файли логів, і, нарешті, будуть закриті підключення до БД і сам publisher. На словах звучить досить серйозно, але на ділі ж досить лише грамотно написати метод Close кожної сутності та
main
при ініціалізації використовувати closer.Bind. Ескіз
main
для наочності:

func main() {
defer closer.Close()

pool, _ := xxx.NewPool()
closer.Bind(pool.Close)

pub, _ := yyy.NewPublisher()
closer.Bind(function(){
pub.Stop()
<-pub.StopChan
})

wChan := make(chan string, BUFFER_SIZE)
workers, _ := zzz.NewWorkgroup(pool, pub, wChan)
closer.Bind(workers.Close)

sub, _ := yyy.NewConsumer()
closer.Bind(sub.Stop)

// блокуючий виклик (інакше використовуйте closer.Hold)
sub.Consume(wChan)
}


Вдалої вам синхронізації!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.