Pendahuluan

Kondisi berpacu (race conditions) adalah salah satu dari kesalahan pemrograman yang berbahaya dan sukar ditangkap. Kesalahan ini biasanya menyebabkan kegagalan yang tidak menentu dan misterius, terkadang kegagalan ini muncul lama setelah kode berjalan. Walaupun mekanisme konkurensi Go membuat kita mudah menulis kode yang konkuren, tetapi ia tidak mencegah adanya kondisi berpacu. Perhatian, ketekunan, dan pengujian diperlukan. Dan perkakas yang tepat juga dapat membantu.

Kami dengan gembira memperkenalkan pendeteksi data race pada Go 1.1, sebuah perkakas baru untuk menemukan kondisi berpacu dalam kode Go. Sekarang ini tersedia pada sistem Linux, OS X, dan Windows dengan prosesor x86 64-bit.

Pendeteksi data race didasari oleh pustaka ThreadSanitizer dari C/C++, yang telah lama digunakan untuk mendeteksi banyak eror dalam basis kode internal Google dan Chromium. Teknologi ini diintegrasikan pada Go di bulan September 2012; sejak itu ia telah menangkap 42 kondisi berpacu dalam pustaka standar. Sekarang ia telah menjadi bagian dari proses pembangunan berkelanjutan, yang mana terus menangkap kondisi berpacu bila ia muncul.

Cara bekerja

Pendeteksi data race diintegrasikan dengan perkakas go. Saat opsi baris perintah -race di set, compiler membaca semua akses memori dalam kode dan mencatat kapan dan bagaimana memori tersebut diakses, sementara pustaka runtime membaca adanya akses yang tidak disinkronisasi ke variabel yang berbagi. Saat perilaku "berpacu" terdeteksi, sebuah peringatan dicetak. (Bacalah artikel berikut untuk memahami lebih rinci tentang bagaimana algoritma bekerja).

Pendeteksi data race dapat mendeteksi kondisi berpacu hanya saat dipicu oleh kode yang sedang berjalan, yang artinya sangatlah penting untuk menjalankan program dengan opsi -race telah dinyalakan sebelum digunakan di lingkungan kerja yang sebenarnya. Namun, program yang dibangun dengan -race dapat menggunakan CPU dan memori sepuluh kali lebih banyak, jadi tidak praktis untuk selalu menjalankan pendeteksi data race. Salah satu cara untuk mengatasi dilema ini yaitu dengan menjalankan beberapa tes dengan pendeteksi data race dinyalakan. Integrasi tes dan unit tes adalah kandidat yang bagus, secara mereka condong menggunakan bagian kode secara konkuren. Pendekatan lain yaitu dengan menjalankan program dengan pendeteksi data race bersamaan dengan beberapa program yang sama pada beberapa server yang berbeda.

Menggunakan pendeteksi data race

Pendeteksi data race terintegrasi dengan perkakas Go. Untuk membangun kode Anda dengan menyalakan pendeteksi data race, cukup tambahkan opsi -race pada baris perintah:

$ go test -race mypkg    // pengujian paket
$ go run -race mysrc.go  // kompilasi dan menjalankan program
$ go build -race mycmd   // pembangunan program
$ go install -race mypkg // pemasangan paket

Untuk mencoba sendiri pendeteksi data race, ambil dan jalankan contoh program berikut:

$ go get -race golang.org/x/blog/support/racy
$ racy

Contoh-contoh

Berikut dua contoh masalah dunia nyata yang ditangkap oleh pendeteksi data race.

Contoh 1: Timer.Reset

Contoh pertama yaitu versi sederhana dari kesalahan nyata yang ditemukan oleh pendeteksi data race. Program ini menggunakan sebuah time.Timer untuk mencetak sebuah pesan setelah durasi waktu acak antara 0 sampai 1 detik. Hal ini terjadi berulang kali selama 5 detik. Program ini menggunakan time.AfterFunc untuk membuat sebuah Timer untuk pesan yang pertama dan kemudian menggunakan method Reset untuk menjadwalkan pesan selanjutnya, supaya dapat menggunakan ulang variabel Timer yang sudah ada.

