GoSuda

Go Concurrentie Starterspakket

By snowmerak
views ...

Overzicht

Korte introductie

De programmeertaal Go biedt diverse tools voor het beheer van gelijktijdigheid. In dit artikel zullen we enkele van deze tools en bijbehorende technieken introduceren.

Goroutines?

Een goroutine is een nieuw model van gelijktijdigheid dat door de programmeertaal Go wordt ondersteund. Over het algemeen ontvangt een programma OS-threads van het besturingssysteem om meerdere taken gelijktijdig uit te voeren, waarbij taken parallel worden uitgevoerd op het aantal beschikbare cores. Om gelijktijdigheid op een fijnmaziger niveau te bereiken, worden er 'green threads' in de gebruikersruimte gecreëerd. Deze green threads draaien binnen één OS-thread, waarbij meerdere green threads cyclisch taken uitvoeren. In het geval van goroutines zijn deze green threads echter kleiner en efficiënter gemaakt. Deze goroutines gebruiken minder geheugen dan threads en kunnen sneller worden aangemaakt en vervangen dan threads.

Om een goroutine te gebruiken, is het voldoende om simpelweg het sleutelwoord go te gebruiken. Dit maakt het mogelijk om synchrone code op een intuïtieve manier om te zetten naar asynchrone code tijdens het ontwikkelproces.

 1package main
 2
 3import (
 4    "fmt"
 5    "time"
 6)
 7
 8func main() {
 9    ch := make(chan struct{})
10    go func() {
11        defer close(ch)
12        time.Sleep(1 * time.Second)
13        fmt.Println("Hello, World!")
14    }()
15
16    fmt.Println("Waiting for goroutine...")
17    for range ch {}
18}

Deze code wijzigt eenvoudigweg synchrone code die na 1 seconde wachten Hello, World! print naar een asynchrone flow. Dit voorbeeld is eenvoudig, maar als complexere code wordt omgezet van synchroon naar asynchroon, worden de leesbaarheid, zichtbaarheid en het begrip van de code beter dan bij de traditionele aanpak met async await of promises.

In veel gevallen wordt echter slechte goroutine-code gecreëerd als men de simpele asynchrone aanroep en flows zoals fork & join (een flow die lijkt op verdeel en heers) niet begrijpt. In dit artikel zullen we een aantal methoden en technieken introduceren om dergelijke situaties te vermijden.

Beheer van gelijktijdigheid

context

Het feit dat context als eerste managementtechniek verschijnt, kan verrassend zijn. In de programmeertaal Go speelt context echter een uitstekende rol bij het beheer van een volledige werkboom, in plaats van alleen een eenvoudige annuleringsfunctie. Voor degenen die er niet bekend mee zijn, volgt hier een korte uitleg van dit pakket.

 1package main
 2
 3func main() {
 4    ctx, cancel := context.WithCancel(context.Background())
 5    defer cancel()
 6
 7    go func() {
 8        <-ctx.Done()
 9        fmt.Println("Context is done!")
10    }()
11
12    time.Sleep(1 * time.Second)
13
14    cancel()
15
16    time.Sleep(1 * time.Second)
17}

De bovenstaande code gebruikt context om na 1 seconde Context is done! af te drukken. De context kan via de Done() methode controleren of een annulering heeft plaatsgevonden en biedt diverse annuleringsmethoden via methoden als WithCancel, WithTimeout, WithDeadline en WithValue.

Laten we een eenvoudig voorbeeld creëren. Stel dat u de aggregator-pattern gebruikt om user, post en comment op te halen om data op te halen. Als alle verzoeken binnen 2 seconden moeten worden uitgevoerd, dan kan de code als volgt worden geschreven:

 1package main
 2
 3func main() {
 4    ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)
 5    defer cancel()
 6
 7    ch := make(chan struct{})
 8    go func() {
 9        defer close(ch)
10        user := getUser(ctx)
11        post := getPost(ctx)
12        comment := getComment(ctx)
13
14        fmt.Println(user, post, comment)
15    }()
16
17    select {
18    case <-ctx.Done():
19        fmt.Println("Timeout!")
20    case <-ch:
21        fmt.Println("All data is fetched!")
22    }
23}

