Пишем сервис на GO. Runtime контроллер и Graceful Shutdown

Напишем вместе HTTP-сервис на golang с нуля? Я уверен, что это довольно несложно. Для тех, кто каждую неделю этим занимается, моя статья не будет особенно интересна, но я все равно рекомендую взглянуть и оценить, возможно, ваши комментарии спасут кому-то жизнь. А может кое-какие из моих рассуждений спасут вашу.
Эта статья будет полезна тем, кто некоторое время назад начал осваивать язык программирования golang и уже достиг того момента, когда может попробовать окунуться в полный цикл разработки микросервисов на этом языке. Также она подойдет тем, кто решил сменить профильный язык, и по каким-то причинам его выбор пал на golang. Я не буду останавливаться на очевидных вещах вроде конструкций языка, парадигм конкурентности и прочего, но уделю время архитектуре приложения и постараюсь заострить внимание на моментах, в которых разработчик может допустить ошибку.
Это первая часть. Первые шаги в нашем нелегком пути. И в этой статье мы попробуем достичь следующих целей:
- Выработаем понимание структуры и жизненного цикла приложения.
- Формализуем наше представление жизненного цикла на языке go.
Для достижения поставленной цели мы пройдем следующие этапы:
- Разработаем контроллер runtime и передадим ему управление переходами из одного состояния в другое.
- Разработаем хелпер управления ресурсами приложения, с которым можно будет работать атомарно.
- Соберем все в аккуратную композицию в контексте веб-сервиса (в следующей статье).
По тексту статьи фигурирует терминология, которая не обязательно является общепринятой и не всегда понятно, что она означает в контексте статьи. Для ознакомления прошу под спойлер. Если нашли неточность или заметили в статье термины, которые могут быть неоднозначно истолкованы, прошу сообщить об этом в комментариях.
Термины
- Контроллер Runtime — сегодня мы пишем библиотеку, которая просто запускает функцию приложения, а в фоновом режиме контролирует работоспособность ресурсов и сигнал от ОС. Таким образом контролирует некоторые аспекты runtime нашего приложения, только и всего.
- ресурсы и сервисы — может быть в некоторых местах статьи эти термины спутаны между собой, но под ресурсами я имел в виду любые ресурсы приложения, работоспособность которых необходимо контролировать в процессе. Это могут быть коннекты к БД и прочее такое. Как вы понимаете, это все также подпадает под термин «сервис», поэтому я называю ресурсы сервисами в контексте разработки ServiceKeeper в остальных местах я стараюсь называть это «ресурсами».
- контекст — на протяжении всей статьи я имею в виду интерфейс context.Context
- основной поток выполнения — тут точно не имеется в виду никакой поток операционной системы, нить или горутина. Этим термином я называю процесс выполнения основной функции нашего приложения, той самой, которую мы хотим обернуть в наш контроллер runtime.
Жизненный цикл серверного приложения
Давайте попробуем определить жизненный цикл серверного приложения, как последовательность статусов, в которые переходит приложение от момента его запуска до непосредственной выгрузки его из оперативной памяти:
- Инициализация. У нас есть коннекты к базе данных, какие-то удаленные API или любые другие ресурсы, которыми необходимо будет пользоваться в процессе обработки запросов. На этом этапе необходимо выполнить все настройки этих ресурсов и по возможности проверить их работоспособность.
- Старт. Приложение запускает процесс чтения запросов из сети, выполняет их обработку и возвращает результат. Ничего такого — просто рабочий процесс.
- Мягкое завершение. После получения от операционной системы команды о завершении работы наш сервис должен завершить обработку текущих запросов без потерь данных, и не стоит принимать новые запросы в этот момент.
- Деинициализация. Когда все процессы остановлены, нужно корректно освободить все ресурсы, в том числе все соединения с базами данных и другими удаленными серверами.
Хорошо, давайте попробуем написать пакет-helper, который бы понимал эти этапы, контролировал жизненный цикл приложения и принимал решения о переходе от одного состояния к другому. Предлагаю в процедуре main не разбираться особо в том, какие этапы наше приложение проходит и весь контроль runtime отдать на откуп реализации контроллера. Сейчас мы попробуем определить, из чего будет состоять контроллер.
Нам необходимы следующие методы: Run — для того, чтобы запускать приложение и Shutdown — чтобы приложение останавливать. Если мы хотим настоящий graceful shutdown, тогда наш сервис не должен прерывать работу на середине, но должен переходить в такое состояние, при котором все новые запросы будут сразу же получать ответ 503 — сервис недоступен, а все текущие запросы будут корректно выполнены, и только после этого сервер выполнит остановку. Учитывая это, давайте добавим промежуточный метод Halt , который будет переводить наш сервис в это состояние.
Определим причины, по которым наше приложение должно завершить работу. Есть две основные причины:
- Основной поток завершил работу. Это может произойти с приложением, если его рабочий цикл четко определен и конечен. Выполнена работа — завершаем. Однако это не единственный пример.
- Получено сообщение о завершении работы от операционной системы. Нас в этом случае будет интересовать следующие сигналы: SIGHUP , SIGINT , SIGTERM и SIGQUIT .
Как вариант, возможная третья причина остановки приложения: отсутствие возможности корректно продолжать работу. Такая ситуация может наступить, если наше приложение потеряло какой-то ресурс: соединение с базой данных или любые другие критичные для выполнения запросов вещи. Давайте не исключать возможность такого состояния и сделаем так, чтобы оно обрабатывалось корректно.
Весь механизм контроля времени выполнения мы инкапсулируем в структуру Application и с помощью методов Run , Halt и Shutdown будем управлять процессом, а механика Application в свою очередь будет контролировать инициализацию и главный поток выполнения.
Инициализация
Предварительный этап выполняющийся непосредственно после запуска нашего приложения — это инициализация. Что туда может входить? Парсинг параметров (конфигурация), создание ресурсов и прочее. Чаще всего приложение имеет не один, а несколько ресурсов, которые нужно проинициализировать, и описать все этапы инициализации внутри одной функции — это не самое лучшее решение, даже если у нас на момент запуска нашего приложения всего один или два ресурса. Дело в том, что вероятные доработки в будущем наверняка увеличат это количество, и в какой-то момент функция main будет выглядеть вот так:
Спагетти-код под спойлером
. . . db, err = postgres.New(cfg.Postgres, l).Connect(context.Background()) if err != nil < log.Fatal("db connection error", err) >redisClient = redis.NewUniversalClient(cfg.Redis) err := redisClient.Ping(context.Background()).Err() if err != nil < log.Fatal("redis connection error", err) >clickhouse, err = clickhouse.NewClient(cfg.Clickhouse) if err != nil < log.Fatal("clickhouse connection error", err) >cache, err := cache.New() if err != nil < log.Fatal("cache service error", err) >rmq, err := queue.New(cfg.RabbitMQ) if err != nil < log.Fatal("rmq service error", err) >. . .
От спагетти-кода нам поможет избавиться еще один хелпер ServiceKeeper . Его тоже придется написать. Давайте создадим структуру, которая будет хранить список ресурсов (назовем их пока сервисами, ведь они являются сервисами для нашего приложения). И напишем пару простых процедур, которые будут управлять этим зоопарком.
В качестве сервиса на данном этапе мы определим вот такой интерфейс. В нем достаточно методов и для инициализации и для проверки здоровья во время выполнения и для завершения работы. И любая структура, обладающая такими методами, может считаться сервисом, жизненный цикл которого будет контролироваться в процессе выполнения нашего приложения.
Service interface
Чтобы проинициализировать все ресурсы, нам нужно будет последовательно вызвать метод Init всех сервисов из списка и вернуть ошибку, если она возникнет. Т.е. получается максимально простой алгоритм:
type ( ServiceKeeper struct < Services []Service state int32 // для контроля этапов выполнения >) func (s *ServiceKeeper) initAllServices(ctx context.Context) error < for i := range s.Services < if err := s.Services[i].Init(ctx); err != nil < return err >> return nil >
Зададимся вопросом, что будет, если мы проинициализируем ресурсы дважды? Ничего хорошего не будет, в лучшем случае мы просто потратим время, но может быть и так, что получим утечку ресурсов или другую серьезную проблему. Уже, наверно, понятно, для чего было добавлено поле state . Давайте используем его для проверки состояния контроллера, чтобы понимать, какие этапы уже прошли и куда можно двигаться дальше.
const ( srvStateInit int32 = iota srvStateReady srvStateRunning srvStateShutdown srvStateOff ) func (s *ServiceKeeper) checkState(old, new int32) bool
Теперь, используя процедуру checkState , мы можем быть уверены, что выполняем все методы последовательно, не нарушая порядка. Обратите внимание, что если мы используем процедуры пакета atomic , то можем рассчитывать на правильное исполнение конкурентного кода, заручившись поддержкой со стороны процессора. В этом примере используется процедура CompareAndSwapInt32 , которая сравнивает текущее значение поля state , и в случае его совпадения с old изменяет значение на new , и все это происходит атомарно, что позволяет нам гарантировать конкурентность.
Конечно, реализовать конкурентность можно было и с помощью Mutex , но в данном случае мы имеем алгоритм, который отлично реализуется атомарными функциями. Давайте посмотрим, как должен выглядеть публичный метод Init :
func (s *ServiceKeeper) Init(ctx context.Context) error < if !s.checkState(srvStateInit, srvStateReady) < return ErrWrongState >return s.initAllServices(ctx) >
Будем считать, что для инициализации приложения нам достаточно инициализировать все сервисы, которые зарегистрированы в ServiceKeeper . Это довольно простой случай, который редко будет встречаться в практике. В реальных условиях нам, скорее всего, нужно будет сначала парсить все параметры, потом передать их каждому ресурсу (ну если у нас один источник параметров), может быть нам для начала нужно будет создать какой-то logger , чтобы сбрасывать туда ошибки, или подключение к opentracing серверу. Да все что угодно, что выходит за рамки шаблона, который мы реализовали, но это все может легко решаться и даже легко ладить с нашими абстракциями.
Выполним ServiceKeeper.Init внутри метода инициализации нашего приложения. При этом, давайте проконтролируем продолжительность инициализации с помощью контекста: добавим в нашу структуру поле InitializationTimeout time.Duration и создадим контекст с таймаутом:
func (a *Application) init() error < if a.Resources != nil < ctx, cancel := context.WithTimeout(context.TODO(), a.InitializationTimeout) defer cancel() return a.Resources.Init(ctx) >return nil >
Старт
Хорошо, давайте попробуем написать процедуру, реализующую жизненный цикл приложения. Учтем опыт предыдущего раздела относительно state приложения. Логика должна быть такая: если приложение находится в состоянии appStateInit , переходим в appStateRunning и запускаем процесс инициализации, если он прошел неудачно, останавливаем выполнение, возвращаем ошибку. Все корректно — запускаем основную процедуру и ждем ее завершения, в фоне делаем две задачи:
- Проверяем работоспособность ресурсов и в случае ошибки немедленно останавливаем выполнение;
- Ожидаем сигнала от операционной системы, в случае получения сигнала, сообщаем основному потоку выполнения об этом, давая ему время на корректное завершение работы.
В любом случае по завершению основной процедуры выполняем освобождение ресурсов и выход из функции Run .
Давайте посмотрим на реализацию
type ( Resources interface < Init(context.Context) error // чтобы инициализировать Watch(context.Context) error // чтобы наблюдать Stop() // остановить наблюдение Release() error // освободить ресурсы >Application struct < // это будет выполняться основным потоком MainFunc func(ctx context.Context, holdOn ) error // это абстракция, чтобы не усложнять код Resources Resources TerminationTimeout time.Duration InitializationTimeout time.Duration appState int32 err error mux sync.Mutex halt chan struct<> done chan struct<> > ) const ( appStateInit int32 = iota appStateRunning appStateHalt appStateShutdown ) func (a *Application) Run() error < if a.MainFunc == nil < // если у нас не задана эта функция, то и выполнять нечего return ErrMainOmitted >if a.checkState(appStateInit, appStateRunning) < // сюда дважды не войти if err := a.init(); err != nil < a.err = err a.appState = appStateShutdown // не сбылась инициализация ресурсов return err >// с помощью servicesRunning мы синхронизируем жизненный цикл ресурсов // с жизненным циклом приложения var servicesRunning = make(chan struct<>) if a.Resources != nil < go func() < defer close(servicesRunning) // вот сигнал о том, что Watch остановлено // Shutdown просто остновит a.run(sig), это мы потом увидим defer a.Shutdown() a.setError(a.Resources.Watch(context.TODO())) >() > sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) // запускаем основной поток выполнения a.setError(a.run(sig)) // в этом месте программа должна завершиться if a.Resources != nil < a.Resources.Stop() // посылаем сигнал ресурсам return a.getError() > return ErrWrongState >
Выглядит неплохо. Что мы тут сделали? В первой части (сразу же после checkState ) идет инициализация, но тут мы не вызываем инициализацию ресурсов пока, а вызываем собственный метод init . Так будет проще изменять инициализацию и добавлять туда какие-то элементы не связанные с ресурсами. В средней части (вот в этом ветвлении if a.Resources != nil < ) запускается горутина, которая будет контролировать жизнеспособность ресурсов, если они есть.
Обратите тут внимание на два момента:
- defer a.Shutdown() — сразу же, как только будет остановлен контроль жизнеспособности ресурсов, выполняется немедленная остановка приложения. Для приложения нет смысла дальше выполнять запросы, если что-то работает неправильно. Правда есть тут тоже нюансы, но пока мы о них не будем говорить.
- defer close(servicesRunning) — это синхронизация. Гарантирует, что вызовы Resources.Watch и Resources.Release не пересекутся, иначе возможно состояние гонки и прочие пакости.
В третьей части просим рантайм go передать нам управление обработкой сигналов о завершении от операционной системы (вот это signal.Notify ) и запускаем основную функцию (опять инкапсулируем запуск внутри run ). В этом месте выполнение функции должно блокироваться до завершения выполнения основной функции, которая в идеале может работать бесконечно.
Далее, если были ресурсы, мы передаем сигналы о том, что ресурсы больше не нужны в таком порядке:
Я пока ничего не сказал о странном методе a.setError , я его нарочно обошел, чтобы оставить напоследок. Встречаем мы его тут три раза — он поглощает результат выполнения Resources.Watch , a.run(sig) и Resources.Release . На самом деле, все эти функции выполняются в тот момент, когда мы можем назвать состояние приложения как «выполняется», и любая ошибка в этих трех процедурах должна иметь право стать результатом вызова метода Run в целом. Т.е. метод Run должен вернуть ошибку, если таковая была в процессе выполнения. Мне показалось удобным добавить поле err error в структуру Application , и в случае возникновения ошибок в разных потоках выполнения, мы можем заполнять это поле первой попавшейся ошибкой и даже инициировать остановку всего приложения.
Имплементация методов setError и getError
func (a *Application) setError(err error) < if err == nil < return >a.mux.Lock() if a.err == nil < a.err = err >a.mux.Unlock() a.Shutdown() > func (a *Application) getError() error
Да, я здесь использую мьютекс в качестве синхронизации и устанавливаю ошибку единожды.
На самом деле, в правильном go редко встретите такую конструкцию, когда функция принимающая error получает в качестве аргумента вызов функции, которая возвращает error . Это затрудняет чтение кода, поэтому лучше написать что-то вроде этого:
if err := a.run(sig); err != nil
Однако, я позволил себе это сделать по следующим причинам: все три вызова a.setError располагаются в пределах одной функции и не планируется поддержка этого кода никем, кроме меня. Так себе причины, но как уж есть.
Контролируем runtime
- Запускать основной поток выполнения. Т.е. запускать MainFunc . И контролировать возврат из нее.
- Контролировать сигналы операционной системы и в случае необходимости сообщать основному потоку выполнения о том, что нужно завершить работу.
Механика будет следующая: мы запустим обработчики этих функций в двух параллельных горутинах, предоставив каждой из них собственный канал error , в который можно будет отправить ошибку или просто закрыть этот канал при выходе из горутины, а сама функция будет ждать в состоянии чтения из этих каналов.
Давайте посмотрим, как такое написать
func (a *Application) run(sig >() var errHld = make(chan error, 1) // канал для сигнала от потока слушающего chan os.Signal go func() < defer close(errHld) select < // ожидаем сигнала операционной системы case case >() // на этом месте выполнение процедуры будет блокировано // пока не произойдет одно из следующих событий select < // получим ошибку от основного потока выполнения или закроется канал errRun case err, ok := // получим ошибку от рутины, слушающей сигналы ОС или закроется ее канал case err, ok := // это жесткий путь - кто-то вызвал процедуру Shutdown() case return nil >
Теперь давайте уделим немного времени методам Halt и Shutdown , что они такое и для чего они нужны мы определили в самом начале статьи. Одной из причин завершения работы является сигнал от операционной системы, и он может возникнуть в любой момент, даже тогда, когда наше приложение находится в состоянии при котором велика вероятность потери данных. Попробуем реализовать правильный метод «мягкого завершения работы». А как основной поток поймет, что нужно все завершить и не набирать новых задач? Я реализую это с помощью канала, который закрывается сразу же, как мы получаем сигнал от ОС. Это делает функция Halt .
func (a *Application) Halt() < if a.checkState(appStateRunning, appStateHalt) < close(a.halt) >>
Обратите внимание на то, что тут выполняется синхронизация с текущим статусом нашего приложения: если state установлено в appStateRunning , мы переводим его в appStateHalt и закрываем канал, сигнализируя основному потоку о том, что необходимо начать процесс остановки.
func (a *Application) Shutdown() < a.Halt() if a.checkState(appStateHalt, appStateShutdown) < close(a.done) >>
В самом начале этой функции мы вызовем Halt , это необходимо потому, что есть два разрешенных статуса при которых мы можем вызывать эту функцию: appStateRunning и appStateHalt . Поэтому если сигнал основному потоку еще не был передан, мы сделаем и это. Это «жесткий» способ завершить работу и все будет остановлено, даже если основной поток еще не закончил работу. Фактически канал a.done это то, чего ждет процедура run выход из которой инициирует выгрузку ресурсов и выход из процедуры Run .

У нас вырисовывается следующая последовательность смены статусов приложения: appStateInit -> appStateRunning -> appStateHalt -> appStateShutdown.
Хочу обратить ваше внимание на то, что вызов Shutdown существует в трех местах:
- setError — если мы детектировали критическую ошибку, останавливаем все и выходим.
- defer a.Shutdown — в горутине, которая контролирует жизнеспособность ресурсов. Тут все просто — сбой критически важных ресурсов останавливает приложение, потому, что работать в такой обстановке невозможно.
- выход из run — для смены статуса.
Теперь немного по поводу ServiceKeeper и его метода Watch . Вызов Watch должен быть блокирующий, ведь в нашем коде Application мы вызываем его только раз, и после его выполнения происходит немедленное завершение работы через вызов Shutdown . Что требуется от реализации этого метода:
- С некоторой периодичностью выполнять Ping ресурсов, которые зарегистрированы внутри ServiceKeeper .
- Прекращать циклическое выполнение Ping при обнаружении критической ошибки и возвращать error .
- Прекращать циклическое выполнение Ping и возвращать nil , если был вызван метод Stop .
Вот реализация этих функций с учетом перехода по статусам:
func (s *ServiceKeeper) Watch(ctx context.Context) error < if !s.checkState(srvStateReady, srvStateRunning) < return ErrWrongState >if err := s.cycleTestServices(ctx); err != nil && err != ErrShutdown < return err >return nil > func (s *ServiceKeeper) Stop() < if s.checkState(srvStateRunning, srvStateShutdown) < close(s.stop) >>
Тут следует обратить внимание на обработку полученной от cycleTestServices ошибки. Т.к. все это выполняется асинхронно с основным потоком приложения, у нас есть небольшая вероятность того, что в какой то момент контекст вернет нам ошибку, которую мы зарегистрировали в поле err структуры Application . Каким образом это произойдет? Я собираюсь имплементировать все методы интерфейса context.Context в структуре Application и передавать ее в качестве контекста вместо context.TODO . Далее в имплементации cycleTestServices будет понятно, как ошибка основного приложения будет влиять на результаты выполнения метода Watch .
В реализации цикличного выполнения проверки ресурсов достаточно сделать бесконечный цикл с конструкцией select внутри и следующими вариантами выхода:
Небольшие улучшения
Контекст
Немного о контексте. В коде несколько раз проскакивал context.TODO() обычно это используют, когда еще не определились, что будет за контекст и оставили решение на потом. Для того, чтобы определиться, нам нужно понять контекст. Что это такое? Фактически контекст — это абстракция, которую можно передавать от одной функции к другой. Она иерархична — мы можем вкладывать контекст, который получили в качестве аргумента в другой контекст, который только что сами создали.
Но я не буду запутывать читателя дальше, давайте просто представим, что контекст — это переменная, передающая состояние времени выполнения. И в качестве состояния выступают: таймаут выполнения, возможность отмены процесса или какие то любые значения, которые вы можете положить внутрь контекста, если знаете, как это делать. Не будем рассматривать последнее, пока ограничимся только таймаутом и отменой.
Контекст с таймаутом создать просто, сигнатура вот этой функции подсказывает нам, что вы можете передать любой контекст (в качестве базового подойдет context.Background() ) и какой-то time.Duration в функцию WithTimeout и получить контекст с таймаутом:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
Такая же история с WithCancel и WithDeadline . Но только не нужно думать, что это какая то магия и что чудесный go-runtime это все сразу осознает и далее все работает само, а вам ничего делать не надо. Недостаточно создать контекст, его нужно еще правильно понимать. Все родные go-библиотеки и go-функции, которые принимают контекст в качестве аргумента умеют работать с контекстом и поймут все таймауты и отмены, то же самое касается сторонних библиотек, если они написаны хорошо. Но вот ваш код, если вы не научите, как работать с контекстом, будет этот контекст игнорировать, поскольку тут нет никакой магии, тут просто абстракция.
Короче, интерфейс context.Context нам предоставляет следующие методы, которые мы должны понимать:
- Done() и Err() — вызовите метод Done , чтобы получить канал, когда канал закроется, контекст достиг дедлайна или таймаута, или его попросту отменили — выходите из функции и возвращайте context.Err() в качестве ошибки.
- Deadline() — вернет вам дедлайн, если контекст содержит таймаут или дедлайн.
- Value(interface<>) — это предоставит вам доступ к переменным, которые скрыты в контексте.
Типичный пример «понимания» контекста реализован у нас в процедуре cycleTestServices
Откройте, чтобы посмотреть
func (s *ServiceKeeper) cycleTestServices(ctx context.Context) error < for < select < case case > >
Давайте имплементируем методы интерфейса context.Context , чтобы можно было передавать приложение в качестве контекста:
Имплементация context.Context
type AppContext struct<> func (a *Application) Deadline() (deadline time.Time, ok bool) < return time.Time<>, false > func (a *Application) Done() < return a.done >func (a *Application) Err() error < if err := a.getError(); err != nil < return err >// даже если никакой ошибки нет, мы должны вернуть не nil, когда наше приложение остановлено // просто потому, что канал Done() закрыт и от Err() будут ожидать причину этого if atomic.LoadInt32(&a.appState) == appStateShutdown < return ErrShutdown >return nil > func (a *Application) Value(key interface<>) interface<> < // таким способом можно получить структуру Application из контекста var appContext = AppContext<>if key == appContext < return a >return nil >
Теперь в init мы сможем заменить context.TODO() на указатель приложения
if a.Resources != nil
Таймауты для всего
С учетом таймаутов на инициализацию и ожидания завершения работы структура Application теперь выглядит вот так:
Application struct
type ( Application struct < MainFunc func(ctx context.Context, holdOn ) error Resources Resources TerminationTimeout time.Duration InitializationTimeout time.Duration appState int32 mux sync.Mutex err error holdOn chan struct<> done chan struct<> > )
Тогда, чтобы таймауты не были нулевыми и нам не приходилось их каждый раз указывать, добавим проверку на ноль и установку значения по умолчанию в init :
Инициализация таймаутов по умолчанию
const ( defaultTerminationTimeout = time.Second defaultInitializationTimeout = time.Second * 15 ) func (a *Application) init() error < if a.TerminationTimeout == 0 < a.TerminationTimeout = defaultTerminationTimeout >if a.InitializationTimeout == 0 < a.InitializationTimeout = defaultInitializationTimeout >a.holdOn = make(chan struct<>) a.done = make(chan struct<>) if a.Resources != nil < ctx, cancel := context.WithTimeout(a, a.InitializationTimeout) defer cancel() return a.Resources.Init(ctx) >return nil >
Далее немного таких же улучшений в абстракции, которая реализует контроллер ресурсов.
Добавим таймауты в ServiceKeeper
type( ServiceKeeper struct < Services []Service PingPeriod time.Duration PingTimeout time.Duration ShutdownTimeout time.Duration stop chan struct<>state int32 > ) const ( defaultPingPeriod = time.Second * 5 defaultPingTimeout = time.Millisecond * 1500 defaultShutdownTimeout = time.Millisecond * 15000 ) func (s *ServiceKeeper) Init(ctx context.Context) error < if !s.checkState(appStateInit, appStateReady) < return ErrWrongState >if err := s.initAllServices(ctx); err != nil < return err >s.stop = make(chan struct<>) if s.PingPeriod == 0 < s.PingPeriod = defaultPingPeriod >if s.PingTimeout == 0 < s.PingTimeout = defaultPingTimeout >if s.ShutdownTimeout == 0 < s.ShutdownTimeout = defaultShutdownTimeout >return nil >
Типовые error
По коду есть возврат ошибок константами, вот тут их код:
type appError string const ( ErrWrongState appError = "wrong application state" ErrMainOmitted appError = "main function is omitted" ErrShutdown appError = "application is in shutdown state" ErrTermTimeout appError = "termination timeout" ) func (e appError) Error() string
Освобождение ресурсов
Попробуем реализовать параллельное освобождение ресурсов с учетом таймаута, код представлен ниже
func (s *ServiceKeeper) release() error < // создадим контекст, его магия поможет нам ограничить выполнение функций Close shCtx, cancel := context.WithTimeout(context.Background(), s.ShutdownTimeout) defer cancel() var errCh = make(chan error, len(s.Services)) var wg sync.WaitGroup // для синхронизации будем использовать вот это wg.Add(len(s.Services)) // сразу говорим wg, сколько сигналов будем ожидать for i := range s.Services < // все Close() выполняем одновременно в разных горутинах go func(service Service) < defer wg.Done() // синхронизация // наверно правильно было бы передавать в процедуру Close контекст // для того, чтобы затянувшаяся процедура получила информацию о том, что мы ее уже не ждем // но вот в процессе освобождения ресурсов критичность в таком сигнале отпадает // мы же все равно сейчас все вырубим - не прерывать же Close . if err := service.Close(); err != nil < errCh >(s.Services[i]) > go func() < // ждем завершения всех запущенных Close wg.Wait() close(errCh) >() select < case err, ok := // норм, все без ошибок, сработал wg.Wait() return nil case > func (s *ServiceKeeper) Release() error < if s.checkState(srvStateShutdown, srvStateOff) < return s.release() >return ErrWrongState >
Для тех, кому сложно понимать комментарии по коду, я объясню словами. Мы создаем контекст с таймаутом в самом начале для того, чтобы ограничить время выполнения процедуры release , мы же не хотим, чтобы наше приложение завершалось вечно (зависло). Далее в цикле запускаем метод Close для всех зарегистрированных ресурсов и ждем их выполнения. Синхронизацию тут обеспечивает WaitGroup , мы задали число потоков методом wg.Add и этот счетчик будет откручиваться обратно с каждым вызовом wg.Done и только, когда счетчик станет равным нулю метод wg.Wait позволит пройти дальше и закрыть канал errCh .
В конце функции блокировка выполнена с помощью select конструкции, и у нас всего два варианта завершения функции: или сработает таймаут контекста shCtx.Done или что-то произойдет с каналом errCh .
Как заключение
В статье не представлен полный код библиотеки, которую мы с вами написали. Код, представленный выше, является черновым вариантом и работать не будет, если вы его скопируете и вставите в свою IDE. Весь код представлен на моем github. Кроме того, там уже готово тестовое приложение, которое я собираюсь описать в следующей статье.
Если что-то в этой статье показалось «туманным», задавайте вопросы в комментариях. Если что-то показалось неправильным, пишите в комментариях свои претензии, пообщаемся.
Я искренне надеюсь, что из этой статьи понятно, каким образом реализован сигнал о завершении работы для основного потока. Более того, я согласен, если кто-то из вас считает, что нужно было сделать сигнатуру основной функции идеоматичной, т.е. func (context.Context) error и при получении сигнала от ОС просто выполнять отмену контекста, но тут свои проблемы: в этом случае захочется передать этот контекст во все внутренние функции и отмена контекста приведет не к «мягкому завершению», а к «жесткому», а мы условились на том, чтобы выполнять завершение работы в два этапа: корректное завершение текущих работ и выход из основного потока, а это уже никак не разделить в простом контексте. В моем же случае отмена контекста наступает, когда выполнено Shutdown , а это уже правильно и разумно.
Возможно, есть такие, кто уже понимает, как пользоваться этим кодом и как его применить в своих проектах, но представленного материала мало для того, чтобы это было понятно массе. Поэтому прошу пока освоить материал и подождать выхода следующей статьи, в которой я расскажу, как с помощью этой библиотеки построить веб-приложение и при этом уделить внимание логике приложения, а не внешним проблемам вроде обработки сигнала завершения работы.
Под капотом Golang — как работают каналы. Часть 1.
Поразительное удобство написания конкурентного кода в эпоху высоконагруженных приложений и широкой распространнености микросервисов позволило приобрести языку Golang столь высокую популярность. И для написания конкурентного кода в Golang можно выделить два основных примитива:
- Горутины, предназначенные для выполнения задач независимо друг от друга, в конкурентной или параллельной манере;
- Каналы, о которых и пойдет речь в этой статье, предназначенные для обмена сообщениями и синхронизации между горутинами.
Я думаю каждый, кто уже знаком с языком Golang, так или иначе использовал каналы. И о том, как использовать каналы, есть предостаточно информации, так что сегодня мы заглянем “под капот”, и рассмотрим, как они работают изнутри. Ведь зная, как та или иная технология работает изнутри, мы можем использовать эту технологию более эффективно.
Структура каналов
Исходный код каналов досутпен на github в файле chan.go, и центральной структурой данных для канала является hchan:
type hchan struct qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
>
Давайте теперь создадим канал, запишем в него два значения, и проанализируем его внутренюю структуру(структуру hchan):
ch := make(chan int, 2)
ch // заполненная структура hchanch =
qcount = 1
dataqsiz = 2
*buf = len:2
elemsize = 8
closed = 0
*elemtype =
sendx = 1
recvx = 0
recvq = >
sendq = >
lock =
- qcount определяет количество элементов в буфере(мы видим 1 т.к. записали одно значение в наш канал)
- dataqsiz определяет размерность буфера для буферизированного канала, в нашем случае это 2;
- buf — определяет буфер с данными, записанными в канал, реализованный с помощью структуры данных “кольцевой буфер”
- elemsize — размер одного элемента в канале, в нашем случае это 8 байт
- closed — определяет закрыт или открыт канал в данный момент;
- elemype — содержит указатель на тип данных в канале;
- sendx и recvx содержат индексы (смещения) в буфере, по которым должна производиться запись и чтение из буфера соответственно;
- sendq и recvq — односвязные списки, содержащие заблокированные горутины, ожидающие чтения или записи;
- lock — мьютекс, используемый для операций, изменяющих состояние канала
Создание каналов
Создание канала происходит при помощи функции makechan, которая описана в том же исходном файле chan.go:
func makechan(t *chantype, size int) *hchan
Функция makechan выделяет память под структуру hchan в куче, инициализирует эту структуру, и возвращает указатель.
И несмотря на то, что в go все передается по значению, передавать канал по ссылке бессмысленно, потому что под капотом канал — это и есть указатель.
Запись и чтение
ch := make(chan int, 2)
ch
При записи в канал, происходит следующее:
- структура приобретает блокировку мьютексом;
- в буфер копируется значение, которое мы положили в канал;
- структура освобождается от блокировки мьютексом;
И стоит обратить внимание на то, что при добавлении в буфер, элемент копируется.
При чтении из канала, последовательность действий аналогичная:
- структура приобретает блокировку мьютексом;
- из слота буфера копируется значение с последующим удалением;
- структура освобождается от блокировки мьютексом.
Копирование значений при записи в буфер/чтении из буфера гарантирует нам отсутствие разделяемой памяти. Поэтому единственная разделяемая память, которая присутствует в канале — это сама структура hchan, которая, однако, блокируется встроенным в структуру мьютексом.
Ожидание записи
Почему при заполнении буфера, последующая попытка записи в канал приведет к приостановке потока выполнения пишущей горутины?
Поскольку создание и управление горутинами — это зона ответственности runtime самого Go, то runtime может без труда приостанавливать выполнение одной горутины, и запускать выполнение другой.
Итак, когда горутина осуществляет попытку записи в канал, буфер которого полон, рантайм переводит эту горутину в состояние “waiting”.
Предположим, что теперь запись возможна, так как другая горутина прочитала данные из канала. Как рантайм узнает, что нужно продолжить выполнение нашей горутины?
Для дальнейшего понимания процесса ожидания, нам нужно рассмотреть вспомогательную структуру sudog:
// sudog представляет заблокированную горутину, ожидающую чтения или записи
type sudog struct g *g // ссылка на горутину
elem unsafe.Pointer // данные для записи // .
>
Мы видим, что канал содержит в себе ссылку на ожидающую горутину, представленную структурой sudog. Эта структура помещается в односвязный список waitq. И когда буфер становится доступным для заполнения, происходит следующее:
- очередная структура, представляющая ожидающую горутину sudog извлекается из списка waitq;
- данные из поля elem добавляются в буфер канала;
- горутина из sudog переходит из состояния “waiting” в состояние “runnable” (готова к выполнению).
Ожидание чтения
Что происходит при попытке чтения из канала, буфер которого пуст?
- Для горутины, осуществляющей попытку чтения из канала, создается структура sudog;
- Горутина переходит в состояние “waiting”
- Односвязный список recv пополняется структурой sudog;
Стоит отметить, что в поле elem структуры sudog содержится адрес памяти, в который будет записано значение из канала. Этот факт пригодится для дальнейшего понимания процесса.
Когда другая горутина записывает данные в канал, последовательность действий отличается:
- Данные не пишутся в буфер, а пишутся напрямую в стек читающей горутины;
- Читающая горутина переходит из состояния “waiting” в состояние “runnable” (готова к выполнению).
Такой подход позволяет сократить накладные расходы на блокировку буфера мьютексом. Сам буфер также остается нетронутым, и не проиходит дополнительных операций копирования.
Итак, мы рассмотрели общее устройство каналов, процесс их создания, а также запись и чтение для буферизированных каналов.
Во второй части статьи мы рассмотрим небуферизированные каналы, функцию close(), а также оператор выбора select.
Деплой, базы данных и мониторинг: жизнь после перехода на Go
Спикер курса «Golang для инженеров», Team Lead & Backend Developer в «Ситимобил» Тигран Ханагян рассказывает о том, как и почему произошел переход на Golang в онлайн-сервисе такси.

Интересно получается. На первом месте, конечно же, Python. Это логично: на Python написана тонна проектов. Например, основная масса Data Scientist проектов делается на нем. Сфера применения этого языка, в принципе, очень широкая.
На втором месте JS. Здесь тоже понятно: фронтенд-разработка, дизайн, ui/ux. С недавних пор эта область начала стремительно развиваться, поэтому JS вместе с фронтенд-разработкой претерпел серьезные изменения и, можно сказать, вышел на другой уровень.
Барабанная дробь. На третьем месте – Go! Это довольно достойное место, потому что к JS подмешан еще и фронтенд. А Python не подходил, потому что не совсем решал список проблем разработки в компании.
Выбор был сделан в пользу Golang
Golang больше всего используется в IT-сфере. На нем разрабатывается солидный процент веб-сервисов, но что интересно: на Go также пишутся различные утилиты для приложений, библиотеки и фреймворки, осуществляется много инфраструктурной разработки. БД тоже есть. Стоит вообще упоминать про Kubernetes? С некоторыми дополнительными инструментами можно даже вести фронтенд-разработку.

В дополнение к сфере IT язык активно используют в финансовом секторе и облачных вычислениях.
Golang – компилируемый язык со строгой типизацией. Таких языков есть достаточно много, поэтому нужно было смотреть, чем Go отличается от того же C++. Вот график со временем компиляции проекта.

Go достаточно быстрый при компиляции – даже быстрее, чем С++. В больших проектах, где разработка ведется на C++, разработчик запускает компиляцию и идет пить кофе в ожидании, пока проект соберется, чтобы что-то потестировать. В этом плане Golang сразу приятно удивляет: он быстро собирается и компилируется в бинарник из коробки, дополнительно делать ничего не нужно.
Помимо компиляции есть runtime. Перед вами график, который показывает скорость выполнения тех или иных алгоритмов.

Это открытый ресурс, который проводит серию стандартных тестирований, чтобы определить, как язык справляется с разными алгоритмами и структурами данных.
Сравнивались Python, Node js, Golang, C++ и Java. Сразу видно, что Python проигрывает по производительности. Остается С++, Java, Node js и Golang. Node js в некоторых местах гораздо слабее остальных языков.
Остается выбор между Golang, Java и С++. Последний, конечно же, быстрее всех, а у Java с Golang показатели плюс-минус одинаковые.
Почему не С++? Разработка на этом языке – трудоемкий процесс, плюс переехать с PHP на С++ очень дорого и долго. Писать на C++ круто, но когда необходимо переписать монолит на отдельные микросервисы, решение не самое лучшее.
Остались Java и Golang. С Java не все так просто: у этого языка есть виртуальные машины, которые требуют определенной инфраструктуры и ресурсов.
Что там с простотой
Go достаточно прост тем, что у него очень мало синтаксического сахара. Вот список ключевых слов, которые есть в этом языке.

Изучив этот небольшой набор слов, вы уже сможете писать программы на Go. Поэтому освоить синтаксис языка не составит большого труда.
Что Golang предоставляет из коробки? Выше уже было написано, что в PHP по стандарту количество клиентов равно количеству соединений в БД, поэтому каждый новый запрос открывает новое соединение в базу. В Go из коробки используется пул соединений.

Здесь можно контролировать количество соединений для входа в базу на уровне приложения, прямо в коде. Возможно даже держать несколько таких пулов, например: пул для мастер-базы, пул для slave-базы, отдельный пул для slave-а, но с менее требовательными к таймаутам настройками – медленный slave. Он часто используется для аналитических запросов, где можно настроить тайм-аут на 10-20 секунд.
Также можно использовать отдельные пулы на приоритезацию. Представьте: есть пул соединений, на котором висит важная бизнес-логика. Потерять этот пул равносильно катастрофе. Так же есть пул с менее критичной логикой. При проблемах с сетью можно пожертвовать этим пулом, чтобы основной пул продолжил работать. С инструментами, которые дает Golang из коробки, это очень легко и удобно делать. Работать с базой данных в Go – приятно.
Переходим к отладке и debugging-у. У Go есть большое комьюнити, масса разных инструментов и библиотек. Не нравятся стандартные инструменты, которые идут из коробки? Похожий инструмент, но с доработками или дополнительными аспектами, можно найти в комьюнити.
В отладке то же самое. Из коробки имеем достаточно большой спектр инструментов, который позволит профилировать код. Справа на картинке вы видите граф вызовов функций.

Такой граф строит профилировщик, который есть в языке. Что это дает? Можно оперативно найти какие-то бутылочные горлышки в программах и устранить проблему. Без труда определить, какое место в коде тормозит. Существуют различные форматы, в которых это можно делать, но главное – инструмент это позволяет.
Слева на картинке показан результат просмотра программы в таком инструменте, как трассировщик. Он может показать более подробную информацию, так как его квант времени детальнее, чем у профилировщика. И здесь уже можно посмотреть: в каких горутинах что выполняется, что тормозит, что ждет и т. д. Это тоже мощный инструмент, с помощью которого можно находить узкие места в программе.
Как go решает проблему билда и деплоя
В PHP было очень неудобно работать с множеством файлов и папок. Go дает возможность скомпилировать все в один бинарник, причем это легко сделать для различных систем.

Находясь в Windows, мы можем собрать бинарник для Linux и доставить на продакшн тот бинарник, который подходит для этой системы. В конечном счете получается, что выкатка кода в production – это просто доставка одного файла, одного бинарника. Что очень удобно, согласитесь.
Подводя итоги
Перейти на Go достаточно просто, потому что язык не так многословен в плане синтаксиса. Управляющих конструкций не так много, как в других языках.
Golang сильно упростил в онлайн-сервисе такси «Ситимобил» вопросы билда и доставки кода: все свелось единственному файлу. Теперь разработчики имеют дело с одним бинарником, который можно доставить с помощью докер образа. Не хотите с помощью докера? Никто не мешает просто собрать этот бинарник, каким-либо образом доставить его на сервер и просто запустить.
С метриками тоже все прекрасно складывается, не нужна никакая дополнительная инфраструктура: в Go сбор метрик происходит в отдельной горутине параллельно с выполнением наших программ. Метрики копятся в памяти, пока за ними не придут. Как только кто-то вызывает API-метод, сервис на Go достаточно вежливо предоставляет метрики. Здесь не нужно изобретать велосипед, и все происходит компактно, в рамках 1 сервиса.
Очень важно, что Go позволил серьезно оптимизировать работу с БД. При больших нагрузках страдает, в основном, именно база данных. Особенно при неоптимальных запросах к ней. Уметь оперативно среагировать на это в нужной ситуации – серьезное преимущество, потому что можно держать намного больше нагрузки на приложение.
La Fin (конец). Практическую часть вебинара смотрите на этом видео, тайминг: 37:18 – 59:00.
Если хотите глубже погрузиться в Go
Занимайте места на курсе Golang для инженеров. Разберём особенности работы с контейнерами, тестами, кастомными операторами и паттернами Kubernetes. К концу курса создадим систему, которая будет собирать состояние других сервисов, сохранять собранное состояние в базу данных и предоставлять WEB API для доступа к сохраненным данным.
Обучение пройдёт в живом формате — будут онлайн-встречи со спикерами, обратная связь по домашним заданиям от ревьюеров и закрытый чат для участников.
Saved searches
Use saved searches to filter your results more quickly
Cancel Create saved search
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session. You switched accounts on another tab or window. Reload to refresh your session.
Вопросы для собеседования на языке Golang
dmitryrpm/maxima-tests
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Switch branches/tags
Branches Tags
Could not load branches
Nothing to show
Could not load tags
Nothing to show
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Cancel Create
- Local
- Codespaces
HTTPS GitHub CLI
Use Git or checkout with SVN using the web URL.
Work fast with our official CLI. Learn more about the CLI.
Sign In Required
Please sign in to use Codespaces.
Launching GitHub Desktop
If nothing happens, download GitHub Desktop and try again.
Launching GitHub Desktop
If nothing happens, download GitHub Desktop and try again.
Launching Xcode
If nothing happens, download Xcode and try again.
Launching Visual Studio Code
Your codespace will open once ready.
There was a problem preparing your codespace, please try again.
Latest commit
Git stats
Files
Failed to load latest commit information.
Latest commit message
Commit time
README.md
10 тестов на понимание golang. Нужно прочитав код ответить на один и тот же вопрос — «что выполняет программа», далее можно проверить свой ответ запустив конкрентный файл
go get -d -u github.com/dmitryrpm/maxima-tests cd $ /src/github.com/dmitryrpm/maxima-tests go run test1.go # etc
Так же популярные вопросы задаваемые на интервью
- Как хранятся переменные в Golang? Что такое «стек» и «куча»? Почему аллокация в «куче» дорогая? Во сколько раз?
- Как в golang освобождаетс память и можно ли отключить это поведение и зачем это делать?
- Что такое интерфейс и как используется? Как устроен пустой интерфейс?
- Как устроен слайс и чем он отличается от массива? Как создать многомерный массив в Golang? Нужно ли передавать slice по ссылке в фукнцию?
- Что происходит в runtime Golang?
- В чем различия goroutine от потока системы?
- Как огранить число потоков на системы при запуске Golang программы и возможно ли огранить их до 1 потока?
- Что такое каналы и каких видов они бывают? Что будет если писать в закрытый канал? Что будет если писать в неинициализированный канал?
- Расскажите о ООП в Golang
Вопросы по database/sql:
- Когда создается и закрывается подключение к БД?
- Как ограничить кол-во подключений к БД, сколько их создается по умолчанию?
- Что если в пуле нет соединеней?
- Что произойдет если БД закроет соединение?
PS: Если Вы посещали собеседования и нашли интересную задачку/тест/вопрос по языку Golang — смело делайте PR в мастер, давайте вместе расширим базу знаний, которая поможет множеству программистов разных уровней разобраться в особенности языка. Любые вопросы/пожеления/pr (особенно в целях обучения) и тд — приветствуются!
About
Вопросы для собеседования на языке Golang