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:

  1. Menggunakan Mutex (sync.Mutex)
  2. 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.

Referensi