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:
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:
go run rekap.goOutput yang Dihasilkan:
Total dengan mutex : 2000
Total dengan atomic: 2000Mari kita telaah bagian-bagian utamanya:
- Baris 11-23 (
RekapTanpaLock): struct dan method tanpa proteksi apa pun; komentar pada baris 17 menandai titik race. - Baris 25-43 (
RekapAmanMutex): setiap akses ketotaldibungkusr.mu.Lock()dandefer r.mu.Unlock(), sehingga paling banyak satu goroutine berada di dalam blok tersebut. - Baris 45-57 (
RekapAmanAtomic):atomic.Int64menjamin operasiAdddanLoadberlangsung atomik tanpa kunci eksplisit. - 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:
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:
go test -v ./...Output yang Dihasilkan:
=== 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.001sKetiga 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:
go test -race ./...Output yang Dihasilkan:
==================
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
FAILAnatomi laporan tersebut:
| Bagian laporan | Informasi yang disampaikan |
|---|---|
WARNING: DATA RACE | Penanda resmi bahwa akses bertabrakan terdeteksi |
Read at 0x... by goroutine 10 | Akses baca pada alamat tersebut, beserta stack yang berakhir di rekap.go:17 |
Previous write at 0x... by goroutine 9 | Tulisan 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 test | TestRekapTanpaLock 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:
go test -race -run 'TestRekapAman' ./...Output yang Dihasilkan:
ok tutorial-website/content/go/testing/race-detector 1.008sPola TestRekapAman mencocokkan TestRekapAmanMutex dan TestRekapAmanAtomic; keduanya lulus tanpa laporan race karena semua akses total sudah tersinkronisasi.
Mutex atau Atomic #
| Aspek | sync.Mutex | sync/atomic |
|---|---|---|
| Satuan proteksi | Blok kode sepanjang apa pun | Satu operasi memori (Add, Load, CompareAndSwap) |
| Cocok untuk | Invariant lintas beberapa variabel, logika kompleks | Counter, flag status, pointer tunggal |
| Bentuk kode | Lock/Unlock eksplisit, mudah dibaca | Panggilan 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 #
- Artikel: Data Race Detector
— cara kerja, biaya, dan opsi
GORACE. - Blog: Introducing the Go race detector — latar belakang dan contoh kasus nyata.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
rekap_test.go
Unduh berkaspackage 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 berkaspackage 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())
}