1. Peran mekanisme sinkronisasi
Dalam pemrograman konkuren, ketika beberapa goroutine berbagi sumber daya, sangat penting untuk memastikan bahwa sumber daya hanya dapat diakses oleh satu goroutine pada satu waktu untuk mencegah kondisi perlombaan. Hal ini memerlukan penggunaan mekanisme sinkronisasi. Mekanisme sinkronisasi dapat mengkoordinasikan urutan akses dari berbagai goroutine ke sumber daya bersama, memastikan konsistensi data dan sinkronisasi status dalam lingkungan konkuren.
Bahasa Go menyediakan kumpulan mekanisme sinkronisasi yang kaya, termasuk tetapi tidak terbatas pada:
- Mutexes (sync.Mutex) dan mutex baca-tulis (sync.RWMutex)
- Saluran (Channels)
- WaitGroups
- Fungsi atomik (paket atomic)
- Variabel kondisi (sync.Cond)
2. Primitif sinkronisasi
2.1 Mutex (sync.Mutex)
2.1.1 Konsep dan peran mutex
Mutex adalah mekanisme sinkronisasi yang memastikan operasi aman dari sumber daya bersama dengan memungkinkan hanya satu goroutine untuk memegang kunci untuk mengakses sumber daya bersama pada suatu saat. Mutex mencapai sinkronisasi melalui metode Lock
dan Unlock
. Memanggil metode Lock
akan memblokir hingga kunci dilepaskan, dan pada saat ini, goroutine lain yang mencoba untuk memperoleh kunci akan menunggu. Memanggil Unlock
melepaskan kunci, memungkinkan goroutine lain yang menunggu untuk memperolehnya.
var mu sync.Mutex
func criticalSection() {
// Peroleh kunci untuk mengakses sumber daya secara eksklusif
mu.Lock()
// Akses sumber daya bersama di sini
// ...
// Lepaskan kunci untuk memungkinkan goroutine lain untuk memperolehnya
mu.Unlock()
}
2.1.2 Penggunaan praktis dari mutex
Misalkan kita perlu mempertahankan penghitung global, dan beberapa goroutine perlu menambahkan nilainya. Dengan menggunakan mutex, dapat menjamin ketepatan penghitung.
var (
mu sync.Mutex
counter int
)
func increment() {
mu.Lock() // Kunci sebelum memodifikasi penghitung
counter++ // Secara aman tambahkan nilai penghitung
mu.Unlock() // Lepas kunci setelah operasi, memungkinkan goroutine lain untuk mengakses penghitung
}
func main() {
for i := 0; i < 10; i++ {
go increment() // Memulai beberapa goroutine untuk menambahkan nilai penghitung
}
// Tunggu beberapa waktu (dalam praktik, seharusnya menggunakan WaitGroup atau metode lain untuk menunggu semua goroutine selesai)
time.Sleep(1 * time.Second)
fmt.Println(counter) // Output nilai penghitung
}
2.2 Mutex Baca-Tulis (sync.RWMutex
)
2.2.1 Konsep dari mutex baca-tulis
RWMutex adalah jenis kunci khusus yang memungkinkan beberapa goroutine untuk membaca sumber daya bersama secara bersamaan, sementara operasi tulis adalah eksklusif. Dibandingkan dengan mutex, kunci baca-tulis dapat meningkatkan kinerja dalam skenario multi-pembaca. Ini memiliki empat metode: RLock
, RUnlock
untuk mengunci dan membuka kunci operasi baca, dan Lock
, Unlock
untuk mengunci dan membuka kunci operasi tulis.
2.2.2 Kasus Penggunaan Praktis dari Mutex Baca-Tulis
Dalam aplikasi basis data, operasi baca mungkin jauh lebih sering daripada operasi tulis. Menggunakan kunci baca-tulis dapat meningkatkan kinerja sistem karena memungkinkan beberapa goroutine untuk membaca secara bersamaan.
var (
rwMu sync.RWMutex
data int
)
func readData() int {
rwMu.RLock() // Peroleh kunci baca, memungkinkan operasi baca lain untuk berlanjut secara bersamaan
defer rwMu.RUnlock() // Pastikan kunci dilepaskan menggunakan defer
return data // Baca data dengan aman
}
func writeData(newValue int) {
rwMu.Lock() // Peroleh kunci tulis, mencegah operasi baca atau tulis lain pada saat ini
data = newValue // Menulis nilai baru dengan aman
rwMu.Unlock() // Lepaskan setelah menulis selesai
}
func main() {
go writeData(42) // Memulai goroutine untuk melakukan operasi tulis
fmt.Println(readData()) // Goroutine utama melakukan operasi baca
// Gunakan WaitGroup atau metode sinkronisasi lain untuk memastikan semua goroutine selesai
}
Pada contoh di atas, beberapa pembaca dapat menjalankan fungsi readData
secara bersamaan, tetapi penulis yang menjalankan writeData
akan memblokir pembaca baru dan penulis lainnya. Mekanisme ini memberikan keuntungan kinerja untuk skenario dengan lebih banyak operasi baca daripada operasi tulis.
2.3 Variabel Kondisi (sync.Cond
)
2.3.1 Konsep Variabel Kondisional
Dalam mekanisme sinkronisasi bahasa Go, variabel kondisional digunakan untuk menunggu atau memberi tahu perubahan kondisi sebagai primitif sinkronisasi. Variabel kondisional selalu digunakan bersama dengan mutex (sync.Mutex
), yang digunakan untuk melindungi konsistensi kondisi itu sendiri.
Konsep variabel kondisional berasal dari domain sistem operasi, memungkinkan sekelompok goroutine untuk menunggu sampai suatu kondisi terpenuhi. Lebih spesifik lagi, sebuah goroutine dapat menghentikan eksekusi saat menunggu suatu kondisi terpenuhi, dan goroutine lain dapat memberi tahu goroutine lainnya untuk melanjutkan eksekusi setelah mengubah kondisi menggunakan variabel kondisional.
Dalam pustaka standar Go, variabel kondisional disediakan melalui tipe sync.Cond
, dan metode utamanya meliputi:
-
Wait
: Memanggil metode ini akan melepaskan kunci yang dipegang dan menghalangi sampai goroutine lain memanggilSignal
atauBroadcast
pada variabel kondisional yang sama untuk membangunkannya, setelah itu akan mencoba untuk memperoleh kunci lagi. -
Signal
: Membangunkan satu goroutine yang menunggu variabel kondisional ini. Jika tidak ada goroutine yang menunggu, memanggil metode ini tidak akan berpengaruh. -
Broadcast
: Membangunkan semua goroutine yang menunggu variabel kondisional ini.
Variabel kondisional sebaiknya tidak disalin, sehingga umumnya digunakan sebagai bidang penunjuk dari suatu struktur tertentu.
2.3.2 Kasus Praktis dari Variabel Kondisional
Berikut adalah contoh penggunaan variabel kondisional yang menunjukkan model produsen-konsumen sederhana:
package main
import (
"fmt"
"sync"
"time"
)
// SafeQueue adalah antrian aman yang dilindungi oleh mutex
type SafeQueue struct {
mu sync.Mutex
cond *sync.Cond
queue []interface{}
}
// Enqueue menambahkan elemen ke akhir antrian dan memberitahukan goroutine yang menunggu
func (sq *SafeQueue) Enqueue(item interface{}) {
sq.mu.Lock()
defer sq.mu.Unlock()
sq.queue = append(sq.queue, item)
sq.cond.Signal() // Memberitahu goroutine yang menunggu bahwa antriannya tidak kosong
}
// Dequeue menghapus elemen dari awal antrian, menunggu jika antriannya kosong
func (sq *SafeQueue) Dequeue() interface{} {
sq.mu.Lock()
defer sq.mu.Unlock()
// Menunggu saat antrian kosong
for len(sq.queue) == 0 {
sq.cond.Wait() // Menunggu perubahan kondisi
}
item := sq.queue[0]
sq.queue = sq.queue[1:]
return item
}
func main() {
queue := make([]interface{}, 0)
sq := SafeQueue{
mu: sync.Mutex{},
cond: sync.NewCond(&sync.Mutex{}),
queue: queue,
}
// Goroutine Produsen
go func() {
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Second) // Simulasi waktu produksi
sq.Enqueue(fmt.Sprintf("item%d", i)) // Memproduksi suatu elemen
fmt.Println("Produksi:", i)
}
}()
// Goroutine Konsumen
go func() {
for i := 0; i < 5; i++ {
item := sq.Dequeue() // Mengkonsumsi suatu elemen, menunggu jika antrian kosong
fmt.Printf("Konsumsi: %v\n", item)
}
}()
// Tunggu cukup waktu untuk memastikan semua produksi dan konsumsi selesai
time.Sleep(10 * time.Second)
}
Dalam contoh ini, kita telah mendefinisikan struktur SafeQueue
dengan antrian internal dan variabel kondisional. Saat konsumen memanggil metode Dequeue
dan antrian kosong, ia menunggu menggunakan metode Wait
. Saat produsen memanggil metode Enqueue
untuk memasukkan elemen baru, ia menggunakan metode Signal
untuk membangunkan konsumen yang menunggu.
2.4 WaitGroup
2.4.1 Konsep dan Penggunaan WaitGroup
sync.WaitGroup
adalah mekanisme sinkronisasi yang digunakan untuk menunggu kelompok goroutine menyelesaikan. Ketika Anda memulai goroutine, Anda dapat meningkatkan penghitung dengan memanggil metode Add
, dan setiap goroutine dapat memanggil metode Done
(yang sebenarnya melakukan Add(-1)
) saat selesai. Goroutine utama dapat memblokir dengan memanggil metode Wait
sampai penghitung mencapai 0, menandakan bahwa semua goroutine telah menyelesaikan tugasnya.
Ketika menggunakan WaitGroup
, berikut adalah beberapa hal yang perlu diperhatikan:
- Metode
Add
,Done
, danWait
tidak aman secara benang dan sebaiknya tidak dipanggil secara bersamaan dalam beberapa goroutine. - Metode
Add
sebaiknya dipanggil sebelum goroutine yang baru dibuat dimulai.
2.4.2 Kasus Penggunaan Praktis WaitGroup
Berikut adalah contoh penggunaan WaitGroup
:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Memberitahukan WaitGroup setelah selesai
fmt.Printf("Pekerja %d mulai\n", id)
time.Sleep(time.Second) // Mensimulasikan operasi yang memakan waktu
fmt.Printf("Pekerja %d selesai\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Menambahkan counter sebelum menjalankan goroutine
go worker(i, &wg)
}
wg.Wait() // Tunggu semua goroutine pekerja selesai
fmt.Println("Semua pekerja selesai")
}
Pada contoh ini, fungsi worker
mensimulasikan pelaksanaan tugas. Di dalam fungsi utama, kita memulai lima goroutine worker
. Sebelum menjalankan setiap goroutine, kita memanggil wg.Add(1)
untuk memberitahukan WaitGroup
bahwa tugas baru sedang dijalankan. Ketika setiap fungsi pekerja selesai, ia memanggil defer wg.Done()
untuk memberitahukan WaitGroup
bahwa tugas tersebut selesai. Setelah menjalankan semua goroutine, fungsi utama memblokir pada wg.Wait()
hingga semua pekerja melaporkan selesai.
2.5 Operasi Atomic (sync/atomic
)
2.5.1 Konsep Operasi Atomic
Operasi atomic merujuk pada operasi dalam pemrograman konkuren yang tidak dapat dipisahkan, artinya operasi tersebut tidak terganggu oleh operasi lain selama eksekusi. Untuk beberapa goroutine, menggunakan operasi atomic dapat memastikan konsistensi data dan sinkronisasi status tanpa perlu mengunci, karena operasi atomic sendiri menjamin atomisitas eksekusi.
Di dalam bahasa Go, paket sync/atomic
menyediakan operasi memori atomik pada tingkat rendah. Untuk tipe data dasar seperti int32
, int64
, uint32
, uint64
, uintptr
, dan pointer
, metode paket sync/atomic
dapat digunakan untuk operasi konkuren yang aman. Pentingnya operasi atomic terletak pada menjadi batu fondasi untuk membangun primitif konkuren lainnya (seperti kunci dan variabel kondisi) dan seringkali lebih efisien daripada mekanisme penguncian.
2.5.2 Kasus Penggunaan Praktis Operasi Atomic
Bayangkan sebuah skenario di mana kita perlu melacak jumlah pengunjung secara konkuren pada sebuah situs web. Dengan menggunakan variabel hitungan sederhana secara intuitif, kita akan menambahkan hitungan ketika seorang pengunjung datang dan menguranginya ketika seorang pengunjung pergi. Namun, dalam lingkungan konkuren, pendekatan ini akan menyebabkan race condition pada data. Oleh karena itu, kita dapat menggunakan paket sync/atomic
untuk memanipulasi hitungan dengan aman.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var jumlahPengunjung int32
func tambahJumlahPengunjung() {
atomic.AddInt32(&jumlahPengunjung, 1)
}
func kurangiJumlahPengunjung() {
atomic.AddInt32(&jumlahPengunjung, -1)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
tambahJumlahPengunjung()
time.Sleep(time.Second) // Waktu kunjungan pengunjung
kurangiJumlahPengunjung()
wg.Done()
}()
}
wg.Wait()
fmt.Printf("Jumlah pengunjung saat ini: %d\n", jumlahPengunjung)
}
Pada contoh ini, kita membuat 100 goroutine untuk mensimulasikan kedatangan dan kepergian pengunjung. Dengan menggunakan fungsi atomic.AddInt32()
, kita memastikan bahwa penambahan dan pengurangan hitungan bersifat atomik, bahkan dalam situasi konkuren yang sangat tinggi, sehingga memastikan ketepatan jumlahPengunjung
.
2.6 Mekanisme Sinkronisasi Channel
2.6.1 Karakteristik Sinkronisasi Channel
Channel adalah cara bagi goroutine untuk berkomunikasi dalam bahasa Go pada level bahasa. Sebuah channel menyediakan kemampuan untuk mengirim dan menerima data. Ketika sebuah goroutine mencoba membaca data dari channel dan channel tidak memiliki data, ia akan diblokir hingga ada data yang tersedia. Demikian pula, jika channel penuh (untuk channel yang tidak berbuffer, ini berarti sudah ada data), goroutine yang mencoba mengirim data juga akan diblokir hingga ada ruang untuk menulis. Fitur ini membuat channel sangat berguna untuk sinkronisasi antara goroutine.
2.6.2 Kasus Penggunaan Sinkronisasi dengan Channel
Misalkan kita memiliki sebuah tugas yang perlu diselesaikan oleh beberapa goroutine, masing-masing menangani sub-tugas, dan kemudian kita perlu menggabungkan hasil dari semua sub-tugas. Kita dapat menggunakan channel untuk menunggu semua goroutine selesai.
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
defer wg.Done()
// Melakukan beberapa operasi...
fmt.Printf("Pekerja %d mulai\n", id)
// Diasumsikan hasil sub-tugas adalah id pekerja
resultChan <- id
fmt.Printf("Pekerja %d selesai\n", id)
}
func main() {
var wg sync.WaitGroup
numWorkers := 5
resultChan := make(chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, &wg, resultChan)
}
go func() {
wg.Wait()
close(resultChan)
}()
// Mengumpulkan semua hasil
for result := range resultChan {
fmt.Printf("Menerima hasil: %d\n", result)
}
}
Pada contoh ini, kita memulai 5 goroutine untuk menyelesaikan tugas dan mengumpulkan hasil melalui channel resultChan
. Goroutine utama menunggu semua pekerjaan selesai di goroutine terpisah dan kemudian menutup channel hasil. Setelah itu, goroutine utama menelusuri channel resultChan
, mengumpulkan dan mencetak hasil dari semua goroutine.
2.7 Eksekusi Sekali (sync.Once
)
sync.Once
adalah primitif sinkronisasi yang memastikan operasi hanya dieksekusi sekali selama eksekusi program. Penggunaan umum dari sync.Once
adalah dalam inisialisasi objek tunggal atau dalam skenario yang memerlukan inisialisasi tertunda. Terlepas dari berapa banyak goroutine yang memanggil operasi ini, itu akan hanya dijalankan sekali, oleh karena itu disebut fungsi Do
.
sync.Once
sempurna seimbang antara masalah konkurensi dan efisiensi eksekusi, menghilangkan kekhawatiran tentang masalah kinerja yang disebabkan oleh inisialisasi ulang.
Sebagai contoh sederhana untuk menunjukkan penggunaan sync.Once
:
package main
import (
"fmt"
"sync"
)
var once sync.Once
var instance *Singleton
type Singleton struct{}
func Instance() *Singleton {
once.Do(func() {
fmt.Println("Membuat instance tunggal sekarang.")
instance = &Singleton{}
})
return instance
}
func main() {
for i := 0; i < 10; i++ {
go Instance()
}
fmt.Scanln() // Tunggu untuk melihat output
}
Pada contoh ini, bahkan jika fungsi Instance
dipanggil secara konkuren beberapa kali, pembuatan instance Singleton
akan terjadi hanya sekali. Panggilan selanjutnya akan langsung mengembalikan instance singleton yang dibuat pertama kali, memastikan keunikan instance.
2.8 ErrGroup
ErrGroup
adalah sebuah library dalam bahasa Go yang digunakan untuk menyinkronkan multiple goroutine dan mengumpulkan kesalahan mereka. Ini merupakan bagian dari paket "golang.org/x/sync/errgroup", menyediakan cara yang ringkas untuk menangani skenario kesalahan pada operasi konkuren.
2.8.1 Konsep dari ErrGroup
Idea inti dari ErrGroup
adalah mengikat sekelompok tugas terkait (biasanya dieksekusi secara konkuren) bersama, dan jika salah satu tugas gagal, eksekusi seluruh grup akan dibatalkan. Pada saat yang sama, jika salah satu dari operasi konkuren ini mengembalikan kesalahan, ErrGroup
akan menangkap dan mengembalikan kesalahan tersebut.
Untuk menggunakan ErrGroup
, pertama-tama impor paket tersebut:
import "golang.org/x/sync/errgroup"
Kemudian, buat sebuah instance dari ErrGroup
:
var g errgroup.Group
Setelah itu, Anda dapat meneruskan tugas-tugas ke ErrGroup
dalam bentuk closure dan memulai Goroutine baru dengan memanggil metode Go
:
g.Go(func() error {
// Melakukan sebuah tugas tertentu
// Jika semuanya berjalan lancar
return nil
// Jika terjadi kesalahan
// return fmt.Errorf("terjadi kesalahan")
})
Terakhir, panggil metode Wait
, yang akan memblokir dan menunggu semua tugas selesai. Jika salah satu dari tugas ini mengembalikan kesalahan, Wait
akan mengembalikan kesalahan tersebut:
if err := g.Wait(); err != nil {
// Tangani kesalahan
log.Fatalf("Kesalahan eksekusi tugas: %v", err)
}
2.8.2 Kasus Praktis dari ErrGroup
Mari kita pertimbangkan sebuah skenario di mana kita perlu untuk secara bersamaan mengambil data dari tiga sumber data yang berbeda, dan jika salah satu sumber data gagal, kita ingin segera membatalkan operasi pengambilan data lainnya. Tugas ini dapat dengan mudah dicapai menggunakan ErrGroup
:
package main
import (
"fmt"
"golang.org/x/sync/errgroup"
)
func fetchDataFromSource1() error {
// Menyimulasikan pengambilan data dari sumber 1
return nil // atau mengembalikan error untuk mensimulasikan kegagalan
}
func fetchDataFromSource2() error {
// Menyimulasikan pengambilan data dari sumber 2
return nil // atau mengembalikan error untuk mensimulasikan kegagalan
}
func fetchDataFromSource3() error {
// Menyimulasikan pengambilan data dari sumber 3
return nil // atau mengembalikan error untuk mensimulasikan kegagalan
}
func main() {
var g errgroup.Group
g.Go(fetchDataFromSource1)
g.Go(fetchDataFromSource2)
g.Go(fetchDataFromSource3)
// Tunggu semua goroutine untuk selesai dan kumpulkan error mereka
if err := g.Wait(); err != nil {
fmt.Printf("Error terjadi saat pengambilan data: %v\n", err)
return
}
fmt.Println("Semua data berhasil diambil!")
}
Dalam contoh ini, fungsi fetchDataFromSource1
, fetchDataFromSource2
, dan fetchDataFromSource3
mensimulasikan pengambilan data dari sumber data yang berbeda. Mereka diteruskan ke metode g.Go
dan dieksekusi dalam Gorutin terpisah. Jika salah satu fungsi mengembalikan error, g.Wait
akan segera mengembalikan error tersebut, memungkinkan penanganan error yang sesuai ketika error terjadi. Jika semua fungsi dieksekusi dengan sukses, g.Wait
akan mengembalikan nil
, menandakan bahwa semua tugas telah selesai dengan sukses.
Fitur penting lainnya dari ErrGroup
adalah bahwa jika salah satu Gorutin mengalami panic, ErrGroup
akan mencoba untuk pulih dari panic tersebut dan mengembalikannya sebagai error. Hal ini membantu mencegah Gorutin lain yang sedang berjalan secara bersamaan agar tidak gagal dalam menutup dengan baik. Tentu saja, jika Anda ingin tugas-tugas merespons sinyal pembatalan eksternal, Anda dapat menggabungkan fungsi WithContext
dari errgroup
dengan paket konteks untuk menyediakan konteks yang dapat dibatalkan.
Dengan cara ini, ErrGroup
menjadi mekanisme sinkronisasi dan penanganan error yang sangat praktis dalam praktik pemrograman konkuren Go.