Go menjadikan concurrency sebagai fitur utama, tetapi setiap goroutine yang berbagi variabel tanpa koordinasi membuka peluang bug paling senyap di dunia pemrograman: data race. Data race jarang membuat test gagal — ia hanya merusak data sesekali di produksi, pada momen scheduling yang tepat, lalu menghilang saat diperiksa. Untuk itu Go membangun race detector langsung ke dalam toolchain: satu flag -race pada go test, dan setiap akses memori yang bertabrakan dilaporkan beserta lokasi kodenya. Pada chapter ini kita menyiapkan program yang mengandung race, membuktikan test biasa tidak melihatnya, lalu menangkapnya dengan -race dan memperbaikinya.

Apa itu Data Race #

Data race terjadi ketika dua goroutine mengakses lokasi memori yang sama pada waktu yang tumpang tindih, sedikitnya satu akses berupa penulisan, dan tidak ada sinkronisasi yang mengatur urutannya. Memory model Go menyatakan program seperti ini berperilaku tidak terdefinisi: hasilnya bisa benar, salah, atau crash, dan berbeda setiap dijalankan.

Sumber race pada chapter ini adalah operasi sesederhana r.total += n. Satu baris itu sebenarnya tiga langkah mesin: baca nilai lama, tambah, tulis kembali. Dua goroutine yang menjalankan ketiganya bersamaan saling menimpa tulisan — lost update — sehingga total akhir lebih kecil dari seharusnya.

Tiga Versi Rekap Penjualan #

rekap.go berisi tiga versi rekap penjualan: tanpa proteksi, dengan mutex, dan dengan atomic:

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// RekapTanpaLock menyimpan total penjualan tanpa sinkronisasi apa pun.
// Dua goroutine yang memanggil Tambah bersamaan memicu data race.
type RekapTanpaLock struct {
	total int
}

// Tambah menambah nilai tanpa proteksi apa pun.
func (r *RekapTanpaLock) Tambah(n int) {
	r.total += n // baca-modifikasi-tulis tanpa sinkronisasi
}

// Total mengembalikan nilai total saat ini.
func (r *RekapTanpaLock) Total() int {
	return r.total
}

// RekapAmanMutex melindungi total dengan sync.Mutex.
type RekapAmanMutex struct {
	mu    sync.Mutex
	total int
}

// Tambah menambah nilai di dalam kunci mutex.
func (r *RekapAmanMutex) Tambah(n int) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.total += n
}

// Total membaca nilai di dalam kunci mutex.
func (r *RekapAmanMutex) Total() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.total
}

// RekapAmanAtomic memakai atomic.Int64 untuk operasi tunggal.
type RekapAmanAtomic struct {
	total atomic.Int64
}

// Tambah menambah nilai secara atomik.
func (r *RekapAmanAtomic) Tambah(n int) {
	r.total.Add(int64(n))
}

// Total membaca nilai secara atomik.
func (r *RekapAmanAtomic) Total() int {
	return int(r.total.Load())
}

func main() {
	aman := &RekapAmanMutex{}
	atomik := &RekapAmanAtomic{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				aman.Tambah(1)
				atomik.Tambah(1)
			}
		}()
	}
	wg.Wait()
	fmt.Println("Total dengan mutex :", aman.Total())
	fmt.Println("Total dengan atomic:", atomik.Total())
}

Jalankan berkas tersebut dari folder chapter:

bash
go run rekap.go

Output yang Dihasilkan:

text
Total dengan mutex : 2000
Total dengan atomic: 2000

Mari kita telaah bagian-bagian utamanya:

  1. Baris 11-23 (RekapTanpaLock): struct dan method tanpa proteksi apa pun; komentar pada baris 17 menandai titik race.
  2. Baris 25-43 (RekapAmanMutex): setiap akses ke total dibungkus r.mu.Lock() dan defer r.mu.Unlock(), sehingga paling banyak satu goroutine berada di dalam blok tersebut.
  3. Baris 45-57 (RekapAmanAtomic): atomic.Int64 menjamin operasi Add dan Load berlangsung atomik tanpa kunci eksplisit.
  4. Baris 60-76 (func main): menjalankan dua goroutine yang masing-masing menambah 1.000 kali pada versi mutex dan atomic; keduanya aman sehingga hasil selalu 2.000 dan output program tetap deterministik.

Test yang Menyembunyikan Race #

Berkas rekap_test.go menguji ketiga versi dengan dua goroutine berisi 1.000 penambahan:

go
package main

import (
	"sync"
	"testing"
)

