Если вы нашли где-то ошибку, сообщите мне, пожалуйста!

«Группа ожидания ожидает завершения набора горутин». — Спасибо GoDoc.

Кратко как это работает

Этот пост будет первым из серии об источнике Golang. Сейчас я точно хочу описать, как работает группа ожидания, когда мы вызываем три метода (добавить, ожидание, выполнено), принадлежащие типу группы ожидания. Если у нас много горутин и мы хотим заблокировать их без сложно спроектированных каналов, просто используя WaitGroup. Каждая горутина типизирует один модуль в состоянии WaitGroup, прежде чем мы запустим их, вызовем WaitGroup Add(n), где n представляет количество запущенных горутин. Внутреннее состояние группы ожидания будет суммой чисел, которые мы добавили с помощью метода Add.

Метод Wait() должен был вызываться, когда мы создавали состояние группы ожидания, и он будет блокировать код до тех пор, пока состояние снова не станет равным нулю. Очевидно, что когда мы вызываем Wait() с нулевым состоянием, это приводит к неблокированной среде выполнения, которая переходит к следующему разделу, за которым следует метод Wait(). Это не может быть проблемой, в отличие от взаимоблокировки, которая может возникнуть, когда она имеет ненулевое состояние группы ожидания и блокирует код без какого-либо вызова метода Done().

Метод Done() означает, что уже запущенная горутина завершена, мы можем удалить этот модуль из состояния. Done() так же, как мы вызываем Add(-1), я имею в виду, что это фактическая реализация метода Done() .

Что такое группа ожидания?

type WaitGroup struct {
    noCopy noCopy
    state1 [12]byte
    sema   uint32
}

Основной тип WaitGroup, где наиболее важной частью является поле state1, так как в нем хранится состояние фактической группы ожидания.

Но сначала поговорим о том, что такое noCopy. Это просто встроенная структура для блокировки WaitGroup от копий. Если мы попытаемся сделать следующее:

var wg = sync.WaitGroup{}
wg2 := wg

go vet предупреждает нас о «присваивание копирует значение блокировки в wg2: sync.WaitGroup содержит sync.noCopy», поэтому мы не можем скопировать значение типа WaitGroup. (Конечно, если wg является указателем, указывающим на значение типа WaitGroup, мы можем сказать wg2 := wg, но это не означает, что мы копируем Значение.)

Возвращаясь к wg.state1, это массив байтов длиной 12, чтобы гарантировать, что WaitGroup.state() возвращает 64-битный выровненный указатель. Как можно представить это значение, оно выглядит следующим образом:

[12]byte{12, 12, 23, 44, 55} // uint64 value: 236962909196
// each element of the slice is a byte which means 8bit (0 or 1)
// the 12 is 0000 1100 as a binary
// the 23 is 0001 0111
// the 44 is 0010 1100
// the 55 is 0011 0111
// if I want to write that as the whole binary it will be
0011 0111 0010 1100 0001 0111 0000 1100 0000 1100
55        44        23        12        12

Таким образом, группа ожидания сохраняет текущее состояние в поле state1.

Вероятно, это изменится из-за следующей проблемы Github: https://github.com/golang/go/issues/19057

Состояние группы ожидания ()

Если вы поняли, как WaitGroup хранит текущее состояние, то теперь я могу описать, как преобразовать его в указатель, указывающий на значение типа uint64.

func (wg *WaitGroup) state() *uint64 {
  if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
    return (*uint64)(unsafe.Pointer(&wg.state1))
  } else {
    return (*uint64)(unsafe.Pointer(&wg.state1[4]))
  }
}

Если вы не понимаете, что именно он делает, вы не одиноки. Я потратил часы, чтобы понять, что происходит в этой функции.

Идем наизнанку и сначала разбираемся с двумя разделами возврата:

return (*uint64)(unsafe.Pointer(&wg.state1))
return (*uint64)(unsafe.Pointer(&wg.state1[4]))
// The only one difference is the [4], 
// let's tell you what is it exactly
// Now I'm using a new byte slice with plus one value 
// [12]byte{12, 12, 23, 44, 55, 66}
// If I want to print out the first exact value I have to use
// a pointer dereference:
fmt.Println(*(*uint64)(unsafe.Pointer(&state1)))
// value: 72804730342412
// Same with the second:
fmt.Println(*(*uint64)(unsafe.Pointer(&state1[4])))
// value: 16951

Мы знаем, что они возвращают разные значения, но почему? Очевидно, из-за того, что [4]определяет другой адрес памяти, на который он указывает. Чтобы описать, как это работает, я распечатал адреса памяти.

fmt.Println(unsafe.Pointer(&state1))
// 0x10410020
 
fmt.Println(unsafe.Pointer(&state1[4]))
// 0x10410024
0x10410020 0x10410021 0x10410022 0x10410023 0x10410024 0x10410025 ..
pointer 12|pointer 12|pointer 23|pointer 44|pointer 55|pointer 66
// So the second print out chunk the first 4 byte and we will get // // the following binary representation:
0100 0010 0011 0111
66        55

