В рамках моей повседневной работы над IBM Cloud иногда я пишу код на Golang. Эта статья о технике, которая мне показалась полезной.

Каналы в Голанге великолепны. Ожидается, что они решат различные проблемы, когда дело доходит до общения внутри процесса. Существует множество руководств, в которых показаны решения, основанные на каналах + программах + контекстах. Шаблоны включают в себя разветвление, разветвление, конвейеры, ограничители скорости, балансировщики нагрузки и т. Д. Однако, когда я начал создавать код с нуля, иногда мне приходилось бороться, особенно в случаях запроса-ответа. Оказалось, что мне по-прежнему следует использовать мьютексы, но я не хотел. Этот пост о том, как этого не делать и вместо этого помещать каналы внутри каналов.

Эта проблема

Сначала это звучит странно, поэтому позвольте мне показать пример из реальной жизни. Представьте, что у вас есть данные, которые время от времени меняются. Вы можете назначить метод владельца, работающий в обычном режиме, для выполнения обновлений. Допустим, ваш процесс является сетевой службой, и данные субъекта обслуживаются через его API, когда приходит клиентский запрос. Это означает, что данные будут считываться из той же структуры данных, которая тем временем может измениться внутри процедуры ее владельца. Давайте посмотрим на фиктивный пример этого:

Как видите, функция Run() меняет данные каждую секунду. Функция Get() той же структуры может читать данные при каждом вызове. После этого есть тестовый пример без каких-либо утверждений, но он все еще полезен. Он порождает функцию Run(), засыпает секунду и считывает данные. В цепочке инструментов Golang есть очень полезный инструмент, который может обнаруживать гонки данных. Попробуйте это с помощью этой команды: go test . -race. Результат вроде как ожидаемый, у нас серьезная гонка данных:

➜ go test . -race
==================
WARNING: DATA RACE
Write at 0x00c00009e070 by goroutine 9:
  github.com/libesz/datarace.(*Data).Run()
      /Users/gergo/go/src/github.com/libesz/datarace/datarace_test.go:22 +0x213
Previous read at 0x00c00009e070 by goroutine 8:
  github.com/libesz/datarace.TestConcurrent()
      /Users/gergo/go/src/github.com/libesz/datarace/datarace_test.go:29 +0x96
  testing.tRunner()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:909 +0x199
Goroutine 9 (running) created at:
  github.com/libesz/datarace.TestConcurrent()
      /Users/gergo/go/src/github.com/libesz/datarace/datarace_test.go:34 +0x7a
  testing.tRunner()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:909 +0x199
Goroutine 8 (running) created at:
  testing.(*T).Run()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:960 +0x651
  testing.runTests.func1()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:1202 +0xa6
  testing.tRunner()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:909 +0x199
  testing.runTests()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:1200 +0x521
  testing.(*M).Run()
      /usr/local/Cellar/go/1.13.4/libexec/src/testing/testing.go:1117 +0x2ff
  main.main()
      _testmain.go:44 +0x223
==================
--- FAIL: TestConcurrent (1.00s)
FAIL
FAIL    github.com/libesz/datarace      1.325s
FAIL

Он отлично сообщает, что чтение и запись могут происходить одновременно и могут вызвать повреждение данных. Проблема в том, что в вашем приложении может быть несколько одновременных задач, которые хотят получить доступ к одним и тем же данным. Вы никогда не знаете, как данные записываются в рутине вашего владельца, а чтение будет запланировано в потоках операционной системы. Это классический вариант использования с несколькими авторами и несколькими читателями, и именно здесь решения обычно сводятся к алгоритмам блокировки (как использовать rw-мьютексы и т. Д.). Даже учебники Golang предлагают здесь мьютексы, в то время как философия языка такова: Не общайтесь, разделяя память; вместо этого делитесь памятью, общаясь .

Некоторые начальные попытки

Поэтому мы хотим создать общий шаблон без использования мьютексов или других примитивов синхронизации в коде для приведенного выше варианта использования. Каналы всегда должны помогать, верно? :)

Но для того, чтобы отправить что-либо по каналу, обработчик должен быть известен как отправителю, так и получателю. Должен ли он быть частью структуры Data? Скажем, у нас есть поле UpdateChan chan int в структуре, где программа-владелец может объявить об изменениях. У нас сразу возникает несколько проблем:

  • Если обновление никого не интересует, то отправитель будет заблокирован. Если мы создадим буферизованный канал, то в первую очередь будут использованы устаревшие обновления.
  • Если запрос является специальным (то есть когда кто-то выполняет запрос API), передача информации в канал также должна выполняться по запросу.

Можем ли мы использовать канал как для отправки запроса на чтение данных, так и для последующей отправки данных по тому же самому каналу? Что ж, лучше не надо. Каналы набраны, и это отличная особенность Go. Это означает, что данные должны иметь конкретный тип данных, и лучше избегать struct{} плюс неприятных приведений. Это также подразумевает, что отправитель и получатель не должны постоянно меняться местами для двусторонней связи.

Кажется, нам нужны два канала, чтобы исправить это ... Один для отправки запроса на чтение, а другой для отправки данных обратно в процедуру выполнения запроса. Что-то вроде этого:

type Data struct {
 secretOfTheSecond int
 ReadRequest chan struct{}
 ReadResponse chan int
}

Мы приближаемся. Если какая-либо подпрограмма требует данных, она отправляет что-либо в канал ReadRequest и начинает прослушивание на канале ReadResponse. Run() ожидает, что что-нибудь будет помещено в ReadRequest канал, и отправит текущие данные в ReadResponse канал.

Но теперь у нас есть новая проблема ...

Канал может свободно прослушиваться несколькими программами одновременно. Если несколько получателей заинтересованы в обновлении, исходящем из канала ответа, только один из них сможет прочитать обновление. Конечно, если есть несколько получателей, все они должны отправлять запросы данных, но какой ответ - для кого и т. Д. На наш взгляд, это небезопасно.

Канал ответа должен принадлежать инициатору запроса данных и не должен использоваться совместно.

Создать канал в канале

Конечно, это не я изобретал. Каналы - это первоклассные граждане, своего рода базовый тип данных в Go. В рамках объявления им назначается другой произвольный тип данных, который они могут транспортировать позже. При этом канал может содержать данные любого типа, которые, опять же, могут быть каналом! Элегантный.

Наша новая структура данных может выглядеть так:

type Data struct {
 secretOfTheSecond int
 readRequest chan chan int
}

Теперь мы можем повторно реализовать метод Get() для структуры, чтобы создать эксклюзивный канал (типа int) в контексте читателя и отправить его в канал readRequest. Процедура управления владельцем данных может отправить это событие и может извлечь канал, созданный инициатором запроса данных, для отправки ответа.

Полный пример выглядит так:

Как мы видим, у нас больше нет гонки за данные:

➜ go test . -race
ok      github.com/libesz/datarace      2.200s

В рамках усовершенствования мы начали преобразовывать наш метод Run() в небольшой цикл обработки событий (для ясности обработка ошибок не реализована). Он будет сериализовать все входящие запросы с обновлениями данных. Вы можете расширить этот пример, добавив несколько разных запросов на чтение (или запись!) С их соответствующими каналами в полях членов канала в структуре.

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

Если у вас очень чувствительное к производительности и времени приложение, вы можете прочитать другую статью, прежде чем применять описанную выше идею. Это подробный тест производительности канала Голанг: https://syslog.ravelin.com/so-just-how-fast-are-channels-anyway-4c156a407e45