1. Peran mekanisme sinkronisasi

Dalam pemrograman konkuren, ketika beberapa goroutine berbagi sumber daya, sangat penting untuk memastikan bahwa sumber daya hanya dapat diakses oleh satu goroutine pada satu waktu untuk mencegah kondisi perlombaan. Hal ini memerlukan penggunaan mekanisme sinkronisasi. Mekanisme sinkronisasi dapat mengkoordinasikan urutan akses dari berbagai goroutine ke sumber daya bersama, memastikan konsistensi data dan sinkronisasi status dalam lingkungan konkuren.

Bahasa Go menyediakan kumpulan mekanisme sinkronisasi yang kaya, termasuk tetapi tidak terbatas pada:

  • Mutexes (sync.Mutex) dan mutex baca-tulis (sync.RWMutex)
  • Saluran (Channels)
  • WaitGroups
  • Fungsi atomik (paket atomic)
  • Variabel kondisi (sync.Cond)

2. Primitif sinkronisasi

2.1 Mutex (sync.Mutex)

2.1.1 Konsep dan peran mutex

Mutex adalah mekanisme sinkronisasi yang memastikan operasi aman dari sumber daya bersama dengan memungkinkan hanya satu goroutine untuk memegang kunci untuk mengakses sumber daya bersama pada suatu saat. Mutex mencapai sinkronisasi melalui metode Lock dan Unlock. Memanggil metode Lock akan memblokir hingga kunci dilepaskan, dan pada saat ini, goroutine lain yang mencoba untuk memperoleh kunci akan menunggu. Memanggil Unlock melepaskan kunci, memungkinkan goroutine lain yang menunggu untuk memperolehnya.

var mu sync.Mutex

func criticalSection() {
    // Peroleh kunci untuk mengakses sumber daya secara eksklusif
    mu.Lock()
    // Akses sumber daya bersama di sini
    // ...
    // Lepaskan kunci untuk memungkinkan goroutine lain untuk memperolehnya
    mu.Unlock()
}

2.1.2 Penggunaan praktis dari mutex

Misalkan kita perlu mempertahankan penghitung global, dan beberapa goroutine perlu menambahkan nilainya. Dengan menggunakan mutex, dapat menjamin ketepatan penghitung.

var (
    mu      sync.Mutex
    counter int
)

func increment() {
    mu.Lock()         // Kunci sebelum memodifikasi penghitung
    counter++         // Secara aman tambahkan nilai penghitung
    mu.Unlock()       // Lepas kunci setelah operasi, memungkinkan goroutine lain untuk mengakses penghitung
}

func main() {
    for i := 0; i < 10; i++ {
        go increment()  // Memulai beberapa goroutine untuk menambahkan nilai penghitung
    }
    // Tunggu beberapa waktu (dalam praktik, seharusnya menggunakan WaitGroup atau metode lain untuk menunggu semua goroutine selesai)
    time.Sleep(1 * time.Second)
    fmt.Println(counter)  // Output nilai penghitung
}

2.2 Mutex Baca-Tulis (sync.RWMutex)

2.2.1 Konsep dari mutex baca-tulis

RWMutex adalah jenis kunci khusus yang memungkinkan beberapa goroutine untuk membaca sumber daya bersama secara bersamaan, sementara operasi tulis adalah eksklusif. Dibandingkan dengan mutex, kunci baca-tulis dapat meningkatkan kinerja dalam skenario multi-pembaca. Ini memiliki empat metode: RLock, RUnlock untuk mengunci dan membuka kunci operasi baca, dan Lock, Unlock untuk mengunci dan membuka kunci operasi tulis.

2.2.2 Kasus Penggunaan Praktis dari Mutex Baca-Tulis

Dalam aplikasi basis data, operasi baca mungkin jauh lebih sering daripada operasi tulis. Menggunakan kunci baca-tulis dapat meningkatkan kinerja sistem karena memungkinkan beberapa goroutine untuk membaca secara bersamaan.

var (
    rwMu  sync.RWMutex
    data  int
)

func readData() int {
    rwMu.RLock()         // Peroleh kunci baca, memungkinkan operasi baca lain untuk berlanjut secara bersamaan
    defer rwMu.RUnlock() // Pastikan kunci dilepaskan menggunakan defer
    return data          // Baca data dengan aman
}