11 func main() {
12     start := time.Now()
13     var t *time.Timer
14     t = time.AfterFunc(randomDuration(), func() {
15         fmt.Println(time.Now().Sub(start))
16         t.Reset(randomDuration())
17     })
18     time.Sleep(5 * time.Second)
19 }
20
21 func randomDuration() time.Duration {
22     return time.Duration(rand.Int63n(1e9))
23 }

Kode tersebut tampak masuk akal, namun pada kondisi tertentu ia akan gagal:

panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x8 pc=0x41e38a]

goroutine 4 [running]:
time.stopTimer(0x8, 0x12fe6b35d9472d96)
    src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
    src/pkg/time/sleep.go:81 +0x42
main.funcĀ·001()
    race.go:14 +0xe3
created by time.goFunc
    src/pkg/time/sleep.go:122 +0x48

Apa yang terjadi? Menjalankan program dengan menyalakan pendeteksi data race akan tampak lebih jelas:

==================
WARNING: DATA RACE
Read by goroutine 5:
  main.funcĀ·001()
     race.go:16 +0x169

Previous write by goroutine 1:
  main.main()
      race.go:14 +0x174

Goroutine 5 (running) created at:
  time.goFunc()
      src/pkg/time/sleep.go:122 +0x56
  timerproc()
     src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
==================

Pendeteksi data race memperlihatkan masalahnya: pembacaan dan penulisan tanpa sinkronisasi pada variabel t dari goroutine yang berbeda. Jika durasi timer awal sangat kecil, fungsi timer bisa dipanggil sebelum main goroutine telah menyimpan nilai ke t sehingga pemanggilan t.Reset dilakukan pada t yang nil.

Untuk memperbaiki kondisi berpacu ini kita mengubah kode untuk membaca dan menulis variabel t hanya dari main goroutine:

11 func main() {
12     start := time.Now()
13     reset := make(chan bool)
14     var t *time.Timer
15     t = time.AfterFunc(randomDuration(), func() {
16         fmt.Println(time.Now().Sub(start))
17         reset <- true
18     })
19     for time.Since(start) < 5*time.Second {
20         <-reset
21         t.Reset(randomDuration())
22     }
23 }

Di sini, main goroutine sajalah yang bertanggung jawab men-set dan me-reset Timer t dan kanal reset yang baru mengkomunikasikan kebutuhan untuk mereset timer dengan cara yang aman.

Pendekatan lain yang lebih sederhana dan kurang efisien yaitu dengan menghindari menggunakan timer yang sama.

Contoh 2: ioutil.Discard

Contoh kedua lebih halus.

Paket ioutil memiliki objek Discard yang mengimplementasikan io.Writer, yang meniadakan semua data yang ditulis ke dalam objek tersebut. Seperti /dev/null: sebuah tempat mengirim data yang Anda bisa baca tapi tidak ingin disimpan. Objek Discard ini biasanya digunakan oleh io.Copy untuk mengosongkan pembaca, seperti ini:

io.Copy(ioutil.Discard, reader)

Pada bulan Juli 2011, time Go menyadari bahwa menggunakan Discard dengan cara ini tidak efisien: fungsi Copy mengalokasikan penyangga sebesar 32 kB setiap kali dipanggil, namun saat digunakan dengan Discard penyangga tersebut tidak dipakai secara kita hanya akan melempar data yang dibaca saja. Kita memikirkan bahwa penggunaan idiomatis dari Copy dan Discard ini seharusnya tidak terlalu membebankan.

Perbaikannya cukup sederhana. Jika Writer mengimplementasi method ReadFrom, sebuah pemanggilan Copy seperti berikut:

io.Copy(writer, reader)

didelegasikan ke pemanggilan yang lebih efisien:

