Go. Concurrency
Модуль 4
Модуль 4
Оператор select
Аналогия: Оператор у нескольких телефонов

Представьте себе оператора колл-центра, у которого на столе три телефона: Телефон 1 (для клиентов), Телефон 2 (для начальника), Телефон 3 (внутренний).

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

Эта аналогия идеально передает суть select: он позволяет одной горутине ждать сразу несколько событий.
Ожидание нескольких каналов одновременно
select — это конструкция, похожая на switch, но ее case’ы работают с операциями ввода-вывода на каналах (отправка и получение).
Как работает?
Ожидание: select блокирует выполнение горутины и просматривает все свои case.

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

Выбор: Как только хотя бы один канал становится готов, select выполняет соответствующий блок кода (case) и завершает свою работу.

Случайность: Если в один и тот же момент готовы несколько каналов, Go случайным образом выберет один из них для выполнения. Это гарантирует, что ни один из каналов не будет «обижен».
Кейсы с таймаутом (time.After) и каналом остановки
Это самый классический и полезный пример для select. У нас есть горутина, которая выполняет какую-то работу. Но мы не хотим, чтобы она работала вечно. Мы должны уметь остановить ее двумя способами: по таймауту, если она не справляется за отведенное время, и по внешнему сигналу, если мы решили остановить ее досрочно.
Идея
Для этого идеально подходят: time. After (duration) — функция, которая возвращает канал. Через указанный duration этот канал «прилетит» текущее время. И канал остановки — обычный канал, по которому мы можем отправить сигнал об остановке.
Управляемый воркер
package main

import (
    "fmt"
    "time"
)

// worker - это наша горутина, которая выполняет "работу"
// Она будет работать до тех пор, пока не получит сигнал об остановке
// или не истечет время таймаута.
func worker(stopCh <-chan struct{}) {
    fmt.Println("Воркер начал работу...")

    // Используем select для ожидания нескольких событий
    select {
    // Case 1: Если в канал stopCh что-то пришло (сигнал об остановке)...
    case <-stopCh:
        fmt.Println("Воркер получил сигнал об остановке и завершил работу.")

    // Case 2: Если прошло 2 секунды (сработал таймаут)...
    case <-time.After(2 * time.Second):
        fmt.Println("Воркер не завершил работу за 2 секунды и остановился по таймауту.")
    }
}


func main() {
    // --- Сценарий 1: Остановка по таймауту ---
    fmt.Println("--- Сценарий 1: Таймаут ---")
    stopCh1 := make(chan struct{})
    go worker(stopCh1)
    // Ждем 3 секунды, чтобы гарантированно сработал таймаут в 2 секунды
    time.Sleep(3 * time.Second)

    // --- Сценарий 2: Остановка по сигналу ---
    fmt.Println("\n--- Сценарий 2: Внешний сигнал ---")
    stopCh2 := make(chan struct{})
    go worker(stopCh2)
    // Ждем 1 секунду и отправляем сигнал об остановке
    time.Sleep(1 * time.Second)
    fmt.Println("Main отправляет сигнал об остановке...")
    close(stopCh2) // Закрытие канала - это тоже сигнал, который получат все слушающие

    // Даем воркеру время на завершение
    time.Sleep(500 * time.Millisecond)
    fmt.Println("Программа завершена.")
}
Этот пример демонстрирует, как select позволяет горутине одновременно ждать нескольких событий: сигнал об остановке и таймаута.
Неблокирующий default
У select может быть default-блок. Он выполнится немедленно, если ни один из других case не готов. Это превращает select из блокирующей операции в неблокирующую проверку.
Когда это нужно?
Когда вы хотите попробовать отправить или получить данные, но не хотите «зависать», если канал не готов.

Для реализации логики «попробуй, а если не получилось — сделай что-то другое».
Неблокирующая отправка
package main

import (
    "fmt"
    "time"
)

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

    // Запускаем горутину, которая будет получать сообщение через 2 секунды
    go func() {
        time.Sleep(2 * time.Second)
        msg := <-messages // Получаем сообщение, разблокируя main
        fmt.Println("Получатель получил:", msg)
    }()

    // Попытка отправить сообщение без ожидания
    select {
    case messages <- "привет!":
        fmt.Println("Сообщение отправлено.")
    default:
        fmt.Println("Никто не готов принять сообщение. Отправка отменена.")
    }

    // Подождем немного, чтобы получатель успел сработать
    time.Sleep(3 * time.Second)
    fmt.Println("Программа завершена.")
}
Результат выполнения
Никто не готов принять сообщение. Отправка отменена.
Получатель получил: привет!
Программа завершена.
Обратите внимание: сообщение все равно было получено! Это произошло потому, что наша горутина-получатель все равно ждала на msg := <-messages. Когда main завершился, канал messages был собран сборщиком мусора, но горутина все еще держала на него ссылку. Когда мы ждали 3 секунды в main, горутина-получатель «дождалась» и забрала сообщение, которое на самом деле никуда не отправлялось.

