Типичная ошибка с горутинами: что не так с этим кодом?

ошибка с горутинами

Разберем классическую задачку на многопоточность в Go, которая часто встречается на собеседованиях и в учебных материалах.

На первый взгляд код кажется простым, но содержит несколько важных нюансов, которые должен понимать каждый Go-разработчик.

Исходный код

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 выполняется дольше.

Ключевые выводы

  1. Всегда синхронизируйте горутины — используйте sync.WaitGroup, каналы или другие механизмы синхронизации.
  2. Избегайте гонок данных — передавайте параметры в горутины по значению, а не захватывайте переменные из внешней области видимости.
  3. Помните о планировщике — порядок выполнения горутин не гарантирован.
  4. Главная горутина управляет жизненным циклом приложения — если она завершается, завершаются все остальные горутины.

Эта задачка — отличная иллюстрация того, как важно понимать модель многопоточности в Go и не забывать о базовых принципах concurrent-программирования.

Напишите в комментариях, а какой из способов синхронизации горутин вы используете чаще всего в своих проектах и почему?

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: