Активные объекты в Go

Активные объекты – это прекрасно, но не всегда и везде легко доступны. Почему они могут быть полезные в Go и как их эмулировать ниже. И, да, я осознанно использую дремучий термин Активные объекты из POSA, так как Акторы в современном виде – это сильно более объемный и разносторонний концепт. BlaBlaManager о котором пойдет речь ниже, был взят в качестве иллюстрации исключительно потому, что с чем-то подобным я не так давно боролся, но сама идея Активных объектов и уж тем более Акторов куда более широко применима, именно за этой информацией я бы посоветовал либо сходить к eao197, либо почитать про AKKA.

В большинстве приложений обладающих состоянием скорее рано чем поздно заводиться объект с именем так или иначе похожим на BlaBlaManager. В стародавние времена, аккурат на пике популярности GOF, он был формой синглтона, да и сейчас, к сожалению, часто им остается. Если дизайн у проекта изначально был верный, или если проект пережил рефакторинг, то BlaBlaManager будет управлять только одним ресурсом, но может и не повезти, тогда BlaBlaManager окажется свалкой всего и вся, объектом-Богом. Так же BlaBlaManager обычно имеет методы похожие на RegisterFoo, RemoveBoo, FindBazz и тому подобное. То есть речь идет об объекте, который хранит некоторое динамически изменяемое состояние системы и/или одной из её подсистем. Думаю, все так или иначе вспомнили о подобном объекте в текущем проекте, если же нет, то я вам очень завидую.

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

Но как решить эту проблему просто и вообще без синхронизации? Самым оптимальным решением будет некое подобие Активного объекта, который выполняется в отдельном потоке, а для общения с внешним миром использует сообщения. В зависимости от языка программирования, каким-то разработчикам повезло больше, каким-то меньше. Так разработчики на Erlang имеют решение из коробки, разработчики на Scala имеют де-факто стандарт для таких задач – AKKA. Разработчики на C++ тоже не обделены вниманием, у них есть SObjectizer и другие решения. А вот в мире Go, всё чуть сложнее чем хотелось бы. У нас есть CSP и довольно мощные решения типа Proto.actor. При этом CSP при прямом использовании не решает проблемы, а скорее усугубляет ее, а Proto.actor является слишком громоздким решением, которое обеспечивает распределенные вычисления по принципу Акторов, а не небольшая внутрипроцесная задача по разграничению доступа к ресурсам. Как мне думается, тут нужно небольшое, простое решение, а именно – эмуляция требуемого поведения при помощи каналов.

Для упрощения предположим, что BlaBlaManager умеет выполнять две задачи: генерировать случайные числа (1) и запускать внешние команды с возвратом содержимого stdout вызвавшему (2). При этом, несмотря на то что выполнение внешней команды куда более долгая операция, она не должна блокировать генерацию случайных чисел.

type requestCommand int

const(
        genNum requestCommand = iota // (1)
        runCmd                       // (2)
)

Сам запрос состоит из идентификатора запроса, данных необходимых для выполнения запроса и канала через который получатель ожидает результат.

type request struct {
        cmd requestCommand
        data interface {}
        out chan response
}

Так как в Go нет обобщенного программирования, без приведения типов не обойтись. Как аргументы запроса так и ответ могут быть совершенно чем угодно.

type response interface {}

Обработчик запросов довольно прост, это просто бесконечный цикл в котором ожидается либо запрос на завершение (2), либо запрос на выполнение команды (1). При желании эти два запроса можно объединить, но мне кажется данный вариант более наглядным.

func eventLoop(done chan struct{}, inCh chan request) {
        for {   // infinity event loop
                select {
                case req := <-inCh:         // (1)
                        processRequest(req)
                case <-done:                // (2)
                        break
                }
        }
}

С выполнением команд имеется небольшая тонкость. Главный цикл НЕ ДОЛЖЕН блокироваться ни при каких условиях, НИКОГДА. Поэтому, для каждого из запросов необходимо решить, можно ли его выполнить в рамках главного цикла обработки сообщений (1) или стоит вынести в отдельную goroutine (2). Вариант с выносом в отдельную goroutine (2) представлен в упрощенном виде, когда явно можно вернуть данные (5) или ошибку (4) в канал запроса. Задержка (3) используется исключительно в целях иллюстрации того, что одна команда выполняется заметно дольше другой команды. При более сложной логике может появиться необходимость в расширении requestCommand новой командой, например runCmdRes, которую запущенная goroutine поместит на обработку в processRequest в качестве результата.

func processRequest(req request)  {
        switch req.cmd {
        case genNum:           // (1)
                req.out <- rand.Intn(100)
        case runCmd:           // (2)
                go func() {
                        time.Sleep(1*time.Second)  // (3)

                        cmd := req.data.(string)
                        out, err := exec.Command(cmd).Output()
                        if err != nil {
                                req.out <- err.Error() // (4)
                        } else {
                                req.out <- string(out) // (5)
                        }
                }()
        }
}

Осталось чуть-чуть технических деталей,а именно: какие каналы нужны и почему.

done := make(chan struct{})      // (1)
defer close(done)
inCh := make(chan request, 10)   // (2)
defer close(inCh)

Обработчик запросов eventLoop принимает два канала, один в качестве признака завершения цикла обработки (1), который должен быть синхронным каналом, так как это позволит дождаться приема запроса на выход в eventLoop и каналом в запросами (2), который должен иметь очередь разумного размера. Дело в том, что каналы в Go не могут менять размер очереди на лету (тут немного информации о том, почему так), а блокировка на ожидании помещения запроса в очередь ломает логику.

Теперь пример того, как отправить запрос и получить на него ответ. Само собой, все эти вызовы целесообразно обернуть в функции, но для наглядности привожу в виде выжимки.

go eventLoop(done, inCh)        // (1)

resp1 := make(chan response, 1) // (2)
inCh <- request{                // (3)
        resp1,
        runCmd,
        "ls",
}

resp2 := make(chan response, 1) // (4)
inCh <- request{                // (5)
        resp2,
        genNum,
        "resp1",
}

fmt.Println(<-resp2)            // (6)
fmt.Println(<-resp1)            // (7)

done <- struct{}{}              // (8)

Как и говорил раньше, обработчик событий должен быть запущен в своей собственной, отдельной goroutine (1). Общаться с этим обработчиком можно при помощи отправки запросов (3), (5). eventLoop не должен блокироваться при отправке ответа, поэтому каналы ожидания ответа (2), (4) должны иметь один слот для сообщения, больше так же не требуется, так как будет вернут строго один ответ на отправленный запрос. Несмотря на то, что запрос на запуск команды “ls” (3) будет гарантированно (см. код processRequest) выполняться больше 1-й секунды и он был помещен до запроса на генерацию числа (5), число придет первым (6). И в рамках завершения работы eventLoop остается только отправить запрос на выход (8).

И в заключение Gist со всем этим безобразием.

Leave a Reply