Multithreading e assincronia em Go (Golang): um guia detalhado
Что это такое и зачем? (аналогия из реальной жизни)
Представьте, что вы — шеф-повар на кухне ресторана. У вас есть один помощник. Вам нужно приготовить три блюда: суп, салат и десерт.
Последовательная работа (без многопоточности): Вы говорите помощнику: «Сначала нарежь овощи для супа. Когда закончишь — нарежь овощи для салата. Потом — укрась десерт». Помощник делает всё по очереди. Если он режет суп, салат и десерт ждут. Это медленно, но просто.
Многопоточность: Вы нанимаете трёх помощников. Каждый берёт одно блюдо и работает над ним одновременно. Первый режет суп, второй — салат, третий украшает десерт. Готово в три раза быстрее! Но теперь вам нужно следить, чтобы они не мешали друг другу (не взяли один нож, не перепутали продукты).
Асинхронность: У вас один помощник, но он очень умный. Вы даёте ему задание: «Поставь суп вариться. Пока он варится — нарежь салат. Когда суп сварится — выключи плиту». Помощник не ждёт, пока суп сварится, он делает другие дела. Он как бы «переключается» между задачами, не простаивая.
В программировании всё то же самое. Многопоточность — это когда несколько задач выполняются реально одновременно (на разных ядрах процессора). Асинхронность — это когда одна задача ждёт (например, ответа от сервера), а программа в это время делает другую работу.
💡 Важно: В Go (Golang) многопоточность и асинхронность тесно связаны. Go использует горутины (goroutines) — лёгкие потоки, которые работают как асинхронные задачи, но могут выполняться параллельно на нескольких ядрах.
Теория: как это работает изнутри
Процессы, потоки и горутины
Чтобы понять Go, нужно разобраться с тремя уровнями:
| Понятие | Что это? | Пример из жизни |
|---|---|---|
| Процесс | Запущенная программа. Имеет свою память, ресурсы. | Целая кухня ресторана. |
| Поток (thread) | Лёгкий процесс внутри программы. Потоки делят память процесса. | Повар на кухне. |
| Горутина (goroutine) | Лёгкий поток в Go. Их может быть миллионы в одной программе. | Робот-помощник, который может делать много дел почти без затрат. |
Как работают горутины?
Go использует собственный планировщик (scheduler). Он распределяет горутины по потокам операционной системы. Когда горутина ждёт (например, читает файл или ждёт ответа от сети), планировщик не блокирует поток, а переключает его на другую горутину. Это называется асинхронным вводом-выводом.
Представьте, что у вас есть 10 потоков ОС и 1000 горутин. Планировщик Go сам решает, какая горутина сейчас будет работать на каком потоке. Если горутина засыпает (ждёт), её место занимает другая.
Каналы (channels) — главный инструмент синхронизации
Горутины общаются через каналы. Это как труба, по которой можно отправлять данные из одной горутины в другую. Канал может быть:
- Небуферизированным: отправитель ждёт, пока получатель не заберёт данные. Получатель ждёт, пока отправитель не пошлёт.
- Буферизированным: есть место для хранения данных. Отправитель может отправить несколько значений, не дожидаясь получателя.
🔑 Девиз Go: «Не общайтесь через разделяемую память, разделяйте память через общение». Это значит: вместо того чтобы блокировать доступ к переменной (как в других языках), используйте каналы для передачи данных между горутинами.
Синтаксис в Go (Golang): все формы и варианты
1. Запуск горутины
Достаточно написать go перед вызовом функции:
go myFunction()
Функция выполнится в отдельной горутине. Программа не ждёт её завершения.
2. Анонимные функции в горутинах
go func() {
fmt.Println("Привет из горутины!")
}()
3. Создание канала
// Небуферизированный канал для целых чисел
ch := make(chan int)
// Буферизированный канал на 3 элемента
bufferedCh := make(chan string, 3)
4. Отправка и получение из канала
ch <- 42 // Отправить число 42 в канал
value := <-ch // Получить значение из канала
5. Закрытие канала
close(ch)
После закрытия из канала можно читать (вернутся нулевые значения), но нельзя писать.
6. Select — работа с несколькими каналами
select {
case msg1 := <-ch1:
fmt.Println("Получено из ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено из ch2:", msg2)
case <-time.After(1 * time.Second):
fmt.Println("Таймаут!")
default:
fmt.Println("Ничего не готово")
}
7. WaitGroup — ожидание группы горутин
var wg sync.WaitGroup
wg.Add(3) // Говорим, что будем ждать 3 горутины
go func() {
defer wg.Done() // Уменьшаем счётчик на 1
// работа...
}()
wg.Wait() // Ждём, пока счётчик не станет 0
8. Mutex — взаимное исключение
var mu sync.Mutex
mu.Lock()
// критическая секция (только одна горутина за раз)
mu.Unlock()
Примеры кода — от простого к сложному
Пример 1: Первая горутина
package main
import (
"fmt"
"time"
)
func main() {
// Запускаем горутину
go fmt.Println("Привет из горутины!")
// Даём время горутине выполниться
time.Sleep(100 * time.Millisecond)
fmt.Println("Привет из main!")
}
Объяснение: go fmt.Println(...) запускает печать в отдельной горутине. Без time.Sleep программа завершилась бы сразу, не дождавшись вывода из горутины.
Пример 2: Передача данных через канал
package main
import "fmt"
func main() {
// Создаём канал для строк
ch := make(chan string)
// Запускаем горутину, которая отправляет сообщение
go func() {
ch <- "Привет из горутины!"
}()
// Получаем сообщение (блокируемся, пока не получим)
message := <-ch
fmt.Println(message)
}
Объяснение: Канал ch небуферизированный. Горутина отправляет строку и ждёт, пока main её не получит. main ждёт, пока горутина не отправит. Это синхронизация.
Пример 3: Буферизированный канал
package main
import "fmt"
func main() {
// Канал с буфером на 2 элемента
ch := make(chan int, 2)
// Можно отправить 2 значения без получателя
ch <- 1
ch <- 2
// Теперь читаем
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
}
Объяснение: Буферизированный канал не блокирует отправителя, пока буфер не заполнится. Это как очередь с ограниченной вместимостью.
Пример 4: Параллельный поиск (Fan-Out)
package main
import (
"fmt"
"time"
)
func search(term string, result chan<- string) {
// Имитация долгого поиска
time.Sleep(time.Duration(len(term)) * 100 * time.Millisecond)
result <- "Результат для: " + term
}
func main() {
results := make(chan string, 3)
// Запускаем 3 поиска параллельно
go search("Go", results)
go search("Python", results)
go search("Rust", results)
// Собираем результаты
for i := 0; i < 3; i++ {
fmt.Println(<-results)
}
}
Объяснение: Мы запускаем три горутины одновременно. Каждая ищет свой термин. Результаты приходят по мере готовности. Это Fan-Out (веерный выход) — одна задача порождает много подзадач.
Пример 5: Select с таймаутом
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "Данные готовы"
}()
select {
case msg := <-ch:
fmt.Println(msg)
case <-time.After(1 * time.Second):
fmt.Println("Таймаут! Данные не получены за 1 секунду")
}
}
Объяснение: select ждёт, пока один из каналов не будет готов. time.After создаёт канал, который отправляет значение через 1 секунду. Если данные не пришли за секунду — срабатывает таймаут.
Пример 6: Паттерн Worker Pool (пул воркеров)
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
// Обрабатываем задачу
result := job * 2
fmt.Printf("Воркер %d обработал задачу %d -> %d
", id, job, result)
results <- result
}
}
func main() {
const numJobs = 10
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
var wg sync.WaitGroup
// Запускаем воркеров
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Отправляем задачи
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // Закрываем канал задач — воркеры завершат цикл
// Ждём завершения всех воркеров
wg.Wait()
close(results) // Закрываем канал результатов
// Собираем результаты
for result := range results {
fmt.Println("Результат:", result)
}
}
Объяснение: Это классический паттерн. У нас есть 3 воркера (горутины), которые берут задачи из общего канала jobs. Каждый воркер обрабатывает задачу и отправляет результат в results. WaitGroup ждёт, пока все воркеры закончат. Это эффективно для обработки большого количества независимых задач.
Пример 7: Канал для сигнала завершения (done channel)
package main
import (
"fmt"
"time"
)
func main() {
done := make(chan bool)
go func() {
fmt.Println("Горутина работает...")
time.Sleep(2 * time.Second)
fmt.Println("Горутина завершилась")
done <- true // Отправляем сигнал
}()
// Ждём сигнала
<-done
fmt.Println("Программа завершена")
}
Объяснение: Канал done используется как сигнал завершения. main блокируется на <-done, пока горутина не отправит true. Это простой способ синхронизации.
Сравнение с Python: чем похоже и чем отличается
| Характеристика | Go | Python |
|---|---|---|
| Основная единица | Горутина (goroutine) | Поток (thread) или корутина (async/await) |
| Синтаксис запуска | go func() |
threading.Thread(target=func).start() или asyncio.create_task() |
| Обмен данными | Каналы (chan) — основной способ | Очереди (queue.Queue) или разделяемые переменные с блокировками |
| Блокировки | Mutex, RWMutex (редко, предпочитают каналы) | Lock, RLock, Semaphore (используются часто) |
| Параллелизм | Настоящий (горутины распределяются по потокам ОС) | GIL (Global Interpreter Lock) — только один поток выполняется за раз. Асинхронность — кооперативная |
| Производительность | Высокая (компилируемый язык, лёгкие горутины) | Ниже (интерпретируемый, GIL, тяжёлые потоки) |
| Сложность | Средняя (простой синтаксис, но нужно понимать каналы) | Высокая (много разных подходов: threading, multiprocessing, asyncio) |
💡 Главное отличие: В Python вы выбираете между потоками (threading) и асинхронностью (asyncio). В Go горутины объединяют оба подхода: они лёгкие как корутины, но могут выполняться параллельно как потоки.
Типичные ошибки и подводные камни
1. Горутина не успевает выполниться
go fmt.Println("Привет") // Программа завершится, не напечатав это
Решение: Используйте WaitGroup или каналы для синхронизации.
2. Дедлок (deadlock) с небуферизированным каналом
ch := make(chan int)
ch <- 1 // Блокировка навсегда! Нет получателя
fmt.Println(<-ch)
Решение: Всегда обеспечивайте, чтобы отправитель и получатель были в разных горутинах, или используйте буферизированный канал.
3. Запись в закрытый канал
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Решение: Закрывайте канал только в горутине-отправителе. Не закрывайте канал, если есть другие отправители.
4. Гонка данных (data race)
var counter int
for i := 0; i < 1000; i++ {
go func() {
counter++ // Небезопасно! Гонка данных
}()
}
Решение: Используйте sync.Mutex или атомарные операции (sync/atomic). Или, лучше, используйте каналы.
5. Утечка горутин
go func() {
for {
// Бесконечный цикл без выхода
}
}()
Решение: Всегда предусматривайте способ остановки горутины (канал done, контекст).
6. Паника в горутине
go func() {
panic("Ошибка!") // Паника убьёт всю программу
}()
Решение: Используйте recover внутри горутины для обработки паники.
Лучшие практики и советы профессионалов
- Не используйте горутины без необходимости. Если задача выполняется за микросекунды, горутина только замедлит.
- Предпочитайте каналы блокировкам. Каналы делают код понятнее и безопаснее.
- Используйте контекст (context) для отмены.
context.WithCancelиcontext.WithTimeout— стандартный способ остановить горутины. - Ограничивайте количество горутин. Используйте паттерн Worker Pool, чтобы не создавать миллионы горутин.
- Всегда закрывайте каналы. Иначе возможна утечка памяти. Закрывайте в горутине-отправителе.
- Используйте go vet и -race.
go vetнаходит подозрительные конструкции,go run -raceобнаруживает гонки данных. - Тестируйте конкурентный код. Используйте
testingс флагом-race.
🎯 Золотое правило: «Не общайтесь через разделяемую память, разделяйте память через общение». Если вы следуете этому правилу, 90% проблем с многопоточностью исчезнут.
Вопросы для самопроверки
- Что такое горутина и чем она отличается от потока ОС?
Ответ: Горутина — лёгкий поток, управляемый планировщиком Go. Она занимает меньше памяти (несколько КБ против 1-8 МБ у потока ОС) и быстрее создаётся/переключается. - Какой канал вызовет дедлок:
make(chan int)илиmake(chan int, 1)?
Ответ:make(chan int)(небуферизированный) вызовет дедлок, если отправить и получить в одной горутине. Буферизированный на 1 элемент — нет. - Что произойдёт при чтении из закрытого канала?
Ответ: Вернётся нулевое значение для типа канала. Второе возвращаемое значение (ok) будет false. - Как дождаться завершения 10 горутин?
Ответ: Использоватьsync.WaitGroup:wg.Add(10), в каждой горутинеdefer wg.Done(), в mainwg.Wait(). - В чём разница между select и switch?
Ответ:selectработает только с каналами. Он ждёт, пока один из каналов будет готов к чтению/записи.switch— это условный оператор. - Что такое гонка данных (data race)?
Ответ: Ситуация, когда две горутины одновременно обращаются к одной переменной, и хотя бы одна из них пишет. Результат непредсказуем. - Как обнаружить гонку данных?
Ответ: Запустить программу с флагом-race:go run -race main.go. - Что делает
close(ch)?
Ответ: Закрывает канал. После закрытия в него нельзя писать, но можно читать (вернутся нулевые значения). Используется для сигнала о завершении. - Какой паттерн используется для ограничения количества горутин?
Ответ: Worker Pool (пул воркеров). Создаётся фиксированное количество горутин, которые читают задачи из общего канала. - Что такое контекст (context) в Go?
Ответ: Пакетcontextпозволяет передавать сигналы отмены, таймауты и значения между горутинами. Стандартный способ управления жизненным циклом.
Итог и что изучить дальше
Мы разобрали основы многопоточности и асинхронности в Go. Вы узнали:
- Что такое горутины и как они работают
- Как использовать каналы для общения между горутинами
- Как синхронизировать горутины с помощью WaitGroup и Mutex
- Как избегать типичных ошибок
Что изучать дальше:
- Пакет context — для отмены горутин и передачи таймаутов.
- Паттерны конкурентности — Fan-In, Fan-Out, Pipeline, Pub/Sub.
- sync/atomic — атомарные операции для счётчиков.
- Реальные проекты — напишите веб-сервер, который обрабатывает запросы в горутинах.
- Книга «Concurrency in Go» Кэтрин Кокс-Бадей — глубокая теория.
🚀 Главный вывод: Go создан для многопоточности. Его горутины и каналы — элегантный и мощный инструмент. Не бойтесь экспериментировать, но всегда помните о безопасности и синхронизации.