Горутина (goroutine) в Go, Каналы (channels) в Go и Конвейеры (Pipelines) в Go.

Горутина (goroutine) в Go — это легковесный “поток”, который позволяет выполнять код конкурентно (почти одновременно) без создания полноценных потоков ОС. Каналы (channels) в Go — это специальный тип данных, который позволяет безопасно передавать данные между горутинами

Простыми словами о Goroutine:

  • Это как виртуальный работник, который выполняет задачи параллельно с другими горутинами.
  • Горутины дешевле настоящих потоков (их можно создавать тысячи без нагрузки на систему).
  • Управляются средой Go (runtime), а не операционной системой.

Пример:

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Привет!")
}

func main() {
	// Запуск горутины (выполняется параллельно с main)
	go sayHello() 

	// Главная функция продолжает работу
	fmt.Println("Главная функция...")
	time.Sleep(1 * time.Second) // Ждём, чтобы горутина успела выполниться
}

Вывод:

Главная функция...
Привет!

Особенности:

  1. Легковесные — занимают всего ~2 КБ памяти (потоки ОС — от 1 МБ).
  2. Быстрый старт — создаются и запускаются почти мгновенно.
  3. Автомасштабирование — Go сам распределяет их по доступным ядрам CPU.
  4. Не блокируют main — если главная функция завершится, все горутины умрут.

Важно:

  • Горутины не гарантируют порядок выполнения.
  • Для обмена данными между горутинами используют каналы (channels).

Аналог из жизни:

Представьте, что вы (главная функция) даёте задание коллеге (горутине):

  • Вы можете продолжать работать, пока коллега выполняет свою часть.
  • Вам не нужно ждать его — всё происходит конкурентно.

Горутины — одна из главных “фишек” Go, делающая его мощным для многозадачных программ!

Представьте их как трубы, по которым одна горутина отправляет данные, а другая — принимает. Это помогает избежать гонок данных (data races) и синхронизировать работу горутин.


Базовые операции с каналами

1. Создание канала

Каналы бывают:

  • Буферизированные (с фиксированной ёмкостью)
  • Небуферизированные (ждёт, пока получатель заберёт данные)
ch := make(chan int)       // Небуферизированный канал для int
chBuf := make(chan int, 3) // Буферизированный (ёмкость = 3)

2. Отправка данных (<-)

ch <- 42 // Отправить число 42 в канал

3. Получение данных (<-)

value := <-ch // Получить данные из канала
fmt.Println(value) // 42

4. Закрытие канала (close)

Закрытый канал нельзя использовать для отправки, но можно читать оставшиеся данные.

close(ch)

Пример: Простая передача данных

package main

import "fmt"

func sendData(ch chan string) {
    ch <- "Привет из горутины!" // Отправляем данные
}

func main() {
    ch := make(chan string) // Создаём канал
    
    go sendData(ch) // Запускаем горутину
    
    msg := <-ch    // Получаем данные
    fmt.Println(msg) // "Привет из горутины!"
    
    close(ch)      // Закрываем канал
}

Вывод:

Привет из горутины!

Важные особенности каналов

1. Блокировка операций

  • Небуферизированный канал:

    • Отправка (ch <- data) блокирует горутину, пока другая горутина не прочитает данные.
    • Чтение (<-ch) блокирует, пока не поступят данные.
  • Буферизированный канал:

    • Отправка блокируется только если буфер заполнен.
    • Чтение блокируется только если буфер пуст.

2. Проверка на закрытие

value, ok := <-ch
if !ok {
    fmt.Println("Канал закрыт!")
}

3. range по каналу

Можно перебирать данные, пока канал не закроется:

for msg := range ch {
    fmt.Println(msg)
}

Пример: Синхронизация горутин

package main

import (
    "fmt"
    "time"
)

func worker(done chan bool) {
    fmt.Println("Работаю...")
    time.Sleep(2 * time.Second)
    fmt.Println("Готово!")
    done <- true // Сигнализируем о завершении
}

func main() {
    done := make(chan bool)
    go worker(done)
    
    <-done // Ждём сигнал от worker
    fmt.Println("Рабочая горутина завершилась.")
}

Вывод:

Работаю...
Готово!
Рабочая горутина завершилась.

Когда использовать каналы?

  • Для синхронизации (ожидание завершения горутин).
  • Для передачи данных между горутинами.
  • Для организации конвейеров (pipeline) обработки данных.