func writeData(newValue int) {
    rwMu.Lock()          // Peroleh kunci tulis, mencegah operasi baca atau tulis lain pada saat ini
    data = newValue      // Menulis nilai baru dengan aman
    rwMu.Unlock()        // Lepaskan setelah menulis selesai
}

func main() {
    go writeData(42)     // Memulai goroutine untuk melakukan operasi tulis
    fmt.Println(readData()) // Goroutine utama melakukan operasi baca
    // Gunakan WaitGroup atau metode sinkronisasi lain untuk memastikan semua goroutine selesai
}

Pada contoh di atas, beberapa pembaca dapat menjalankan fungsi readData secara bersamaan, tetapi penulis yang menjalankan writeData akan memblokir pembaca baru dan penulis lainnya. Mekanisme ini memberikan keuntungan kinerja untuk skenario dengan lebih banyak operasi baca daripada operasi tulis.

2.3 Variabel Kondisi (sync.Cond)

2.3.1 Konsep Variabel Kondisional

Dalam mekanisme sinkronisasi bahasa Go, variabel kondisional digunakan untuk menunggu atau memberi tahu perubahan kondisi sebagai primitif sinkronisasi. Variabel kondisional selalu digunakan bersama dengan mutex (sync.Mutex), yang digunakan untuk melindungi konsistensi kondisi itu sendiri.

Konsep variabel kondisional berasal dari domain sistem operasi, memungkinkan sekelompok goroutine untuk menunggu sampai suatu kondisi terpenuhi. Lebih spesifik lagi, sebuah goroutine dapat menghentikan eksekusi saat menunggu suatu kondisi terpenuhi, dan goroutine lain dapat memberi tahu goroutine lainnya untuk melanjutkan eksekusi setelah mengubah kondisi menggunakan variabel kondisional.

Dalam pustaka standar Go, variabel kondisional disediakan melalui tipe sync.Cond, dan metode utamanya meliputi:

  • Wait: Memanggil metode ini akan melepaskan kunci yang dipegang dan menghalangi sampai goroutine lain memanggil Signal atau Broadcast pada variabel kondisional yang sama untuk membangunkannya, setelah itu akan mencoba untuk memperoleh kunci lagi.
  • Signal: Membangunkan satu goroutine yang menunggu variabel kondisional ini. Jika tidak ada goroutine yang menunggu, memanggil metode ini tidak akan berpengaruh.
  • Broadcast: Membangunkan semua goroutine yang menunggu variabel kondisional ini.

Variabel kondisional sebaiknya tidak disalin, sehingga umumnya digunakan sebagai bidang penunjuk dari suatu struktur tertentu.

2.3.2 Kasus Praktis dari Variabel Kondisional

Berikut adalah contoh penggunaan variabel kondisional yang menunjukkan model produsen-konsumen sederhana:

package main

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

// SafeQueue adalah antrian aman yang dilindungi oleh mutex
type SafeQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    queue []interface{}
}

// Enqueue menambahkan elemen ke akhir antrian dan memberitahukan goroutine yang menunggu
func (sq *SafeQueue) Enqueue(item interface{}) {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    sq.queue = append(sq.queue, item)
    sq.cond.Signal() // Memberitahu goroutine yang menunggu bahwa antriannya tidak kosong
}

// Dequeue menghapus elemen dari awal antrian, menunggu jika antriannya kosong
func (sq *SafeQueue) Dequeue() interface{} {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    // Menunggu saat antrian kosong
    for len(sq.queue) == 0 {
        sq.cond.Wait() // Menunggu perubahan kondisi
    }

    item := sq.queue[0]
    sq.queue = sq.queue[1:]
    return item
}

func main() {
    queue := make([]interface{}, 0)
    sq := SafeQueue{
        mu:    sync.Mutex{},
        cond:  sync.NewCond(&sync.Mutex{}),
        queue: queue,
    }

    // Goroutine Produsen
    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(1 * time.Second)         // Simulasi waktu produksi
            sq.Enqueue(fmt.Sprintf("item%d", i)) // Memproduksi suatu elemen
            fmt.Println("Produksi:", i)
        }
    }()

    // Goroutine Konsumen
    go func() {
        for i := 0; i < 5; i++ {
            item := sq.Dequeue() // Mengkonsumsi suatu elemen, menunggu jika antrian kosong
            fmt.Printf("Konsumsi: %v\n", item)
        }
    }()

    // Tunggu cukup waktu untuk memastikan semua produksi dan konsumsi selesai
    time.Sleep(10 * time.Second)
}