De bovenstaande code drukt Timeout! af als alle data niet binnen 2 seconden kan worden opgehaald, en All data is fetched! als alle data is opgehaald. Door context op deze manier te gebruiken, kan de annulering en time-outs eenvoudig worden beheerd, zelfs in code met meerdere actieve goroutines.

Diverse context-gerelateerde functies en methoden zijn te vinden in godoc context. Wij moedigen u aan om de basis te leren zodat u het gemakkelijk kunt gebruiken.

channel

Unbuffered channel

Een channel is een tool voor communicatie tussen goroutines. Een channel kan worden aangemaakt met make(chan T). Hierbij is T het type data dat via het channel verstuurd wordt. Data kan via <- naar een channel worden gestuurd en van een channel worden ontvangen. Een channel kan worden gesloten met close.

 1package main
 2
 3func main() {
 4    ch := make(chan int)
 5    go func() {
 6        ch <- 1
 7        ch <- 2
 8        close(ch)
 9    }()
10
11    for i := range ch {
12        fmt.Println(i)
13    }
14}

De bovenstaande code gebruikt een channel om 1 en 2 af te drukken. In deze code wordt alleen het verzenden en ontvangen van waarden van een channel gedemonstreerd. Een channel biedt echter meer functionaliteiten dan dit. Laten we beginnen met het bekijken van buffered channels en unbuffered channels. De voorbeeldcode hierboven is een unbuffered channel, waarbij het verzenden en ontvangen van data op een channel gelijktijdig moeten gebeuren. Als deze acties niet gelijktijdig plaatsvinden, kan een deadlock ontstaan.

Buffered channel

Wat als de bovenstaande code niet simpelweg output is, maar twee processen die zware bewerkingen uitvoeren? Als het tweede proces de data leest en verwerkt en langdurig vastloopt, zal het eerste proces ook gedurende die periode stoppen. Om deze situatie te voorkomen, kunnen we een buffered channel gebruiken.

 1package main
 2
 3func main() {
 4    ch := make(chan int, 2)
 5    go func() {
 6        ch <- 1
 7        ch <- 2
 8        close(ch)
 9    }()
10
11    for i := range ch {
12        fmt.Println(i)
13    }
14}

De bovenstaande code gebruikt een buffered channel om 1 en 2 af te drukken. In deze code gebruiken we een buffered channel zodat het verzenden en ontvangen van data via het channel niet gelijktijdig hoeft te gebeuren. Door een buffer aan het channel toe te voegen, wordt er een zekere mate van speling gecreëerd, waardoor vertragingen als gevolg van achterliggende taken worden voorkomen.

select

Bij het afhandelen van meerdere channels kan de select syntax worden gebruikt om gemakkelijk een fan-in structuur te implementeren.

 1package main
 2
 3import (
 4    "fmt"
 5    "time"
 6)
 7
 8func main() {
 9    ch1 := make(chan int, 10)
10    ch2 := make(chan int, 10)
11    ch3 := make(chan int, 10)
12
13    go func() {
14        for {
15            ch1 <- 1
16            time.Sleep(1 * time.Second)
17        }
18    }()
19    go func() {
20        for {
21            ch2 <- 2
22            time.Sleep(2 * time.Second)
23        }
24    }()
25    go func() {
26        for {
27            ch3 <- 3
28            time.Sleep(3 * time.Second)
29        }
30    }()
31
32    for i := 0; i < 3; i++ {
33        select {
34        case v := <-ch1:
35            fmt.Println(v)
36        case v := <-ch2:
37            fmt.Println(v)
38        case v := <-ch3:
39            fmt.Println(v)
40        }
41    }
42}

De bovenstaande code creëert drie channels die periodiek 1, 2 en 3 versturen en gebruikt select om de waarden van de channels te ontvangen en af te drukken. Door select op deze manier te gebruiken, kunnen data gelijktijdig van meerdere channels ontvangen worden en kunnen de waarden worden verwerkt op het moment dat ze van een channel worden ontvangen.

for range

Een channel kan eenvoudig data ontvangen met for range. Als for range op een channel wordt gebruikt, wordt het uitgevoerd wanneer er data aan het channel wordt toegevoegd, en wordt de loop beëindigd wanneer het channel wordt gesloten.

 1package main
 2
 3func main() {
 4    ch := make(chan int)
 5    go func() {
 6        ch <- 1
 7        ch <- 2
 8        close(ch)
 9    }()
10
11    for i := range ch {
12        fmt.Println(i)
13    }
14}

