Dua goroutine yang membaca dan menulis memori yang sama tanpa sinkronisasi adalah bencana paling laris di program concurrent — bahayanya justru diam-diam: program berjalan, hasil tampak wajar, kesalahan muncul sesekali di beban tertinggi. Chapter ini membedah data race pada counter bersama, menangkapnya dengan race detector, membahas semantik loop variable sejak Go 1.22, lalu memperbaikinya dengan mutex dan atomic.

Apa Itu Data Race #

Data race terjadi bila tiga syarat terpenuhi sekaligus: dua goroutine mengakses memori yang sama, sedikitnya satu akses adalah penulisan, dan keduanya tidak tersinkronisasi lewat channel, mutex, atau atomic. Operator shared++ tampak tunggal tetapi sebenarnya tiga langkah — baca, tambah, tulis — sehingga dua goroutine bersamaan dapat membaca nilai sama dan saling menimpa hasilnya. Go memory model menyatakan program semacam itu berperilaku tidak terdefinisi.

Program yang Berlomba #

Berkas race-demo.go menghidupkan tiga syarat itu: delapan goroutine masing-masing menambah shared sebanyak 1250 kali — total seharusnya 10000 — tanpa proteksi:

go
package main

import (
	"sync"
)

func main() {
	var wg sync.WaitGroup
	shared := 0

	// Delapan goroutine menambah variabel yang sama tanpa proteksi.
	for range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1250 {
				shared++ // data race: baca dan tulis bersamaan
			}
		}()
	}

	wg.Wait()
}

Program ini sengaja tidak mencetak shared; nilainya berbeda setiap eksekusi — itulah gejalanya — dan kode keluar tetap 0 seolah sehat. Tambahkan dua baris berikut setelah wg.Wait() untuk melihatnya:

go
	// Tambahkan setelah wg.Wait() untuk melihat nilai shared:
	fmt.Println("nilai shared:", shared)

Lima eksekusi pada mesin kami menghasilkan 7082, 7794, 8174, 7329, dan sekali 10000. Nilai yang kurang adalah penambahan hilang: dua goroutine membaca nilai sama lalu saling menimpa tulisannya. Nilai tepat 10000 pun bukan tanda aman — hanya kebetulan waktu scheduler.

Mendeteksi dengan Race Detector #

Flag -race mengompilasi program dengan perekam bayangan (shadow memory) yang mencatat setiap akses memori dan melaporkan pasangan yang bertentangan. Jalankan berkas yang sama dengan flag itu:

bash
go run -race race-demo.go

Output yang Dihasilkan:

text
==================
WARNING: DATA RACE
Read at 0x00c000018128 by goroutine 11:
  main.main.func1()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:17 +0x99

Previous write at 0x00c000018128 by goroutine 8:
  main.main.func1()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:17 +0xab

Goroutine 11 (running) created at:
  main.main()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:14 +0x7d

Goroutine 8 (running) created at:
  main.main()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:14 +0x7d
==================
==================
WARNING: DATA RACE
Write at 0x00c000018128 by goroutine 10:
  main.main.func1()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:17 +0xab

Previous write at 0x00c000018128 by goroutine 11:
  main.main.func1()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:17 +0xab

Goroutine 10 (running) created at:
  main.main()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:14 +0x7d

Goroutine 11 (running) created at:
  main.main()
      /home/z/my-project/content/go/concurrency/race-condition/race-demo.go:14 +0x7d
==================
Found 2 data race(s)
exit status 66

Anatomi laporan: WARNING: DATA RACE membuka laporan; Read at ... by goroutine 11 menunjukkan akses baca beserta pelakunya; Previous write at ... menunjukkan penulisan sebelumnya pada alamat sama; dua blok created at menunjukkan baris kelahiran goroutine — semuanya menunjuk race-demo.go:17, tepat baris shared++; exit status 66 adalah kode khusus race detector.

Untuk proyek dengan berkas test, jalankan go test -race ./... agar seluruh suite diperiksa — layak dipasang di CI. Detector menaikkan waktu eksekusi dan memori beberapa kali lipat, jadi gunakan saat pengembangan, bukan build produksi.

Semantik Loop Variable Sejak Go 1.22 #

