Goroutine

Kata kunci go, thread ringan, dan intuisi penjadwal.

Daftar isi

Topik ini membahas kata kunci go, yaitu cara Go memulai goroutine, lengkap dengan sifat goroutine yang ringan dan cara menunggu pekerjaannya selesai. Pada akhir topik, program dengan beberapa goroutine dapat ditulis sedemikian rupa sehingga hasil kerjanya terkumpul lengkap dan tercetak dengan urutan yang pasti. Pemahaman ini menjadi dasar bagi seluruh topik konkurensi pada modul ini.

Kata kunci go#

Kata kunci go memulai sebuah fungsi sebagai goroutine baru tanpa menunggu fungsinya selesai. Baris go piket(1, ...) kembali seketika, sedangkan badan fungsi piket berjalan bersamaan dengan sisa program. Argumennya dievaluasi segera pada saat pernyataan go dijalankan, sedangkan eksekusi badan fungsinya dijadwalkan runtime Go pada waktu yang tidak dapat dipastikan. Pernyataan ini tidak mengembalikan nilai apa pun, sehingga hasil kerja goroutine tidak dapat ditampung melalui nilai kembalian dan harus dikumpulkan lewat jalur lain.

Sebelum masuk ke mekanismenya, perlu dipastikan dahulu apa itu goroutine. Goroutine adalah unit eksekusi yang dikelola runtime Go, berbagi ruang alamat dengan goroutine lain di dalam proses yang sama, dan dijadwalkan tanpa campur tangan kernel pada pengalihan biasa. Berbagi ruang alamat membuat penyerahan data antar goroutine menjadi murah, tetapi sekaligus menuntut disiplin karena semua goroutine mampu menjangkau memori yang sama. Sebuah goroutine berhenti ketika fungsinya kembali. Tidak ada perintah untuk menghentikan goroutine dari luar, sehingga fungsi goroutine perlu dirancang agar selesai dengan sendirinya.

Program berikut memulai tiga goroutine piket, lalu mengumpulkan hasil kerjanya sebelum mencetak. Setiap goroutine menulis hasilnya pada elemen slice yang berbeda, sehingga tidak ada dua goroutine yang menulis pada memori yang sama. Fungsi main menunggu seluruh goroutine selesai dengan sync.WaitGroup, yaitu penghitung pustaka standar yang dirancang untuk mengoordinasikan penyelesaian goroutine.

go main.go
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func piket(n int, tugas, hasil []string, wg *sync.WaitGroup) {
	defer wg.Done()
	time.Sleep(50 * time.Millisecond)
	hasil[n] = tugas[n] + " selesai"
}

func main() {
	tugas := []string{"sapu lantai", "rapikan meja", "isi dispenser"}
	hasil := make([]string, len(tugas))

	var wg sync.WaitGroup
	for i := range tugas {
		wg.Add(1)
		go piket(i, tugas, hasil, &wg)
	}
	fmt.Println("goroutine yang sedang hidup:", runtime.NumGoroutine())

	wg.Wait()
	for _, h := range hasil {
		fmt.Println(h)
	}
	fmt.Println("goroutine yang tersisa:", runtime.NumGoroutine())
}
text Keluaran
goroutine yang sedang hidup: 4
sapu lantai selesai
rapikan meja selesai
isi dispenser selesai
goroutine yang tersisa: 1

Baris wg.Add(1) menaikkan penghitung sebelum goroutine dimulai, sedangkan defer wg.Done() menurunkannya pada saat goroutine selesai. Pemanggilan wg.Wait() menahan main sampai penghitung kembali nol, sehingga ketiga penulisan hasil sudah pasti selesai sebelum hasil dibaca. Fungsi runtime.NumGoroutine() melaporkan jumlah goroutine yang sedang hidup, dan pencetakannya aman karena dipanggil oleh main saja. Angka pertama adalah empat, yaitu main ditambah tiga goroutine piket yang sedang bekerja, sedangkan angka terakhir kembali satu setelah semuanya selesai.