De bovenstaande code gebruikt een channel om 1 en 2 af te drukken. In deze code wordt for range gebruikt om data te ontvangen en af te drukken wanneer er data aan het channel wordt toegevoegd. De loop wordt beëindigd wanneer het channel wordt gesloten.

Zoals we hierboven een paar keer hebben gezien, kan deze syntax ook worden gebruikt als een eenvoudig middel voor synchronisatie.

 1package main
 2
 3func main() {
 4    ch := make(chan struct{})
 5    go func() {
 6        defer close(ch)
 7        time.Sleep(1 * time.Second)
 8        fmt.Println("Hello, World!")
 9    }()
10
11    fmt.Println("Waiting for goroutine...")
12    for range ch {}
13}

De bovenstaande code drukt Hello, World! af na 1 seconde wachten. In deze code wordt een channel gebruikt om synchrone code om te zetten naar asynchrone code. Door channel op deze manier te gebruiken, kan synchrone code eenvoudig worden omgezet in asynchrone code en kan het join-punt worden ingesteld.

etc

  1. Als data wordt verzonden naar of ontvangen van een nil channel, kan een oneindige loop ontstaan, met een deadlock als gevolg.
  2. Als data wordt verzonden naar een gesloten channel, treedt er een panic op.
  3. Het is niet noodzakelijk om een channel te sluiten; de garbage collector (GC) sluit het channel wanneer het wordt opgeruimd.

mutex

spinlock

Een spinlock is een synchronisatiemethode die in een loop blijft proberen om een lock te krijgen. In de programmeertaal Go kan eenvoudig een spinlock worden geïmplementeerd met behulp van pointers.

 1package spinlock
 2
 3import (
 4    "runtime"
 5    "sync/atomic"
 6)
 7
 8type SpinLock struct {
 9    lock uintptr
10}
11
12func (s *SpinLock) Lock() {
13    for !atomic.CompareAndSwapUintptr(&s.lock, 0, 1) {
14        runtime.Gosched()
15    }
16}
17
18func (s *SpinLock) Unlock() {
19    atomic.StoreUintptr(&s.lock, 0)
20}
21
22func NewSpinLock() *SpinLock {
23    return &SpinLock{}
24}

De bovenstaande code implementeert een spinlock-pakket. In deze code wordt het sync/atomic-pakket gebruikt om een SpinLock te implementeren. De Lock-methode gebruikt atomic.CompareAndSwapUintptr om een lock te proberen, en de Unlock-methode gebruikt atomic.StoreUintptr om de lock vrij te geven. Omdat deze methode continu een lock probeert, blijft de CPU continu gebruikt worden totdat de lock is verkregen, wat een oneindige loop kan veroorzaken. Daarom wordt een spinlock het best gebruikt voor eenvoudige synchronisatie of wanneer de lock slechts korte tijd nodig is.

sync.Mutex

Een mutex is een tool voor synchronisatie tussen goroutines. De mutex die door het sync-pakket wordt geleverd, biedt methoden zoals Lock, Unlock, RLock en RUnlock. Een mutex kan worden aangemaakt met sync.Mutex, en een lees/schrijf-lock kan worden gebruikt met sync.RWMutex.

 1package main
 2
 3import (
 4    "sync"
 5)
 6
 7func main() {
 8    var mu sync.Mutex
 9    var count int
10
11    go func() {
12        mu.Lock()
13        count++
14        mu.Unlock()
15    }()
16
17    mu.Lock()
18    count++
19    mu.Unlock()
20
21    println(count)
22}

In de bovenstaande code hebben twee goroutines bijna gelijktijdig toegang tot dezelfde count-variabele. Als we op dit punt de code die toegang krijgt tot de count-variabele tot een kritieke sectie maken met behulp van een mutex, dan kunnen we de gelijktijdige toegang tot de count-variabele voorkomen. Dan zal deze code, ongeacht hoe vaak deze wordt uitgevoerd, altijd 2 afdrukken.

sync.RWMutex