Jebakan klasik lain adalah loop variable. Sebelum Go 1.22, semua iterasi for berbagi satu variabel, sehingga goroutine yang menangkap i sering melihat nilai iterasi terakhir:

go
// Sebelum Go 1.22: satu variabel i dibagi semua iterasi,
// goroutine di bawah sering melihat nilai 9 (iterasi terakhir).
for i := 0; i < 10; i++ {
	go func() { fmt.Print(i) }()
}
// Sejak Go 1.22: setiap iterasi memiliki salinan i sendiri,
// sehingga goroutine melihat nilai iterasinya masing-masing.

Perbaikan ini menghapus satu kategori bug, tetapi jangan tertukar: loop variable aman tidak menyelamatkan state bersama — shared tetap berlomba tanpa terlibat penangkapan loop.

Perbaikan dengan Mutex #

Solusi paling umum: kunci akses state bersama. Berkas race-safe-demo.go memindahkan counter ke struct dengan sync.Mutex:

go
package main

import (
	"fmt"
	"sync"
)

// CounterAman melindungi field nilai dengan sync.Mutex,
// sehingga hanya satu goroutine yang boleh mengubahnya pada satu waktu.
type CounterAman struct {
	mu    sync.Mutex
	nilai int
}

func (c *CounterAman) Tambah() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.nilai++
}

func (c *CounterAman) Nilai() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.nilai
}

func main() {
	var wg sync.WaitGroup
	counter := &CounterAman{}

	for range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1250 {
				counter.Tambah()
			}
		}()
	}

	wg.Wait()
	fmt.Println("nilai counter:", counter.Nilai())
}

Jalankan program tersebut dari direktori chapter:

bash
go run race-safe-demo.go

Output yang Dihasilkan:

text
nilai counter: 10000

Mari kita telaah baris demi baris program di atas:

  1. Baris 10–13 (CounterAman): struct counter berfield nilai dan mu — mutex menjadi bagian data yang dilindunginya.
  2. Baris 15–19 (Tambah): Lock merebut kunci, defer Unlock memastikan kunci terlepas, dan nilai++ kini berjalan di wilayah yang hanya boleh dimasuki satu goroutine.
  3. Baris 21–25 (Nilai): pembacaan juga dikunci agar tidak berlomba dengan penulisan.
  4. Baris 31–39: delapan goroutine sama persis dengan versi berlomba — hanya counter.Tambah() yang kini aman.
  5. Baris 41–42: setelah wg.Wait(), counter terbaca 10000 — benar di setiap eksekusi.

Kami menjalankan ulang versi ini dengan flag -race: hasilnya tetap nilai counter: 10000 tanpa laporan WARNING dan kode keluar 0 — konflik memori lenyap. Untuk counter murni, atomic dari chapter Atomic Operation lebih murah:

go
var counter atomic.Int64

counter.Add(1) // penambahan aman tanpa mutex

Mutex tetap tepat saat beberapa field harus berubah konsisten bersamaan; atomic hanya mengikat satu operasi.

Kerusakan terakhir concurrency tanpa disiplin bukan di data, melainkan goroutine yang tertinggal hidup. Chapter penutup section ini memburu goroutine leak sampai ke akarnya.

Referensi #

Berkas Sumber

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

race-demo.go

Unduh berkas
go
package main

import (
	"sync"
)

func main() {
	var wg sync.WaitGroup
	shared := 0

	// Delapan goroutine menambah variabel yang sama tanpa proteksi.
	for range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1250 {
				shared++ // data race: baca dan tulis bersamaan
			}
		}()
	}

	wg.Wait()
}

race-safe-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"sync"
)

// CounterAman melindungi field nilai dengan sync.Mutex,
// sehingga hanya satu goroutine yang boleh mengubahnya pada satu waktu.
type CounterAman struct {
	mu    sync.Mutex
	nilai int
}

func (c *CounterAman) Tambah() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.nilai++
}

func (c *CounterAman) Nilai() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.nilai
}

func main() {
	var wg sync.WaitGroup
	counter := &CounterAman{}

	for range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1250 {
				counter.Tambah()
			}
		}()
	}

	wg.Wait()
	fmt.Println("nilai counter:", counter.Nilai())
}