Это может стать вашим ускоренным курсом по WaitGroup. Давайте начнем шаг за шагом.

Прочитайте до конца, иначе вы можете узнать что-то неправильное и обвинить в этом меня😝

0. Пример рабочего

Ниже приведен простой пример с рабочей функцией для печати сообщения. Нет параллелизма 🍰.

// https://go.dev/play/p/vWL9zhQC1LT
package main

import (
 "fmt"
)

func worker(msg string) {
 fmt.Println("Worker", msg)
}

func main() {
 worker("w1")
 fmt.Println("exiting")
}

Вывод:

Worker w1
exiting

1. Больше рабочих 🐜🐜🐜

// https://go.dev/play/p/qE-UT_NM5Q3
package main

import (
 "fmt"
)

func worker(msg string) {
 fmt.Println("Worker", msg)
}

func main() {
 worker("w1")
 worker("w2")
 worker("w3")
 fmt.Println("exiting")
}

Вывод:

Worker w1
Worker w2
Worker w3
exiting

2. Добавьте параллелизма 🧂

// https://go.dev/play/p/qE-UT_NM5Q3
package main

import (
 "fmt"
)

func worker(msg string) {
 fmt.Println("Worker", msg)
}

func main() {
 go worker("w1")
 go worker("w2")
 go worker("w3")
 fmt.Println("exiting")
}

Вывод:

Worker w1
Worker w2
Worker w3
exiting

или, может быть

Worker w1
exiting

or

Worker w1
Worker w2
exiting

или даже

Worker w2
Worker w1
exiting

Честно говоря, мы не знаем 🤷🏻‍♂️

3. Исправление с помощью группы ожидания 🔧

Чтобы убедиться, что мы ждем завершения всех рабочих. У WaitGroup есть простой API.

  • Add(int) добавляет дельту, которая может быть отрицательной, к счетчику группы ожидания.
  • Done() уменьшает значение счетчика группы ожидания на единицу.
  • Wait() блокируется до тех пор, пока счетчик WaitGroup не станет равным нулю.
// https://go.dev/play/p/r-4JT1upJQf
package main

import (
 "fmt"
 "sync"
)

var wg sync.WaitGroup

func worker(msg string) {
 wg.Add(1)
 defer wg.Done()
 fmt.Println("Worker", msg)
}

func main() {
 go worker("w1")
 go worker("w2")
 go worker("w3")

 fmt.Println("waiting")
 wg.Wait()
 fmt.Println("exiting")
}

Вывод:

waiting
Worker w1
Worker w2
Worker w3
exiting

or

Worker w1
waiting
Worker w2
Worker w3
exiting

or

Worker w2
waiting
Worker w1
Worker w3
exiting

или что-то другое. Но у нас есть гарантия. Гарантируйте, что exiting всегда печатается после того, как все строки, начинающиеся с Worker, будут напечатаны.

4. Передача группы ожидания в качестве аргумента

Часто... или в основном мы не создаем глобальные переменные. Мы создаем переменные области видимости функции. Давайте посмотрим, как изменится наш код, когда мы передаем функцию WaitGroup.

// https://go.dev/play/p/QoCJIbPkXkC
package main

import (
 "fmt"
 "sync"
)

func worker(msg string, wg sync.WaitGroup) {
 wg.Add(1)
 defer wg.Done()
 fmt.Println("Worker", msg)
}

func main() {
 var wg sync.WaitGroup
 go worker("w1", wg)
 go worker("w2", wg)
 go worker("w3", wg)

 fmt.Println("waiting")
 wg.Wait()
 fmt.Println("exiting")
}

Обратите внимание, что объявление wg перемещается внутри main, и наш рабочий процесс теперь принимает группу ожидания в качестве второго аргумента.

Вывод:

waiting
exiting

Не совсем ожидаемо, верно !!

Причина: Если вы внимательно изучите определение WaitGroup методов, вы увидите приемник указателя. И WaitGroup — это не интерфейс с реализацией, использующей приемник указателей💡.

Это означает, что если мы передаем группу ожидания без указателя, создается копия.

Также обратите внимание

5. Исправим 💪🏻

// https://go.dev/play/p/QoCJIbPkXkC
package main

import (
 "fmt"
 "sync"
)

// Notice the * here
func worker(msg string, wg *sync.WaitGroup) {
 wg.Add(1)
 defer wg.Done()
 fmt.Println("Worker", msg)
}