writer.ReadFrom(reader)

Kita menambahkan method ReadFrom ke tipe Discard, yang memiliki penyangga internal yang dibagi dengan semua penggunanya. Kita tahu bahwa secara teori ini adalah kondisi berpacu, namun secara semua penulisan ke penyangga seharusnya langsung dibuang kami berpikir masalah kondisi berpacu di sini tidak begitu penting.

Saat pendeteksi data race diimplementasikan ia langsung menandakan kode tersebut sebagai "berpacu". Sekali lagi, kita menyadari bahwa kode tersebut bermasalah, namun memutuskan bahwa kondisi berpacu tersebut tidak "nyata". Untuk menghindari kondisi "positif salah" ini pada saat pembangunan, kita mengimplementasikan versi yang tidak berpacu yang dinyalakan hanya saat pendeteksi data race berjalan.

Akan tetapi beberapa bulan kemudian Brad menemui sebuah bug yang janggal dan menyebalkan. Setelah beberapa hari melakukan debug, dia menemukan kondisi berpacu yang nyata yang disebabkan oleh ioutil.Discard.

Berikut kode yang diketahui berpacu dalam io/ioutil, yang mana Discard adalah devNull yang berbagi sebuah penyangga tunggal dengan semua penggunanya.

var blackHole [4096]byte // shared buffer

func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
    readSize := 0
    for {
        readSize, err = r.Read(blackHole[:])
        n += int64(readSize)
        if err != nil {
            if err == io.EOF {
                return n, nil
            }
            return
        }
    }
}

Program Brad memiliki sebuah tipe trackDigestReader, yang membungkus sebuah io.Reader dan mencatat hash dari apa yang ia baca.

type trackDigestReader struct {
    r io.Reader
    h hash.Hash
}

func (t trackDigestReader) Read(p []byte) (n int, err error) {
    n, err = t.r.Read(p)
    t.h.Write(p[:n])
    return
}

Sebagai contoh, ia bisa digunakan untuk menghitung hash SHA-1 dari sebuah berkas saat membacanya:

tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))

Pada kasus-kasus tertentu data terkadang tidak perlu ditulis—tetapi hash masih diperlukan—maka Discard digunakan:

io.Copy(ioutil.Discard, tdr)

Namun pada kasus ini penyangga blackHole bukan hanya lubang hitam; ia adalah tempat untuk menyimpan data antara pembacaan dari sumber io.Reader dan penulisan ke hash.Hash. Saat beberapa goroutine mulai melakukan hash secara bersamaan, setiap goroutine akan berbagi penyangga blackHole yang sama, kondisi berpacu mulai timbul dengan mengkorupsi data antara pembacaan dan penulisan. Tidak ada eror atau panic yang terjadi, namun hash yang dihasilkan selalu salah.

func (t trackDigestReader) Read(p []byte) (n int, err error) {
    // penyangga p adalah blackHole
    n, err = t.r.Read(p)
    // p bisa dikorupsi oleh goroutine yang lain,
    // baik oleh Read di atas atau oleh  Write di bawah.
    t.h.Write(p[:n])
    return
}

Bug ini akhirnya diperbaiki dengan memberikan penyangga yang unik untuk setiap penggunaan ioutil.Discard, mengeliminasi kondisi berpacu pada penyangga yang berbagi.

Kesimpulan

Pendeteksi data race adalah perkakas yang tangguh untuk memeriksa ketepatan dari program yang konkuren. Ia tidak akan menimbulkan kondisi positif-salah, jadi perhatikan baik-baik peringatan yang dikeluarkan oleh pendeteksi ini. Namun ia hanya akan bekerja baik seperti halnya tes-tes Anda; Anda harus memastikan mereka benar-benar menggunakan properti konkuren dari kode Anda supaya pendeteksi data race dapat melakukan kerjanya dengan baik.

Apa yang Anda tunggu lagi? Jalankan "go test -race" pada kode Anda hari ini!