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:

  1. r tidak terjadi sebelum w.

  2. 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:

  1. w terjadi sebelum r.

  2. 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.