Перехват пользовательских сигналов в Go

17 minute read

Перевод статьи "Handling User Defined Signals in Go"

Сигналы представляют собой ограниченный способ коммуникации между процессами. Сигналы используют для прерывания выполнения программы и срабатывания определенного обработчика. К примеру, когда вы нажимаете комбинацию клавиш CTRL+C в терминале для остановки запущенной программы, то таким образом вы посылаете сигнал SIGINT. Обычно, при получении сигнала SIGINT процесс убивается, но некоторые процессы могут перехватывать сигнал и игнорировать его или выполнять некоторую подготовительную работу перед выходом. Еще один довольно известный сигнал SIGKILL намного "брутальнее". Как только процесс получает этот сигнал, то сразу же прекращает свою работу и нет никаких способов игнорировать этот сигнал. Существует очень много различных сигналов, их описание можно найти на wikipedia.

Посылать сигналы процессу можно с помощью команды kill <signal> <PID>. Тут PID - это идентификатор процесса. К примеру, чтобы завершить определенный процесс, вы можете послать сигнал SIGKILL воспользовавшись командой kill -KILL <PID>. Для удобства можно использовать команду pkill и вместо идентификатора указывать имя процесса.

В этом посте мы будем говорить о специальных пользовательских сигналах USR1 и USR2. Оба эти сигнала предоставляются всецело в ваше распоряжение и вы сами можете определить их обработчики. Я покажу несколько популярных способов их использования. Все примеры написанны на языке Go, но основные идеи не зависят от выбранного языка и могут быть реализованны на чем угодно.

Перехватывание сигналов

В Go вы можете перехватывать сигналы с использованием функции signal.Notify(chan os.Signal, syscall.Signal). Эта функция принимает канал и сообщает вам, когда произошел перехвать сигнала. Когда программа получает сигнал, то в канал посылается значение типа os.Signal. Примерно это выглядит так:

func handle(c chan os.Signal) {
    for {
        <-c // This line will block until a signal is received
        // DO SOMETHING
    }
}

func main() {
    c := make(chan os.Signal, 1)
    signal.Notify(c, syscall.SIGUSR1)
    go handle(c)
}

Теперь мы знаем как перехватывать сигналы и определять наши собственные обработчики.

Переключатель для режима логирования.

Давайте представим, что у вас есть веб-приложение, которое, конечно же, пишет логи. Сделать лог очень полным и многословным считается не очень хорошей идеей, потому что файлы логов разрастутся моментально. Именно поэтому придумали различные уровни логирования (INFO, DEBUG, WARN, и т.д.). Вы запускаете свое приложение с указанием уровня, но если вы захотите отдебажить какую ни будь проблему, то вам нужно будет перезагрузить ваше приложение с указанием другого уровня логирования.

Для простоты представим, что у вас есть только для два уровня логирования(бесшумный и многословный). Код ниже переключает уровни логирования как только процес получает сигнал USR1.

package main

import (
    "fmt"
    "io"
    "io/ioutil"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

// Определяем, куда мы будем выводить сообщения
var writeTo io.Writer = ioutil.Discard
var mutex sync.Mutex

func toggleOutput(c chan os.Signal) {
    for {
        // На этой строчке мы заблокируемся пока не получим сигнал
        <-c

        // Как только мы получили сигнал, необходимо сменить наш writer.
        // Если раньше мы писали в stdout, то меняем на ioutil.Discard
        // и, соответствено, наоборот.
        mutex.Lock()
        if writeTo == os.Stdout {
            writeTo = ioutil.Discard
        } else {
            writeTo = os.Stdout
        }
        mutex.Unlock()
    }
}

func main() {

    fmt.Printf("Process PID : %v\n", os.Getpid())

    c := make(chan os.Signal, 1)
    // В канал `c` попадет сообщение как только получим сигнал SIGUSR1.
    signal.Notify(c, syscall.SIGUSR1)
    go toggleOutput(c)

    // Бесконечный цикл, в котором мы просто увеличиваем счетчик.
    counter := 1
    for {
        mutex.Lock()
        fmt.Fprintln(writeTo, counter)
        mutex.Unlock()
        counter++
        time.Sleep(time.Second)
    }
}

Напомню, что для отправки сигнала нужно выполнить команду:

$ ps -C signals
  PID TTY          TIME CMD
17659 pts/6    00:00:00 signals
$ kill -s USR1 17659

В этом примере бинарник с программой называется signals.

И как это выглядит в действии.

Чтение конфигурационных файлов.

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

package main

import (
    "fmt"
    "io/ioutil"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"

    "gopkg.in/yaml.v2"
)

// Структура для нашего конфига. Есть только одно поле, его и будем выводить.
type config struct {
    Name string
}

// Обертка над конфигом, которая защищает его через mutex
type context struct {
    config
    sync.Mutex
}

// Глобальная переменная контекста
var ctx *context

func (c *context) Config() config {
    c.Lock()
    cnf := c.config
    c.Unlock()
    return cnf
}

// Функция читает конфигурационный файл "config.yml" и возвращает
// указатель на структуру конфигурации.
func readConfig() config {
    content, err := ioutil.ReadFile("config.yml")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    var tmpCnf config
    err = yaml.Unmarshal(content, &tmpCnf)

    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    return tmpCnf
}

// Это самая интересная часть
func swapConfig(c chan os.Signal) {
    for {
        // На этой строчке мы заблокируемся пока не получим сигнал
        <-c

        // Как только получаем сигнал, читаем конфигурационный
        // файл и переключаем старый конфиг.
        ctx.Lock()
        ctx.config = readConfig()
        ctx.Unlock()
    }
}

func main() {
    fmt.Printf("Process PID : %v\n", os.Getpid())

    ctx = &context{config: readConfig()}

    c := make(chan os.Signal, 1)

    // В канал `c` попадет сообщение как только получим сигнал SIGUSR1.
    signal.Notify(c, syscall.SIGUSR1)
    go swapConfig(c)

    for {
        fmt.Println(ctx.Config().Name)
        time.Sleep(time.Second)
    }
}

И как все это выглядит в жизни.

Заключение

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