Concurrent Goroutine Synchronization

Beginner

This tutorial is from open-source community. Access the source code

Introduction

This lab aims to demonstrate how to use channels and goroutines to synchronize access to shared state across multiple goroutines.

Stateful Goroutines

In concurrent programming, it is essential to synchronize access to shared state to avoid race conditions and data corruption. This lab presents a scenario where a single goroutine owns the state, and other goroutines send messages to read or write the state.

  • Use channels to issue read and write requests to the state-owning goroutine.
  • Use readOp and writeOp structs to encapsulate requests and responses.
  • Use a map to store the state.
  • Use resp channels to indicate success and return values.
  • Use atomic package to count read and write operations.
  • Use time package to add a delay between operations.
## Running our program shows that the goroutine-based
## state management example completes about 80,000
## total operations.
$ go run stateful-goroutines.go
readOps: 71708
writeOps: 7177

## For this particular case the goroutine-based approach
## was a bit more involved than the mutex-based one. It
## might be useful in certain cases though, for example
## where you have other channels involved or when managing
## multiple such mutexes would be error-prone. You should
## use whichever approach feels most natural, especially
## with respect to understanding the correctness of your
## program.

There is the full code below:

// In the previous example we used explicit locking with
// [mutexes](mutexes) to synchronize access to shared state
// across multiple goroutines. Another option is to use the
// built-in synchronization features of  goroutines and
// channels to achieve the same result. This channel-based
// approach aligns with Go's ideas of sharing memory by
// communicating and having each piece of data owned
// by exactly 1 goroutine.

package main

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

// In this example our state will be owned by a single
// goroutine. This will guarantee that the data is never
// corrupted with concurrent access. In order to read or
// write that state, other goroutines will send messages
// to the owning goroutine and receive corresponding
// replies. These `readOp` and `writeOp` `struct`s
// encapsulate those requests and a way for the owning
// goroutine to respond.
type readOp struct {
    key  int
    resp chan int
}
type writeOp struct {
    key  int
    val  int
    resp chan bool
}

func main() {

    // As before we'll count how many operations we perform.
    var readOps uint64
    var writeOps uint64

    // The `reads` and `writes` channels will be used by
    // other goroutines to issue read and write requests,
    // respectively.
    reads := make(chan readOp)
    writes := make(chan writeOp)

    // Here is the goroutine that owns the `state`, which
    // is a map as in the previous example but now private
    // to the stateful goroutine. This goroutine repeatedly
    // selects on the `reads` and `writes` channels,
    // responding to requests as they arrive. A response
    // is executed by first performing the requested
    // operation and then sending a value on the response
    // channel `resp` to indicate success (and the desired
    // value in the case of `reads`).
    go func() {
        var state = make(map[int]int)
        for {
            select {
            case read := <-reads:
                read.resp <- state[read.key]
            case write := <-writes:
                state[write.key] = write.val
                write.resp <- true
            }
        }
    }()

    // This starts 100 goroutines to issue reads to the
    // state-owning goroutine via the `reads` channel.
    // Each read requires constructing a `readOp`, sending
    // it over the `reads` channel, and then receiving the
    // result over the provided `resp` channel.
    for r := 0; r < 100; r++ {
        go func() {
            for {
                read := readOp{
                    key:  rand.Intn(5),
                    resp: make(chan int)}
                reads <- read
                <-read.resp
                atomic.AddUint64(&readOps, 1)
                time.Sleep(time.Millisecond)
            }
        }()
    }

    // We start 10 writes as well, using a similar
    // approach.
    for w := 0; w < 10; w++ {
        go func() {
            for {
                write := writeOp{
                    key:  rand.Intn(5),
                    val:  rand.Intn(100),
                    resp: make(chan bool)}
                writes <- write
                <-write.resp
                atomic.AddUint64(&writeOps, 1)
                time.Sleep(time.Millisecond)
            }
        }()
    }

    // Let the goroutines work for a second.
    time.Sleep(time.Second)

    // Finally, capture and report the op counts.
    readOpsFinal := atomic.LoadUint64(&readOps)
    fmt.Println("readOps:", readOpsFinal)
    writeOpsFinal := atomic.LoadUint64(&writeOps)
    fmt.Println("writeOps:", writeOpsFinal)
}

Summary

This lab demonstrated how to use channels and goroutines to synchronize access to shared state. By having a single goroutine own the state and using channels to issue read and write requests, we can avoid race conditions and data corruption.