Распределение замков в режиме «go» без изменения приложения


Введение

Для процессов, которые должны всегда существовать только в одном экземпляре в распределенной системе, мы реализуем блокировки. Для этого существует множество подходящих инструментов, в основном это хранилища ключевых значений. И это реализуется по мере роста проекта и, скорее всего, непосредственно в коде приложения. И приложение начинает что-то знать об окружающей его инфраструктуре, но так ли это. Конечно, нет. Это усложняет работу разработчиков, когда они запускают приложение в dev- или тестовой среде. И если инфраструктура снова изменится, это снова потребует работы над сервисом.
Я изучал интернет в поисках готовых решений этой проблемы, но не нашел ничего подходящего. В этом цикле статей я расскажу вам, как реализовать распределенные блокировки, не меняя код самого приложения и не тратя время разработчиков (особенно если у вас десятки или сотни сервисов).
Это первая часть, в которой я познакомлю читателя с процессами linux, и мы увидим, как запускать и управлять ими программно.

Процессы

Для достижения основной цели необходимо понять, как работают процессы в linux. В ядре процессы представлены просто как структура. У процесса есть атрибуты и состояния. В структуре много атрибутов, но для нас сейчас важны несколько:

  • PID. Уникальный идентификатор процесса в системе
  • PPID. Уникальный идентификатор процесса родителя
  • Команда процесса. Какая команда процесса запущена и в каком каталоге
  • Код возврата. Появляется при завершении процесса.

В Linux ни один процесс не появляется из ниоткуда. У каждого процесса есть породивший его процесс — родительский процесс. Исключением является процесс init (pid 1), который запускается при старте ядра (в некоторых дистрибутивах вместо init может работать systemd или другие).
При запуске нового процесса сначала происходит форк родительского, после чего мы получаем почти идентичный процесс, но с новым PID и PPID, равным PID родительского. При этом копируется вся память, дескрипторы файлов, текущий рабочий каталог и так далее.
Затем в полученном форке выполняется новая команда exec.
Все процессы могут находиться в нескольких состояниях существования:

  • Рождение
  • Готовность. Готов к работе и ждет, когда планировщик Linux начнет его выполнение и выделит ресурсы процессора.
  • Выполнение. Процесс находится в процессе выполнения.
  • Ожидание. Процесс находится в ожидании или заблокирован по другим причинам.
  • Завершение (смерть). Завершает свою работу и освобождает ресурсы.Родитель ждет, пока дочерний процесс завершит работу, и считывает результат его завершения — код ответа. На основании этих данных он может принять решение, либо вывести информацию в stdout, либо попытаться перезапустить процесс, либо записать что-то в логи. Например, известный веб-сервер nginx при каждом подключении создает новый процесс и записывает в логи информацию обо всех ошибках в дочерних процессах. Кстати, дочерние процессы тоже могут писать в те же журналы, так как наследуют файловые дескрипторы от родителя.Но что если родитель умрет раньше ребенка? Его дочерние процессы не умрут вместе с ним. Они будут переданы другому процессу, можно сказать, усыновленному. Обычно это PID 1, но в зависимости от дистрибутива это может отличаться.Этих знаний достаточно, чтобы продолжить разговор. Мы можем написать утилиту, которая будет запускать наше основное приложение и одновременно реализовывать блокировки. Тогда вам не придется описывать нюансы инфраструктуры внутри приложения. Я буду углубляться в детали и добавлять возможности нашего util шаг за шагом, постепенно, для лучшего понимания. В Go есть пакет os/exec, который позволяет нам это сделать. Давайте попробуем запустить какой-нибудь процесс.