Pencetakan sengaja dilakukan oleh main, bukan oleh goroutine. Urutan selesainya goroutine tidak terjamin, sehingga pencetakan langsung dari tiga goroutine dapat menghasilkan urutan baris yang berbeda pada setiap dijalankan. Dengan menampung hasil ke slice dan mencetaknya setelah wg.Wait(), keluaran program menjadi sama persis meskipun pekerjaannya berjalan bersamaan. Pola mengumpulkan lalu mencetak inilah disiplin yang dipakai sepanjang modul ini.

Goroutine yang ringan#

Goroutine dirancang untuk dibuat dalam jumlah besar. Memori awalnya hanya berukuran beberapa kilobyte dan bertumbuh sesuai kebutuhan, sedangkan thread sistem operasi membutuhkan memori yang jauh lebih besar dan biaya pembuatan yang jauh lebih mahal. Runtime Go menjadwalkan banyak goroutine bergantian di atas sedikit thread, sehingga jumlah goroutine tidak terikat jumlah inti prosesor. Program berikut memulai seratus goroutine sekaligus hanya untuk memperlihatkan betapa murahnya membuat goroutine.

Perbedaan itu dapat diurai dari sumber biayanya. Stack awal goroutine dialokasikan runtime Go dengan ukuran beberapa kilobyte dan menyusut atau bertumbuh mengikuti kebutuhan, sedangkan stack thread dialokasikan bersama kernel dan umumnya berukuran tetap berorde megabyte. Pembuatan thread menuntut panggilan ke kernel beserta sumber daya yang menyertainya, sedangkan pembuatan goroutine hanya berupa penambahan struktur kecil di dalam memori runtime. Pengalihan antar goroutine ditangani penjadwal di ruang pengguna dengan biaya berorde ratusan nanodetik, sedangkan pengalihan antar thread keluar masuk kernel dan berorde mikrodetik. Tabel berikut merangkum perbandingannya.

AspekGoroutineThread sistem operasi
Stack awalbeberapa kilobyte, bertumbuh sesuai kebutuhanumumnya berorde megabyte dan berukuran tetap
Pembuatanalokasi kecil oleh runtime Gopanggilan kernel dengan biaya lebih besar
Pengalihan konteksdi ruang pengguna, berorde ratusan nanodetikmelalui kernel, berorde mikrodetik
Jumlah yang lazimpuluhan ribu dalam satu prosespuluhan hingga ratusan per proses

Angka pada tabel berupa orde besaran yang bergantung pada arsitektur dan versi runtime, sehingga yang penting bukan angkanya yang persis, melainkan selisih orde di setiap barisnya. Selisih itulah yang membuat seribu goroutine dalam satu program terasa wajar, sedangkan seribu thread jarang menjadi pilihan desain. Biaya yang rendah mengubah gaya pemrograman, yaitu masalah dipecah menjadi banyak pekerjaan kecil yang masing-masing berjalan sebagai goroutine.

go seratus-goroutine.go
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			time.Sleep(50 * time.Millisecond)
		}()
	}
	fmt.Println("goroutine yang sedang hidup:", runtime.NumGoroutine())

	wg.Wait()
	fmt.Println("goroutine yang tersisa:", runtime.NumGoroutine())
}
text Keluaran
goroutine yang sedang hidup: 101
goroutine yang tersisa: 1

Angka 101 adalah main ditambah seratus goroutine yang sedang menunggu, sedangkan angka 1 dihitung setelah wg.Wait() kembali. Penjadwal menempatkan goroutine yang siap dijalankan pada antrean, lalu membagikannya ke thread yang tersedia. Goroutine yang sedang menunggu sesuatu, misalnya data dari jaringan, tidak menyandera threadnya, karena thread yang sama dipakai goroutine lain. Inilah alasan satu program Go dapat menangani puluhan ribu koneksi sekaligus dengan jumlah thread yang sedikit.

Sinkronisasi seratus goroutine itu ditopang oleh satu penghitung yang sama. Setiap putaran perulangan memanggil wg.Add(1) sebelum pernyataan go dijalankan, sedangkan setiap goroutine memanggil wg.Done() melalui defer pada saat fungsinya selesai. Pemanggilan wg.Wait() hanya kembali setelah penghitung mencapai nol, sehingga baris terakhir tidak mungkin tercetak sebelum seratus goroutine selesai. Bentuk sinkronisasi ini tidak bergantung pada jumlah, sehingga pola yang sama mengoordinasikan tiga goroutine maupun sepuluh ribu goroutine tanpa perubahan apa pun pada bentuk kodenya.

