Разберем классическую задачку на многопоточность в Go, которая часто встречается на собеседованиях и в учебных материалах.
На первый взгляд код кажется простым, но содержит несколько важных нюансов, которые должен понимать каждый Go-разработчик.
- Исходный код
- Почему так происходит?
- 1. Главная проблема: Основная горутина не ждет завершения остальных.
- 2. Проблема гонки данных (Race Condition)
- 3. Планировщик горутин
- Как исправить код?
- 1. Использование WaitGroup (рекомендуемый)
- 2. Передача параметра в горутину
- 3. Использование каналов
- А что если надо сохранить порядок вывода?
- А как же многопоточность?
- Ключевые выводы
Исходный код
package main
import "fmt"
func main() {
a := 5000
for i := 0; i < a; i++ {
go fmt.Println(i)
}
}
Вопрос: Что выведет этот код и почему?
Правильный ответ: Учитывая большой диапазон чисел, скорее всего выведет несколько случайных чисел, и программа завершится. Если диапазон чисел будет маленьким, например а = 10, то программа ничего не выведет.
Почему так происходит?
Давайте разберем по пунктам, что не так с этим кодом.
1. Главная проблема: Основная горутина не ждет завершения остальных.
В Go приложение завершается, когда завершается главная горутина (функция main). В нашем случае:
- Главная горутина очень быстро создает 5000 дочерних горутин.
- Сразу после создания последней горутины, функция
mainзавершается. - При завершении
mainвсе остальные горутины уничтожаются, не успев выполниться.
Аналогия: Представьте, что вы наняли 5000 рабочих, дали каждому задание, но сразу же закрыли фабрику. Большинство рабочих даже не успели приступить к работе.
2. Проблема гонки данных (Race Condition)
Переменная i используется всеми горутинами одновременно. К моменту, когда горутина начинает выполнять fmt.Println(i), значение i в цикле уже могло измениться. Некоторые горутины могут выводить одно и то же значение, другие могут пропускать числа.
3. Планировщик горутин
Планировщик Go не гарантирует порядок выполнения горутин. Даже если бы они все успели выполнится, числа выводились бы в случайном порядке.
Как исправить код?
Есть несколько способов заставить программу работать корректно:
1. Использование WaitGroup (рекомендуемый)
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
a := 5000
for i := 0; i < a; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
}
2. Передача параметра в горутину
package main
import (
"fmt"
"time"
)
func main() {
a := 10
for i := 0; i < a; i++ {
go func(n int) {
fmt.Println(i)
}(i) // i передается по значению
}
// вполне рабочий вариант, но лучше так не делать
time.Sleep(time.Second)
}
3. Использование каналов
package main
import "fmt"
func main() {
a := 5000
done := make(chan bool, a) // буферизованный канал
for i := 0; i < a; i++ {
go func(n int) {
fmt.Println(n)
done <- true
}(i)
}
// ждем завершения всех горутин
for i := 0; i < a; i++ {
<-done
}
}
А что если надо сохранить порядок вывода?
Это уже немного другая задача и решается она намного проще.
package main
import "fmt"
func main() {
a := 5000
for i := 0; i < a; i++ {
fmt.Println(i)
}
}
А как же многопоточность?
Если нужно сохранить порядок и использовать многопоточность, то тут больше проигрыш чем выигрыш.
Напишем две функции: одна без многопоточности, а другая с многопоточностью и замерим результат выполнения.
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
// сначала замерим printNumbers1()
// затем printNumbers2()
secs := time.Since(start).Seconds()
fmt.Printf("Sec: %.6fs", secs)
}
func printNumbers1() {
a := 5000
for i := 0; i < a; i++ {
fmt.Println(i)
}
}
func printNumbers2() {
a := 5000
ch := make(chan int)
// горутина-писатель
go func() {
for i := 0; i < a; i++ {
ch <- i
}
close(ch)
}()
// главная горутина-читатель
for n := range ch {
fmt.Println(n)
}
}
// printNumbers1: 0.154324s
// printNumbers2: 0.183987s
Видим то, что printNumbers2 выполняется дольше.
Ключевые выводы
- Всегда синхронизируйте горутины — используйте
sync.WaitGroup, каналы или другие механизмы синхронизации. - Избегайте гонок данных — передавайте параметры в горутины по значению, а не захватывайте переменные из внешней области видимости.
- Помните о планировщике — порядок выполнения горутин не гарантирован.
- Главная горутина управляет жизненным циклом приложения — если она завершается, завершаются все остальные горутины.
Эта задачка — отличная иллюстрация того, как важно понимать модель многопоточности в Go и не забывать о базовых принципах concurrent-программирования.
Напишите в комментариях, а какой из способов синхронизации горутин вы используете чаще всего в своих проектах и почему?