Синхронизированные горутины (часть I)

Предположим, что программа Go запускает две горутины:

package main
import (
    "fmt"
    "sync"
)
func main() {
    var v int
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        v = 1
        wg.Done()
    }()
    go func() {
        fmt.Println(v)
        wg.Done()
    }()
    wg.Wait()
}

Обе горутины работают с общей переменной v. Один из них устанавливает новое значение (записывает), а второй печатает эту переменную (читает).

WaitGroup из пакета синхронизации используется для ожидания завершения двух неосновных горутин. В противном случае не было бы никакой гарантии, что какая-либо из этих горутин вообще запустится.

Поскольку горутины являются независимыми параллельными задачами, не существует неявного упорядочивания между выполняемыми ими операциями. В приведенном выше примере не ясно, будет ли напечатано 0 или 1. На выходе будет 1, когда в момент запуска fmt.Println другая горутина уже выполнила оператор присваивания v = 0. Пока неизвестно, пока программа действительно не запустится. Другими словами, оператор присваивания и вызов fmt.Println не упорядочены - они действуют одновременно.

Плохо, если мы не сможем определить, что делает программа, посмотрев на ее исходный код. Спецификация Go вводит частичный порядок (происходит до) операций с памятью (чтения или записи). Этот порядок позволяет понять, что делает программа. Некоторые механизмы языка дополнительно позволяют программисту устанавливать порядок операций.

Внутри одной горутины все операции упорядочены так, как они помещены в исходный код:

wg.Add(2)
wg.Wait()

Вызовы функций из приведенного выше примера, поскольку они помещены в одну и ту же горутину, упорядочены - wg.Add(2) происходит до wg.Wait().

1. Каналы

Связь с использованием каналов является основным методом синхронизации. Отправка значения в канал происходит до его получения :

var v int
var wg sync.WaitGroup
wg.Add(2)
ch := make(chan int)
go func() {
    v = 1
    ch <- 1
    wg.Done()
}()
go func() {
    <-ch
    fmt.Println(v)
    wg.Done()
}()
wg.Wait()

Новое - канал ch. Поскольку получение происходит после отправки значения в канал, а отправка значения происходит после присвоения v, то вышеуказанная программа всегда печатает 1:

установить v → отправить в ch → получить от ch → print v

Первая и третья стрелки являются следствием упорядочивания в одной и той же горутине. С помощью канала связи введена вторая стрелка. В конечном итоге упорядочиваются операции, распределенные между двумя горутинами.

2. пакет синхронизации

Пакет sync предоставляет примитивы синхронизации. Одно из них, которое могло бы решить нашу проблему - Mutex. При наличии переменной lock типа sync.Mutex гарантировано, что второй вызов lock.Lock() произойдет после первого вызова lock.Unlock(). 3-й вызов lock.Lock() происходит после 1-го и 2-го вызовов lock.Unlock(). Вообще говоря, n -й вызов lock.Lock() происходит после m -го вызова lock.Unlock() для mn. Давайте посмотрим, как использовать эти знания для решения нашей проблемы синхронизации:

var v int
var wg sync.WaitGroup
wg.Add(2)
var m sync.Mutex
m.Lock()
go func() {
    v = 1
    m.Unlock()
    wg.Done()
}()
go func() {
    m.Lock()
    fmt.Println(v)
    wg.Done()
}()
wg.Wait()

В будущих статьях будет рассказано больше о связи с каналами (т.е. как это работает с буферизованными каналами), но также будет подробно объяснено, что предоставляет пакет sync.

Нажмите ❤ ниже, чтобы помочь другим узнать эту историю. Если вы хотите получать обновления о новых сообщениях, подписывайтесь на меня.

Ресурсы