Программист-4chan'овец
- среда, 25 сентября 2024 г. в 00:00:07
Мы используем Go для создания Dolt, первая в мире БД SQL с контролем версий. Как и большинство кодовых баз, основанных Go, мы используем каналы и горутины(от переводчика, автора этой статьи на Хабре: у меня есть хорошая статья на тему параллелизма в Go) для реализации параллелизма. Как правило мы используем эти конструкции очень скучным и обычным путем, ведь параллелизм и так сложен без всяких выдумок. Но в одном месте мы все-таки взяли маленький кусочек кода из другого open-source проекта, который использует каналы очень интересным способом: канал используется для отправки другого канала:
var c chan chan struct{}
Это канал, который отправляет другой канал, который отправляет структуру. По факту, это способ передачи каналов между разными горутинами, чтобы реализовать паттерн fan-out . Думайте о "центральном" канале как о посреднике в рабочем процессе: его работа заключается в том, что бы передавать новые каналы по мере их поступления вплоть до рабочих, которые взаимодействуют с ними что-либо выполняя реальное. Например: записывать число в него.
Это работало, но идея была чересчур специфична, с которой было трудно работать, учитывая утечки горутин. Мы переписали это, но chan chan struct{}
исчезло.
Эта идея заставила меня подумать. Насколько далеко можно зайти в этой глупой идее? Узрите то, чего не ожидали, но за чем пришли. 4-chan:
_4chan := make(chan chan chan chan int)
Это старая шутка из времен, когда C и подобные доминировали. Много людей долгое время не могли понять принцип указателей(pointers), наверное потому, что у них не было этого мема:
Из-за того, что указатели были сложны для понимания многим людям, а использовались они для важных и полезных вещей в C, новички иногда делали довольно глупые вещи, например: объявляли переменные следующим образом:
int****
Эти новички не понимали, какую глупость они делают, считая это признаком опыта. Таких несчастных называли "4-звездочными программистами"(4-star programmers).
Так как Go тоже поддерживает указатели, у Вас есть возможность сделать такую же вещь:
func main() {
i := 1 setInt(&i) fmt.Printf("переменная i равна %d", i)
}
func setInt(i *int) {
setInt2(&i)
}
func setInt2(i **int) {
setInt3(&i)
}
func setInt3(i ***int) {
setInt4(&i)
}
func setInt4(i ****int) {
****i = 100
}
После запуска эта программа выведет переменная i равна 100
. Вы тоже можете быть 4-звездочным программистом, это не сложно.
Но мы можем зайти дальше и использовать конструкцию, которая есть в Go, но которой нет в C: каналы. В коде они обозначены как chan
.
Понимаете к чему я веду?
Идея заключается в том, что мы будем использовать 4 слоя канала для выполнения какой-то задачи. Наш верхний уровень канала будет четвертым, поэтому обозначать его будем так:
_4chan := make(chan chan chan chan int)
(тут немного раздражает то, что Go не разрешает начинать переменные с цифры, но ничего не поделаешь, такова жизнь).
Данные, которые будем посылать в этот канал будут выглядеть так:
_3chan := make(chan chan chan int)
А в этот канал будем отправлять _2chan
и так далее до самого конца, где будем записывать int.
На каждом слое обращения к другому каналу будем создавать продьюсеров(producers) по некоторому постоянному фактору factor
. У нас это будет const factor = 3
:
func sendChanChanChan(c chan chan chan chan int) {
for range factor {
go func() {
logrus.Debug("стартую 3chan producer")
_3chan := make(chan chan chan int)
sendChanChan(c, _3chan)
}()
}
}
И похожее для консюмеров(consumers):
func receiveChanChanChan(c chan chan chan chan int) {
for _3chan := range c {
logrus.Debug("получено сообщение с 4chan")
for range factor {
logrus.Debug("стартую 3chan consumer")
go receiveChanChan(_3chan)
}
}
}
Продолжаем дальше:
func sendChanChan(_4chan chan chan chan chan int, _3chan chan chan chan int) {
_4chan <- _3chan
for range factor {
go func() {
logrus.Debug("стартую 2chan producer")
_2chan := make(chan chan int)
sendChan(_3chan, _2chan)
}()
}
}
И по такому принципу до sendChan
и receiveChan
.
В самом конце очереди мы высылаем фактическое значение, а не еще один канал. К этой функции(send
) будем обращаться из sendChan()
:
func send(_2chan chan chan int, _1chan chan int) {
_2chan <- _1chan
for range factor {
go func() {
logrus.Debug("стартую int producer")
for range factor {
go func() {
logrus.Debug("отправляю число")
_1chan <- 1
}()
}
}()
}
}
Для консюмеров(consumers) мы должны что-то сделать с данными, которые получили в каналы. Давайте их сложим.
func receive(c chan int) {
for s := range c {
logrus.Debug("получено число")
sum.Add(int32(s))
}
}
Теперь все это объединим:
const factor = 3
var sum = &atomic.Int32{}
func main() {
logrus.SetLevel(logrus.DebugLevel)
_4chan := make(chan chan chan chan int)
go sendChanChanChan(_4chan)
go receiveChanChanChan(_4chan)
time.Sleep(500 * time.Millisecond)
fmt.Printf("%d ^ 5: %d", factor, sum.Load())
}
Эта программа печатает 3 ^ 5: 243
. Все правильно! Она представляет собой обобщенный способ вычисления пятой степени числа максимально распределенным способом. Вы можете потыкать это здесь (или посмотреть с подсветкой синтаксиса тут). Для более крупных факторов Вам может потребоваться увеличить длительность "засыпания" в функции time.Sleep()
.
Если Вы включите логгирование(раскомментировав первую строку в main), Вы получите примерно такой вывод, что помогает посмотреть на все слои каналов:
стартую 3chan producer
стартую 2chan producer
стартую 3chan consumer
стартую 2chan consumer
стартую chan producer
стартую 1chan consumer
стартую int producer
получено число
отправляю число
Есть много причин не делать этого в реальном коде: сложность реализации и отладки, наличие небольшого самоуважения, нежелание быть забитым до смерти клавиатурами Ваших коллег и т.д.
С другой стороны, это весело делать и забавно осознавать, что это вообще работает.
Одна из лучших практических причин не отправлять каналы по каналам заключается в том, что это действительно затрудняет закрытие любого из них, что, очевидно, Вы хотели бы сделать в реальном коде, верно? В какой-то момент я фактически реализовал логику закрытия, что потребовало добавления sync.WaitGroup
буквально везде, чтобы я мог отслеживать, когда все отправленные каналы были завершены для их закрытия, но остановился на time.Sleep()
.