Dalam contoh ini, kita telah mendefinisikan struktur SafeQueue dengan antrian internal dan variabel kondisional. Saat konsumen memanggil metode Dequeue dan antrian kosong, ia menunggu menggunakan metode Wait. Saat produsen memanggil metode Enqueue untuk memasukkan elemen baru, ia menggunakan metode Signal untuk membangunkan konsumen yang menunggu.

2.4 WaitGroup

2.4.1 Konsep dan Penggunaan WaitGroup

sync.WaitGroup adalah mekanisme sinkronisasi yang digunakan untuk menunggu kelompok goroutine menyelesaikan. Ketika Anda memulai goroutine, Anda dapat meningkatkan penghitung dengan memanggil metode Add, dan setiap goroutine dapat memanggil metode Done (yang sebenarnya melakukan Add(-1)) saat selesai. Goroutine utama dapat memblokir dengan memanggil metode Wait sampai penghitung mencapai 0, menandakan bahwa semua goroutine telah menyelesaikan tugasnya.

Ketika menggunakan WaitGroup, berikut adalah beberapa hal yang perlu diperhatikan:

  • Metode Add, Done, dan Wait tidak aman secara benang dan sebaiknya tidak dipanggil secara bersamaan dalam beberapa goroutine.
  • Metode Add sebaiknya dipanggil sebelum goroutine yang baru dibuat dimulai.

2.4.2 Kasus Penggunaan Praktis WaitGroup

Berikut adalah contoh penggunaan WaitGroup:

package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Memberitahukan WaitGroup setelah selesai

	fmt.Printf("Pekerja %d mulai\n", id)
	time.Sleep(time.Second) // Mensimulasikan operasi yang memakan waktu
	fmt.Printf("Pekerja %d selesai\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // Menambahkan counter sebelum menjalankan goroutine
		go worker(i, &wg)
	}

	wg.Wait() // Tunggu semua goroutine pekerja selesai
	fmt.Println("Semua pekerja selesai")
}

Pada contoh ini, fungsi worker mensimulasikan pelaksanaan tugas. Di dalam fungsi utama, kita memulai lima goroutine worker. Sebelum menjalankan setiap goroutine, kita memanggil wg.Add(1) untuk memberitahukan WaitGroup bahwa tugas baru sedang dijalankan. Ketika setiap fungsi pekerja selesai, ia memanggil defer wg.Done() untuk memberitahukan WaitGroup bahwa tugas tersebut selesai. Setelah menjalankan semua goroutine, fungsi utama memblokir pada wg.Wait() hingga semua pekerja melaporkan selesai.

2.5 Operasi Atomic (sync/atomic)

2.5.1 Konsep Operasi Atomic

Operasi atomic merujuk pada operasi dalam pemrograman konkuren yang tidak dapat dipisahkan, artinya operasi tersebut tidak terganggu oleh operasi lain selama eksekusi. Untuk beberapa goroutine, menggunakan operasi atomic dapat memastikan konsistensi data dan sinkronisasi status tanpa perlu mengunci, karena operasi atomic sendiri menjamin atomisitas eksekusi.

Di dalam bahasa Go, paket sync/atomic menyediakan operasi memori atomik pada tingkat rendah. Untuk tipe data dasar seperti int32, int64, uint32, uint64, uintptr, dan pointer, metode paket sync/atomic dapat digunakan untuk operasi konkuren yang aman. Pentingnya operasi atomic terletak pada menjadi batu fondasi untuk membangun primitif konkuren lainnya (seperti kunci dan variabel kondisi) dan seringkali lebih efisien daripada mekanisme penguncian.

2.5.2 Kasus Penggunaan Praktis Operasi Atomic

Bayangkan sebuah skenario di mana kita perlu melacak jumlah pengunjung secara konkuren pada sebuah situs web. Dengan menggunakan variabel hitungan sederhana secara intuitif, kita akan menambahkan hitungan ketika seorang pengunjung datang dan menguranginya ketika seorang pengunjung pergi. Namun, dalam lingkungan konkuren, pendekatan ini akan menyebabkan race condition pada data. Oleh karena itu, kita dapat menggunakan paket sync/atomic untuk memanipulasi hitungan dengan aman.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var jumlahPengunjung int32

func tambahJumlahPengunjung() {
	atomic.AddInt32(&jumlahPengunjung, 1)
}

