Горутины — краткое введение
Знакомимся с конкурентностью в Go: запускаем горутины, видим гонку данных и понимаем, почему это тема для целого курса
Поздравляю — вы добрались до бонусного модуля! В уроке 5.4 мы завершили финальный проект — полноценный TODO-менеджер в терминале. Вы уже умеете писать реальные программы на Go. Но есть одна тема, которая делает Go по-настоящему знаменитым — конкурентность. Именно благодаря горутинам Go стал языком номер один для высоконагруженных серверов, микросервисов и облачной инфраструктуры. В этом уроке мы заглянем «за забор»: увидим, что такое горутины, как их запускать и почему без правильных инструментов синхронизации они могут привести к хаосу. Это краткое знакомство — глубокое погружение ждёт вас в платном курсе.
🔀 Конкурентность vs параллельность
Прежде чем писать код, давайте разберёмся в двух терминах, которые часто путают. Это важно, потому что Go — язык конкурентности, и нам нужно понимать, что это вообще значит.
Конкурентность (concurrency) — это способность программы управлять несколькими задачами одновременно. Представьте повара на кухне: он поставил суп на плиту, пока суп варится — режет салат, потом проверяет суп, потом ставит хлеб в духовку. Повар один, но он переключается между задачами, и все они движутся вперёд.
Параллельность (parallelism) — это когда задачи действительно выполняются в один и тот же момент времени. Это как два повара на кухне: один варит суп, другой одновременно режет салат. Физически два действия происходят в одну секунду.
Конкурентность — это про структуру программы. Вы разбиваете работу на независимые части, которые могут выполняться одновременно.
Параллельность — это про исполнение. Части действительно работают одновременно на нескольких ядрах процессора.
Конкурентность возможна даже на одном ядре процессора (задачи просто быстро переключаются). Параллельность требует нескольких ядер. Go даёт вам конкурентность «из коробки», а если у вашего компьютера несколько ядер (а у любого современного компьютера они есть) — Go автоматически использует их для параллельного выполнения.
Один из создателей Go, Роб Пайк, сказал знаменитую фразу:
«Concurrency is not parallelism.» — Конкурентность — это не параллельность. Конкурентность — это способ организации программы. Параллельность — это способ исполнения. Конкурентная программа может работать параллельно, а может и нет — но она всегда структурирована правильно.
Зачем вообще нужна конкурентность? Представьте веб-сервер. К нему одновременно обращаются тысячи пользователей. Если обрабатывать запросы по одному — пока один пользователь ждёт ответ от базы данных, все остальные стоят в очереди. С конкурентностью каждый запрос обрабатывается в своей горутине — и сервер может жонглировать тысячами соединений без проблем.
🚀 Что такое горутина
Горутина (goroutine) — это легковесный поток выполнения, управляемый средой Go. Если вы слышали про потоки в других языках (threads в Java, Python, C++) — горутины похожи на них, но гораздо легче.
Насколько легче? Обычный поток операционной системы занимает около 1-2 мегабайт памяти. Горутина стартует с примерно 2-8 килобайт. Это значит, что вы можете запустить сотни тысяч горутин на обычном ноутбуке, в то время как тысяча обычных потоков уже вызовет проблемы.
Важный факт: Когда вы запускаете любую Go-программу, функция main() уже работает в горутине! Она называется «главная горутина» (main goroutine). Всё, что мы писали раньше — уже работало в горутине. Просто она была одна.
Запустить новую горутину невероятно просто — достаточно поставить слово go перед вызовом функции:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Привет из горутины!")
}
func main() {
go sayHello() // Запускаем функцию в отдельной горутине
fmt.Println("Привет из main!")
time.Sleep(time.Second) // Ждём секунду, чтобы горутина успела выполниться
}
Разберём по строчкам:
go sayHello() — ключевое слово go запускает функцию sayHello в новой горутине. Выполнение main не останавливается — оно тут же переходит к следующей строке.
time.Sleep(time.Second) — без этой строки программа завершится раньше, чем горутина успеет что-то напечатать. Почему? Потому что когда функция main завершается — все горутины немедленно останавливаются. Программа не ждёт их.
Попробуйте запустить этот код несколько раз. Порядок вывода может меняться! Иногда сначала выведется «Привет из main!», иногда «Привет из горутины!». Это нормально — горутины выполняются независимо, и мы не контролируем порядок.
💡 Подсказка: Использовать time.Sleep для ожидания горутин — плохая практика. Мы делаем это только для простоты примера. В реальном коде используют sync.WaitGroup, каналы и другие инструменты синхронизации. Об этом — в платном курсе.
⚡ go func() — анонимные горутины
Необязательно создавать отдельную функцию для каждой горутины. Можно использовать анонимную функцию прямо на месте:
package main
import (
"fmt"
"time"
)
func main() {
// Анонимная горутина
go func() {
fmt.Println("Я работаю в отдельной горутине!")
}()
// Анонимная горутина с параметром
go func(name string) {
fmt.Println("Привет,", name, "из горутины!")
}("Студент")
time.Sleep(time.Second)
}
Обратите внимание на () в конце — это вызов анонимной функции. Без этих скобок мы бы просто объявили функцию, но не запустили бы её. А go перед func говорит: «запусти эту функцию в новой горутине».
Давайте посмотрим на более практичный пример — запуск нескольких горутин в цикле:
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("Работник %d: начал работу\n", id)
time.Sleep(time.Millisecond * 500) // Имитация работы
fmt.Printf("Работник %d: закончил работу\n", id)
}
func main() {
fmt.Println("Запускаем 5 работников...")
for i := 1; i <= 5; i++ {
go worker(i) // Каждый работник в своей горутине
}
time.Sleep(time.Second * 2) // Ждём завершения всех
fmt.Println("Все работники завершили работу")
}
Возможный вывод (порядок будет разным при каждом запуске!):
Запускаем 5 работников...
Работник 3: начал работу
Работник 1: начал работу
Работник 5: начал работу
Работник 2: начал работу
Работник 4: начал работу
Работник 1: закончил работу
Работник 3: закончил работу
Работник 5: закончил работу
Работник 4: закончил работу
Работник 2: закончил работу
Все работники завершили работу
Видите? Все пять работников стартовали практически мгновенно, не дожидаясь друг друга. А потом завершились в случайном порядке. Если бы мы вызывали worker(i) без go — они бы работали строго по очереди, каждый по 500 миллисекунд, и суммарное время было бы 2.5 секунды. С горутинами все пять работали одновременно, и суммарное время — около 500 миллисекунд. Вот вам реальная выгода конкурентности!
🚪 Когда main завершается — всё останавливается
Это первая ловушка, в которую попадают новички. Давайте специально покажем проблему:
package main
import "fmt"
func main() {
go func() {
fmt.Println("Эта строка может не напечататься!")
}()
// main сразу завершается, не дожидаясь горутины
fmt.Println("main завершился")
}
Запустите этот код несколько раз. С высокой вероятностью вы увидите только «main завершился». Горутина просто не успела выполниться — программа закрылась раньше.
Правило: Когда функция main() завершает работу, программа прекращается. Все горутины, которые ещё работают, немедленно уничтожаются. Go не ждёт их автоматически.
Именно поэтому в наших примерах мы используем time.Sleep. Но это костыль — мы угадываем, сколько времени нужно. В реальных программах для решения этой проблемы используют sync.WaitGroup (ждёт завершения группы горутин) или каналы (горутины отправляют сигнал о завершении). Это темы платного курса, где мы разберём их подробно.
💥 Гонка данных — главная опасность конкурентности
Теперь самое интересное (и опасное). Когда несколько горутин работают одновременно и обращаются к одним и тем же данным — возникает гонка данных (race condition). Это ситуация, когда результат программы зависит от того, какая горутина выполнится первой. И результат непредсказуем.
Представьте банковский счёт с балансом 1000 рублей. Два человека одновременно пытаются снять по 700 рублей. Если проверка баланса и снятие не синхронизированы — оба увидят «1000», оба решат, что денег достаточно, и оба снимут 700. Итог: на счёте минус 400 рублей. Вот что такое гонка данных.
Посмотрим на это в коде:
package main
import (
"fmt"
"time"
)
func main() {
counter := 0
// Запускаем 1000 горутин, каждая увеличивает counter на 1
for i := 0; i < 1000; i++ {
go func() {
counter++ // Опасно! Несколько горутин пишут в одну переменную
}()
}
time.Sleep(time.Second)
fmt.Println("Ожидаемый результат: 1000")
fmt.Println("Реальный результат: ", counter)
}
Казалось бы, 1000 горутин, каждая увеличивает счётчик на 1. Результат должен быть 1000. Но если вы запустите эту программу несколько раз, вы увидите разные числа: 987, 1000, 952, 998... Каждый раз разное!
Почему? Операция counter++ выглядит как одно действие, но на самом деле это три шага:
1. Прочитать текущее значение counter (например, 42)
2. Увеличить на 1 (получить 43)
3. Записать новое значение обратно в counter
Если две горутины одновременно выполняют шаг 1, обе прочитают одно и то же значение (42). Обе увеличат его до 43. Обе запишут 43. Вместо двух увеличений получилось одно — инкремент потерялся.
Go имеет встроенный инструмент для обнаружения гонок данных — race detector. Запустите программу с флагом -race:
go run -race main.go
И вы увидите предупреждение вроде:
WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 8:
main.main.func1()
main.go:13 +0x3c
Previous write at 0x00c0000b4010 by goroutine 7:
main.main.func1()
main.go:13 +0x52
Go прямо говорит: «В строке 13 есть гонка данных. Горутина 8 читает, горутина 7 пишет — одновременно».
💡 Совет: Всегда запускайте тесты с флагом -race (go test -race ./...). Это хорошая привычка, которая спасёт вас от неприятных багов в продакшене. Race condition — один из самых коварных багов, потому что он воспроизводится не всегда.
🔒 Как решают проблему гонки данных?
Мы специально не будем решать проблему в этом уроке — это тема для глубокого изучения. Но давайте хотя бы назовём инструменты, которые для этого существуют в Go:
Каналы (channels) — главный способ коммуникации между горутинами в Go. Вместо того чтобы несколько горутин писали в одну переменную, они отправляют данные через канал. Философия Go: «Не общайтесь через разделяемую память — разделяйте память через общение».
sync.Mutex — мьютекс (mutual exclusion, взаимное исключение). Это замок: горутина «запирает» доступ к данным, работает с ними, потом «отпирает». Пока замок закрыт, другие горутины ждут.
sync.WaitGroup — позволяет дождаться завершения группы горутин без time.Sleep. Вы говорите: «Я запустил 5 горутин, подождите, пока все 5 закончат».
sync/atomic — пакет для атомарных операций. Позволяет безопасно увеличивать счётчик из нескольких горутин без мьютексов.
Все эти инструменты, паттерны конкурентности (fan-in, fan-out, pipeline, worker pool), а также context для отмены операций — подробно разбираются в платном курсе. Конкурентность — это целый мир, и он заслуживает отдельного модуля с десятком уроков и практических заданий.
🌐 Реальный пример: параллельная загрузка
Чтобы вы увидели, как горутины применяются в реальных задачах, давайте напишем программу, которая «загружает» данные из нескольких источников одновременно:
package main
import (
"fmt"
"time"
)
// Имитация загрузки данных с сервера
func fetchData(source string, duration time.Duration) {
fmt.Printf("[%s] Начинаю загрузку...\n", source)
time.Sleep(duration) // Имитация сетевого запроса
fmt.Printf("[%s] Загрузка завершена! (заняло %v)\n", source, duration)
}
func main() {
start := time.Now()
// Без горутин: последовательно
// fetchData("База данных", 2*time.Second)
// fetchData("API погоды", 1*time.Second)
// fetchData("Файловая система", 500*time.Millisecond)
// Суммарно: 3.5 секунды
// С горутинами: параллельно
go fetchData("База данных", 2*time.Second)
go fetchData("API погоды", 1*time.Second)
go fetchData("Файловая система", 500*time.Millisecond)
time.Sleep(3 * time.Second) // Ждём завершения всех
fmt.Printf("\nВсё завершено за %v\n", time.Since(start).Round(time.Millisecond))
}
Вывод:
[Файловая система] Начинаю загрузку...
[API погоды] Начинаю загрузку...
[База данных] Начинаю загрузку...
[Файловая система] Загрузка завершена! (заняло 500ms)
[API погоды] Загрузка завершена! (заняло 1s)
[База данных] Загрузка завершена! (заняло 2s)
Всё завершено за 3s
Без горутин три запроса заняли бы 2 + 1 + 0.5 = 3.5 секунды. С горутинами все три работают одновременно, и общее время равно самому длинному запросу — 2 секунды (плюс наш запас в Sleep). В реальных приложениях вместо time.Sleep вы бы использовали sync.WaitGroup или каналы, чтобы точно знать, когда все горутины завершились.
📋 Итоги урока
- Конкурентность — это структура программы (умение жонглировать задачами), параллельность — это исполнение (задачи физически работают одновременно)
- Горутина — легковесный поток выполнения. Стартует с ~2 КБ, можно запустить сотни тысяч
- Запуск горутины:
go имяФункции()илиgo func() { ... }() - Когда
main()завершается — все горутины немедленно прекращаются - Гонка данных (race condition) — когда несколько горутин одновременно читают и пишут общие данные. Результат непредсказуем
- Детектор гонок:
go run -race main.go— всегда используйте при разработке - Инструменты синхронизации: каналы, Mutex, WaitGroup, atomic — всё это темы платного курса
В следующем уроке (6.2) — финальный урок курса! Мы подведём итоги вашего пути, посмотрим, что ждёт в платном курсе (REST API, PostgreSQL, Docker, gRPC, деплой), и поговорим о том, почему Go — один из самых востребованных языков на рынке. До встречи!