Заключительная часть определяет, для чего предназначен оператор if. Давайте снова опишем это с некоторой распечаткой, но сначала определим, что такое uintptr.

«uintptr — это целочисленный тип, достаточно большой для хранения битового шаблона любого указателя» — Спасибо, GoDoc

Оператор if проверяет, что фрагмент памяти, который мы получили от системы, подходит для фрагмента длиной 8 байт, который определяет, что это 64-битная система. Обратное доказательство:

fmt.Println(unsafe.Pointer(&state1[4])) // out: 0x10410024
fmt.Println(uintptr(unsafe.Pointer(&state1[4]))) // out: 272695332
 
fmt.Println(uintptr(unsafe.Pointer(&state1[4]))%8) // out: 4

Так что это система, которая не является 64-битной системой. (Просто примерные значения, я же не в средневековье живу)

Добавить группу ожидания

Хорошо, теперь мы понимаем, как хранится состояние в пакете WaitGroup, и я думаю, что это была самая сложная часть этой библиотеки. Функция Add просто добавляет целое число дельты к состоянию.

Я хочу поговорить о внутренних/гоночных решениях позже. Так что пропустите все части кода, которые проверяют race.Enabled.

Есть источник: https://golang.org/src/sync/waitgroup.go

Первая строка метода Add вызывает описанную выше функцию WaitGroup.state(). У нас есть это значение как указатель на значение uint64. Если мы пропустим гонку, чистая линия будет фактическим приращением состояния.

state := atomic.AddUint64(statep, uint64(delta)<<32)

«Пакет atomic предоставляет низкоуровневые примитивы атомарной памяти, полезные для реализации алгоритмов синхронизации. “— Спасибо GoDoc

Но разбить на мелкие части. Начните с uint64(delta), оно преобразуется в int в значение uint64, и есть бинарная операция ‹‹ 32, которая является левой сдвиг. После этого используется функция AddUint64 из пакета atomic. Но что такое двоичный сдвиг влево?

delta := 2
// binary representation of delta is
0000 0010
2
// binary left shift 32 means adding 32 zero after the current value
0010 0000 0000 0000 0000 0000 0000 0000 0000
8589934592
statep := state([12]byte{12})
// the statep now 12
// after the AddUint64 function the state will be:
0010 0000 0000 0000 0000 0000 0000 0000 1100
8589934604

Если мы перейдем к следующему разделу, там будет другая бинарная операция, называемая сдвигом вправо.

v := int32(state >> 32)
// the state currently:
0010 0000 0000 0000 0000 0000 0000 0000 1100
8589934604
// the v
0000 0010
2

Теперь возникает вопрос, почему я использовал [12]byte{12} в качестве начального значения для состояния. Просто чтобы показать, как работает функция WaitGroup. Таким образом, состояние не сохраняется как небольшое целое число.

С другой стороны, нужно преобразовать в int32, потому что после бинарного сдвига значение может быть минус. Например, если мы сейчас добавим -3, будет -1, а uint64 может вызвать панику. А вот если счетчик ниже нуля, то это тоже паника, но вызванная методом Add.

Существует w := uint32(state), что описано ниже. Перейдите к следующему разделу и допустим, что w равно 0.

if v > 0 || w == 0 {
  return
}

Готово, мы добавили дельту в состояние.

Готово

Я не очень хочу говорить об этом, это довольно очевидно:

func (wg *WaitGroup) Done() {
  wg.Add(-1)
}

Группа ожидания

Сложная часть реализации WaitGroup Wait уже описана выше. Когда он получает v, проверяет его в бесконечном цикле for, и когда состояние достигает нулевого значения, он просто возвращается и завершает ожидание.

Последний вопрос: что такое atomic.CompareAndSwapUint64? «Он сравнивает содержимое ячейки памяти с заданным значением и, только если они совпадают, изменяет содержимое этой ячейки памяти на новое заданное значение. Это делается как одна атомарная операция». — спасибо Википедии.

Но зачем это нужно? Если мы проверим переменную w в функциях Add. Эта часть функции ожидания увеличивает это число, которое обеспечивает, что мы не можем использовать метод добавления после того, как мы вызвали ожидание один раз. Это очень умный способ защитить функцию ожидания.

Что такое внутреннее/раса?

Группа ожидания уже предоставляет решения для обнаружения состояний гонки внутри группы ожидания, поэтому, когда мы используем флаг -race в наших командах тесты, сборка, запуск или установка, группа ожидания обрабатывает обнаружение гонок данных. Если вы проверите папку internal/race, там есть race.go и norace.go, что означает, что при использовании флага -race будет установлен race.Enabled имеет значение true, группа ожидания проверяет это и меняет поведение на основе логического значения.