Конкуренция в Go

Теория: Каналы: основы

Каналы продолжают модель конкуренции Go и связывают горутины так же естественно, как функции связываются параметрами и возвращаемыми значениями. Если горутины отвечают за выполнение работы, то каналы задают траекторию движения данных между ними. В этой связке и рождается практическая модель CSP: последовательные участки кода общаются не через общую память, а через явно обозначенные потоки сообщений.

Что такое канал и как он создается

Канал в Go представляет собой типизированный путь передачи значений. У него есть направление потока данных, возможный буфер и строгий тип T, который определяет, какие значения пропускает этот путь. Объявление канала использует форму chan T, а создание выполняется через функцию make.

package main

import "fmt"

func main() {
	ch := make(chan int)   // создание небуферизованного канала целых чисел
	fmt.Printf("%T\n", ch) // выводит: chan int
}

Здесь создается канал для целых чисел. До вызова make переменная канала имеет нулевое значение nil, и любая попытка отправить или прочитать из такого канала приводит к вечной блокировке горутины. Именно поэтому инициализация через make является обязательной частью жизненного цикла канала.

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

Отправка и получение через оператор <-

Оператор <- определяет, в какую сторону движется значение. Конструкция ch <- x означает отправку x в канал ch, а выражение v := <-ch — получение значения из канала. При этом обе операции блокирующие: отправитель ждет приемника, а приемник ждет отправителя.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string) // канал для строк

	go func() {
		ch <- "готово" // отправка значения в канал
		fmt.Println("отправка завершена")
	}()

	msg := <-ch // получение значения из канала
	fmt.Println("получено:", msg)

	time.Sleep(10 * time.Millisecond) // небольшая пауза для вывода второго сообщения
}

Сначала горутина, выполняющая main, блокируется на операции <-ch, если отправитель еще не успел. Как только анонимная горутина выполняет ch <- "готово", обе стороны синхронизируются: значение передается, main получает строку и продолжает работу, а отправитель переходит к выводу сообщения «отправка завершена».

Если убрать отправителя и оставить только чтение из канала, горутина с чтением останется ждать вечно.

func main() {
	ch := make(chan int)
	<-ch // чтение без отправителя приводит к deadlock
}

В такой ситуации рантайм завершит программу с ошибкой взаимной блокировки и сообщением о том, что все горутины уснули. Этот пример показывает, что каждая операция чтения или записи должна иметь понятную вторую сторону.

Каналы типизированы. Это означает, что chan int пропускает только целые числа, chan string — только строки. Попытка отправить значение другого типа приведет к ошибке компиляции. Поэтому канал не только синхронизирует выполнение, но и защищает протокол взаимодействия на уровне типов.

Направленные каналы и роли участников

Живой конкурентный код редко ограничивается одной связкой «один пишет — один читает». Появляются цепочки обработчиков, конвейеры, пулы воркеров. В таких конструкциях важно фиксировать роли: кто отправляет данные, а кто только принимает. Go позволяет зафиксировать направление на уровне типов.

Если функция должна только отправлять значения, ее параметр объявляется как chan<- T. Если задача функции — только читать, используется <-chan T. Стрелка показывает направление потока данных относительно функции.

package main

import "fmt"

func producer(out chan<- int) {
	for i := 1; i <= 3; i++ {
		out <- i // отправка значения потребителю
	}
	close(out) // сигнал о завершении потока данных
}

func consumer(in <-chan int) {
	for v := range in { // чтение до закрытия канала
		fmt.Println("получено:", v)
	}
}

func main() {
	ch := make(chan int)
	go producer(ch) // горутина-производитель
	consumer(ch)    // потребитель, работающий в основной горутине
}

В этой схеме producer имеет доступ только к записи, а consumer — только к чтению. Попытка выполнить <-out или in <- 10 будет пресечена компилятором. Так типы фиксируют направление взаимодействия и защищают архитектуру от случайных нарушений протокола.

Тот же прием особенно удобен в конвейерах. Каждая стадия принимает входной поток <-chan T, создает выходной chan<- T и не смешивает роли. Такой код становится ближе к описанию производственной линии: данные двигаются слева направо, а каждая стадия делает только свой шаг.

Простой обмен данными между горутинами

Базовый рисунок использования канала — простая передача значения между двумя горутинами. Одна горутина создает данные и отправляет их в канал, другая читает и обрабатывает. В этом месте каналы полностью раскрывают предназначение: связывают независимые исполнители без мьютексов и явных блокировок.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		ch <- "привет" // отправка строки
		fmt.Println("сообщение отправлено")
	}()

	msg := <-ch // прием строки
	fmt.Println("получено:", msg)

	time.Sleep(10 * time.Millisecond) // пауза для вывода второго сообщения
}

Здесь канал является единственной точкой контакта между горутинами. Одна линия исполнения отвечает за формирование сообщения, другая — за его прием и вывод. Синхронизация встроена в сами операции ch <- и <-ch, дополнительный код не требуется.

Если канал должен передать несколько значений подряд, используется закрытие. Производитель отправляет серию значений и затем закрывает канал. Потребитель воспринимает закрытие как сигнал окончания потока.

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		for i := 1; i <= 3; i++ {
			ch <- i // отправка очередного числа
		}
		close(ch) // больше значений не будет
	}()

	for v := range ch { // чтение до закрытия канала
		fmt.Println("получено:", v)
	}
}

Закрытый канал нельзя использовать для повторной отправки, но чтение из него остается корректным: for range завершится автоматически. Это превращает закрытие в аккуратный сигнал окончания работы для всех потребителей.

Если требуется только уведомить о событии, используют отдельные каналы-сигналы или контекст. В такой схеме ценность представляет не значение, а сам факт приходящего сообщения или закрытия канала.

Типичные ошибки и правильный подход

Частая проблема при работе с каналами — отсутствие второй стороны операции. Отправка без читателя или чтение без отправителя приводят к состоянию deadlock. Еще одна типичная ошибка — попытка закрыть канал со стороны получателя или из нескольких мест одновременно. Закрыть канал должен тот, кто пишет в него данные и контролирует окончание потока данных.

Правильный подход опирается на простой набор правил. Канал создается в том месте, где формируется общий протокол взаимодействия. Запись и закрытие принадлежат владельцу канала, который отвечает за жизненный цикл потока данных. Чтение распределяется между потребителями, которые не пытаются управлять состоянием канала. Операции отправки и получения всегда имеют понятную парную сторону.

Вывод и связь с конкуренцией

Каналы в Go превращают синхронизацию в обычные выражения языка. Вместо явных мьютексов и условий в коде появляются операции ch <- x и v := <-ch, а порядок взаимодействия горутин описывается самим потоком данных. Эта модель хорошо сочетается с горутинами: легкие исполнители обмениваются значениями по понятным траекториям, а планировщик следит за тем, чтобы никто не простаивал лишнее время.

С такой опорой конкуренция в Go перестает быть набором трюков. Она превращается в последовательное описание: горутина выполняет работу, канал задает протокол общения, пара операторов <- формирует точки синхронизации. На этой базе строятся конвейеры, worker-пулы и сложные схемы обработки событий, но все начинается именно с простого примера обмена данными между двумя горутинами через make(chan T), отправку и прием.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845