Этот пример показывает суть default, но в реальном коде так делать не стоит. Более реалистичный пример default — это проверка состояния без блокировки, например, в цикле обработки событий.
Итог
select — это дирижер оркестра каналов. Он позволяет горутине гибко реагировать на несколько событий одновременно, что делает код на Go для управления конкурентными задачами невероятно мощным и выразительным.
Ключевая идея: Кооперативная отмена
В Go нет функции типа goroutine. Kill (). Вы не можете forcefully «убить» горутину извне. Это сделано намеренно, чтобы избежать ситуаций, когда горутину убивают посреди обновления критически важной структуры данных, оставляя ее в «сломанном» состоянии.

Вместо этого в Go используется кооперативная отмена: горутины должны быть спроектированы так, чтобы они сами решали, когда им пора завершиться. Они должны «слушать» сигналы об отмене и корректно завершать свою работу.
Канал done/stop как сигнал завершения
Это самый базовый и универсальный способ сообщить горутине, что пора останавливаться.
Что это?
Это канал, который используется не для передачи данных, а исключительно для отправки сигнала «стоп».
Аналогия: Менеджер на стройке

Менеджер на стройке, который дает сигнал свистком, чтобы все рабочие прекратили работу и собрали инструменты. Сам свисток — это и есть сигнал, неважно, как громко он прозвучал.
Идиома
Обычно используют канал типа chan struct{}, так как struct{ не занимает памяти (0 байт).
Воркер с каналом остановки
package main

import (
    "fmt"
    "time"
)

// worker будет работать, пока не получит сигнал по каналу stopCh
func worker(id int, stopCh <-chan struct{}) {
    fmt.Printf("Воркер #%d запущен\n", id)

    for {
        select {
        // Case 1: Пришел сигнал об остановке
        case <-stopCh:
            fmt.Printf("Воркер #%d получил сигнал СТОП и завершает работу\n", id)
            return // Выходим из функции, горутина завершается

        // Case 2: Сигнала нет, продолжаем работать
        default:
            fmt.Printf("Воркер #%d работает...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    stopCh := make(chan struct{}) // Канал для сигнала

    // Запускаем воркера
    go worker(1, stopCh)

    // Позволим ему поработать 2 секунды
    fmt.Println("Main дает воркеру поработать 2 секунды...")
    time.Sleep(2 * time.Second)

    // Отправляем сигнал об остановке. Закрытие канала — это и есть сигнал.
    // Все горутины, которые слушают <-stopCh, получат этот сигнал.
    fmt.Println("Main отправляет сигнал СТОП.")
    close(stopCh)

    // Даем время воркеру на корректное завершение
    time.Sleep(500 * time.Millisecond)
    fmt.Println("Программа завершена.")
}
Этот пример показывает, как канал stopCh позволяет корректно завершать воркера. Закрытие канала — это сигнал, который немедленно «срабатывает» в case <-stopCh.
Завершение воркеров по закрытию канала входящих задач
Это частный случай предыдущего паттерна, который идеально подходит для «пулов воркеров».
Сигналом к завершению здесь служит закрытие канала, из которого воркеры читают задания. Как мы уже знаем, цикл for… range по каналу автоматически завершается, когда канал закрывается.
Пул воркеров, который останавливается корректно
package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Воркер #%d запущен\n", id)

    // Этот цикл автоматически завершится, когда канал jobs будет закрыт
    for j := range jobs {
        fmt.Printf("Воркер #%d выполняет задание %d\n", id, j)
        time.Sleep(time.Second)
    }

    fmt.Printf("Воркер #%d: канал заданий закрыт, работа завершена.\n", id)
}

func main() {
    jobs := make(chan int)
    var wg sync.WaitGroup

    // Запускаем 3 воркеров
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go worker(w, jobs, &wg)
    }

    // Отправляем 5 заданий
    for j := 1; j <= 5; j++ {
        jobs <- j
    }

    // КЛЮЧЕВОЙ МОМЕНТ: Закрываем канал заданий.
    // Это сигнал для ВСЕХ воркеров, что новых заданий больше не будет,
    // и они могут завершиться, как только закончат текущую работу.
    close(jobs)
    fmt.Println("Все задания отправлены, канал закрыт. Воркеры завершают текущую работу и останавливаются.")

    // Ждем, пока все воркеры завершат свою работу
    wg.Wait()
    fmt.Println("Все воркеры завершили работу. Программа завершена.")
}
Обратите внимание: после close (jobs) все воркеры благополучно завершили свою последнюю задачу и вышли из цикла for… range. Без закрытия канала они бы «зависли» на строке for j := range jobs в ожидании новых заданий навсегда.
Утечки горутин: примеры и как их избежать
Утечка горутины (goroutine leak) — это ситуация, когда горутина запущена, но заблокирована навсегда (например, в ожидании данных из канала, в который никто никогда не отправляет). Она никогда не завершится и будет вечно потреблять ресурсы (память на ее стек, место в планировщике Go).
Пример 1: Забытый получатель
package main

func main() {
    // Создаем канал, но никто никогда не будет отправлять в него данные
    ch := make(chan int)

    // Запускаем горутину, которая будет вечно ждать
    go func() {
        // Эта строка заблокирует горутину навсегда
        fmt.Println("Горутина ждет данные...")
        <-ch
        fmt.Println("Горутина получила данные.") // ЭТОТ КОД НИКОГДА НЕ ВЫПОЛНИТСЯ
    }()

    // main завершает работу, но горутина остается "зависшей" в памяти.
    // Это и есть утечка.
    fmt.Println("Main завершается.")
}
Как избежать?
Всегда предоставляйте способ для выхода. В данном случае — это канал done или таймаут.
Пример 2: Непрочитанный результат
package main

import "time"

func leakyProducer(ch chan<- string) {
    // Эта горутина пытается отправить данные и заблокируется,
    // пока кто-то не будет готов их принять.
    ch <- "результат"
    fmt.Println("Данные отправлены") // ЭТОТ КОД НИКОГДА НЕ ВЫПОЛНИТСЯ
}

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

    // main что-то делает и завершается, не забрав данные из канала.
    // Горутина leakyProducer навсегда останется заблокированной на операции ch <- "результат".
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Main завершается, не прочитав результат.")
    // Это утечка.
}
Как избежать?
Убедитесь, что для каждой операции отправки есть соответствующая операция получения. Используйте select с default для неблокирующей отправки или done канал для отмены.
Золотые правила, чтобы избежать утечек
Создатель отвечает за жизненный цикл. Та горутина, которая запустила другую горутину (например, main), несет ответственность за ее остановку.

Всегда предоставляйте путь к выходу. Если ваша горутина что-то ждет (в select, в for… range), всегда должен быть case для канала done или time.After.

Используйте WaitGroup. Убедитесь, что вы дожидаетесь (Wait) завершения всех запущенных вами горутин, прежде чем завершать программу.

Закрывайте каналы. Закрывайте каналы заданий, чтобы сигнализировать воркерам о завершении работы.
context.Context как стандарт отмены
Проблема, которую решает context
В предыдущих примерах мы использовали канал done или stop для остановки одной горутины. А что если у нас цепочка вызовов?
main вызывает handleRequest, которая вызывает fetchFromDB, которая вызывает processData. Если main хочет отменить всю операцию, ей нужно передать свой stopCh во все эти функции. Это неудобно и загромождает код.
context.Context решает эту проблему. Это стандартный способ передать «сигнал отмены» и другие значения по всему стеку вызовов.

Аналогия: Документ путешественника
Представьте, что вы отправляете курьера с важным пакетом (запросом). Вы даете ему специальный документ (context), в котором указано:

Срок доставки (Deadline/Timeout): «Если не доставишь через 5 секунд, возвращайся».

Инструкция об отмене (Cancel): «Если я позвоню и скажу 'отмена', немедленно возвращайся».

Дополнительная информация (Values): «Код от склада: 1234».
Курьер передает этот документ каждому, с кем взаимодействует на своем пути (на складе, в машине). Каждый может посмотреть на документ и понять, актуальна ли еще миссия.
Создание и управление контекстами
Контексты образуют дерево. У вас есть корневой контекст, из которого вы создаете дочерние контексты с разными правилами. Отмена дочернего контекста не влияет на родительский, но отмена родителя отменяет всех его потомков.
Функции для создания дочерних контекстов
context.Background (): Корень дерева. Пустой контекст, который никогда не отменяется. Используется в main (), в инициализации или в тестах.

context.TODO (): Заглушка. Используется, когда вы еще не решили, какой контекст использовать, но нужно передать что-то в функцию.

context.WithCancel (parent Context): Создает новый контекст, который можно отменить вручную. Возвращает сам контекст и функцию cancel (). Вызов cancel () — это и есть «звонок курьеру».

context.WithTimeout (parent Context, timeout time. Duration): Создает контекст, который автоматически отменяется через указанный промежуток времени. Это самый частый вариант использования. «Если операция не завершится за 3 секунды, отмени».

context.WithDeadline (parent Context, deadline time. Time): То же, что и WithTimeout, но вместо длительности вы указываете конкретное время, когда контекст должен быть отменен.
Проброс контекста и уважение к ctx.Done ()
Проброс по стеку (Propagation)
Контекст всегда передается в функцию первым аргументом. Это стандартное соглашение в Go.
func handleRequest(ctx context.Context) { ... }
func fetchFromDB(ctx context.Context, query string) { ... }
Уважение к контексту в воркерах
Функция, которая получила контекст, должна его уважать. Это значит, что она должна периодически проверять, не был ли контекст отменен.

Самый правильный способ — использовать select и канал ctx. Done (). ctx. Done () — это как раз тот самый канал done, о котором мы говорили, только уже встроенный в контекст.
Воркер, уважающий контекст
package main

import (
    "context"
    "fmt"
    "time"
)

// doWork выполняет "долгую" работу, но уважает контекст
func doWork(ctx context.Context) {
    fmt.Println("Воркер начал работу...")

    for i := 0; i < 5; i++ {
        select {
        // Case 1: Контекст был отменен (по таймауту или вручную)
        case <-ctx.Done():
            // ctx.Err() расскажет причину отмены
            fmt.Printf("Работа отменена. Причина: %v\n", ctx.Err())
            return

        // Case 2: Все в порядке, продолжаем работать
        default:
            fmt.Printf("Работаю... шаг %d\n", i+1)
            time.Sleep(1 * time.Second)
        }
    }

    fmt.Println("Работа успешно завершена.")
}

func main() {
    // Сценарий 1: Отмена по таймауту
    fmt.Println("--- Сценарий 1: Таймаут 2 секунды ---")
    // Создаем контекст, который отменится через 2 секунды
    ctxTimeout, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel() // Важно! Вызывать defer cancel() для освобождения ресурсов

    doWork(ctxTimeout)

    // Сценарий 2: Ручная отмена
    fmt.Println("\n--- Сценарий 2: Ручная отмена через 1 секунду ---")
    // Создаем контекст, который можно отменить вручную
    ctxCancel, cancel := context.WithCancel(context.Background())

    go doWork(ctxCancel) // Запускаем в горутине

    // Ждем 1 секунду и отменяем
    time.Sleep(1 * time.Second)
    fmt.Println("Main решает отменить работу...")
    cancel() // Отправляем сигнал отмены

    // Даем время воркеру на завершение
    time.Sleep(500 * time.Millisecond)
}
Этот пример демонстрирует, как context. Context позволяет корректно управлять жизненным циклом горутин и отменять их как по таймауту, так и вручную.
Применение в сетевом/микросервисном коде
context — это кровь современных Go-сервисов.
HTTP-серверы
Каждый входящий HTTP-запрос (http.Request) уже содержит внутри себя контекст. Его можно получить через req. Context ().

Если клиент закрывает соединение (например, закрывает вкладку в браузере), этот контекст автоматически отменяется.

Ваш обработчик (handler) должен передавать req. Context () во все вызовы (базы данных, другие сервисы), чтобы немедленно остановить всю работу, если клиент ушел.
func myHandler(w http.ResponseWriter, r *http.Request) {
    // Берем контекст из запроса
    ctx := r.Context()

    // Передаем его в функцию работы с БД
    data, err := db.Query(ctx, "SELECT * FROM products")

    // Если клиент закроет соединение, запрос к БД будет отменен.
    // ...
}
Вызовы баз данных
Современные драйверы БД (например, database/sql) имеют функции QueryContext и ExecContext, которые принимают context. Context в качестве первого аргумента.

Это позволяет отменять долгие SQL-запросы, не нагружая базу данных.
Микросервисы
Когда ваш сервис, А вызывает сервис Б, он должен передать свой контекст. Если запрос к сервису, А был отменен, эта отмена «распространяется» на сервис Б. Это позволяет строить отказоустойчивые системы, где не выполняется бесполезная работа.
Итог: Золотые правила context
Передавайте контекст первым аргументом.

Не храните контекст в структурах, передавайте его явно.

Не создавайте nil контексты, используйте context.Background() или context.TODO().

Всегда уважайте полученный контекст, проверяя ctx.Done().

При использовании WithCancel/WithTimeout всегда вызывайте cancel(), обычно через defer.
Задача 4: «Воркер с таймаутом и остановкой»
Реализуй воркер, который:

В бесконечном цикле читает задания из канала jobs, обрабатывает каждое задание (например, time. Sleep (500ms) + вывод). При этом воркер должен останавливаться по сигналу stopCh (канал struct{}), прерывая обработку задания по таймауту (например, 1 секунда) через select.

Структура цикла:

Внутри обработки:
  • select между:
  • time.After (timeout) — считаем, что задание «не успело»;
  • stopCh — немедленный выход;
  • завершение обработки (можно смоделировать через второй канал или просто time. Sleep + ещё один select).

Требования:

main:
  • Запускает воркера (ов),
  • Отправляет несколько заданий,
  • Через некоторое время посылает stop (закрывает stopCh),
  • Корректно завершает программу.

Воркер:
  • Уважает сигнал об остановке,
  • Корректно завершает работу.