sync.RWMutex is een mutex waarbij lees- en schrijflocks afzonderlijk kunnen worden gebruikt. Met de methoden RLock en RUnlock kan een leeslock worden verkregen en vrijgegeven.

 1package cmap
 2
 3import (
 4    "sync"
 5)
 6
 7type ConcurrentMap[K comparable, V any] struct {
 8    sync.RWMutex
 9    data map[K]V
10}
11
12func (m *ConcurrentMap[K, V]) Get(key K) (V, bool) {
13    m.RLock()
14    defer m.RUnlock()
15
16    value, ok := m.data[key]
17    return value, ok
18}
19
20func (m *ConcurrentMap[K, V]) Set(key K, value V) {
21    m.Lock()
22    defer m.Unlock()
23
24    m.data[key] = value
25}

De bovenstaande code implementeert een ConcurrentMap met behulp van sync.RWMutex. In deze code wordt in de Get-methode een leeslock verkregen en in de Set-methode een schrijflock, zodat de data-map veilig kan worden geopend en aangepast. De reden voor het vereisen van een leeslock is dat als er veel leesbewerkingen zijn, meerdere goroutines de leesbewerkingen gelijktijdig kunnen uitvoeren zonder een schrijflock te verkrijgen. Hierdoor kunnen de prestaties worden verbeterd in gevallen waarin een schrijflock niet nodig is omdat de status niet wordt gewijzigd.

fakelock

Een fakelock is een simpele techniek die sync.Locker implementeert. Deze structuur biedt dezelfde methoden als sync.Mutex, maar voert in feite geen handelingen uit.

1package fakelock
2
3type FakeLock struct{}
4
5func (f *FakeLock) Lock() {}
6
7func (f *FakeLock) Unlock() {}

De bovenstaande code implementeert het fakelock-pakket. Dit pakket implementeert sync.Locker en biedt de methoden Lock en Unlock, maar voert in feite geen handelingen uit. De reden waarom deze code nodig is, zal op een later moment worden besproken.

waitgroup

sync.WaitGroup

sync.WaitGroup is een tool om te wachten tot alle taken van de goroutines zijn voltooid. Het biedt de methoden Add, Done en Wait. De Add-methode voegt het aantal goroutines toe, de Done-methode geeft aan dat de taak van een goroutine is voltooid en de Wait-methode wacht tot alle taken van alle goroutines zijn voltooid.

 1package main
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8func main() {
 9    wg := sync.WaitGroup{}
10    c := atomic.Int64{}
11
12    for i := 0; i < 100 ; i++ {
13        wg.Add(1)
14        go func() {
15            defer wg.Done()
16            c.Add(1)
17        }()
18    }
19
20    wg.Wait()
21    println(c.Load())
22}

Deze code, geschreven in Go, gebruikt sync.WaitGroup om 100 goroutines gelijktijdig een waarde aan de variabele c te laten toevoegen. De sync.WaitGroup zorgt ervoor dat gewacht wordt tot alle goroutines klaar zijn, waarna de totale waarde die aan c is toegevoegd, wordt afgedrukt. In gevallen waarin een beperkt aantal taken fork & join vereisen, is het gebruik van kanalen voldoende; echter, voor het beheren van een groot aantal fork & join operaties is sync.WaitGroup een gepaste keuze.

met slice

Indien gebruikt met een slice, kan waitgroup een uitstekend hulpmiddel zijn voor het beheren van gelijktijdige operaties zonder locks.

 1package main
 2
 3import (
 4	"fmt"
 5	"sync"
 6    "rand"
 7)
 8
 9func main() {
10	var wg sync.WaitGroup
11	arr := [10]int{}
12
13	for i := 0; i < 10; i++ {
14		wg.Add(1)
15		go func(id int) {
16			defer wg.Done()
17
18			arr[id] = rand.Intn(100)
19		}(i)
20	}
21
22	wg.Wait()
23	fmt.Println("Done")
24
25    for i, v := range arr {
26        fmt.Printf("arr[%d] = %d\n", i, v)
27    }
28}

Deze code gebruikt waitgroup om 10 goroutines gelijktijdig een willekeurig geheel getal te laten genereren en op te slaan op de toegewezen index in de array. Het waitgroup zorgt ervoor dat gewacht wordt tot alle goroutines klaar zijn, voordat Done wordt afgedrukt. Op deze manier kan waitgroup worden gebruikt om meerdere goroutines gelijktijdig taken te laten uitvoeren, zonder locks data op te slaan en na afloop van alle taken een batch-gewijze nabehandeling uit te voeren.

