Введение

Мы используем 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-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().

Комментарии (3)


  1. johhy13
    24.09.2024 15:06
    +2

    Занятно) Может тэг добавить Ненормальное программирование?


    1. mo0Oonnn Автор
      24.09.2024 15:06

      Добавлено)


  1. Sly_tom_cat
    24.09.2024 15:06

    Ну chan chan struct{...} вполне юзабельный вариант.
    Канал в который пихаются каналы со структурами - это канал в который попадают собранные задачи на обработку данных, а вот chan struct{...} это и есть набор задач, который можно из разных го-рутин нагрузить и закрыть. На приемном конце читаем канал и далее по нему for range - обрабатываем.
    Недавно такое пришлось в одном проекте прописать что-бы разделить монструозную функцию на две более простых (как в понимании работы так и в тестировании).

    Если не усердствовать то там все хорошо отслеживается и никуда не течет.