func kurangiJumlahPengunjung() {
	atomic.AddInt32(&jumlahPengunjung, -1)
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			tambahJumlahPengunjung()
			time.Sleep(time.Second) // Waktu kunjungan pengunjung
			kurangiJumlahPengunjung()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("Jumlah pengunjung saat ini: %d\n", jumlahPengunjung)
}

Pada contoh ini, kita membuat 100 goroutine untuk mensimulasikan kedatangan dan kepergian pengunjung. Dengan menggunakan fungsi atomic.AddInt32(), kita memastikan bahwa penambahan dan pengurangan hitungan bersifat atomik, bahkan dalam situasi konkuren yang sangat tinggi, sehingga memastikan ketepatan jumlahPengunjung.

2.6 Mekanisme Sinkronisasi Channel

2.6.1 Karakteristik Sinkronisasi Channel

Channel adalah cara bagi goroutine untuk berkomunikasi dalam bahasa Go pada level bahasa. Sebuah channel menyediakan kemampuan untuk mengirim dan menerima data. Ketika sebuah goroutine mencoba membaca data dari channel dan channel tidak memiliki data, ia akan diblokir hingga ada data yang tersedia. Demikian pula, jika channel penuh (untuk channel yang tidak berbuffer, ini berarti sudah ada data), goroutine yang mencoba mengirim data juga akan diblokir hingga ada ruang untuk menulis. Fitur ini membuat channel sangat berguna untuk sinkronisasi antara goroutine.

2.6.2 Kasus Penggunaan Sinkronisasi dengan Channel

Misalkan kita memiliki sebuah tugas yang perlu diselesaikan oleh beberapa goroutine, masing-masing menangani sub-tugas, dan kemudian kita perlu menggabungkan hasil dari semua sub-tugas. Kita dapat menggunakan channel untuk menunggu semua goroutine selesai.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
    defer wg.Done()
    // Melakukan beberapa operasi...
    fmt.Printf("Pekerja %d mulai\n", id)
    // Diasumsikan hasil sub-tugas adalah id pekerja
    resultChan <- id
    fmt.Printf("Pekerja %d selesai\n", id)
}

func main() {
    var wg sync.WaitGroup
    numWorkers := 5
    resultChan := make(chan int, numWorkers)

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, &wg, resultChan)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    // Mengumpulkan semua hasil
    for result := range resultChan {
        fmt.Printf("Menerima hasil: %d\n", result)
    }
}

Pada contoh ini, kita memulai 5 goroutine untuk menyelesaikan tugas dan mengumpulkan hasil melalui channel resultChan. Goroutine utama menunggu semua pekerjaan selesai di goroutine terpisah dan kemudian menutup channel hasil. Setelah itu, goroutine utama menelusuri channel resultChan, mengumpulkan dan mencetak hasil dari semua goroutine.

2.7 Eksekusi Sekali (sync.Once)

sync.Once adalah primitif sinkronisasi yang memastikan operasi hanya dieksekusi sekali selama eksekusi program. Penggunaan umum dari sync.Once adalah dalam inisialisasi objek tunggal atau dalam skenario yang memerlukan inisialisasi tertunda. Terlepas dari berapa banyak goroutine yang memanggil operasi ini, itu akan hanya dijalankan sekali, oleh karena itu disebut fungsi Do.

sync.Once sempurna seimbang antara masalah konkurensi dan efisiensi eksekusi, menghilangkan kekhawatiran tentang masalah kinerja yang disebabkan oleh inisialisasi ulang.

Sebagai contoh sederhana untuk menunjukkan penggunaan sync.Once:

package main

import (
    "fmt"
    "sync"
)

var once sync.Once
var instance *Singleton

type Singleton struct{}

func Instance() *Singleton {
    once.Do(func() {
        fmt.Println("Membuat instance tunggal sekarang.")
        instance = &Singleton{}
    })
    return instance
}

func main() {
    for i := 0; i < 10; i++ {
        go Instance()
    }
    fmt.Scanln() // Tunggu untuk melihat output
}

Pada contoh ini, bahkan jika fungsi Instance dipanggil secara konkuren beberapa kali, pembuatan instance Singleton akan terjadi hanya sekali. Panggilan selanjutnya akan langsung mengembalikan instance singleton yang dibuat pertama kali, memastikan keunikan instance.

2.8 ErrGroup

