Demo Race Condition

Flag -race dan alasan berbagi data menuntut disiplin.

Daftar isi

Topik ini menunjukkan apa yang terjadi apabila dua goroutine mengubah satu variabel yang sama tanpa disiplin apa pun, lalu memperkenalkan flag -race sebagai alat untuk menangkap kejadian itu. Setelah membacanya, laporan detektor race dapat dibaca bagian demi bagian, dan alasan berbagi data menuntut sinkronisasi dapat dijelaskan dengan bukti eksekusi nyata.

Dua goroutine, satu penghitung#

Perhatikan program berikut. Dua goroutine sama-sama menambah satu variabel penghitung sebanyak seratus ribu kali, sehingga hasil yang wajar diharapkan adalah 200000. Program ini tidak mencetak apa pun, dan di situlah masalahnya bersembunyi, yaitu kerusakan yang terjadi tanpa satu pun pesan error.

go Program yang sengaja ber-race
package main

import "time"

var penghitung int

func tambahSeratusRibu() {
	for i := 0; i < 100000; i++ {
		penghitung++
	}
}

func main() {
	go tambahSeratusRibu()
	go tambahSeratusRibu()
	time.Sleep(200 * time.Millisecond)
}

Pernyataan penghitung++ bukan satu langkah tunggal, melainkan tiga langkah, yaitu membaca nilai, menambahkannya, lalu menulis kembali. Apabila dua goroutine membaca nilai yang sama sebelum salah satunya menulis, penambahan milik goroutine yang pertama hilang tertimpa. Kejadian semacam ini disebut race condition, yaitu hasil program yang bergantung pada urutan waktu antar goroutine, sedangkan sepasang akses yang tidak tersinkronisasi itu disebut data race.

Apa pun yang dilakukan terhadap variabel itu, program tetap tampak berjalan normal. Nilai akhirnya berubah-ubah dari satu percobaan ke percobaan lain dan biasanya lebih kecil dari 200000, tetapi tidak ada pesan error yang menunjuk pelakunya. Menambahkan fmt.Println(penghitung) di akhir fungsi main akan memperlihatkan nilai yang berbeda pada setiap dijalankan. Kerusakan yang diamati dengan mata sering terlambat ditemukan, sehingga pengujian membutuhkan alat bantu.

Akses yang dianggap race#

Detektor race bekerja dengan definisi yang ketat tentang akses yang dianggap berbahaya. Sepasang akses dilaporkan apabila tiga syarat terpenuhi sekaligus, yaitu dua goroutine mengakses lokasi memori yang sama, sekurang-kurangnya satu akses berupa penulisan, dan tidak ada hubungan urutan yang menghubungkan keduanya. Dua pembacaan pada lokasi yang sama tidak pernah dilaporkan, sedangkan penulisan yang bertemu pembacaan atau penulisan lain tanpa sinkronisasi berpeluang dilaporkan. Definisi ini berlaku pada lokasi memori, bukan pada nama variabel, sehingga dua penulisan pada elemen slice yang berbeda aman, sedangkan dua penulisan pada elemen yang sama berpotensi dilaporkan.

Lokasi memori yang dimaksud mencakup semua data yang dapat dijangkau lebih dari satu goroutine, misalnya variabel global seperti pada program di atas, field struct yang sama, elemen slice pada indeks yang sama, dan data yang dijangkau melalui pointer. Pengiriman nilai lewat channel, yakni jalur komunikasi bertipe antar goroutine, menyalin nilainya sehingga penerima memegang salinan yang tidak dibagi dengan pengirim. Sebaliknya, meneruskan pointer atau slice ke goroutine lain tanpa sinkronisasi hanya membagikan akses ke memori yang sama, sehingga risikonya ikut berpindah kepada penerima.

Hubungan urutan yang menyelamatkan sepasang akses dibangun oleh peralatan sinkronisasi pustaka standar, antara lain pengiriman dan penerimaan channel, penguncian dan pembukaan sync.Mutex, serta metode sync.WaitGroup. Sebagai contoh, penulisan hasil sebelum wg.Done() terlihat oleh pembacaan yang dilakukan setelah wg.Wait() kembali, karena kedua peristiwa itu terhubung oleh penghitung yang sama. Program pada bagian sebelumnya menangkap race justru karena tidak ada satu pun peralatan semacam ini di antara kedua goroutine-nya.

Flag -race dan laporannya#

