Горутины и каналы в Go: Параллельное программирование

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

Почему важны горутины и каналы?

Горутины и каналы позволяют:

  • Выполнять задачи параллельно
  • Эффективно использовать ресурсы системы
  • Создавать масштабируемые приложения
  • Упрощать синхронизацию
  • Писать чистый и понятный код

💡 Интересный факт: Горутины легче потоков операционной системы в 100-1000 раз, что позволяет создавать миллионы горутин без значительных накладных расходов.

Основы горутин

1. Простая горутина

package main

import (
    "fmt"
    "time"
)

// Функция, которая будет выполняться в горутине
func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Число: %d\n", i)
        time.Sleep(time.Millisecond * 500)
    }
}

func main() {
    // Запуск горутины
    go printNumbers()

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

Объяснение:

  • Ключевое слово go запускает функцию в отдельной горутине
  • Горутина выполняется параллельно с основным потоком
  • time.Sleep используется для демонстрации параллельного выполнения

Ожидаемый вывод:

Основной поток продолжает работу...
Число: 1
Число: 2
Число: 3
Число: 4
Число: 5

2. Множественные горутины

package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Работник %d начал работу\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Работник %d закончил работу\n", id)
}

func main() {
    // Запуск нескольких горутин
    for i := 1; i <= 3; i++ {
        go worker(i)
    }

    // Ждём завершения всех горутин
    time.Sleep(time.Second * 2)
}

Объяснение:

  • Каждая горутина выполняет свою копию функции worker
  • Горутины выполняются параллельно
  • Порядок вывода может варьироваться

Ожидаемый вывод:

Работник 1 начал работу
Работник 2 начал работу
Работник 3 начал работу
Работник 1 закончил работу
Работник 2 закончил работу
Работник 3 закончил работу

Основы каналов

1. Простой канал

package main

import "fmt"

func main() {
    // Создание канала для передачи целых чисел
    ch := make(chan int)

    // Горутина для отправки данных
    go func() {
        ch <- 42 // Отправка значения в канал
    }()

    // Получение данных из канала
    value := <-ch
    fmt.Printf("Получено значение: %d\n", value)
}

Объяснение:

  • make(chan int) создаёт канал для целых чисел
  • Оператор <- используется для отправки и получения данных
  • Канал блокирует выполнение до получения данных

Ожидаемый вывод:

Получено значение: 42

2. Буферизованный канал

package main

import "fmt"

func main() {
    // Создание буферизованного канала с ёмкостью 3
    ch := make(chan int, 3)

    // Отправка нескольких значений
    ch <- 1
    ch <- 2
    ch <- 3

    // Получение значений
    fmt.Printf("Получено: %d\n", <-ch)
    fmt.Printf("Получено: %d\n", <-ch)
    fmt.Printf("Получено: %d\n", <-ch)
}

Объяснение:

  • Буферизованный канал может хранить несколько значений
  • Отправка не блокируется, пока буфер не заполнен
  • Получение происходит в порядке FIFO

Ожидаемый вывод:

Получено: 1
Получено: 2
Получено: 3

Продвинутые техники

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

package main

import "fmt"

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

    // Горутина для отправки данных
    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch) // Закрытие канала
    }()

    // Чтение данных до закрытия канала
    for value := range ch {
        fmt.Printf("Получено: %d\n", value)
    }
}

Объяснение:

  • close(ch) закрывает канал
  • range автоматически завершается при закрытии канала
  • Закрытие канала сигнализирует о завершении отправки

Ожидаемый вывод:

Получено: 1
Получено: 2
Получено: 3
Получено: 4
Получено: 5

2. Select для множественных каналов

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    // Горутина для первого канала
    go func() {
        time.Sleep(time.Second)
        ch1 <- "Сообщение из первого канала"
    }()

    // Горутина для второго канала
    go func() {
        time.Sleep(time.Second * 2)
        ch2 <- "Сообщение из второго канала"
    }()

    // Ожидание сообщений из любого канала
    select {
    case msg1 := <-ch1:
        fmt.Println(msg1)
    case msg2 := <-ch2:
        fmt.Println(msg2)
    }
}

Объяснение:

  • select позволяет ждать сообщений из нескольких каналов
  • Выполняется первый доступный case
  • Полезно для таймаутов и обработки множественных каналов

Ожидаемый вывод:

Сообщение из первого канала

Практические задания

Задание 1: Параллельная обработка данных

Создайте программу, которая:

  1. Читает данные из файла
  2. Обрабатывает каждую строку в отдельной горутине
  3. Собирает результаты через канал
  4. Выводит статистику обработки

Ожидаемый результат:

  • Эффективная обработка данных
  • Корректная синхронизация
  • Чёткая структура кода

Задание 2: Пул горутин

Реализуйте пул горутин для:

  1. Обработки задач из очереди
  2. Ограничения количества одновременно работающих горутин
  3. Сбора результатов
  4. Обработки ошибок

Ожидаемый результат:

  • Контролируемое использование ресурсов
  • Масштабируемость
  • Надёжная обработка ошибок

Задание 3: Чат-сервер

Создайте простой чат-сервер, который:

  1. Принимает подключения от клиентов
  2. Передаёт сообщения между клиентами
  3. Управляет подключениями
  4. Обрабатывает отключения

Ожидаемый результат:

  • Многопользовательский чат
  • Устойчивость к ошибкам
  • Эффективная работа с сетью

Что дальше?

В следующем уроке мы:

  • Изучим более сложные паттерны синхронизации
  • Познакомимся с контекстами
  • Узнаем о работе с таймаутами
  • Начнём писать распределённые системы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать и управлять горутинами
  • Работать с каналами
  • Использовать select для множественных каналов
  • Писать параллельные программы