ErrGroup adalah sebuah library dalam bahasa Go yang digunakan untuk menyinkronkan multiple goroutine dan mengumpulkan kesalahan mereka. Ini merupakan bagian dari paket "golang.org/x/sync/errgroup", menyediakan cara yang ringkas untuk menangani skenario kesalahan pada operasi konkuren.

2.8.1 Konsep dari ErrGroup

Idea inti dari ErrGroup adalah mengikat sekelompok tugas terkait (biasanya dieksekusi secara konkuren) bersama, dan jika salah satu tugas gagal, eksekusi seluruh grup akan dibatalkan. Pada saat yang sama, jika salah satu dari operasi konkuren ini mengembalikan kesalahan, ErrGroup akan menangkap dan mengembalikan kesalahan tersebut.

Untuk menggunakan ErrGroup, pertama-tama impor paket tersebut:

import "golang.org/x/sync/errgroup"

Kemudian, buat sebuah instance dari ErrGroup:

var g errgroup.Group

Setelah itu, Anda dapat meneruskan tugas-tugas ke ErrGroup dalam bentuk closure dan memulai Goroutine baru dengan memanggil metode Go:

g.Go(func() error {
    // Melakukan sebuah tugas tertentu
    // Jika semuanya berjalan lancar
    return nil
    // Jika terjadi kesalahan
    // return fmt.Errorf("terjadi kesalahan")
})

Terakhir, panggil metode Wait, yang akan memblokir dan menunggu semua tugas selesai. Jika salah satu dari tugas ini mengembalikan kesalahan, Wait akan mengembalikan kesalahan tersebut:

if err := g.Wait(); err != nil {
    // Tangani kesalahan
    log.Fatalf("Kesalahan eksekusi tugas: %v", err)
}

2.8.2 Kasus Praktis dari ErrGroup

Mari kita pertimbangkan sebuah skenario di mana kita perlu untuk secara bersamaan mengambil data dari tiga sumber data yang berbeda, dan jika salah satu sumber data gagal, kita ingin segera membatalkan operasi pengambilan data lainnya. Tugas ini dapat dengan mudah dicapai menggunakan ErrGroup:

package main

import (
    "fmt"
    "golang.org/x/sync/errgroup"
)

func fetchDataFromSource1() error {
    // Menyimulasikan pengambilan data dari sumber 1
    return nil // atau mengembalikan error untuk mensimulasikan kegagalan
}

func fetchDataFromSource2() error {
    // Menyimulasikan pengambilan data dari sumber 2
    return nil // atau mengembalikan error untuk mensimulasikan kegagalan
}

func fetchDataFromSource3() error {
    // Menyimulasikan pengambilan data dari sumber 3
    return nil // atau mengembalikan error untuk mensimulasikan kegagalan
}

func main() {
    var g errgroup.Group

    g.Go(fetchDataFromSource1)
    g.Go(fetchDataFromSource2)
    g.Go(fetchDataFromSource3)

    // Tunggu semua goroutine untuk selesai dan kumpulkan error mereka
    if err := g.Wait(); err != nil {
        fmt.Printf("Error terjadi saat pengambilan data: %v\n", err)
        return
    }

    fmt.Println("Semua data berhasil diambil!")
}

Dalam contoh ini, fungsi fetchDataFromSource1, fetchDataFromSource2, dan fetchDataFromSource3 mensimulasikan pengambilan data dari sumber data yang berbeda. Mereka diteruskan ke metode g.Go dan dieksekusi dalam Gorutin terpisah. Jika salah satu fungsi mengembalikan error, g.Wait akan segera mengembalikan error tersebut, memungkinkan penanganan error yang sesuai ketika error terjadi. Jika semua fungsi dieksekusi dengan sukses, g.Wait akan mengembalikan nil, menandakan bahwa semua tugas telah selesai dengan sukses.

Fitur penting lainnya dari ErrGroup adalah bahwa jika salah satu Gorutin mengalami panic, ErrGroup akan mencoba untuk pulih dari panic tersebut dan mengembalikannya sebagai error. Hal ini membantu mencegah Gorutin lain yang sedang berjalan secara bersamaan agar tidak gagal dalam menutup dengan baik. Tentu saja, jika Anda ingin tugas-tugas merespons sinyal pembatalan eksternal, Anda dapat menggabungkan fungsi WithContext dari errgroup dengan paket konteks untuk menyediakan konteks yang dapat dibatalkan.

Dengan cara ini, ErrGroup menjadi mekanisme sinkronisasi dan penanganan error yang sangat praktis dalam praktik pemrograman konkuren Go.