Flag -race mengaktifkan detektor race (race detector), yaitu pemeriksa yang dijahit ke dalam program pada saat kompilasi dan memantau setiap akses memori yang tidak tersinkronisasi. Program yang sama dijalankan kembali di folder eksperimen, kali ini dengan flag tersebut. Alamat memori dan nomor ID goroutine pada laporan berbeda-beda antar percobaan, sehingga keduanya disaring menjadi 0xALAMAT dan goroutine N agar keluaran yang disajikan tetap dapat dibandingkan antar percobaan.

bash
# menyiapkan folder demo race
rm -rf /tmp/gorace
mkdir -p /tmp/gorace
cd /tmp/gorace
# menulis program yang sengaja ber-race
cat > main.go <<'EOF'
package main

import "time"

var penghitung int

func tambahSeratusRibu() {
	for i := 0; i < 100000; i++ {
		penghitung++
	}
}

func main() {
	go tambahSeratusRibu()
	go tambahSeratusRibu()
	time.Sleep(200 * time.Millisecond)
}
EOF
# menjalankan dengan detektor race, alamat dan ID goroutine disamarkan
go run -race main.go 2>&1 | sed -E 's/0x[0-9a-f]+/0xALAMAT/g; s/goroutine [0-9]+/goroutine N/g' | head -6
# menjaga status keluar blok tetap nol meskipun program diakhiri detektor
true
text Keluaran
==================
WARNING: DATA RACE
Read at 0xALAMAT by goroutine N:
  main.tambahSeratusRibu()
      /tmp/gorace/main.go:9 +0xALAMAT

Laporan itu dapat dibaca sebagai berikut. Baris Read at ... by goroutine N menunjukkan akses yang memicu laporan beserta jejak panggilannya, yaitu baris penghitung++ di fungsi tambahSeratusRibu. Bagian Previous write di bawahnya menunjukkan tulisan sebelumnya dari goroutine lain pada alamat yang sama, yang bersaing dengan pembacaan tadi. Laporan lengkapnya masih panjang, dan di ujungnya detektor menutup dengan jumlah race yang ditemukan, sedangkan program diakhiri dengan status keluar 66, bukan 0.

Keluaran di atas hanya enam baris pertama dari laporan yang jauh lebih panjang. Angka seratus ribu kali perulangan dipilih agar peluang tertangkapnya race mendekati pasti, karena setiap perulangan memberi detektor satu kesempatan tambahan. Pada pekerjaan yang singkat, race dapat lolos dari satu kali percobaan, tetapi mengandalkan keberuntungan bukanlah strategi pengujian.

Setengah berikutnya dari laporan memuat akses kedua, yang diawali baris Previous write at 0xALAMAT by goroutine N beserta jejak panggilannya, lalu keterangan tentang alamat tersebut, misalnya bahwa alamat itu berada pada variabel global. Bagian paling akhir berupa ringkasan Found 1 data race(s) yang menyatakan jumlah race yang tertangkap. Kedua jejak panggilan memuat nama berkas dan nomor baris, sehingga sepasang akses yang bersaing dapat ditemukan tanpa menebak. Apabila satu kali dijalankan menangkap lebih dari satu race, perbaiki satu race terlebih dahulu, lalu jalankan kembali programnya, karena laporan berikutnya dapat berubah setelah race pertama dihilangkan.

Batasan detektor race#

Detektor race mengamati eksekusi yang benar-benar terjadi, bukan menelaah kode secara statis. Akibatnya, laporan yang bersih pada satu kali dijalankan bukanlah sertifikat bebas race, melainkan bukti bahwa race tidak terjadi pada eksekusi itu. Race yang membutuhkan pengaturan waktu tertentu dapat lolos dari ratusan percobaan, lalu muncul pada percobaan berikutnya, misalnya setelah beban mesin berubah atau jalur kode baru ikut berjalan. Jalur kode yang tidak dieksekusi sama sekali juga tidak pernah diperiksa, sehingga pengujian yang jarang menyentuh cabang tertentu menyembunyikan race yang ada di sana.

Keterbatasan ini menentukan cara penggunaannya yang baik. Flag -race paling berguna ketika dipasang pada pengujian yang dijalankan berulang-ulang, karena setiap putaran pengujian memberi detektor kesempatan baru mengamati eksekusi yang berbeda. Beban kecepatan dan memorinya dapat ditoleransi pada lingkungan pengujian, sedangkan program yang melayani pengguna biasanya berjalan tanpa detektor. Satu hal yang menjadi kekuatannya adalah presisi, yaitu laporan yang muncul merujuk akses yang sungguh terjadi. Laporan race praktis selalu menunjuk masalah yang nyata dan layak diperbaiki, bukan peringatan palsu.