golang.org/x/sync/errgroup.ErrGroup

errgroup is een uitbreiding van het sync.WaitGroup pakket. In tegenstelling tot sync.WaitGroup annuleert errgroup alle goroutines en retourneert een foutmelding wanneer er in een van de goroutines een fout optreedt.

 1package main
 2
 3import (
 4    "context"
 5    "fmt"
 6    "golang.org/x/sync/errgroup"
 7)
 8
 9func main() {
10    g, ctx := errgroup.WithContext(context.Background())
11    _ = ctx
12
13    for i := 0; i < 10; i++ {
14        i := i
15        g.Go(func() error {
16            if i == 5 {
17                return fmt.Errorf("error")
18            }
19            return nil
20        })
21    }
22
23    if err := g.Wait(); err != nil {
24        fmt.Println(err)
25    }
26}

Deze code illustreert het gebruik van errgroup om 10 goroutines te creëren, waarbij de vijfde goroutine een fout genereert. Dit is opzettelijk gedaan om het gedrag bij fouten te tonen. In de praktijk kan errgroup gebruikt worden om goroutines te creëren, en nabehandelingen uit te voeren op basis van de fouten die door elke goroutine gegenereerd worden.

once

Dit is een hulpmiddel om code uit te voeren die slechts één keer uitgevoerd mag worden. De onderstaande constructors kunnen worden gebruikt om de bijbehorende code uit te voeren.

1func OnceFunc(f func()) func()
2func OnceValue[T any](f func() T) func() T
3func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)

OnceFunc

OnceFunc zorgt er simpelweg voor dat de betreffende functie slechts eenmalig uitgevoerd wordt gedurende de gehele levensduur van het programma.

 1package main
 2
 3import "sync"
 4
 5func main() {
 6    once := sync.OnceFunc(func() {
 7        println("Hello, World!")
 8    })
 9
10    once()
11    once()
12    once()
13    once()
14    once()
15}

Deze code maakt gebruik van sync.OnceFunc om de string Hello, World! af te drukken. Hoewel de once functie meerdere malen wordt aangeroepen, zal Hello, World! slechts één keer worden afgedrukt.

OnceValue

OnceValue zorgt er niet alleen voor dat de betreffende functie slechts eenmalig wordt uitgevoerd gedurende de gehele levensduur van het programma, maar slaat ook de geretourneerde waarde van de functie op en retourneert deze opgeslagen waarde bij latere aanroepen.

 1package main
 2
 3import "sync"
 4
 5func main() {
 6    c := 0
 7    once := sync.OnceValue(func() int {
 8        c += 1
 9        return c
10    })
11
12    println(once())
13    println(once())
14    println(once())
15    println(once())
16    println(once())
17}

Deze code maakt gebruik van sync.OnceValue om de variabele c stapsgewijs met 1 te verhogen. Hoewel de once functie meerdere malen wordt aangeroepen, wordt c slechts eenmalig verhoogd tot 1 en zal dit als geretourneerde waarde worden teruggegeven.

OnceValues

OnceValues werkt op dezelfde manier als OnceValue, maar kan meerdere waarden retourneren.

 1package main
 2
 3import "sync"
 4
 5func main() {
 6    c := 0
 7    once := sync.OnceValues(func() (int, int) {
 8        c += 1
 9        return c, c
10    })
11
12    a, b := once()
13    println(a, b)
14    a, b = once()
15    println(a, b)
16    a, b = once()
17    println(a, b)
18    a, b = once()
19    println(a, b)
20    a, b = once()
21    println(a, b)
22}

Deze code maakt gebruik van sync.OnceValues om de variabele c stapsgewijs met 1 te verhogen. Hoewel de once functie meerdere malen wordt aangeroepen, wordt c slechts eenmalig verhoogd tot 1 en zullen de twee geretourneerde waarden elk 1 zijn.

atomic

Het atomic pakket biedt atomaire operaties. Het pakket biedt methodes zoals Add, CompareAndSwap, Load, Store en Swap, maar recentelijk wordt het gebruik van types als Int64, Uint64 en Pointer aangeraden.

 1package main
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8func main() {
 9    wg := sync.WaitGroup{}
10    c := atomic.Int64{}
11
12    for i := 0; i < 100 ; i++ {
13        wg.Add(1)
14        go func() {
15            defer wg.Done()
16            c.Add(1)
17        }()
18    }
19
20    wg.Wait()
21    println(c.Load())
22}

