Go Concurrentie Starterspakket
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
- Als data wordt verzonden naar of ontvangen van een nil channel, kan een oneindige loop ontstaan, met een deadlock als gevolg.
- Als data wordt verzonden naar een gesloten channel, treedt er een panic op.
- 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.