Disiplin berbagi data#

Laporan race selalu berujung pada dua pilihan perbaikan, yaitu mengunci variabel bersama atau mengalirkan datanya lewat channel, yang menjadi pokok bahasan sepanjang sisa modul ini. Penguncian dengan sync.Mutex dijelaskan pada modul Sinkronisasi dan Context. Keduanya membangun hubungan urutan antar goroutine, sehingga penulisan satu goroutine pasti terlihat oleh goroutine yang datang kemudian.

Hubungan urutan itu dalam istilah resmi Go disebut happens before, yaitu jaminan bahwa peristiwa yang selesai lebih dahulu terlihat oleh peristiwa yang mengikutinya. Detektor race bekerja dengan aturan yang sama, yaitu melaporkan sepasang akses pada alamat yang sama yang tidak dihubungkan oleh urutan apa pun. Bebas race bukan berarti tidak pernah berbagi data, melainkan berbagi data yang selalu berlangsung lewat jalur tersinkronisasi.

Bentuk kasar kedua jalur itu dapat digambarkan pada potongan berikut. Potongan ini bukan program utuh dan tidak dijalankan, melainkan sketsa dua arah yang memutus laporan race pada program penghitung di atas.

go Dua arah perbaikan atas penghitung yang ber-race
// tidak-dijalankan
func tambahDenganMutex() {
	mu.Lock()     // satu goroutine menahan kunci, goroutine lain menunggu
	penghitung++
	mu.Unlock()   // pembukaan kunci menyerahkan giliran kepada goroutine berikutnya
}

func tambahDenganChannel(keluar chan<- int) {
	total := 0    // berhitung pada variabel lokal, tidak dibagi sama sekali
	for i := 0; i < 100000; i++ {
		total++
	}
	keluar <- total // hasil akhir diserahkan kepada pemilik penghitung
}

Jalur pertama menjaga variabel bersama tetap ada, tetapi memastikan hanya satu goroutine yang menyentuhnya pada satu waktu. Jalur kedua menghilangkan variabel bersama itu sendiri, karena setiap goroutine berhitung pada variabel lokalnya lalu menyerahkan hasil akhirnya kepada satu pihak pemilik. Pilihan di antara keduanya mengikuti bentuk datanya, yaitu keadaan yang sering dibaca dan ditulis cocok dikunci, sedangkan aliran nilai dari satu tahap ke tahap berikutnya cocok dialirkan. Topik dasar channel pada modul ini memperlihatkan jalur kedua secara lengkap.

Cara menjalankanYang terlihat
go run main.goProgram selesai normal tanpa satu pun pesan
go run -race main.goLaporan race tercetak dan status keluar 66

Kesalahan umum#

  • Mengira detektor race memperbaiki program. Flag -race hanya melaporkan, sedangkan perbaikannya tetap berupa sinkronisasi yang ditulis sendiri oleh pemrogram.
  • Mengira hasil yang benar pada satu percobaan berarti bebas race. Race bergantung pada waktu, sehingga program dapat menunjukkan hasil benar berhari-hari lalu rusak tanpa perubahan kode.
  • Menganggap race sebagai masalah yang hanya terjadi di mesin cepat atau lambat. Race muncul di mana pun dua goroutine mengakses memori yang sama tanpa sinkronisasi, dan jumlah inti prosesor hanya memengaruhi seberapa sering ia terlihat.

Ringkasan#

  • Penambahan satu variabel dari dua goroutine tanpa sinkronisasi menghasilkan data race yang menghilangkan sebagian penambahan.
  • Flag -race mengaktifkan detektor race yang melaporkan sepasang akses tidak tersinkronisasi beserta jejak panggilannya.
  • Detektor race mengamati eksekusi yang benar-benar terjadi, sehingga laporan yang bersih bukan bukti bebas race secara mutlak, sedangkan laporan yang muncul merujuk akses yang nyata.
  • Program yang ber-race berakhir dengan status keluar 66 ketika dijalankan dengan -race, dan perbaikannya selalu berupa sinkronisasi, baik penguncian variabel bersama maupun pengaliran data lewat channel.

Referensi#