Dit is een herhaling van een eerder voorbeeld. Het maakt gebruik van het atomic.Int64 type om de variabele c atomair te verhogen. De methodes Add en Load worden gebruikt om de variabele atomair te verhogen en te lezen. De Store methode kan worden gebruikt om waarden op te slaan, Swap om waarden te verwisselen en CompareAndSwap om de waarde te vergelijken en indien deze overeenkomt deze te vervangen.

cond

sync.Cond

Het cond pakket biedt voorwaardelijke variabelen. Een sync.Cond kan worden aangemaakt met behulp van sync.NewCond en biedt de methodes Wait, Signal en Broadcast.

 1package main
 2
 3import (
 4    "sync"
 5)
 6
 7func main() {
 8    c := sync.NewCond(&sync.Mutex{})
 9    ready := false
10
11    go func() {
12        c.L.Lock()
13        ready = true
14        c.Signal()
15        c.L.Unlock()
16    }()
17
18    c.L.Lock()
19    for !ready {
20        c.Wait()
21    }
22    c.L.Unlock()
23
24    println("Ready!")
25}

Deze code maakt gebruik van sync.Cond om te wachten tot de ready variabele true is. De sync.Cond wordt gebruikt om te wachten tot de ready variabele true is, waarna Ready! wordt afgedrukt. Met behulp van sync.Cond kunnen meerdere goroutines simultaan wachten tot aan een specifieke voorwaarde is voldaan.

Dit kan worden gebruikt om een simpele queue te implementeren.

 1package queue
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8type Node[T any] struct {
 9    Value T
10    Next  *Node[T]
11}
12
13type Queue[T any] struct {
14    sync.Mutex
15    Cond *sync.Cond
16    Head *Node[T]
17    Tail *Node[T]
18    Len  int
19}
20
21func New[T any]() *Queue[T] {
22    q := &Queue[T]{}
23    q.Cond = sync.NewCond(&q.Mutex)
24    return q
25}
26
27func (q *Queue[T]) Push(value T) {
28    q.Lock()
29    defer q.Unlock()
30
31    node := &Node[T]{Value: value}
32    if q.Len == 0 {
33        q.Head = node
34        q.Tail = node
35    } else {
36        q.Tail.Next = node
37        q.Tail = node
38    }
39    q.Len++
40    q.Cond.Signal()
41}
42
43func (q *Queue[T]) Pop() T {
44    q.Lock()
45    defer q.Unlock()
46
47    for q.Len == 0 {
48        q.Cond.Wait()
49    }
50
51    node := q.Head
52    q.Head = q.Head.Next
53    q.Len--
54    return node.Value
55}

Met behulp van sync.Cond kan men efficiënt wachten en opnieuw handelen wanneer aan de voorwaarden is voldaan, in plaats van spin-lock te gebruiken die veel CPU verbruikt.

semaphore

golang.org/x/sync/semaphore.Semaphore

Het semaphore pakket biedt semaforen. Een golang.org/x/sync/semaphore.Semaphore kan worden aangemaakt en biedt de methodes Acquire, Release en TryAcquire.

 1package main
 2
 3import (
 4    "context"
 5    "fmt"
 6    "golang.org/x/sync/semaphore"
 7)
 8
 9func main() {
10    s := semaphore.NewWeighted(1)
11
12    if s.TryAcquire(1) {
13        fmt.Println("Acquired!")
14    } else {
15        fmt.Println("Not Acquired!")
16    }
17
18    s.Release(1)
19}

Deze code illustreert het aanmaken van een semafoor met behulp van het semaphore pakket. Het laat zien hoe de semafoor kan worden verworven met de Acquire methode en vrijgegeven met de Release methode. Dit voorbeeld demonstreert de basisfunctionaliteit van het verwerven en vrijgeven van semaforen.

Tot slot

De basisprincipes zijn hierbij besproken. Ik hoop dat u, door middel van dit artikel, begrijpt hoe u goroutines kan gebruiken om gelijktijdigheid te beheren, en dat u deze in de praktijk kunt gebruiken. Ik hoop dat dit artikel nuttig voor u is geweest. Dank u.