Jumlah goroutine yang benar-benar berjalan paralel dibatasi oleh pengaturan GOMAXPROCS, yang nilai bakunya mengikuti jumlah inti prosesor. Goroutine di atas batas itu tetap hidup dan tetap terjadwal, tetapi berjalan bergantian. Dengan demikian, menambah goroutine bukan cara mempercepat pekerjaan berhitung murni, sedangkan pekerjaan yang banyak menunggu justru cocok ditangani banyak goroutine sekaligus. Kedua keadaan itu sama-sama memanfaatkan goroutine yang murah untuk dibuat dan dibuang.

Intuisi penjadwal G-M-P#

Runtime Go menjadwalkan goroutine dengan model tiga pihak yang dikenal sebagai model G-M-P. G adalah goroutine itu sendiri beserta stack dan status eksekusinya. M adalah thread sistem operasi yang benar-benar menjalankan kode. P adalah prosesor logis, yaitu konteks penjadwalan yang memegang antrean goroutine miliknya. Sebuah M hanya dapat mengeksekusi G selama memegang P, dan jumlah P ditetapkan oleh GOMAXPROCS. Pemisahan tiga peran ini menjelaskan dua sifat yang terlihat pada contoh sebelumnya, yaitu goroutine yang dapat dibuat banyak dan thread yang tetap sedikit.

Setiap P mengerjakan antrean lokalnya, dan apabila antrean itu habis sementara antrean P lain masih penuh, P yang menganggur mengambil sebagian pekerjaan dari pihak lain. Mekanisme tersebut dikenal sebagai work stealing, yaitu pencurian pekerjaan antar antrean yang menjaga beban tetap tersebar tanpa satu pun antrean pusat. Goroutine yang menunggu sesuatu diletakkan terpisah dari antrean siap sehingga tidak menyandera M yang membawanya. Pemanggilan sistem yang memblokir memicu penyerahan P kepada M lain agar goroutine yang siap tetap berjalan.

Seluruh mekanisme ini tidak memiliki API untuk diatur secara langsung. Yang perlu dipegang hanyalah dua hal, yaitu goroutine dimulai dengan kata kunci go dan berakhir ketika fungsinya kembali. Pembagian pekerjaan ke thread adalah urusan penjadwal, bukan urusan penulis program. Dengan mental model G-M-P, goroutine yang murah dan jumlah thread yang sedikit bukan lagi perilaku yang membingungkan, melainkan akibat wajar dari pemisahan antara goroutine, thread, dan konteks penjadwalannya.

Kesalahan umum#

  • Menunggu goroutine dengan menambah jeda waktu. Pola go kerja() yang diikuti time.Sleep sering tampak berhasil, tetapi durasi yang selalu cukup tidak dapat dijamin. Tahan main dengan sync.WaitGroup agar penungguannya pasti.
  • Mengira urutan pemulutan sama dengan urutan penyelesaian. Goroutine yang dimulai lebih dulu dapat selesai lebih lambat karena penjadwalan dan bebannya berbeda. Kumpulkan hasilnya terlebih dahulu, lalu cetak dalam urutan yang ditentukan main.
  • Mengira banyak goroutine selalu mempercepat program. Pekerjaan berhitung murni dibatasi jumlah inti prosesor, sehingga seribu goroutine berhitung tidak lebih cepat daripada GOMAXPROCS goroutine berhitung. Banyak goroutine justru menguntungkan pekerjaan yang banyak menunggu.

Ringkasan#

  • Kata kunci go memulai fungsi sebagai goroutine, kembali seketika, dan tidak mengembalikan nilai.
  • Goroutine murah karena stack awalnya kecil, penjadwal G-M-P menjalankannya bergantian di atas sedikit thread sistem operasi, dan pengalihannya ditangani runtime di ruang pengguna.
  • sync.WaitGroup menahan main sampai seluruh goroutine selesai, sedangkan hasil kerjanya dikumpulkan sebelum dicetak.
  • Keluaran program konkuren menjadi pasti bila pencetakan dilakukan oleh satu goroutine setelah seluruh hasil terkumpul.

Referensi#