---
id: 01933e13f963942e4c9bc8c3c89fbc2b
author: snowmerak
title: Paket Pemula Konkurensi Go
description: Artikel ini memperkenalkan berbagai alat dan teknik untuk pengelolaan konkurensi dalam Go, termasuk goroutine, channel, dan mutex.
language: id
date: 2024-10-18T05:51:30.010680581Z
path: /blog/posts/go-concurrency-starter-pack-z221a3399
category: concurrency
---

## Ikhtisar

### Pengantar Singkat

Bahasa Go menyediakan banyak alat untuk mengelola konkurensi. Dalam artikel ini, saya akan memperkenalkan beberapa di antaranya beserta trik-trik yang relevan.

### Goroutine?

goroutine adalah model konkurensi baru yang didukung oleh bahasa Go. Secara umum, program meminta OS thread dari OS untuk menjalankan banyak tugas secara bersamaan, dan menjalankan tugas secara paralel sebanyak jumlah core. Untuk melakukan konkurensi dalam skala yang lebih kecil, program membuat green thread di userland, sehingga beberapa green thread bergantian menjalankan tugas di dalam satu OS thread. Namun, goroutine membuat bentuk green thread ini menjadi lebih kecil dan lebih efisien. Goroutine menggunakan memori lebih sedikit dibandingkan thread, dan dapat dibuat serta dialihkan lebih cepat daripada thread.

Untuk menggunakan goroutine, cukup dengan menggunakan kata kunci `go`. Hal ini memungkinkan kode sinkron dijalankan sebagai kode asinkron secara intuitif selama proses penulisan program.

```go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan struct{})
    go func() {
        defer close(ch)
        time.Sleep(1 * time.Second)
        fmt.Println("Hello, World!")
    }()

    fmt.Println("Waiting for goroutine...")
    for range ch {}
}
```

Kode ini mengubah kode sinkron yang beristirahat selama 1 detik lalu mencetak `Hello, World!` menjadi alur asinkron. Contoh ini memang sederhana, tetapi ketika kode yang lebih kompleks diubah dari sinkron menjadi asinkron, keterbacaan, visibilitas, dan pemahaman kode menjadi lebih baik dibandingkan pendekatan yang ada seperti async await atau promise.

Namun, dalam banyak kasus, goroutine yang buruk sering dihasilkan ketika seseorang tidak memahami alur yang hanya memanggil kode sinkron secara asinkron, maupun alur seperti `fork & join` (yang menyerupai divide and conquer). Untuk mengantisipasi kasus seperti ini, artikel ini akan memperkenalkan beberapa metode dan teknik.

## Pengelolaan Konkurensi

### context

Mungkin mengejutkan bahwa `context` muncul sebagai teknik pertama. Namun, dalam bahasa Go, `context` memiliki peran yang sangat baik dalam mengelola seluruh pohon pekerjaan, melampaui sekadar fungsi pembatalan. Untuk pembaca yang belum familiar, saya akan menjelaskan package ini secara singkat.

```go
package main

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go func() {
        <-ctx.Done()
        fmt.Println("Context is done!")
    }()

    time.Sleep(1 * time.Second)

    cancel()

    time.Sleep(1 * time.Second)
}
```

Kode di atas menggunakan `context` untuk mencetak `Context is done!` setelah 1 detik. `context` dapat memeriksa status pembatalan melalui metode `Done()`, dan menyediakan berbagai cara pembatalan melalui metode seperti `WithCancel`, `WithTimeout`, `WithDeadline`, dan `WithValue`.

Mari kita buat contoh sederhana. Misalkan Anda menulis kode untuk mengambil data `user`, `post`, dan `comment` dengan menggunakan pola `aggregator`. Jika semua permintaan harus selesai dalam 2 detik, Anda dapat menulisnya sebagai berikut.

```go
package main

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)
    defer cancel()

    ch := make(chan struct{})
    go func() {
        defer close(ch)
        user := getUser(ctx)
        post := getPost(ctx)
        comment := getComment(ctx)

        fmt.Println(user, post, comment)
    }()

    select {
    case <-ctx.Done():
        fmt.Println("Timeout!")
    case <-ch:
        fmt.Println("All data is fetched!")
    }
}
```

