Byte Ebi's Logo

Byte Ebi 🍀

A Bit everyday A Byte every week

[A Tour of Go Study Notes] 09 concurrency

Understanding Go concurrency using the official tutorial

Ray

Entering the world of Golang, we first explore basic Golang usage using the official tutorial: A Tour of Go .
This section introduces the powerful concurrency feature of Go.

Goroutine

Lightweight thread management operated by Go runtime.
Starting and executing a new Goroutine can be understood as creating a new Thread.

go f(x, y, z)

Using go followed by a function call enables the specified function f to run on a new Goroutine.

f, x, y, z are taken from the current Goroutine, and main also executes within the same thread.

In the example below, say("world") will start another execution sequence concurrently with the original one executing say("hello").

Usually, say("world") will execute first, followed by say("hello") upon completion.
However, if go is added before say("world"), changing it to go say("world"), it will initiate a new Goroutine while the original sequence continues with say("hello").

Both threads will run concurrently, and after the main Goroutine finishes execution, other Goroutines will be forcefully closed.

package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 5; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	go say("world")
	say("hello")
}
/*
>>> world
>>> hello
>>> world
>>> hello
>>> hello
>>> world
>>> world
>>> hello
>>> hello
*/

Channels

Channels are typed communications that use <- to send or receive values. They possess a blocking nature, enabling threads to wait.

Similar to maps and slices, channels must be created before use.

ch := make(chan int)

As channels function like pipelines, sending or receiving halts until the other end is ready.
They can be used for synchronizing data content among Goroutines.

ch <- v    // Send v to channel ch
v := <-ch  // Receive a value from ch and assign it to v

The following example demonstrates summing numbers in a slice, distributing the task among two Goroutines.
The final result is computed only when both Goroutines complete their calculations.

package main

import "fmt"

func sum(s []int, ch chan int) {
	sum := 0
	for _, v := range s {
		sum += v
	}
	ch <- sum // send sum to ch
}

func main() {
	s := []int{7, 2, 8, -9, 4, 0}

	ch := make(chan int)
	go sum(s[:len(s)/2], ch)
	go sum(s[len(s)/2:], ch)
	x, y := <-ch, <-ch // receive from ch

	fmt.Println(x, y, x+y)
}
/*
>>> -5 17 12
*/

Buffered Channels

Channels can have a buffer, where the buffer length is provided as the second argument during channel initialization.

Regular channels, as mentioned earlier, exhibit:

  • Blocking the sender when data is pushed without a receiver.
  • Blocking the receiver when pulling from an empty channel.

With buffering, blocking occurs only when the buffer is full.

In the following example, only after the 101st data is pushed does the sender Goroutine start waiting:

ch := make(chan int, 100)

The buffer length determines how many elements the channel can store.
Sending more elements than the buffer length leads to a deadlock.

package main

import "fmt"

func main() {
	ch := make(chan int, 1)
	ch <- 1
	ch <- 2
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}
/*
>>> fatal error: all goroutines are asleep - deadlock!
*/

No error occurs if values are retrieved.

package main

import "fmt"

func main() {
	ch := make(chan int, 1)
	ch <- 1
	fmt.Println(<-ch)
	ch <- 2
	fmt.Println(<-ch)
}
/*
>>> 1
>>> 2
*/

Range and Close

Senders can close a channel to signify no more values to be sent.
Receivers can test if a channel is closed using the second argument.

If there are no values to receive and the channel is closed, ok is set to false.

When a channel is iterated using a for loop like for i := range c, it continuously receives values until the channel is closed.

package main

import (
	"fmt"
)

func fibonacci(n int, c chan int) {
	x, y := 0, 1
	for i := 0; i < n; i++ {
		c <- x
		x, y = y, x+y
	}
	close(c)
}

func main() {
	c := make(chan int, 10)
	go fibonacci(cap(c), c)
	for i := range c {
		fmt.Println(i)
	}
}
/*
>>> 0
>>> 1
>>> 1
>>> 2
>>> 3
>>> 5
>>> 8
>>> 13
>>> 21
>>> 34
*/

Sending data to a closed channel will cause a panic.
Thus, the channel should be closed by the Goroutine that is sending data.

Additionally, channels differ from files; typically, they don’t need manual closing.
Closing is only necessary when it’s crucial to inform receivers that no new values will be sent, such as in a range loop.

Select

Using select, various scenarios involving channels can be handled, including handling during blocking.

select {
case i := <-c:
    // use i
default:
    // receiving from c would block
}

When none of the cases match, the default case executes.

package main

import (
	"fmt"
	"time"
)

func main() {
	tick := time.Tick(100 * time.Millisecond)
	boom := time.After(500 * time.Millisecond)
	for {
		select {
		case <-tick:
			fmt.Println("tick.")
		case <-boom:
			fmt.Println("BOOM!")
			return
		default:
			fmt.Println("    .")
			time.Sleep(50 * time.Millisecond)
		}
	}
}
/*
>>>     .
>>>     .
>>> tick.
>>>     .
>>>     .
>>> tick.
>>>     .
>>>     .
>>> tick.
>>>     .
>>>     .
>>> tick.
>>>     .
>>>     .
>>> tick.
>>> BOOM!
*/

sync.Mutex

Previously, we used channels to facilitate communication and data transfer between Goroutines. Due to their blocking

nature, when pulling data without any, it waits for other Goroutines to push data into the channel.

However, what if we don’t use channels and directly share variables among multiple Goroutines?

When multiple Goroutines share a variable and simultaneously operate on it, a Race Condition occurs, where ensuring the correctness of computation results becomes challenging.

This involves the concept of mutual exclusion, which can be handled using a Mutex to prevent conflicts.

Go provides a struct: sync.Mutex , offering two methods:

  • Lock
  • Unlock

Operations between Lock and Unlock make other Goroutines wait.

package main

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

// SafeCounter is safe to use concurrently.
type SafeCounter struct {
	mu sync.Mutex
	v  map[string]int
}

// Inc increments the counter for the given key.
func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	c.v[key]++
	c.mu.Unlock()
}

// Value returns the current value of the counter for the given key.
func (c *SafeCounter) Value(key string) int {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	defer c.mu.Unlock()
	return c.v[key]
}

func main() {
	c := SafeCounter{v: make(map[string]int)}
	for i := 0; i < 1000; i++ {
		go c.Inc("somekey")
	}

	time.Sleep(time.Second)
	fmt.Println(c.Value("somekey"))
}
/*
>>> 1000
*/

Recent Posts

Categories

Tags