Pendahuluan
Model memori pada Go menspesifikasikan kondisi-kondisi di mana pembacaan sebuah variabel dalam satu goroutine dijamin mendapatkan nilai yang dihasilkan oleh penulisan ke variabel yang sama pada goroutine yang berbeda.
Petunjuk
Program yang mengubah data yang diakses secara simultan oleh beberapa goroutine harus membuat akses tersebut secara serial.
Supaya akses tersebut serial, lindungi data dengan operasi kanal (channel) atau model sinkronisasi primitif lainnya seperti yang ada dalam paket sync dan sync/atomic.
Terjadi-sebelum
Dalam sebuah goroutine, pembacaan dan penulisan harus dieksekusi dengan urutan
yang dispesifikasikan oleh program.
Compiler dan prosesor bisa saja mengubah urutan eksekusi pembacaan dan
penulisan dalam sebuah goroutine hanya bila pengurutan tersebut tidak mengubah
perilaku pada goroutine tersebut seperti yang didefinisikan oleh spesifikasi
bahasa.
Karena pengubahan urutan ini, urutan eksekusi yang diobservasi oleh sebuah
goroutine bisa berbeda dengan yang diobservasi oleh goroutine yang lain.
Sebagai contoh, jika salah satu goroutine mengeksekusi a = 1; b = 2;
,
goroutine yang lain bisa saja membaca nilai b
yang telah diperbarui sebelum
nilai a
diisi.
Untuk menentukan kebutuhan-kebutuhan dari pembacaan dan penulisan, kita
mendefinisikan terjadi-sebelum, sebuah bagian pengurutan eksekusi dari
operasi memori dalam sebuah program Go.
Jika kejadian e1
terjadi sebelum kejadian e2
, maka kita bisa katakan
bahwa e2
terjadi setelah e1
.
Dan juga, jika e1
tidak terjadi sebelum e2
dan tidak setelah e2
,
maka kita katakan bahwa e1
dan e2
terjadi secara konkuren.
Dalam sebuah goroutine, urutan terjadi-sebelum adalah urutan yang diekspresikan oleh program.
Sebuah pembacaan r pada sebuah variabel v
dibolehkan mengobservasi
penulisan w ke v
jika kondisi-kondisi berikut terpenuhi:
-
r tidak terjadi sebelum w.
-
Tidak ada penulisan lain w’ terhadap
v
yang terjadi setelah w tetapi sebelum r.
Untuk menjamin bahwa sebuah pembacaan r dari variabel v
mengobservasi
sebuah penulisan w ke v
, pastikan bahwa w adalah satu-satunya
penulisan yang mana r dibolehkan mengobservasi.
Dengan kata lain, r dijamin mengobservasi w jika kondisi-kondisi
berikut terpenuhi:
-
w terjadi sebelum r.
-
Penulisan lainnya ke variabel
v
terjadi sebelum w atau setelah r.
Pasangan kondisi ini lebih kuat dari pasangan sebelumnya; ia membutuhkan kondisi yang mana tidak ada penulisan lain terjadi secara konkuren dengan w atau r.
Dalam sebuah goroutine, tidak ada konkurensi, jadi kedua definisi berikut
adalah sama: sebuah pembacaan r mengobservasi nilai yang ditulis oleh
penulisan terakhir w ke v
.
Saat beberapa goroutine mengakses variabel berbagi v
, mereka harus
menggunakan kejadian sinkronisasi untuk mendapatkan kondisi terjadi-sebelum
yang memastikan pembacaan mengobservasi penulisan yang diinginkan.
Inisialisasi variabel v
dengan nilai kosong, sesuai dengan tipe dari v
,
berlaku seperti penulisan dalam model memori.
Pembacaan dan penulisan nilai yang lebih besar dari ukuran word pada sebuah mesin berjalan seperti operasi-operasi pada banyak mesin dengan urutan yang tidak ditentukan. (red: Misal, pada mesin x86-64 dengan ukuran word adalah 64-bit, maka pembacaan atau penulisan nilai yang lebih dari 64-bit akan menyebabkan operasi yang belum tentu berurutan).
Sinkronisasi
Inisialisasi
Inisialisasi program berjalan dalam sebuah goroutine, namun goroutine tersebut bisa saja membuat goroutine yang lain, yang berjalan secara konkuren.
Jika sebuah paket p mengimpor paket q, maka fungsi init
pada q akan
berakhir sebelum init
pada p dimulai.
Mulainya fungsi main.main
terjadi setelah semua fungsi init
telah
selesai.
Pembuatan goroutine
Perintah go
, yang memulai sebuah goroutine yang baru, terjadi sebelum
eksekusi goroutine dimulai.
Artinya, sebuah goroutine memiliki dua perintah: perintah go
dan diikuti
dengan fungsi yang akan dieksekusi.
Perintah go
itu sendiri berjalan dan selesai, terjadi-sebelum fungsi yang
akan dieksekusi dimulai.
Sebagai contoh, pada program berikut:
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
Memanggil fungsi hello
akan mencetak "hello, world" pada suatu saat di masa
depan (kemungkinan setelah hello
selesai).
Destruksi goroutine
Selesainya sebuah goroutine tidak dijamin selalu terjadi sebelum event apa pun dalam program. Artinya, tidak ada kejadian yang memberitahu bahwa sebuah goroutine itu selesai atau belum.
Contohnya, dalam program berikut:
var a string func hello() { go func() { a = "hello" }() print(a) }
Penempatan nilai ke a
tidak diikuti oleh sinkronisasi, jadi tidak
menjamin diobservasi oleh goroutine yang lain.
Compiler yang agresif bisa saja menghapus perintah go
tersebut.
Jika efek dari sebuah goroutine harus diobservasi oleh goroutine yang lain, gunakan mekanisme sinkronisasi seperti sebuah pengunci (lock) atau komunikasi dengan kanal untuk memastikan urutan yang relatif.
Komunikasi dengan kanal
Komunikasi kanal yaitu metode utama sinkronisasi antara goroutine. Setiap pengiriman pada sebuah kanal sama dengan penerimaan dari kanal tersebut, biasanya dalam goroutine yang berbeda.
Sebuah pengiriman ke sebuah kanal terjadi-sebelum penerimaan dari kanal tersebut selesai.
Program berikut:
var c = make(chan int, 10) var a string func f() { a = "hello, world" c <- 0 } func main() { go f() <-c print(a) }
dijamin mencetak "hello, world".
Penulisan ke a
terjadi sebelum pengiriman pada c
, yang terjadi sebelum
penerimaan pada c
selesai, yang terjadi sebelum pencetakan.
Ditutupnya sebuah kanal terjadi sebelum sebuah penerimaan yang mengembalikan nilai kosong, sebuah kejadian yang disebabkan karena kanal telah ditutup.
Pada contoh sebelumnya, mengganti c <- 0
dengan close(c)
menghasilkan
sebuah program yang dijamin berjalan sama.
Menerima sebuah nilai pada kanal tanpa-penyangga terjadi sebelum pengiriman sebuah nilai pada kanal tersebut selesai.
Program berikut (sama seperti program di atas, namun dengan perintah pengiriman dan penerimaan yang di balik dan menggunakan kanal tanpa-penyangga):
var c = make(chan int) var a string func f() { a = "hello, world" <-c } func main() { go f() c <- 0 print(a) }
juga menjamin mencetak "hello, world".
Penulisan ke a
terjadi sebelum penerimaan pada c
, yang terjadi sebelum
pengiriman ke c
selesai, yang terjadi sebelum pencetakan.
Jika kanal tersebut memiliki penyangga (misalnya, c = make(chan int, 1)
) maka
program tersebut tidak menjamin pencetakan "hello, world".
(Program bisa saja mencetak string kosong, crash, atau melakukan hal
lainnya.)
Penerima ke-k pada kanal dengan kapasitas C terjadi sebelum pengiriman k+C pada kanal tersebut selesai.
Aturan ini menggeneralisasi aturan sebelumnya tentang kanal dengan-penyangga. Aturan ini membolehkan penghitungan sinyal (counting semaphore) menggunakan model sebuah kanal dengan-penyangga: jumlah item di dalam kanal berkorespondensi dengan jumlah penggunaan aktif, kapasitas dari kanal berkorespondensi dengan jumlah maksimum dari penggunaan secara simultan, mengirim sebuah item berarti menangkap sinyal, dan menerima sebuah item berarti melepas sinyal. Cara ini adalah idiom umum untuk membatasi konkurensi.
Program berikut menjalankan sebuah goroutine untuk setiap item dalam daftar
work
, tetapi goroutine tersebut berkoordinasi menggunakan kanal limit
untuk memastikan paling banyak tiga fungsi yang bekerja pada satu waktu.
var limit = make(chan int, 3) func main() { for _, w := range work { go func(w func()) { limit <- 1 w() <-limit }(w) } select{} }
Penguncian (lock)
Paket sync
memiliki dua tipe data untuk penguncian, sync.Mutex
dan
sync.RWMutex
.
Untuk setiap sync.Mutex
atau sync.RWMutex
pada variabel l
dengan
n < m
, pemanggilan ke-n
dari l.Unlock()
terjadi sebelum pemanggilan
ke-m
dari l.Lock()
selesai.
Program berikut:
var l sync.Mutex var a string func f() { a = "hello, world" l.Unlock() } func main() { l.Lock() go f() l.Lock() print(a) }
dijamin mencetak "hello, world".
Pemanggilan l.Unlock()
yang pertama (dalam fungsi f
) terjadi sebelum
pemanggilan kedua dari l.Lock()
(dalam fungsi main
) selesai, yang terjadi
sebelum pencetakan.
Untuk setiap pemanggilan ke l.RLock
pada sebuah sync.RWMutext
pada
variabel l
, ada sebuah n
yang mana l.RLock
terjadi (selesai) setelah
pemanggilan ke-n
pada l.Unlock
dan l.RUnlock
terjadi sebelum pemanggilan
ke-n+1
terhadap l.Lock
.
Once
Paket sync
menyediakan sebuah mekanisme aman untuk inisialisasi dalam
beberapa goroutine lewat penggunaan type Once
.
Beberapa thread dapat mengeksekusi once.Do(f)
untuk fungsi f
, namun
hanya satu thread yang akan menjalankan fungsi f()
, dan pemanggilan lainnya
ditahan sampai f()
tersebut selesai.
Sebuah pemanggilan f()
dari once.Do(f)
terjadi (selesai) sebelum ada
pemanggilan lain dari once.Do(f)
selesai.
Pada program berikut:
var a string var once sync.Once func setup() { a = "hello, world" } func doprint() { once.Do(setup) print(a) } func twoprint() { go doprint() go doprint() }
pemanggilan twoprint
akan memanggil fungsi setup
hanya sekali.
Fungsi setup
akan selesai sebelum pemanggilan ke print
.
Hasilnya adalah "hello, world" akan dicetak dua kali.
Sinkronisasi yang salah
Ingatlah bahwa sebuah pembacaan r bisa mengobservasi nilai yang ditulis oleh penulisan w yang terjadi secara konkuren dengan r. Walaupun hal ini terjadi, bukan berarti pembacaan yang terjadi setelah r akan mengobservasi penulisan yang terjadi sebelum w.
Pada program berikut:
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() }
bisa saja g
mencetak 2 kemudian 0.
Fakta ini menyalahkan beberapa idiom umum.
Penguncian dengan pemeriksaan-ganda adalah salah satu cara untuk menghindari
sinkronisasi berlebihan.
Misalnya, program twoprint
bisa saja ditulis dengan cara yang keliru seperti
berikut:
var a string var done bool func setup() { a = "hello, world" done = true } func doprint() { if !done { once.Do(setup) } print(a) } func twoprint() { go doprint() go doprint() }
tetapi tidak ada yang menjamin bahwa, dalam doprint
, memeriksa penulisan ke
done
berarti telah menulis nilai a
.
Versi ini bisa saja (secara keliru) mencetak sebuah string kosong bukan
"hello, world".
Salah satu idiom keliru lainnya yaitu sibuk menunggu sebuah nilai, seperti:
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) }
Seperti sebelumnya, tidak ada yang menjamin, dalam main
, penulisan ke done
berarti selesainya penulisan ke a
, sehingga program tersebut bisa saja
mencetak sebuah string kosong juga.
Lebih parah lagi, tidak ada yang menjamin penulisan ke done
akan dibaca oleh
main
, secara tidak ada kejadian sinkronisasi antara kedua thread.
Pengulangan pada main
tidak dijamin akan berakhir.
Ada beberapa variasi lain dari contoh di atas, seperti program berikut.
type T struct { msg string } var g *T func setup() { t := new(T) t.msg = "hello, world" g = t } func main() { go setup() for g == nil { } print(g.msg) }
Bahkan bila main
membaca g != nil
dan pengulangan berakhir, tidak ada yang
menjamin bahwa ia akan menerima nilai untuk g.msg
.
Di semua contoh tersebut, semua solusi sama: gunakan sinkronisasi secara eksplisit.