Intro to Concurrency in Golang

·

3 min read

When dealing with conccurency problem, it is harder to reason about when moving down the stack of abstraction (machine, process, thread, hardware components, etc). Most programming languages use thread as its highest level of abstraction. Fortunately, Go build on top of that & introduce Goroutine.

“Share memory by communicating, don’t communicate by sharing memory.” - One of Go's mottos

Although Golang provides traditional locking mechanism in sync package, its philosophy prefers “share memory by communicating”. Therefore, Golang introduces channel as the medium for Goroutines to communicate with each other.

//goroutine
go func() { 
    // do some work
}()

//channel
dataStream := make(chan interface{})

Fork-join model

Fork-join concurrency model that Go follows

Fork-join concurrency model that Go follows

This concurrency model is used in Golang. At anytime; a child Goroutine can be fork to do concurrent work with its parent & then will join back at some point.

Every Go program has a main Goroutine. The main one can be exit earlier than its children; as a result, a join point is needed to make sure children Goroutine has chance to finish.

Channel

Channel holds the following properties:

  • Goroutine-safe (Multiple Goroutines can access to shared channel without race condition)
  • FIFO queue semantics

Channel always return 2 value: 1 is object returned, 1 is status (true means valid object, false means no more values will be sent in this channel)

intStream := make(chan int)
close(intStream)
integer, ok := <- intStream 
fmt.Printf("(%v): %v", ok, integer) // (false): 0

Channel direction

var dataStream <-chan interface{} //read from only channel
var dataStream chan<- interface{} // write to only channel
var dataStream chan interface{} // 2 ways

Channel capacity

Default capacity of a channel is 0 (unbuffered channel). Reading from empty or writing to full channel is blocking.

c := make(chan int,10) //buffered size of 10
c := make(chan int) //unbuffered channel

if a buffered channel is empty and has a receiver, the buffer will be bypassed and the value will be passed directly from the sender to the receiver.

Channel behavior against Goroutine

When a Goroutine read from or write to a channel, various of behaviours might happen depending on channel state.

intStream := make(chan int) // 0 capacity channel

go func() {
    defer close(intStream) 
    for i:=1; i<=5; i++{
      intStream <- i
  }
}()

//range from a channel
for integer := range intStream { // always blocked until the channel is closed
    fmt.Printf("%v ", integer)
}

As the behavior is complex, we should have a way to make a robust and scalable program.

Robust & scalable way when working with channel

Here is 1 suggestion way:

  • At most 1 Goroutine have the ownership of a channel. The channel ownership should be small to be managable.
  • Channel owner have a write-access (chan←);
  • While consumer only have read-only view (←chan).

  • Channel owners

    Responsibilities:

    • Init channel
    • Do write to channel or pass ownership to another goroutine
    • Close channel
    • Encapsulate & expose channel as a reader channel
  • Channel consumer

    Responsibilites:

    • Handle when channel is closed
    • Handle blocking behaviour when reading from channel
chanOwner := func() <-chan int { 
    resultStream := make(chan int, 5) //init
    go func() {
        defer close(resultStream) // close
        for i:=0;i<=5;i++{ 
            resultStream <- i // write
    } }()
    return resultStream // read channel returned
}

resultStream := chanOwner()
for result := range resultStream {
  fmt.Printf("Received: %d\n", result)
}
fmt.Println("Done receiving!")

References:

Concurrency in Go: Tools and Techniques for Developers - Book by Katherine Cox-Buday