Kode di atas akan mencetak `Timeout!` jika tidak semua data berhasil diambil dalam 2 detik, dan mencetak `All data is fetched!` jika semua data berhasil diambil. Dengan cara ini, `context` memudahkan pengelolaan pembatalan dan timeout pada kode yang menjalankan banyak goroutine.

Berbagai fungsi dan metode terkait context dapat dilihat di [godoc context](https://pkg.go.dev/context). Saya harap Anda dapat mempelajari bagian yang sederhana dan menggunakannya dengan nyaman.

### channel

#### unbuffered channel

`channel` adalah alat untuk komunikasi antar goroutine. `channel` dapat dibuat dengan `make(chan T)`. Di sini, `T` adalah tipe data yang akan dikirim melalui `channel` tersebut. Data dapat dikirim dan diterima menggunakan `<-`, dan `channel` dapat ditutup menggunakan `close`.

```go
package main

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
        ch <- 2
        close(ch)
    }()

    for i := range ch {
        fmt.Println(i)
    }
}
```

Kode di atas menggunakan `channel` untuk mencetak 1 dan 2. Contoh ini hanya menunjukkan pengiriman dan penerimaan nilai secara sederhana. Namun, `channel` menyediakan lebih banyak fitur daripada itu. Pertama, mari kita pelajari `buffered channel` dan `unbuffered channel`. Sebelum memulai, contoh di atas menggunakan `unbuffered channel`, sehingga aksi mengirim data ke channel dan aksi menerima data harus terjadi secara bersamaan. Jika kedua aksi ini tidak terjadi secara bersamaan, deadlock dapat terjadi.

#### buffered channel

Bagaimana jika kode di atas bukan hanya mencetak, melainkan dua proses yang menjalankan pekerjaan berat? Jika proses kedua membaca dan memproses data namun memakan waktu lama, proses pertama juga akan terhenti selama waktu tersebut. Untuk mencegah situasi ini, kita dapat menggunakan `buffered channel`.

```go
package main

func main() {
    ch := make(chan int, 2)
    go func() {
        ch <- 1
        ch <- 2
        close(ch)
    }()

    for i := range ch {
        fmt.Println(i)
    }
}
```

Kode di atas menggunakan `buffered channel` untuk mencetak 1 dan 2. Pada kode ini, `buffered channel` digunakan sehingga aksi mengirim data ke `channel` dan aksi menerima data tidak harus terjadi secara bersamaan. Dengan menyediakan buffer pada channel, terdapat ruang sebanyak panjang buffer tersebut, sehingga keterlambatan pekerjaan yang disebabkan oleh pekerjaan prioritas rendah dapat dicegah.

#### select

Ketika menangani beberapa channel, penggunaan sintaks `select` memudahkan implementasi struktur `fan-in`.

```go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int, 10)
    ch2 := make(chan int, 10)
    ch3 := make(chan int, 10)

    go func() {
        for {
            ch1 <- 1
            time.Sleep(1 * time.Second)
        }
    }()
    go func() {
        for {
            ch2 <- 2
            time.Sleep(2 * time.Second)
        }
    }()
    go func() {
        for {
            ch3 <- 3
            time.Sleep(3 * time.Second)
        }
    }()

    for i := 0; i < 3; i++ {
        select {
        case v := <-ch1:
            fmt.Println(v)
        case v := <-ch2:
            fmt.Println(v)
        case v := <-ch3:
            fmt.Println(v)
        }
    }
}
```

Kode di atas membuat tiga channel yang secara periodik mengirimkan 1, 2, dan 3, lalu menggunakan `select` untuk menerima nilai dari channel dan mencetaknya. Dengan cara ini, `select` memungkinkan penerimaan data dari beberapa channel secara bersamaan dan pemrosesan setiap nilai segera setelah diterima.

#### for range

`channel` dapat menerima data dengan mudah menggunakan `for range`. Ketika `for range` digunakan pada channel, loop akan dijalankan setiap kali data ditambahkan ke channel tersebut, dan loop akan berakhir ketika channel ditutup.

```go
package main

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
        ch <- 2
        close(ch)
    }()

    for i := range ch {
        fmt.Println(i)
    }
}
```

Kode di atas menggunakan `channel` untuk mencetak 1 dan 2. Pada kode ini, `for range` digunakan untuk menerima dan mencetak data setiap kali data ditambahkan ke channel. Ketika channel ditutup, loop berakhir.

Seperti yang telah ditulis beberapa kali di atas, sintaks ini juga dapat digunakan sebagai mekanisme sinkronisasi sederhana.

```go
package main

func main() {
    ch := make(chan struct{})
    go func() {
        defer close(ch)
        time.Sleep(1 * time.Second)
        fmt.Println("Hello, World!")
    }()

    fmt.Println("Waiting for goroutine...")
    for range ch {}
}
```

Kode di atas beristirahat selama 1 detik lalu mencetak `Hello, World!`. Pada kode ini, `channel` digunakan untuk mengubah kode sinkron menjadi kode asinkron. Dengan cara ini, penggunaan `channel` memudahkan konversi kode sinkron menjadi asinkron dan penetapan titik `join`.

#### etc

1. Mengirim atau menerima data pada nil channel dapat menyebabkan infinite loop dan deadlock.
2. Mengirim data ke channel yang sudah ditutup akan menyebabkan panic.
3. Meskipun channel tidak ditutup secara eksplisit, GC akan membersihkannya dan menutup channel tersebut.

### mutex

#### spinlock

`spinlock` adalah metode sinkronisasi yang terus mencoba mengunci di dalam loop. Di bahasa Go, spinlock dapat diimplementasikan dengan mudah menggunakan pointer.

```go
package spinlock

import (
    "runtime"
    "sync/atomic"
)

type SpinLock struct {
    lock uintptr
}

func (s *SpinLock) Lock() {
    for !atomic.CompareAndSwapUintptr(&s.lock, 0, 1) {
        runtime.Gosched()
    }
}

func (s *SpinLock) Unlock() {
    atomic.StoreUintptr(&s.lock, 0)
}

func NewSpinLock() *SpinLock {
    return &SpinLock{}
}
```

Kode di atas adalah implementasi package `spinlock`. Pada kode ini, `SpinLock` diimplementasikan menggunakan package `sync/atomic`. Metode `Lock` mencoba mengunci menggunakan `atomic.CompareAndSwapUintptr`, dan metode `Unlock` melepas kunci menggunakan `atomic.StoreUintptr`. Karena metode ini terus mencoba mengunci tanpa henti, CPU akan terus digunakan hingga kunci diperoleh, dan hal ini dapat menyebabkan infinite loop. Oleh karena itu, `spinlock` sebaiknya digunakan untuk sinkronisasi sederhana atau hanya dalam durasi yang singkat.

#### sync.Mutex

`mutex` adalah alat untuk sinkronisasi antar goroutine. `mutex` yang disediakan oleh package `sync` memiliki metode seperti `Lock`, `Unlock`, `RLock`, dan `RUnlock`. `mutex` dapat dibuat dengan `sync.Mutex`, dan `sync.RWMutex` dapat digunakan untuk kunci baca/tulis.

```go
package main

import (
    "sync"
)

func main() {
    var mu sync.Mutex
    var count int

    go func() {
        mu.Lock()
        count++
        mu.Unlock()
    }()

    mu.Lock()
    count++
    mu.Unlock()

    println(count)
}
```

Pada kode di atas, dua goroutine hampir bersamaan mengakses variabel `count` yang sama. Dengan menggunakan `mutex` untuk membuat bagian kode yang mengakses variabel `count` menjadi critical section, akses konkuren ke `count` dapat dicegah. Dengan demikian, kode ini akan selalu mencetak `2` berapa kali pun dijalankan.

#### sync.RWMutex

`sync.RWMutex` adalah `mutex` yang dapat digunakan dengan membedakan kunci baca dan kunci tulis. Kunci baca dapat dipasang dan dilepas menggunakan metode `RLock` dan `RUnlock`.

```go
package cmap

import (
    "sync"
)

type ConcurrentMap[K comparable, V any] struct {
    sync.RWMutex
    data map[K]V
}

func (m *ConcurrentMap[K, V]) Get(key K) (V, bool) {
    m.RLock()
    defer m.RUnlock()

    value, ok := m.data[key]
    return value, ok
}

func (m *ConcurrentMap[K, V]) Set(key K, value V) {
    m.Lock()
    defer m.Unlock()

    m.data[key] = value
}
```

Kode di atas adalah implementasi `ConcurrentMap` menggunakan `sync.RWMutex`. Pada kode ini, metode `Get` memasang kunci baca, dan metode `Set` memasang kunci tulis, sehingga map `data` dapat diakses dan dimodifikasi dengan aman. Alasan penggunaan kunci baca adalah agar ketika operasi baca cukup banyak, beberapa goroutine dapat membaca secara bersamaan dengan hanya memasang kunci baca, tanpa memasang kunci tulis. Dengan demikian, pada situasi di mana tidak ada perubahan state dan kunci tulis tidak perlu dipasang, performa dapat ditingkatkan dengan hanya memasang kunci baca.

#### fakelock

`fakelock` adalah trik sederhana yang mengimplementasikan `sync.Locker`. Struct ini menyediakan metode yang sama dengan `sync.Mutex`, tetapi tidak melakukan apa pun secara nyata.

```go
package fakelock

type FakeLock struct{}

func (f *FakeLock) Lock() {}

func (f *FakeLock) Unlock() {}
```

Kode di atas adalah implementasi package `fakelock`. Package ini mengimplementasikan `sync.Locker` dan menyediakan metode `Lock` dan `Unlock`, tetapi sebenarnya tidak melakukan apa pun. Alasan mengapa kode seperti ini diperlukan akan saya jelaskan pada kesempatan lain.

### waitgroup

#### sync.WaitGroup

`sync.WaitGroup` adalah alat untuk menunggu hingga semua pekerjaan goroutine selesai. Alat ini menyediakan metode `Add`, `Done`, dan `Wait`. Metode `Add` digunakan untuk menambah jumlah goroutine, metode `Done` digunakan untuk memberi tahu bahwa pekerjaan sebuah goroutine telah selesai, dan metode `Wait` digunakan untuk menunggu hingga semua pekerjaan goroutine selesai.

```go
package main

import (
    "sync"
    "sync/atomic"
)

func main() {
    wg := sync.WaitGroup{}
    c := atomic.Int64{}

    for i := 0; i < 100 ; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Add(1)
        }()
    }

    wg.Wait()
    println(c.Load())
}
```

Kode di atas menggunakan `sync.WaitGroup` agar 100 goroutine secara bersamaan menambahkan nilai ke variabel `c`. Pada kode ini, `sync.WaitGroup` digunakan untuk menunggu hingga semua goroutine selesai, lalu nilai yang telah ditambahkan ke `c` dicetak. Untuk kasus `fork & join` dengan beberapa pekerjaan saja, penggunaan channel saja sudah cukup, tetapi untuk `fork & join` dengan banyak pekerjaan, `sync.WaitGroup` juga merupakan pilihan yang baik.

#### with slice

Jika digunakan bersama slice, `waitgroup` dapat menjadi alat yang sangat baik untuk mengelola pekerjaan yang dijalankan secara konkuren tanpa lock.

```go
package main

import (
	"fmt"
	"sync"
    "rand"
)

func main() {
	var wg sync.WaitGroup
	arr := [10]int{}

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()

			arr[id] = rand.Intn(100)
		}(i)
	}

	wg.Wait()
	fmt.Println("Done")

    for i, v := range arr {
        fmt.Printf("arr[%d] = %d\n", i, v)
    }
}
```

Kode di atas menggunakan `waitgroup` saja, sehingga setiap goroutine secara bersamaan menghasilkan 10 bilangan bulat acak dan menyimpannya pada indeks yang telah ditentukan. Pada kode ini, `waitgroup` digunakan untuk menunggu hingga semua goroutine selesai, lalu mencetak `Done`. Dengan cara ini, `waitgroup` memungkinkan beberapa goroutine bekerja secara bersamaan, menyimpan data tanpa lock hingga semua goroutine selesai, dan kemudian melakukan pasca-pemrosesan secara sekaligus setelah pekerjaan selesai.

#### golang.org/x/sync/errgroup.ErrGroup

`errgroup` adalah package yang merupakan perluasan dari `sync.WaitGroup`. Berbeda dengan `sync.WaitGroup`, `errgroup` akan membatalkan semua goroutine dan mengembalikan error jika terjadi error pada salah satu pekerjaan goroutine.

```go
package main

import (
    "context"
    "fmt"
    "golang.org/x/sync/errgroup"
)

func main() {
    g, ctx := errgroup.WithContext(context.Background())
    _ = ctx

    for i := 0; i < 10; i++ {
        i := i
        g.Go(func() error {
            if i == 5 {
                return fmt.Errorf("error")
            }
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        fmt.Println(err)
    }
}
```

Kode di atas menggunakan `errgroup` untuk membuat 10 goroutine, dengan error yang sengaja dimunculkan pada goroutine kelima. Error sengaja dibuat pada goroutine kelima untuk menunjukkan kasus terjadinya error. Namun, dalam penggunaan sesungguhnya, `errgroup` digunakan untuk membuat goroutine, lalu melakukan berbagai pasca-pemrosesan untuk setiap kasus error yang terjadi pada masing-masing goroutine.

### once

Ini adalah alat untuk menjalankan kode yang seharusnya hanya dieksekusi satu kali. Kode terkait dapat dijalankan melalui konstruktor berikut.

```go
func OnceFunc(f func()) func()
func OnceValue[T any](f func() T) func() T
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)
```

#### OnceFunc

`OnceFunc` memastikan bahwa fungsi tersebut hanya dapat dijalankan tepat satu kali selama seluruh program berjalan.

```go
package main

import "sync"

func main() {
    once := sync.OnceFunc(func() {
        println("Hello, World!")
    })

    once()
    once()
    once()
    once()
    once()
}
```

Kode di atas menggunakan `sync.OnceFunc` untuk mencetak `Hello, World!`. Pada kode ini, `sync.OnceFunc` digunakan untuk membuat fungsi `once`, dan meskipun fungsi `once` dipanggil berkali-kali, `Hello, World!` hanya dicetak satu kali.

#### OnceValue

`OnceValue` tidak hanya memastikan bahwa fungsi tersebut dijalankan tepat satu kali, tetapi juga menyimpan nilai kembalian fungsi tersebut dan mengembalikan nilai yang tersimpan ketika dipanggil kembali.

```go
package main

import "sync"

func main() {
    c := 0
    once := sync.OnceValue(func() int {
        c += 1
        return c
    })

    println(once())
    println(once())
    println(once())
    println(once())
    println(once())
}
```

Kode di atas menggunakan `sync.OnceValue` untuk menaikkan variabel `c` sebesar 1. Pada kode ini, `sync.OnceValue` digunakan untuk membuat fungsi `once`, dan meskipun fungsi `once` dipanggil berkali-kali, variabel `c` hanya dinaikkan satu kali dan nilai 1 yang dikembalikan.

#### OnceValues

`OnceValues` bekerja sama dengan `OnceValue`, tetapi dapat mengembalikan banyak nilai.

```go
package main

import "sync"

func main() {
    c := 0
    once := sync.OnceValues(func() (int, int) {
        c += 1
        return c, c
    })

    a, b := once()
    println(a, b)
    a, b = once()
    println(a, b)
    a, b = once()
    println(a, b)
    a, b = once()
    println(a, b)
    a, b = once()
    println(a, b)
}
```

Kode di atas menggunakan `sync.OnceValues` untuk menaikkan variabel `c` sebesar 1. Pada kode ini, `sync.OnceValues` digunakan untuk membuat fungsi `once`, dan meskipun fungsi `once` dipanggil berkali-kali, variabel `c` hanya dinaikkan satu kali dan nilai 1 yang dikembalikan.

### atomic

Package `atomic` adalah package yang menyediakan operasi atomik. Package ini menyediakan metode seperti `Add`, `CompareAndSwap`, `Load`, `Store`, dan `Swap`, tetapi belakangan ini penggunaan tipe seperti `Int64`, `Uint64`, dan `Pointer` lebih dianjurkan.

```go
package main

import (
    "sync"
    "sync/atomic"
)

func main() {
    wg := sync.WaitGroup{}
    c := atomic.Int64{}

    for i := 0; i < 100 ; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Add(1)
        }()
    }

    wg.Wait()
    println(c.Load())
}
```

Ini adalah contoh yang digunakan sebelumnya. Kode ini menggunakan tipe `atomic.Int64` untuk menaikkan variabel `c` secara atomik. Dengan metode `Add` dan `Load`, variabel dapat dinaikkan dan dibaca secara atomik. Selain itu, metode `Store` dapat digunakan untuk menyimpan nilai, metode `Swap` untuk menukar nilai, dan metode `CompareAndSwap` untuk membandingkan nilai lalu menggantinya jika sesuai.

### cond

#### sync.Cond

Package `cond` adalah package yang menyediakan condition variable. Package `cond` dapat dibuat dengan `sync.Cond`, dan menyediakan metode `Wait`, `Signal`, dan `Broadcast`.

```go
package main

import (
    "sync"
)

func main() {
    c := sync.NewCond(&sync.Mutex{})
    ready := false

    go func() {
        c.L.Lock()
        ready = true
        c.Signal()
        c.L.Unlock()
    }()

    c.L.Lock()
    for !ready {
        c.Wait()
    }
    c.L.Unlock()

    println("Ready!")
}
```

Kode di atas menggunakan `sync.Cond` untuk menunggu hingga variabel `ready` menjadi `true`. Pada kode ini, `sync.Cond` digunakan untuk menunggu hingga variabel `ready` menjadi `true`, lalu mencetak `Ready!`. Dengan cara ini, `sync.Cond` memungkinkan beberapa goroutine menunggu secara bersamaan hingga kondisi tertentu terpenuhi.

Dengan memanfaatkan hal ini, sebuah `queue` sederhana dapat diimplementasikan.

```go
package queue

import (
    "sync"
    "sync/atomic"
)

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

type Queue[T any] struct {
    sync.Mutex
    Cond *sync.Cond
    Head *Node[T]
    Tail *Node[T]
    Len  int
}

func New[T any]() *Queue[T] {
    q := &Queue[T]{}
    q.Cond = sync.NewCond(&q.Mutex)
    return q
}

func (q *Queue[T]) Push(value T) {
    q.Lock()
    defer q.Unlock()

    node := &Node[T]{Value: value}
    if q.Len == 0 {
        q.Head = node
        q.Tail = node
    } else {
        q.Tail.Next = node
        q.Tail = node
    }
    q.Len++
    q.Cond.Signal()
}

func (q *Queue[T]) Pop() T {
    q.Lock()
    defer q.Unlock()

    for q.Len == 0 {
        q.Cond.Wait()
    }

    node := q.Head
    q.Head = q.Head.Next
    q.Len--
    return node.Value
}
```

Dengan menggunakan `sync.Cond` seperti ini, alih-alih menggunakan `spin-lock` yang banyak mengonsumsi CPU, program dapat menunggu secara efisien dan kembali bekerja ketika kondisi terpenuhi.

### semaphore

#### golang.org/x/sync/semaphore.Semaphore

Package `semaphore` adalah package yang menyediakan semaphore. Package `semaphore` dapat dibuat dengan `golang.org/x/sync/semaphore.Semaphore`, dan menyediakan metode `Acquire`, `Release`, dan `TryAcquire`.

```go
package main

import (
    "context"
    "fmt"
    "golang.org/x/sync/semaphore"
)

func main() {
    s := semaphore.NewWeighted(1)

    if s.TryAcquire(1) {
        fmt.Println("Acquired!")
    } else {
        fmt.Println("Not Acquired!")
    }

    s.Release(1)
}
```

Kode di atas membuat semaphore menggunakan `semaphore`, lalu memperoleh semaphore dengan metode `Acquire` dan melepaskannya dengan metode `Release`. Pada kode ini, ditunjukkan cara memperoleh dan melepaskan semaphore menggunakan `semaphore`.

## Penutup

Sampai di sini saja mungkin sudah cukup untuk materi dasarnya. Berdasarkan artikel ini, saya harap Anda dapat memahami cara mengelola konkurensi menggunakan goroutine dan dapat menerapkannya secara langsung. Semoga artikel ini bermanfaat bagi Anda. Terima kasih.
