Pendahuluan
Dalam pemrograman Go, sering kali kita menghadapi situasi di mana beberapa goroutine perlu mengakses atau memodifikasi variabel yang sama secara bersamaan. Jika tidak ditangani dengan benar, hal ini dapat menyebabkan masalah yang disebut race condition, di mana hasil akhir dari program menjadi tidak dapat diprediksi.
Masalah Race Condition
Misalnya, jika kita melakukan operasi increment atau decrement pada sebuah variabel secara bersamaan menggunakan banyak goroutine tanpa mekanisme sinkronisasi, maka hasil akhirnya bisa salah. Hal ini karena operasi tersebut tidak dilakukan secara atomik (tidak terjadi dalam satu langkah yang tak terpisahkan).
Operasi atomik biasanya diimplementasikan pada level hardware, sehingga memungkinkan kita untuk membuat variabel yang dapat diubah secara aman oleh banyak goroutine sekaligus. Go menyediakan package sync/atomic
untuk membantu melakukan sinkronisasi pada operasi-operasi sederhana seperti increment, decrement, load, dan store pada tipe data primitif.
Contoh Tanpa Atomic
Berikut contoh kode tanpa menggunakan mekanisme atomik atau sinkronisasi:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 1; i <= 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println(counter)
}
Pada kode di atas, kita menjalankan 1000 goroutine yang masing-masing melakukan increment pada variabel counter
. Namun, hasil output sering kali tidak sesuai harapan, misalnya hanya menghasilkan nilai 931, padahal seharusnya 1000.
Counter : 931
Hal ini terjadi karena race condition: dua atau lebih goroutine mengakses dan memodifikasi data yang sama secara bersamaan tanpa sinkronisasi, sehingga hasil akhirnya menjadi tidak konsisten.
Solusi Race Condition
Ada dua cara umum untuk mengatasi race condition di Go:
- Menggunakan Mutex (
sync.Mutex
) - Menggunakan Atomic (
sync/atomic
)
1. Menggunakan Mutex
Mutex adalah mekanisme locking yang memastikan hanya satu goroutine yang dapat mengakses atau memodifikasi data pada satu waktu. Goroutine lain harus menunggu hingga lock dilepas.
Contoh penggunaan mutex:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int64
var wg sync.WaitGroup
var mutex sync.Mutex
for i := 1; i <= 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mutex.Lock()
counter++
mutex.Unlock()
}()
}
wg.Wait()
fmt.Println("Counter :", counter)
}
Pada kode di atas, setiap operasi increment pada counter
dibungkus dengan mutex.Lock()
dan mutex.Unlock()
, sehingga hanya satu goroutine yang dapat melakukan increment pada satu waktu.
Counter : 1000
Hasilnya, nilai counter sesuai dengan yang diharapkan.
2. Menggunakan Atomic
Cara kedua adalah menggunakan package sync/atomic
yang menyediakan operasi atomik untuk tipe data primitif seperti int32
, int64
, uint32
, dan uint64
. Operasi atomik lebih efisien daripada mutex untuk operasi sederhana karena tidak perlu melakukan locking secara eksplisit.
Contoh penggunaan atomic:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 1; i <= 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println(counter)
}
Pada kode di atas, kita menggunakan atomic.AddInt64(&counter, 1)
untuk melakukan increment secara atomik. Parameter pertama adalah pointer ke variabel yang akan diubah, dan parameter kedua adalah nilai yang akan ditambahkan.
Hasilnya, nilai counter akan selalu sesuai dengan jumlah goroutine yang dijalankan.
Kapan Menggunakan Mutex vs Atomic?
- Gunakan atomic untuk operasi sederhana pada tipe data primitif (misal increment, decrement, load, store).
- Gunakan mutex jika operasi yang dilakukan lebih kompleks atau melibatkan beberapa variabel sekaligus.
Kesimpulan
Race condition adalah masalah umum dalam pemrograman concurrent. Go menyediakan dua solusi utama: mutex dan atomic. Pilihlah solusi yang paling sesuai dengan kebutuhan aplikasi Anda.