Go Concurrency Starter Pack
Gambaran Umum
Pengantar Singkat
Bahasa Go memiliki banyak alat untuk manajemen konkurensi. Dalam artikel ini, kami akan memperkenalkan beberapa di antaranya beserta trik-triknya.
Goroutine?
Goroutine adalah bentuk model konkurensi baru yang didukung dalam bahasa Go. Umumnya, program menerima OS thread dari OS untuk melakukan beberapa tugas secara bersamaan, dan menjalankan tugas secara paralel sesuai dengan jumlah core. Untuk menjalankan konkurensi dalam unit yang lebih kecil, green thread dibuat di userspace, dan beberapa green thread beroperasi secara bergiliran dalam satu OS thread. Namun, goroutine membuat green thread jenis ini menjadi lebih kecil dan efisien. Goroutine menggunakan memori yang lebih sedikit daripada thread, dan dapat dibuat serta diganti lebih cepat daripada thread.
Untuk menggunakan goroutine, cukup gunakan kata kunci go. Ini memungkinkan kode sinkron dijalankan sebagai kode asinkron secara intuitif selama proses penulisan program.
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}
Kode ini secara sederhana mengubah kode sinkron yang beristirahat selama 1 detik lalu mencetak Hello, World! menjadi aliran asinkron. Meskipun contoh ini sederhana, jika kode yang sedikit lebih kompleks diubah dari kode sinkron ke kode asinkron, keterbacaan, visibilitas, dan pemahaman kode akan menjadi lebih baik dibandingkan dengan metode async await atau promise yang ada.
Namun, dalam banyak kasus, kode goroutine yang buruk dapat dibuat jika aliran panggilan asinkron sederhana dari kode sinkron dan aliran seperti fork & join (aliran yang mirip dengan divide and conquer) tidak dipahami. Dalam artikel ini, kami akan memperkenalkan beberapa metode dan teknik untuk mempersiapkan situasi seperti ini.
Manajemen Konkurensi
context
Mungkin mengejutkan bahwa context muncul sebagai teknik manajemen pertama. Namun, dalam bahasa Go, context memainkan peran yang sangat baik dalam mengelola seluruh pohon tugas, melampaui fungsi pembatalan sederhana. Bagi yang belum tahu, saya akan menjelaskan paket ini secara singkat.
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}
Kode di atas adalah kode yang menggunakan context untuk mencetak Context is done! setelah 1 detik. context dapat memeriksa status pembatalan melalui metode Done(), dan menyediakan berbagai metode pembatalan melalui metode seperti WithCancel, WithTimeout, WithDeadline, WithValue.
Mari kita buat contoh sederhana. Misalkan Anda menulis kode untuk mengambil user, post, dan comment menggunakan pola aggregator untuk mengambil data tertentu. Dan jika semua permintaan harus diselesaikan dalam waktu 2 detik, Anda dapat menulisnya sebagai berikut:
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}
Kode di atas mencetak Timeout! jika semua data tidak dapat diambil dalam 2 detik, dan mencetak All data is fetched! jika semua data berhasil diambil. Dengan cara ini, menggunakan context memungkinkan Anda mengelola pembatalan dan timeout dengan mudah bahkan dalam kode di mana beberapa goroutine beroperasi.
Berbagai fungsi dan metode terkait context dapat ditemukan di godoc context. Kami berharap Anda dapat mempelajarinya dengan mudah 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 dikirimkan oleh channel tersebut. channel dapat mengirim dan menerima data dengan <-, dan dapat ditutup dengan 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}
Kode di atas adalah kode yang mencetak 1 dan 2 menggunakan channel. Dalam kode ini, hanya ditunjukkan pengiriman dan penerimaan nilai sederhana melalui channel. Namun, channel menyediakan lebih banyak fungsi daripada ini. Pertama, mari kita bahas buffered channel dan unbuffered channel. Sebelum memulai, contoh di atas adalah unbuffered channel, di mana tindakan mengirim data ke channel dan menerima data dari channel harus terjadi secara bersamaan. Jika tindakan ini tidak terjadi secara bersamaan, deadrock dapat terjadi.
buffered channel
Bagaimana jika kode di atas bukan hanya output sederhana tetapi dua proses yang menjalankan tugas berat? Jika proses kedua mengalami hambatan jangka panjang saat membaca dan memproses, proses pertama juga akan berhenti selama waktu tersebut. Untuk mencegah situasi ini, kita dapat menggunakan 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}
Kode di atas adalah kode yang mencetak 1 dan 2 menggunakan buffered channel. Dalam kode ini, buffered channel digunakan agar tindakan mengirim data ke channel dan menerima data dari channel tidak perlu terjadi secara bersamaan. Dengan menambahkan buffer ke channel seperti ini, ruang kosong sebesar panjang buffer akan tercipta, yang dapat mencegah penundaan tugas yang disebabkan oleh pengaruh tugas-tugas berikutnya.
select
Saat menangani beberapa channel, pernyataan select dapat digunakan untuk dengan mudah mengimplementasikan struktur fan-in.
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}
Kode di atas membuat 3 channel yang secara berkala mengirimkan 1, 2, 3, dan menggunakan select untuk menerima dan mencetak nilai dari channel. Dengan cara ini, select dapat digunakan untuk menerima data dari beberapa channel secara bersamaan dan memprosesnya segera setelah nilai diterima dari channel.
for range
channel dapat dengan mudah menerima data menggunakan for range. Ketika for range digunakan pada channel, ia akan beroperasi setiap kali data ditambahkan ke channel, dan akan mengakhiri loop ketika channel ditutup.
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}
Kode di atas adalah kode yang mencetak 1 dan 2 menggunakan channel. Dalam kode ini, for range digunakan untuk menerima dan mencetak data setiap kali data ditambahkan ke channel. Dan ketika channel ditutup, loop diakhiri.
Seperti yang telah ditulis beberapa kali di atas, sintaks ini juga dapat digunakan sebagai sarana sinkronisasi sederhana.
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}
Kode di atas adalah kode yang mencetak Hello, World! setelah beristirahat selama 1 detik. Dalam kode ini, channel digunakan untuk mengubah kode sinkron menjadi kode asinkron. Dengan cara ini, channel dapat digunakan untuk dengan mudah mengubah kode sinkron menjadi kode asinkron, dan mengatur titik join.
dll
- Mengirim atau menerima data dari nil channel dapat menyebabkan deadrock karena loop tak terbatas.
- Mengirim data setelah menutup channel akan menyebabkan panic.
- Channel tidak perlu ditutup secara eksplisit; GC akan menutupnya saat membersihkan memori.
mutex
spinlock
spinlock adalah metode sinkronisasi yang terus-menerus mencoba mengunci dengan berputar dalam loop. Dalam bahasa Go, spinlock dapat dengan mudah diimplementasikan menggunakan pointer.
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}
Kode di atas adalah implementasi paket spinlock. Dalam kode ini, SpinLock diimplementasikan menggunakan paket sync/atomic. Metode Lock mencoba mengunci menggunakan atomic.CompareAndSwapUintptr, dan metode Unlock melepaskan kunci menggunakan atomic.StoreUintptr. Metode ini terus mencoba mengunci tanpa henti, sehingga terus menggunakan CPU hingga kunci diperoleh, yang dapat menyebabkan loop tak terbatas. Oleh karena itu, spinlock sebaiknya digunakan untuk sinkronisasi sederhana atau dalam kasus penggunaan jangka pendek.
sync.Mutex
mutex adalah alat untuk sinkronisasi antar goroutine. mutex yang disediakan dalam paket sync menyediakan metode seperti Lock, Unlock, RLock, RUnlock. mutex dapat dibuat dengan sync.Mutex, dan sync.RWMutex juga dapat digunakan untuk kunci baca/tulis.
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}
Dalam kode di atas, dua goroutine akan mengakses variabel count yang sama hampir secara bersamaan. Pada saat ini, jika kode yang mengakses variabel count dibuat menjadi critical section menggunakan mutex, akses bersamaan ke variabel count dapat dicegah. Kemudian, kode ini akan selalu mencetak 2 tidak peduli berapa kali dijalankan.
sync.RWMutex
sync.RWMutex adalah mutex yang dapat digunakan untuk membedakan antara kunci baca dan kunci tulis. Kunci baca dapat dikunci dan dilepaskan menggunakan metode RLock dan 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}
Kode di atas adalah kode yang mengimplementasikan ConcurrentMap menggunakan sync.RWMutex. Dalam kode ini, Get method mengunci baca, dan Set method mengunci tulis, sehingga data map dapat diakses dan dimodifikasi dengan aman. Alasan mengapa kunci baca diperlukan adalah untuk memungkinkan beberapa goroutine melakukan operasi baca secara bersamaan dengan hanya mengunci baca, tanpa mengunci tulis, jika ada banyak operasi baca sederhana. Dengan demikian, kinerja dapat ditingkatkan dengan hanya mengunci baca dalam kasus di mana tidak ada perubahan status sehingga tidak perlu mengunci tulis.
fakelock
fakelock adalah trik sederhana yang mengimplementasikan sync.Locker. Struktur ini menyediakan metode yang sama dengan sync.Mutex, tetapi tidak melakukan operasi aktual.
1package fakelock
2
3type FakeLock struct{}
4
5func (f *FakeLock) Lock() {}
6
7func (f *FakeLock) Unlock() {}
Kode di atas adalah kode yang mengimplementasikan paket fakelock. Paket ini mengimplementasikan sync.Locker untuk menyediakan metode Lock dan Unlock, tetapi sebenarnya tidak melakukan operasi apa pun. Mengapa kode seperti ini diperlukan akan dijelaskan jika ada kesempatan.
waitgroup
sync.WaitGroup
sync.WaitGroup adalah alat untuk menunggu hingga semua tugas goroutine selesai. Ini menyediakan metode Add, Done, Wait. Metode Add menambahkan jumlah goroutine, metode Done memberi tahu bahwa tugas goroutine telah selesai. Dan metode Wait menunggu hingga semua tugas goroutine selesai.
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}
Kode di atas adalah kode yang menggunakan sync.WaitGroup untuk 100 goroutine yang secara bersamaan menambahkan nilai ke variabel c. Dalam kode ini, sync.WaitGroup digunakan untuk menunggu hingga semua goroutine selesai, lalu mencetak nilai yang ditambahkan ke variabel c. Meskipun cukup menggunakan channel saja untuk fork & join beberapa tugas, menggunakan sync.WaitGroup adalah pilihan yang baik untuk fork & join sejumlah besar tugas.
dengan slice
Ketika digunakan bersama dengan slice, waitgroup dapat menjadi alat yang sangat baik untuk mengelola tugas eksekusi bersamaan tanpa lock.
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}
Kode di atas adalah kode yang menggunakan waitgroup saja untuk membuat setiap goroutine secara bersamaan menghasilkan 10 bilangan bulat acak dan menyimpannya di indeks yang dialokasikan. Dalam kode ini, waitgroup digunakan untuk menunggu hingga semua goroutine selesai, lalu mencetak Done. Dengan cara ini, waitgroup dapat digunakan untuk memungkinkan beberapa goroutine melakukan tugas secara bersamaan, menyimpan data tanpa lock hingga semua goroutine selesai, dan melakukan pascaproses secara massal setelah tugas selesai.
golang.org/x/sync/errgroup.ErrGroup
errgroup adalah paket yang memperluas sync.WaitGroup. Tidak seperti sync.WaitGroup, errgroup membatalkan semua goroutine dan mengembalikan kesalahan jika terjadi kesalahan dalam salah satu tugas goroutine.
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}
Kode di atas adalah kode yang menggunakan errgroup untuk membuat 10 goroutine dan menimbulkan kesalahan pada goroutine ke-5. Saya sengaja menimbulkan kesalahan pada goroutine kelima untuk menunjukkan kasus terjadinya kesalahan. Namun, dalam penggunaan sebenarnya, errgroup digunakan untuk membuat goroutine, dan berbagai pascaproses dilakukan untuk kasus-kasus di mana kesalahan terjadi pada setiap goroutine.
once
Ini adalah alat untuk menjalankan kode yang hanya perlu dijalankan sekali. Kode terkait dapat dijalankan melalui konstruktor di bawah ini.
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 hanya memungkinkan fungsi yang relevan dieksekusi tepat sekali secara keseluruhan.
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}
Kode di atas adalah kode yang mencetak Hello, World! menggunakan sync.OnceFunc. Dalam kode ini, sync.OnceFunc digunakan untuk membuat fungsi once, dan meskipun fungsi once dipanggil berkali-kali, Hello, World! hanya akan dicetak sekali.
OnceValue
OnceValue tidak hanya menjalankan fungsi terkait tepat sekali secara keseluruhan, tetapi juga menyimpan nilai kembalian dari fungsi tersebut dan mengembalikannya saat dipanggil lagi.
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}
Kode di atas adalah kode yang menggunakan sync.OnceValue untuk menambah variabel c sebanyak 1. Dalam kode ini, sync.OnceValue digunakan untuk membuat fungsi once, dan meskipun fungsi once dipanggil berkali-kali, variabel c hanya bertambah sekali dan mengembalikan 1.
OnceValues
OnceValues beroperasi sama dengan OnceValue, tetapi dapat mengembalikan beberapa nilai.
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}
Kode di atas adalah kode yang menggunakan sync.OnceValues untuk menambah variabel c sebanyak 1. Dalam kode ini, sync.OnceValues digunakan untuk membuat fungsi once, dan meskipun fungsi once dipanggil berkali-kali, variabel c hanya bertambah sekali dan mengembalikan 1.
atomic
Paket atomic menyediakan operasi atomik. Paket atomic menyediakan metode seperti Add, CompareAndSwap, Load, Store, Swap, tetapi baru-baru ini disarankan untuk menggunakan tipe seperti Int64, Uint64, Pointer.
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}
Ini adalah contoh yang digunakan sebelumnya. Ini adalah kode yang secara atomik menambah variabel c menggunakan tipe atomic.Int64. Dengan metode Add dan Load, variabel dapat ditambah dan dibaca secara atomik. Selain itu, dengan metode Store, nilai dapat disimpan; dengan metode Swap, nilai dapat ditukar; dan dengan metode CompareAndSwap, nilai dapat dibandingkan dan ditukar jika sesuai.
cond
sync.Cond
Paket cond menyediakan variabel kondisi. Paket cond dapat dibuat dengan sync.Cond dan menyediakan metode Wait, Signal, 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}
Kode di atas adalah kode yang menggunakan sync.Cond untuk menunggu hingga variabel ready menjadi true. Dalam kode ini, sync.Cond digunakan untuk menunggu hingga variabel ready menjadi true, lalu mencetak Ready!. Dengan cara ini, sync.Cond dapat digunakan untuk membuat beberapa goroutine menunggu hingga kondisi tertentu terpenuhi secara bersamaan.
Dengan ini, antrean sederhana dapat diimplementasikan.
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}
Dengan memanfaatkan sync.Cond seperti ini, kita dapat menunggu secara efisien dan beroperasi kembali ketika kondisi terpenuhi, daripada menggunakan spin-lock yang mengkonsumsi banyak CPU.
semaphore
golang.org/x/sync/semaphore.Semaphore
Paket semaphore menyediakan semafor. Paket semaphore dapat dibuat dengan golang.org/x/sync/semaphore.Semaphore dan menyediakan metode Acquire, Release, 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}
Kode di atas adalah kode yang menggunakan semaphore untuk membuat semafor, dan menggunakan semafor untuk memperoleh semafor dengan metode Acquire dan melepaskan semafor dengan metode Release. Dalam kode ini, saya telah menunjukkan cara memperoleh dan melepaskan semafor menggunakan semaphore.
Penutup
Isi dasar sepertinya cukup sampai di sini. Berdasarkan isi artikel ini, saya berharap Anda memahami dan dapat secara praktis menggunakan metode pengelolaan konkurensi menggunakan goroutine. Saya berharap artikel ini bermanfaat bagi Anda. Terima kasih.