Go часто выбирают за простую конкуррентность: можно запустить несколько задач почти одной строкой, а результаты передать через channel. Но здесь есть ловушка для новичка: если начать с красивых слов про параллельность, легко пропустить базовую механику. Поэтому сделаем маленький пример: три задачи выполняются одновременно, каждая возвращает результат, а main собирает их через channel
Это не заменяет полноценную архитектуру фоновых задач, зато дает правильную первую опору
Что получится в конце
Мы напишем программу, которая запускает три проверки:
готово: cache за 1s
готово: database за 2s
готово: api за 3s
Порядок может отличаться, если поменять задержки. Главное: задачи идут не строго одна за другой, а одновременно
Создаем проект
mkdir go-concurrency
cd go-concurrency
go mod init example.com/go-concurrency
Создайте main.go:
package main
import (
"fmt"
"time"
)
func check(name string, delay time.Duration, results chan string) {
time.Sleep(delay)
results <- fmt.Sprintf("готово: %s за %s", name, delay)
}
func main() {
results := make(chan string)
go check("cache", time.Second, results)
go check("database", 2*time.Second, results)
go check("api", 3*time.Second, results)
for i := 0; i < 3; i++ {
fmt.Println(<-results)
}
}
Запустите:
go run .
Что делает go перед вызовом функции
Строка:
go check("cache", time.Second, results)
запускает функцию check в отдельной goroutine. Можно думать о goroutine как о легковесной задаче, которой управляет Go runtime
Если убрать go, вызовы станут последовательными: сначала cache, потом database, потом api. С go все три проверки стартуют почти сразу, а main ждет результаты из channel
Что такое channel
Строка:
results := make(chan string)
создает channel для строк. Через него goroutine отправляет результат:
results <- "текст"
А main получает:
fmt.Println(<-results)
Channel здесь похож не на массив, а на точку передачи. Одна часть программы отправляет значение, другая забирает. Для первого знакомства важно понять направление: стрелка показывает, куда движется значение
Почему main не завершается сразу
Если запустить goroutines и ничего не ждать, программа может завершиться раньше, чем фоновые задачи успеют напечатать результат. В нашем коде main ждет три значения:
for i := 0; i < 3; i++ {
fmt.Println(<-results)
}
Каждое чтение <-results блокирует выполнение, пока в channel не придет значение. Поэтому программа спокойно дожидается всех трех проверок
Первый deadlock
Попробуйте изменить цикл:
for i := 0; i < 4; i++ {
fmt.Println(<-results)
}
Мы запустили три goroutine, а прочитать пытаемся четыре результата. Четвертого значения никто не отправит. Go увидит, что все зависло, и покажет ошибку вида:
fatal error: all goroutines are asleep - deadlock!
Это не «страшная поломка», а сигнал: кто-то ждет значение, которое никто не отправляет, или кто-то отправляет значение, которое никто не читает
Верните 3, чтобы пример снова работал
Buffered channel
Обычный channel без буфера синхронизирует отправителя и получателя. Иногда удобно создать буфер:
results := make(chan string, 3)
Теперь channel может временно хранить три строки. Для нашего примера программа будет работать и с буфером, и без него. Но новичку не стоит лечить все deadlock через буфер. Сначала нужно понять, сколько значений отправляется и сколько читается
select: ждем результат или timeout
Добавим timeout. Иногда задача может зависнуть, и нам нужен безопасный выход:
package main
import (
"fmt"
"time"
)
func slow(results chan string) {
time.Sleep(2 * time.Second)
results <- "данные готовы"
}
func main() {
results := make(chan string)
go slow(results)
select {
case value := <-results:
fmt.Println(value)
case <-time.After(time.Second):
fmt.Println("timeout: задача выполнялась слишком долго")
}
}
select ждет, какая операция с channel станет доступной первой. В этом примере timeout наступит раньше результата
Где goroutines нужны в реальном коде
Goroutines полезны для фоновых задач, параллельных запросов к внешним сервисам, обработки очередей, серверов, таймеров и задач, где не хочется блокировать основной поток выполнения
Но не нужно запускать goroutine ради каждой функции. Если код простой и последовательный, пусть остается последовательным. Конкуррентность должна решать конкретную проблему, а не украшать пример
Частые ошибки
Программа ничего не печатает. Возможно, main завершился раньше goroutine. Нужно дождаться результата через channel, sync.WaitGroup или другой механизм синхронизации
deadlock. Проверьте баланс: сколько значений отправляется в channel и сколько читается. Еще проверьте, не отправляете ли вы в channel без читателя
Порядок вывода неожиданно меняется. Это нормально. Goroutines выполняются независимо, поэтому нельзя опираться на фиксированный порядок без дополнительной синхронизации
Channel закрыт слишком рано. Закрывать channel должен отправитель, когда точно больше не будет значений. Получатель не должен закрывать чужой channel
Что может быть еще интересно по этой теме
Goroutine это то же самое, что thread? Нет. Goroutine легче и управляется Go runtime. Под капотом runtime распределяет множество goroutines по системным потокам
Всегда ли goroutines дают ускорение? Нет. Если задача не блокируется и не делится на независимые части, конкуррентность может только усложнить код
Что учить после channels? sync.WaitGroup, context.Context, mutex и паттерны worker pool. Но их лучше трогать после первого понятного примера с channel
Почему Go так часто связывают с backend? HTTP-серверы в Go естественно работают с множеством запросов, а goroutines дают удобную модель параллельной обработки
Что открыть дальше
- HTTP server на Go: первый API без фреймворка — там Go сам использует конкурентную модель для обработки запросов.
- Interfaces в Go простыми словами — пригодится для отделения логики от конкретных реализаций.
- Go modules: go mod init, tidy и зависимости — когда в проекте появятся внешние пакеты.
- Ошибки в Go: error, panic и recover без суеты — важная тема для goroutines и фоновых задач.