func main() {
 var wg sync.WaitGroup
 go worker("w1", &wg)
 go worker("w2", &wg)
 go worker("w3", &wg)

 fmt.Println("waiting")
 wg.Wait()
 fmt.Println("exiting")
}

Приготовьтесь 🎇🎉🎆🎉🎇

Вывод:

waiting
Worker w1
Worker w2
Worker w3
exiting

Вы действительно получаете выше вывода ?? Эх... одураченный параллелизмом.

Запустите его достаточно раз, и вы получите это

waiting
exiting

Или что-то еще странное.

Но подождите, почему.. в чем проблема 😵

6. Просветление 🧠

Проблема не в группе ожидания или ссылке. Проблема в заказе.

Обратите внимание, что две инструкции wg.Add(1) и defer wg.Done() написаны внутри процедуры go, а не в вызывающей программе. Таким образом, возможно, что вызывающая сторона запускает подпрограмму go... на самом деле все подпрограммы go, но ни одна из них не начинает выполнение, пока мы не достигнем строки wg.Wait(). Все процедуры go ожидают выполнения своего первого оператора wg.Add(1), в то время как основной поток продолжается до конца.

Ждать !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

Как, черт возьми, наш третий пример «2. Добавьте немного параллелизма 🧂» тогда работайте 🙄

Чтобы ответить на этот вопрос самостоятельно, запустите его пару раз, пока не произойдет волшебство. И наш третий пример логически неверный и не гарантирует выполнение всех воркеров.

Как исправить?
Это обычная конструкция, которую вы найдете в коде go. Что выглядит примерно так

// https://go.dev/play/p/QoCJIbPkXkC
package main

import (
 "fmt"
 "sync"
)

func worker(msg string) {
 fmt.Println("Worker", msg)
}

func main() {
 var wg sync.WaitGroup

 wg.Add(1)
 go func() {
  defer wg.Done()
  worker("w1")
 }()

 wg.Add(1)
 go func() {
  defer wg.Done()
  worker("w2")
 }()

 wg.Add(1)
 go func() {
  defer wg.Done()
  worker("w3")
 }()

 fmt.Println("waiting")
 wg.Wait()
 fmt.Println("exiting")
}

Приготовьтесь 🎇🎉🎆🎉🎇

Вывод:

waiting
Worker w1
Worker w2
Worker w3
exiting

Обратите внимание на тему

 wg.Add(1)
 go func() {
  defer wg.Done()
  worker("w1")
 }()

wg.Add(1) появляется перед входом в программу go

defer wg.Done() — это первый оператор в процедуре go.

Эти две вещи обеспечивают правильное завершение наших одновременных рабочих процессов.

Также обратите внимание на еще пару важных вещей:

  1. нам больше не нужно передавать WaitGroup рабочему процессу
  2. вызывающий объект является владельцем wg и заботится об очистке
  3. worker содержит только базовую логику и не должен иметь дело с какими-либо конструкциями параллелизма

Фу 😅
Это большая часть.

7. Можем ли мы сделать лучше?

Могут быть разные способы выражения вышеприведенного кода с использованием циклов. Так мы это видим и на практике. Ниже приведен пример использования цикла для порождения трех рабочих.

// https://go.dev/play/p/0UiybqXphC0
package main

import (
 "fmt"
 "sync"
)

func worker(msg string) {
 fmt.Println("Worker", msg)
}

func main() {
 var wg sync.WaitGroup

 for i := 1; i <= 3; i++ {

  wg.Add(1)
  go func(seq int) {
   defer wg.Done()
   worker(fmt.Sprintf("w%d", seq))
  }(i)

 }

 fmt.Println("waiting")
 wg.Wait()
 fmt.Println("exiting")
}

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

  1. Избегайте передачи WaitGroup в качестве аргумента
  2. Если нужно передать WaitGroup в качестве аргумента, сделайте это по ссылке
  3. Используйте общий синтаксис использования группы ожидания.
wg.Add(1)         // 1 - Add 
go func() {       // 2 - The go routine
  defer wg.Done() // 3 - Done
  someFunc()      // 4 - Your Code
} ()              // 5 - Remember to call the goroutine

Параллелизм настолько сложен, что я до сих пор не уверен, объяснил ли я все приведенные выше примеры достаточно подробно и правильно. Мы все делаем ошибки, и они помогают нам учиться. Развлекайтесь и ИЗБЕГАЙТЕ использования параллельного кода в рабочей среде, пока это не станет действительно необходимым.