func TestRekapTanpaLock(t *testing.T) {
	r := &RekapTanpaLock{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				r.Tambah(1)
			}
		}()
	}
	wg.Wait()
	// Tanpa sinkronisasi, nilai pasti total tidak bisa diprediksi,
	// sehingga test tanpa -race hanya memeriksa rentang wajarnya.
	if got := r.Total(); got < 1 || got > 2000 {
		t.Errorf("total = %d, di luar rentang wajar 1..2000", got)
	}
}

func TestRekapAmanMutex(t *testing.T) {
	r := &RekapAmanMutex{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				r.Tambah(1)
			}
		}()
	}
	wg.Wait()
	if got := r.Total(); got != 2000 {
		t.Errorf("total = %d, diharapkan 2000", got)
	}
}

func TestRekapAmanAtomic(t *testing.T) {
	r := &RekapAmanAtomic{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				r.Tambah(1)
			}
		}()
	}
	wg.Wait()
	if got := r.Total(); got != 2000 {
		t.Errorf("total = %d, diharapkan 2000", got)
	}
}

Jalankan test biasa dari folder chapter:

bash
go test -v ./...

Output yang Dihasilkan:

text
=== RUN   TestRekapTanpaLock
--- PASS: TestRekapTanpaLock (0.00s)
=== RUN   TestRekapAmanMutex
--- PASS: TestRekapAmanMutex (0.00s)
=== RUN   TestRekapAmanAtomic
--- PASS: TestRekapAmanAtomic (0.00s)
PASS
ok  	tutorial-website/content/go/testing/race-detector	0.001s

Ketiga test lulus — dan justru inilah masalahnya. TestRekapTanpaLock pada baris 21-25 hanya memeriksa rentang 1 sampai 2.000, karena tanpa sinkronisasi nilai pastinya memang tidak bisa diprediksi. Lost update terjadi di setiap eksekusi, tetapi assertion longgar tidak pernah tersandung. Race tersembunyi rapi di balik test hijau.

Menangkap Race dengan go test -race #

Tambahkan flag -race agar compiler menyisipkan pemeriksaan akses memori (berbasis ThreadSanitizer) ke dalam test binary:

bash
go test -race ./...

Output yang Dihasilkan:

text
==================
WARNING: DATA RACE
Read at 0x00c000096168 by goroutine 10:
  tutorial-website/content/go/testing/race-detector.(*RekapTanpaLock).Tambah()
      /home/z/my-project/content/go/testing/race-detector/rekap.go:17 +0x9c
  tutorial-website/content/go/testing/race-detector.TestRekapTanpaLock.func1()
      /home/z/my-project/content/go/testing/race-detector/rekap_test.go:16 +0x97

Previous write at 0x00c000096168 by goroutine 9:
  tutorial-website/content/go/testing/race-detector.(*RekapTanpaLock).Tambah()
      /home/z/my-project/content/go/testing/race-detector/rekap.go:17 +0xae
  tutorial-website/content/go/testing/race-detector.TestRekapTanpaLock.func1()
      /home/z/my-project/content/go/testing/race-detector/rekap_test.go:16 +0x97

Goroutine 10 (running) created at:
  tutorial-website/content/go/testing/race-detector.TestRekapTanpaLock()
      /home/z/my-project/content/go/testing/race-detector/rekap_test.go:13 +0x84
  testing.tRunner()
      /home/z/tools/go/src/testing/testing.go:2195 +0x21c
  testing.(*T).Run.gowrap1()
      /home/z/tools/go/src/testing/testing.go:2260 +0x38
  ... (blok asal-usul Goroutine 9 serta frame testing diringkas)

==================
--- FAIL: TestRekapTanpaLock (0.00s)
    testing.go:1867: race detected during execution of test
FAIL
FAIL	tutorial-website/content/go/testing/race-detector	0.007s
FAIL

Anatomi laporan tersebut:

Bagian laporanInformasi yang disampaikan
WARNING: DATA RACEPenanda resmi bahwa akses bertabrakan terdeteksi
Read at 0x... by goroutine 10Akses baca pada alamat tersebut, beserta stack yang berakhir di rekap.go:17
Previous write at 0x... by goroutine 9Tulisan sebelumnya ke alamat yang sama, dari goroutine lain
Goroutine 10 (running) created at:Titik lahir goroutine, di rekap_test.go:13
race detected during execution of testTestRekapTanpaLock otomatis ditandai FAIL

