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
vyang 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
vterjadi 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.