Pakiet startowy Go dotyczący współbieżności
Przegląd
Krótkie wprowadzenie
Język Go oferuje wiele narzędzi do zarządzania współbieżnością. W tym artykule przedstawimy niektóre z nich oraz związane z nimi triki.
Gorutyny?
Gorutyna to nowy model współbieżności obsługiwany przez język Go. Zazwyczaj programy, aby wykonywać wiele zadań jednocześnie, pobierają wątki systemu operacyjnego (OS), a zadania są wykonywane równolegle w liczbie odpowiadającej liczbie rdzeni. Aby osiągnąć współbieżność na mniejszą skalę, w przestrzeni użytkownika tworzone są tzw. zielone wątki, umożliwiające wykonywanie wielu takich wątków w ramach jednego wątku systemu operacyjnego. W przypadku gorutyn, te zielone wątki zostały zoptymalizowane pod kątem mniejszego rozmiaru i większej efektywności. Gorutyny zużywają mniej pamięci niż wątki, a ich tworzenie i przełączanie jest szybsze.
Aby skorzystać z gorutyny, wystarczy użyć słowa kluczowego go
. Pozwala to w intuicyjny sposób uruchomić kod synchroniczny jako asynchroniczny w trakcie pisania programu.
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}
Ten kod w prosty sposób zmienia synchroniczny kod, który czeka 1 sekundę i wyświetla Hello, World!
, w asynchroniczny przepływ. Ten przykład jest prosty, ale gdy bardziej skomplikowany kod synchroniczny jest zmieniany na asynchroniczny, czytelność, przejrzystość i zrozumienie kodu stają się lepsze niż w przypadku tradycyjnych rozwiązań, takich jak async await czy promise.
Jednakże, w wielu przypadkach, jeśli przepływ polegający na prostym asynchronicznym wywoływaniu kodu synchronicznego oraz przepływ typu fork & join
(przypominający algorytm dziel i zwyciężaj) nie są odpowiednio zrozumiane, może dojść do tworzenia nieefektywnego kodu wykorzystującego gorutyny. W tym artykule przedstawimy kilka metod i technik, które mogą pomóc uniknąć takich sytuacji.
Zarządzanie współbieżnością
context
Fakt, że context
pojawia się jako pierwsza technika zarządzania, może być zaskoczeniem. Jednak w języku Go context
pełni rolę wykraczającą poza prostą funkcję anulowania, doskonale nadając się do zarządzania całym drzewem zadań. Dla tych, którzy nie znają tego pakietu, krótko go opiszę.
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}
Powyższy kod, wykorzystując context
, wypisuje Context is done!
po upływie 1 sekundy. context
umożliwia sprawdzenie, czy operacja została anulowana za pomocą metody Done()
, a metody WithCancel
, WithTimeout
, WithDeadline
, WithValue
oferują różnorodne sposoby anulowania.
Stwórzmy prosty przykład. Załóżmy, że piszemy kod, który pobiera dane za pomocą wzorca aggregator
i pobiera user
, post
i comment
. Jeśli wszystkie żądania mają zostać wykonane w ciągu 2 sekund, kod można napisać w następujący sposób:
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}
Powyższy kod wyświetla Timeout!
, jeśli wszystkie dane nie zostaną pobrane w ciągu 2 sekund, a All data is fetched!
, jeśli wszystkie dane zostaną pobrane. Używając context
w ten sposób, można łatwo zarządzać anulowaniem i limitami czasu, nawet w kodzie, gdzie działa wiele gorutyn.
Różne funkcje i metody związane z context
można znaleźć w dokumentacji godoc context. Zachęcam do zapoznania się z nimi, aby móc z nich swobodnie korzystać.
channel
unbuffered channel
channel
to narzędzie do komunikacji między gorutynami. channel
można utworzyć za pomocą make(chan T)
. W tym przypadku T
jest typem danych, które channel
będzie przesyłać. Dane mogą być wysyłane i odbierane za pomocą operatora <-
, a channel
można zamknąć za pomocą 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}
Powyższy kod, używając channel
, wyświetla 1 i 2. Ten przykład pokazuje jedynie wysyłanie i odbieranie wartości za pomocą channel
. Jednak channel
oferuje więcej funkcji. Najpierw omówimy buffered channel
i unbuffered channel
. Powyższy przykład wykorzystuje unbuffered channel
, co oznacza, że wysyłanie danych do kanału i odbieranie danych z kanału muszą następować jednocześnie. Jeśli te operacje nie będą wykonywane jednocześnie, może wystąpić zakleszczenie (deadlock).
buffered channel
Co by było, gdyby powyższy kod nie wykonywał prostego wypisywania, ale dwa procesy wykonujące ciężkie operacje? Jeśli drugi proces będzie odczytywał i przetwarzał dane, a następnie na długi czas zostanie zablokowany, pierwszy proces również zostanie zatrzymany na ten czas. Aby zapobiec takiej sytuacji, możemy użyć buffered channel
.
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}
Powyższy kod, wykorzystując buffered channel
, wyświetla 1 i 2. W tym kodzie buffered channel
sprawia, że wysyłanie i odbieranie danych z kanału nie muszą następować jednocześnie. Dzięki dodaniu bufora do kanału, uzyskuje się pewien margines, który pozwala zapobiec opóźnieniom spowodowanym wpływem operacji z niższego priorytetu.
select
Używając składni select
, można łatwo zaimplementować strukturę fan-in
podczas pracy z wieloma kanałami.
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}
Powyższy kod tworzy trzy kanały, które cyklicznie przesyłają 1, 2 i 3, a następnie za pomocą select
odbiera i wypisuje wartości z kanałów. Używając select
w ten sposób, można odbierać dane z wielu kanałów jednocześnie i przetwarzać je w kolejności, w jakiej są odbierane.
for range
channel
pozwala na łatwe odbieranie danych za pomocą pętli for range
. Użycie for range
na kanale powoduje wykonanie pętli przy każdym dodaniu danych do kanału, a pętla zostaje zakończona, gdy kanał zostanie zamknięty.
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}
Powyższy kod, używając channel
, wyświetla 1 i 2. W tym kodzie for range
jest używane do odbierania i wypisywania danych za każdym razem, gdy są one dodawane do kanału. Pętla zostaje zakończona, gdy kanał zostanie zamknięty.
Jak wspomniano kilka razy powyżej, tej składni można używać także jako prostego środka synchronizacji.
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}
Powyższy kod czeka 1 sekundę i wypisuje Hello, World!
. W tym kodzie channel
zostało wykorzystane do przekształcenia kodu synchronicznego w asynchroniczny. Używając channel
w ten sposób, można łatwo przekształcić kod synchroniczny w asynchroniczny i ustalić punkt join
.
etc
- Wysyłanie lub odbieranie danych z kanału
nil
może spowodować zakleszczenie, ponieważ program wpadnie w nieskończoną pętlę. - Wysyłanie danych do zamkniętego kanału spowoduje panikę.
- Nie ma konieczności zamykania kanałów, ponieważ zostaną one zamknięte przez garbage collector.
mutex
spinlock
spinlock
to metoda synchronizacji, która w pętli próbuje ciągle uzyskać dostęp do blokady. W języku Go można łatwo zaimplementować spinlocka za pomocą wskaźników.
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}
Powyższy kod implementuje pakiet spinlock
. W tym kodzie sync/atomic
jest używane do implementacji SpinLock
. Metoda Lock
używa atomic.CompareAndSwapUintptr
do próby uzyskania blokady, a metoda Unlock
używa atomic.StoreUintptr
do zwolnienia blokady. Ponieważ ta metoda ciągle próbuje uzyskać blokadę, procesor jest nieprzerwanie obciążony do momentu uzyskania blokady, co może prowadzić do nieskończonej pętli. Z tego powodu spinlock
jest zalecany w przypadku prostej synchronizacji lub w sytuacjach, gdy blokada jest używana tylko przez krótki czas.
sync.Mutex
mutex
to narzędzie do synchronizacji gorutyn. mutex
dostarczane przez pakiet sync
oferuje metody Lock
, Unlock
, RLock
, RUnlock
. mutex
można utworzyć za pomocą sync.Mutex
, a sync.RWMutex
można użyć do blokady do odczytu/zapisu.
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}
W powyższym kodzie dwie gorutyny uzyskują dostęp do tej samej zmiennej count
niemal jednocześnie. W tym przypadku, jeśli użyjemy mutex
do utworzenia sekcji krytycznej z kodu, który uzyskuje dostęp do zmiennej count
, możemy zapobiec współbieżnemu dostępowi do zmiennej count
. Wówczas ten kod będzie za każdym razem wypisywał 2
.
sync.RWMutex
sync.RWMutex
to mutex
, które pozwala rozróżnić blokadę do odczytu od blokady do zapisu. Blokadę do odczytu można ustawić i zwolnić za pomocą metod RLock
i RUnlock
.
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}
Powyższy kod implementuje ConcurrentMap
za pomocą sync.RWMutex
. W tym kodzie blokada do odczytu jest ustawiana w metodzie Get
, a blokada do zapisu w metodzie Set
, aby umożliwić bezpieczny dostęp i modyfikację mapy data
. Blokada do odczytu jest potrzebna, ponieważ w przypadku dużej liczby prostych operacji odczytu, blokada do zapisu nie musi być ustawiana, a wiele gorutyn może wykonywać operacje odczytu jednocześnie. W ten sposób, jeśli nie ma potrzeby modyfikowania stanu i blokada do zapisu nie jest konieczna, można użyć blokady do odczytu, co zwiększa wydajność.
fakelock
fakelock
to prosty trik, który implementuje sync.Locker
. Ta struktura oferuje te same metody co sync.Mutex
, ale w rzeczywistości nie wykonuje żadnych operacji.
1package fakelock
2
3type FakeLock struct{}
4
5func (f *FakeLock) Lock() {}
6
7func (f *FakeLock) Unlock() {}
Powyższy kod implementuje pakiet fakelock
. Ten pakiet implementuje sync.Locker
, zapewniając metody Lock
i Unlock
, ale w rzeczywistości nie wykonuje żadnych operacji. Jeśli nadarzy się okazja, opiszę, dlaczego taki kod jest potrzebny.
waitgroup
sync.WaitGroup
sync.WaitGroup
to narzędzie służące do oczekiwania na zakończenie wszystkich zadań gorutyn. Udostępnia metody Add
, Done
, Wait
. Metoda Add
służy do dodawania liczby gorutyn, metoda Done
informuje, że zadanie gorutyny zostało zakończone, a metoda Wait
czeka na zakończenie wszystkich zadań gorutyn.
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}
Powyższy kod wykorzystuje sync.WaitGroup
do inkrementowania zmiennej c
przez 100 gorutyn jednocześnie. W tym kodzie sync.WaitGroup
jest używany do oczekiwania, aż wszystkie gorutyny zakończą swoje działanie, po czym drukowana jest wartość dodana do zmiennej c
. Proste zadania typu fork & join
można realizować za pomocą kanałów, natomiast w przypadku dużej liczby zadań fork & join
użycie sync.WaitGroup
jest również dobrym rozwiązaniem.
z użyciem slice
W połączeniu ze slice, waitgroup
może być doskonałym narzędziem do zarządzania zadaniami współbieżnymi bez użycia blokad.
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}
Powyższy kod wykorzystuje wyłącznie waitgroup
, gdzie każda gorutyna generuje jednocześnie 10 losowych liczb całkowitych i zapisuje je w przypisanej pozycji indeksu. W tym kodzie waitgroup
jest używany, aby poczekać, aż wszystkie gorutyny zakończą pracę, a następnie wypisać Done
. W ten sposób, używając waitgroup
, można sprawić, aby wiele gorutyn wykonywało zadania jednocześnie, zapisywało dane bez blokad i realizowało zbiorcze przetwarzanie po zakończeniu pracy.
golang.org/x/sync/errgroup.ErrGroup
errgroup
to pakiet rozszerzający funkcjonalność sync.WaitGroup
. W przeciwieństwie do sync.WaitGroup
, errgroup
anuluje wszystkie gorutyny i zwraca błąd, jeśli w którejkolwiek z nich wystąpi błąd.
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}
Powyższy kod wykorzystuje errgroup
do utworzenia 10 gorutyn, a w piątej z nich celowo generuje błąd. Intencją było zaprezentowanie, co się dzieje, gdy wystąpi błąd. W praktyce errgroup
można użyć do tworzenia gorutyn i wykonywania różnorodnych operacji post-procesingowych w przypadku wystąpienia błędów w każdej z nich.
once
Narzędzie do wykonywania kodu, który powinien zostać uruchomiony tylko raz. Poniższe konstruktory umożliwiają uruchomienie powiązanego kodu.
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
zapewnia, że dana funkcja zostanie wykonana tylko raz w całym cyklu życia programu.
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}
Powyższy kod używa sync.OnceFunc
do wypisania Hello, World!
. W tym kodzie sync.OnceFunc
służy do utworzenia funkcji once
, a mimo wielokrotnego wywołania funkcji once
, Hello, World!
zostanie wypisane tylko raz.
OnceValue
OnceValue
zapewnia nie tylko, że dana funkcja zostanie wykonana tylko raz, ale również, że jej wartość zwrotna zostanie zapisana i zwrócona przy każdym kolejnym wywołaniu.
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}
Powyższy kod używa sync.OnceValue
do inkrementowania zmiennej c
o 1. W tym kodzie sync.OnceValue
służy do utworzenia funkcji once
, a mimo wielokrotnego wywołania funkcji once
, zmienna c
zostanie zwiększona tylko raz, a funkcja zwróci wartość 1.
OnceValues
OnceValues
działa identycznie jak OnceValue
, ale umożliwia zwracanie wielu wartości.
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}
Powyższy kod używa sync.OnceValues
do inkrementowania zmiennej c
o 1. W tym kodzie sync.OnceValues
służy do utworzenia funkcji once
, a mimo wielokrotnego wywołania funkcji once
, zmienna c
zostanie zwiększona tylko raz, a funkcja zwróci wartość 1.
atomic
Pakiet atomic
oferuje operacje atomowe. Pakiet atomic
udostępnia metody takie jak Add
, CompareAndSwap
, Load
, Store
, Swap
, ale w ostatnim czasie zaleca się używanie typów takich jak Int64
, Uint64
, Pointer
itp.
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}
Powyższy kod jest przykładem użycia typu atomic.Int64
do atomowego inkrementowania zmiennej c
. Metody Add
i Load
służą do atomowego zwiększania i odczytywania wartości zmiennej. Dodatkowo, metoda Store
służy do zapisywania wartości, metoda Swap
do zamiany wartości, a metoda CompareAndSwap
do porównania i ewentualnej zamiany wartości.
cond
sync.Cond
Pakiet cond
udostępnia zmienne warunkowe. Pakiet cond
można utworzyć za pomocą sync.Cond
, który udostępnia metody Wait
, Signal
i 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}
Powyższy kod wykorzystuje sync.Cond
, aby poczekać, aż zmienna ready
przyjmie wartość true
. W tym kodzie sync.Cond
służy do oczekiwania, aż zmienna ready
stanie się true
, a następnie wypisania Ready!
. Używając sync.Cond
, można sprawić, aby wiele gorutyn czekało jednocześnie, aż zostanie spełniony określony warunek.
Korzystając z tego, można zaimplementować prostą kolejkę
.
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}
Wykorzystując sync.Cond
w ten sposób, można efektywnie czekać i wznowić działanie, gdy warunek zostanie spełniony, zamiast stosować spin-lock
, który zużywa dużą ilość zasobów procesora.
semaphore
golang.org/x/sync/semaphore.Semaphore
Pakiet semaphore
oferuje semafory. Pakiet semaphore
można utworzyć za pomocą golang.org/x/sync/semaphore.Semaphore
, który udostępnia metody Acquire
, Release
, i 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}
Powyższy kod wykorzystuje semaphore
do utworzenia semafora, a następnie używa metody Acquire
do uzyskania semafora oraz Release
do jego zwolnienia. W tym kodzie zaprezentowano, jak uzyskać i zwolnić semafor za pomocą semaphore
.
Podsumowanie
Podstawowe informacje na ten temat powinny być wystarczające. Mam nadzieję, że dzięki treści tego artykułu zrozumiecie, jak używać gorutyn do zarządzania współbieżnością i będziecie mogli to wykorzystać w praktyce. Mam nadzieję, że ten artykuł był dla was pomocny. Dziękuję.