Dua stack akses sama-sama menunjuk rekap.go:17 — baris r.total += n yang memang benar titik race-nya. Perintah keluar dengan kode gagal — race langsung memutus pipeline CI, bukan menunggu insiden produksi.

Versi yang Aman Lulus Pemeriksaan #

Filter -run menerima regex nama test, sehingga dua test versi aman dapat berjalan sendiri:

bash
go test -race -run 'TestRekapAman' ./...

Output yang Dihasilkan:

text
ok  	tutorial-website/content/go/testing/race-detector	1.008s

Pola TestRekapAman mencocokkan TestRekapAmanMutex dan TestRekapAmanAtomic; keduanya lulus tanpa laporan race karena semua akses total sudah tersinkronisasi.

Mutex atau Atomic #

Aspeksync.Mutexsync/atomic
Satuan proteksiBlok kode sepanjang apa punSatu operasi memori (Add, Load, CompareAndSwap)
Cocok untukInvariant lintas beberapa variabel, logika kompleksCounter, flag status, pointer tunggal
Bentuk kodeLock/Unlock eksplisit, mudah dibacaPanggilan method, minim boilerplate

Keduanya benar; pilihan menyangkut kejelasan. Bila yang dilindungi satu angka, atomic lebih ringkas. Begitu invariant melibatkan dua field atau lebih yang harus berubah bersamaan, mutex menjaga semuanya dalam satu blok kunci.

Dengan race detector, kelas bug concurrency yang tadinya hanya muncul di produksi kini bisa ditangkap sejak baris test pertama. Chapter terakhir section ini menghadapi tantangan desain yang berbeda: bagaimana menguji kode yang bergantung pada layanan eksternal seperti pengirim email.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

rekap_test.go

Unduh berkas
go
package main

import (
	"sync"
	"testing"
)

func TestRekapTanpaLock(t *testing.T) {
	r := &RekapTanpaLock{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				r.Tambah(1)
			}
		}()
	}
	wg.Wait()
	// Tanpa sinkronisasi, nilai pasti total tidak bisa diprediksi,
	// sehingga test tanpa -race hanya memeriksa rentang wajarnya.
	if got := r.Total(); got < 1 || got > 2000 {
		t.Errorf("total = %d, di luar rentang wajar 1..2000", got)
	}
}

func TestRekapAmanMutex(t *testing.T) {
	r := &RekapAmanMutex{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				r.Tambah(1)
			}
		}()
	}
	wg.Wait()
	if got := r.Total(); got != 2000 {
		t.Errorf("total = %d, diharapkan 2000", got)
	}
}

func TestRekapAmanAtomic(t *testing.T) {
	r := &RekapAmanAtomic{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				r.Tambah(1)
			}
		}()
	}
	wg.Wait()
	if got := r.Total(); got != 2000 {
		t.Errorf("total = %d, diharapkan 2000", got)
	}
}

rekap.go

Unduh berkas
go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// RekapTanpaLock menyimpan total penjualan tanpa sinkronisasi apa pun.
// Dua goroutine yang memanggil Tambah bersamaan memicu data race.
type RekapTanpaLock struct {
	total int
}

// Tambah menambah nilai tanpa proteksi apa pun.
func (r *RekapTanpaLock) Tambah(n int) {
	r.total += n // baca-modifikasi-tulis tanpa sinkronisasi
}

// Total mengembalikan nilai total saat ini.
func (r *RekapTanpaLock) Total() int {
	return r.total
}

// RekapAmanMutex melindungi total dengan sync.Mutex.
type RekapAmanMutex struct {
	mu    sync.Mutex
	total int
}

// Tambah menambah nilai di dalam kunci mutex.
func (r *RekapAmanMutex) Tambah(n int) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.total += n
}

// Total membaca nilai di dalam kunci mutex.
func (r *RekapAmanMutex) Total() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.total
}

// RekapAmanAtomic memakai atomic.Int64 untuk operasi tunggal.
type RekapAmanAtomic struct {
	total atomic.Int64
}

// Tambah menambah nilai secara atomik.
func (r *RekapAmanAtomic) Tambah(n int) {
	r.total.Add(int64(n))
}

// Total membaca nilai secara atomik.
func (r *RekapAmanAtomic) Total() int {
	return int(r.total.Load())
}

func main() {
	aman := &RekapAmanMutex{}
	atomik := &RekapAmanAtomic{}
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				aman.Tambah(1)
				atomik.Tambah(1)
			}
		}()
	}
	wg.Wait()
	fmt.Println("Total dengan mutex :", aman.Total())
	fmt.Println("Total dengan atomic:", atomik.Total())
}