func main() {
    var cmd *exec.Cmd
    if len(os.Args) == 1 {
        cmd = exec.Command(os.Args[1])
    } else {
        cmd = exec.Command(os.Args[1], os.Args[2:]...)
    }

    fmt.Printf("I`am process %d n", os.Getpid())
    println("Let`s start a new process")
    var outb, errb bytes.Buffer
    cmd.Stdout = &outb
    cmd.Stderr = &errb
    if err := cmd.Run(); err != nil {
        panic(err)
    }
    fmt.Printf("New process finished. Pid: %d n", cmd.Process.Pid)
    fmt.Println("out:", outb.String(), "err:", errb.String())
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Результат:

go run main.go ls
I`am process 13405
Let`s start a new process
New process finished. Pid: 13406
out: go.mod
main.go
 err:
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте проанализируем этот код более подробно:

var cmd *exec.Cmd
if len(os.Args) == 1 {
        cmd = exec.Command(os.Args[1])
    } else {
        cmd = exec.Command(os.Args[1], os.Args[2:]...)
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Я описываю команду как структуру. Я беру данные из аргументов. Так как нулевой аргумент — это скомпилированный бинарник, то я вычитаю аргументы, начиная с индекса 1, который будет именем запускаемого файла, затем могут быть аргументы (а может и нет)

var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
Войти в полноэкранный режим Выйти из полноэкранного режима

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

if err := cmd.Run(); err != nil {
    panic(err)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

fmt.Println("out:", outb.String(), "err:", errb.String())
Вход в полноэкранный режим Выход из полноэкранного режима

Управление процессами

На данный момент я могу попытаться вставить логику блокировок перед запуском процесса. Но что делать, если наш util умрет, дочерний процесс останется запущенным, и некому будет его разблокировать, и никакой процесс больше не запустится. Поэтому, когда родительский процесс завершается, сначала должны быть завершены все дочерние.
В Linux есть возможность управлять процессами. Для этого предназначены сигналы. Сигналы — это некоторые события в системе, которые посылаются процессу. Сигналы могут исходить от ядра системы (например, отказ оборудования) или от пользователя (например, нажатие клавиши).
Их очень много, и нет необходимости рассматривать их все. При желании их можно рассмотреть в ядре. Я приведу те, которые могут быть важны для нас далее в рассматриваемой теме.

  • SIGINT. Прерывание. Посылается, когда пользователь посылает процессу сигнал о завершении работы. Ctrl-C
  • SIGKILL. Немедленное завершение процесса. Программа не может обработать или проигнорировать этот сигнал и будет завершена ядром.
  • SIGTERM. Завершить. Вежливое завершение. Программа должна завершить свои действия и корректно завершить работу.

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

func main() {
    var cmd *exec.Cmd
    if len(os.Args) == 1 {
        cmd = exec.Command(os.Args[1])
    } else {
        cmd = exec.Command(os.Args[1], os.Args[2:]...)
    }

    fmt.Printf("I`am process %d n", os.Getpid())
    println("Let`s start a new process")

    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Start(); err != nil {
        panic(err)
    }
    fmt.Printf("Process started. Pid: %d n", cmd.Process.Pid)

    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

    ctx, cancel := context.WithCancel(context.Background())

    go func() {
        select {
        case sig := <-sigs:
            fmt.Printf("got signal: %s n", sig.String())
            if err := cmd.Process.Signal(sig); err != nil {
                log.Fatalf("error sending signal to process: %v", err)
            }
            return
        case <-ctx.Done():
            println("finished go routin")
            return
        }

    }()

    if err := cmd.Wait(); err != nil {
        if _, ok := err.(*exec.ExitError); ok {
            log.Fatalf("Child process failed: %v", err)
        }
        log.Fatalf("Parent failed, wait command: %v", err)
    }

    println("process finished")
    cancel()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Функция сильно изменилась. Давайте рассмотрим ее подробнее.

cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
    panic(err)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Во-первых, лучше перенаправлять потоки вывода процесса на стандартные системные выходы. Тогда вам не придется ждать завершения процесса, чтобы увидеть его вывод. Особенно если процесс может работать долгое время.
Во-вторых, я использовал Start() вместо Run(). Этот метод делает то же самое, запускает новый процесс, но не ждет его завершения и продолжает работу программы.

sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
Вход в полноэкранный режим Выйти из полноэкранного режима

Подпишитесь в родительском процессе на получение сигналов прерывания, о которых говорилось выше. Для этого создается буферизованный канал размером 1.

go func() {
    select {
    case sig := <-sigs:
        fmt.Printf("got signal: %s n", sig.String())
        if err := cmd.Process.Signal(sig); err != nil {
            log.Fatalf("error sending signal to process: %v", err)
        }
        return
    case <-ctx.Done():
        println("finished go routin")
        return
    }
}()
Вход в полноэкранный режим Выйти из полноэкранного режима

В этой goroutine я слушаю сигналы от ядра. И как только они поступают, сразу же дублирую их дочернему процессу. Также планируется завершить работу горутины, если родительский процесс будет завершен до получения какого-либо сигнала. Он может быть вообще не принят, если дочерний процесс отработает и завершится самостоятельно.

if err := cmd.Wait(); err != nil {
    if _, ok := err.(*exec.ExitError); ok {
        log.Fatalf("Child process failed: %v", err)
    }
    log.Fatalf("Parent failed, wait command: %v", err)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь родитель ждет, когда ребенок завершит процесс. Выполнение продолжится, когда ребенок отправит код завершения.

Попробуйте вариант, когда процесс завершается по сигналу Sigint.

go run main.go ping 8.8.8.8
I`am process 18060
Let`s start a new process
Process started. Pid: 18061
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=37.743 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=45.169 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=44.006 ms
^C
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 37.743/42.306/45.169/3.261 ms
got signal: interrupt
process finished
Войти в полноэкранный режим Выйти из полноэкранного режима

И вариант, когда процесс завершился до получения какого-либо сигнала:

go run main.go ls
I`am process 18095
Let`s start a new process
Process started. Pid: 18096
go.mod  main.go
process finished
Войти в полноэкранный режим Выйти из полноэкранного режима

Заключение

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

Оцените статью
Procodings.ru
Добавить комментарий