GoSuda

Pakiet startowy Go dotyczący współbieżności

By snowmerak
views ...

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

  1. Wysyłanie lub odbieranie danych z kanału nil może spowodować zakleszczenie, ponieważ program wpadnie w nieskończoną pętlę.
  2. Wysyłanie danych do zamkniętego kanału spowoduje panikę.
  3. 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ę.