Ошибки при работе с каналами

  1. Deadlock — если нет получателя/отправителя.
    ch := make(chan int)
    ch <- 42 // Ошибка: никто не читает из канала!
    
  2. Паника при отправке в закрытый канал.
    close(ch)
    ch <- 10 // panic: send on closed channel
    
  3. Чтение из пустого закрытого канала вернёт нулевое значение.

Аналог из жизни

Канал — как почтовый ящик:

  • Вы (горутина) кладёте письмо (ch <- data).
  • Другой человек (горутина) забирает его (<-ch).
  • Если ящик переполнен (буфер заполнен), вы ждёте, пока освободится место.

Каналы — это безопасный и удобный способ общения между горутинами в Go!

Конвейер (pipeline) — это цепочка горутин, где каждая выполняет свою часть работы, передавая данные следующей через каналы. Это мощный паттерн для:

  • Обработки данных по этапам (фильтрация, преобразование, агрегация).
  • Параллельного выполнения задач с контролируемым потоком данных.
  • Управления ресурсами (ограничение числа одновременно работающих горутин).

Базовый конвейер (3 этапа)

1. Генерация данных → Обработка → Вывод

package main

import "fmt"

// Этап 1: Генерация чисел (producer)
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// Этап 2: Умножение (processor)
func multiply(in <-chan int, factor int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * factor
        }
        close(out)
    }()
    return out
}

// Этап 3: Вывод (consumer)
func print(in <-chan int) {
    for n := range in {
        fmt.Println(n)
    }
}

func main() {
    // Конвейер: generate → multiply → print
    data := generate(1, 2, 3, 4)
    processed := multiply(data, 10)
    print(processed)
}

Вывод:

10
20
30
40

Особенности конвейеров

1. Каждый этап — отдельная горутина

  • Этапы работают параллельно (если CPU позволяет).
  • Данные передаются последовательно через каналы.

2. Закрытие каналов

  • Когда producer закрывает канал, все processors и consumer получают сигнал завершения.
  • Использование range для чтения из канала автоматически завершается при close().

3. Буферизация для ускорения

Буферизированные каналы уменьшают блокировки:

out := make(chan int, 100) // Буфер на 100 элементов

Фильтрация в конвейере

Добавим этап, который оставляет только чётные числа:

// Этап 2.5: Фильтрация чётных чисел
func filterEven(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            if n%2 == 0 {
                out <- n
            }
        }
        close(out)
    }()
    return out
}

func main() {
    data := generate(1, 2, 3, 4, 5, 6)
    processed := multiply(data, 3) // Умножаем на 3
    evenOnly := filterEven(processed) // Оставляем чётные
    print(evenOnly)
}

Вывод:

6
12
18

Fan-Out/Fan-In: Параллельная обработка

  • Fan-Out: Разделение работы между несколькими горутинами.
  • Fan-In: Объединение результатов в один канал.

Пример:

// Fan-In: объединяет несколько каналов в один
func merge(channels ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Запускаем для каждого канала горутину, которая перенаправляет данные в out
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            for n := range c {
                out <- n
            }
            wg.Done()
        }(ch)
    }

    // Закрываем out после завершения всех горутин
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    data := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-Out: Запускаем 3 обработчика
    proc1 := multiply(data, 10)
    proc2 := multiply(data, 20)
    proc3 := multiply(data, 30)

    // Fan-In: Объединяем результаты
    merged := merge(proc1, proc2, proc3)
    print(merged)
}

Вывод (порядок может меняться):

10
20
30
40
50
60
70
80
20
40
60
80
100
120
140
160
30
60
90
120
150
180
210
240

Ошибки и подводные камни

  1. Утечки горутин

    • Если канал не закрыт, горутина может зависнуть в ожидании.
    • Всегда закрывайте каналы на стороне отправителя.
  2. Deadlock

    • Если канал не читается, отправляющая горутина заблокируется навсегда.
  3. Ограничение параллелизма

    • Если запустить 1000 горутин, они могут перегрузить систему.
    • Решение: пул воркеров с использованием semaphore или buffered channels.

Когда использовать конвейеры?

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

Аналог из жизни

Конвейер — как заводская линия сборки:

  • Этап 1: Детали поступают на ленту (генерация данных).
  • Этап 2: Робот красит детали (обработка).
  • Этап 3: Другой робот упаковывает (фильтрация).
  • Этап 4: Готовые изделия отправляются на склад (вывод).

Каждый этап работает независимо, но согласованно через общий механизм (каналы).


Итог

Конвейеры в Go — это:
Гибкость (можно добавлять/удалять этапы).
Параллелизм (эффективное использование CPU).
Безопасность (данные передаются через каналы, нет гонок).

Используйте их для сложных многопоточных задач!