This article introduces the concepts, usage and issues to keep in mind for concurrent programming in Go.

As we said before, Go is a procedural oriented language. The default program starts with the main function, executes the code line by line, and can only use one core of the CPU. As an example.

1
2
3
4
files := []string{"a.txt", "b.txt", "c.txt"}
for _, f := range files {
  Compress(f)
}

Assume that Compress will compress the files according to the path passed in. The loop above will automatically compress all the files saved in files, but only one at a time. Later files must wait for the previous ones to finish compressing before they can be started. Considering that compression operations are time consuming and that today’s CPUs are multi-core, it would definitely be less time consuming to use multi-core concurrent compression. This effect can be achieved in Go language using Goroutine.

1
2
3
4
files := []string{"a.txt", "b.txt", "c.txt"}
for _, f := range files {
  go Compress(f) // Starting Goroutine
}

Starting a Goroutine in Go is very simple. All you need is the go keyword in front of the calling function. The above code will start three Goroutines in a loop, each running the Compress function independently of the other. Generally different Goroutines will use different CPU cores, so the whole compression process can be executed concurrently.

The above is an example of a simulation that cannot be run directly. I’ll use a simpler example to illustrate what to look for when using a Goroutine.

1
2
3
4
5
6
7
8
9
package main

func main() {
  for i := 0; i < 10; i++ {
    go func() {
      fmt.Println("hello")
    }()
  }
}

Here func(){}() is a function convention, meaning that an anonymous function is declared and executed immediately. The preceding go keyword means that the anonymous function is declared and executed immediately in the new Goroutine. The above example creates ten Goroutines in bulk and outputs the hello string in each Goroutine.

If you run this program, you will see that the program does not output anything and exits directly. The root cause of this problem is that the main function exits and the whole program ends, so the Goroutine created before has no chance to execute and no output is possible.

To solve this problem, the main function, after creating a Goroutine, needs to wait for all Goroutines to finish before exiting, and cannot exit first by itself. This waiting between Goroutines is also called Goroutine synchronization, and the Go language provides a special data type for Goroutine synchronization called channel.

A channel is somewhat similar to a slice, we can think of it as a message queue, we need to assign a type to the message, we can put the message into the queue, and we can get the message from the queue. The declaration syntax of a channel is as follows.

1
2
3
var ch1 chan int // Only int data can be put in the queue
ch1 = make(chan int)
ch2 := make(chan string, 4)

Declaring a channel uses the chan keyword, followed by the type of the message. Channels are similar to dictionaries in that they need to be initialized after declaration, otherwise they cannot be used. So ch1 also needs to be initialized with make(chan int).

We can additionally specify a second parameter when creating a channel with make to indicate the buffer length of the queue. If not specified, the default length is zero. We’ll talk about the use of buffers later.

Once we have the channel variables, we can write data to them or read data from them.

1
2
ch1 <- 1    // write
v := <- ch1 // read

The arrow operator <- is used here, and the arrow indicates the direction of data flow. ch1 <- 1 indicates the data flow to the channel, and <- ch1 indicates the data flow out of the channel. v := <- ch1 means that a piece of data is raised from ch1 and saved to the variable v.

If you write the above two lines of code to the main function and run it, the program will simply exit with an exception.

1
fatal error: all goroutines are asleep - deadlock!

The solution is simple: change ch1 to make(chan int, 1), which means to set a buffer for it.

For channels without buffers, if a Goroutine wants to write data, the Goroutine suspends execution. It will not resume until another Goroutine wants to read a message from that channel. This pause is also called a hang. This kind of hang is bidirectional. If a Goroutine tries to read a message from ch1, but no other Goroutine tries to write at that moment, the Goroutine will also be suspended. The channel is the link between the two Goroutines here, and both Goroutines must read and write at the same time to be able to do so.

Back to the above example, we first try to write a message to ch1, then the runtime will hang the Goroutine where the main function is located, because the subsequent read operation is also in the main function, so no Goroutine can read from ch1, so the main function will be hung forever. This is called a deadlock, where you deadlock with yourself.

Usually the program does not exit after a deadlock occurs. It’s hard to tell at runtime whether a deadlock has occurred or not. But in our example, there is only one Goroutine, the one running the main function, and it hangs, which means that a deadlock must have occurred, so it exits abnormally. But in a real production environment, deadlock problems are hard to find. So you should be careful when writing concurrent code.

If you change ch1 to make(chan int, 1), it will have a buffer. This time ch1 <- 1 will not hang the current Goroutine, and then the v := <- ch1 on the face will be executed normally.

With the channel as a tool, we can solve the problem mentioned earlier. The modification code is as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

func main() {
  ch := make(chan int, 10)
  for i := 0; i < 10; i++ {
    go func() {
      fmt.Println("hello")
      ch <- 1
    }()
  }
  for i := 0; i < 10; i++ {
    <- ch
  }
}

We try to read the message from ch at the end of the main function, ten times in total. Each Goroutine writes a message at the end of its execution. If not all 10 Goroutines are executed, the read operation will hang the Goroutine where the main function is located.

Run the modified code and you will see the program output ten lines of hello . In the above example, you can also use an unbuffered channel. The unbuffered channel requires both read and write operations, so the main function will only exit with a Goroutine for each message read. The buffered version does not have this restriction, so the open Goroutine will exit directly after it finishes its work, regardless of the Goroutine where the main function is located. But in any case, without synchronization, the order of execution of concurrent Goroutines is indeterminate.

Let’s modify the above code slightly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

func main() {
  ch := make(chan int, 10)
  for i := 0; i < 10; i++ {
    go func(id int) {
      fmt.Println("hello", id)
      ch <- 1
    }(i)
  }
  for i := 0; i < 10; i++ {
    <- ch
  }
}

We add a sequential number to each Goroutine, pass it in through the parameters of the anonymous function, and print it out at runtime. Execute it a few more times, and you will see that the order of execution is different each time, which confirms the judgment just made. So, Goroutines are not in a controllable order when they run concurrently!

In practice, we often read channels in a loop, so Go supports the use of the range keyword to achieve the effect of “iterating” over channels. The for loop at the end of the main function can be rewritten as follows

1
2
3
for v := range ch {
  fmt.Println(v)
}

Note that you don’t need to specify the arrow operator when using range! The most real-world example is the timer. If we want to output a message at regular intervals, we can use the channels provided by the time standard library.

1
2
3
4
5
6
ch := time.Tick(3 * time.Second)
go func() {
  for t := range ch {
    fmt.Println(t)
  }
}()

time.Tick(3*time.Second) will return a chan time.Time channel from which a time object can be read every three seconds. The above code creates a single Goroutine that keeps reading time from the timer channel. As long as the main function does not exit, the Goroutine will keep printing the time of the timer trigger.

But the question arises, when does the above cycle end? The answer is that it never ends. If there is no more content in the channel, Goroutine will be hung while reading. But sometimes we want to inform the corresponding Goroutine that there are no more messages, that the job is done and that it can exit.

There are several ways to notify Goroutine of an exit. The simplest is to close the corresponding channel.

1
2
3
4
5
6
7
8
ch := make(chan int)
go func() {
  time.Sleep(1*time.Second)
  close(ch)
}()
for v := range ch {
  fmt.Println(v)
}

After the channel is closed, the for loop receives a signal and ends the loop. However, this approach has a side effect.

1
v := <-ch

If the message is read with the arrow operator, the code will still receive a value when the channel is closed, only this time it will be a zero value. The last value of v in this example is 0. To distinguish between the normal message zero value and the closed zero value, the Go language supports an additional read syntax.

1
v, ok := <-ch

The above read will also return when the channel is closed and ok will be set to false. The program can determine if the channel has been closed by checking the ok variable.

In addition to closing the channel, we can use a separate control channel to notify the Goroutine to exit. For example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ch1 := make(chan int)
ch2 := make(chan int)

go func() {
  for {
    v := <-ch1 
    fmt.Println(v)
    if v := <-ch2 {
      return
    }
  }
}()

This program reads data from ch1 in the Goroutine and then prints it out. It tries to read a message from ch2 before continuing the loop. If there is a message, the job is done and it can exit. One problem with this program is that if there are no messages in ch1, then the Goroutine gets stuck on the line v := <-ch1. At this point, the outside world cannot shut down the Goroutine by signaling through ch2, because the Goroutine is hung.

The core of the problem is that reading messages from a channel in the same Goroutine must be executed sequentially. We want to be able to wait for messages from multiple channels at the same time, either ch1 or ch2, and wake up the Goroutine to continue execution as soon as a message is available. This feature requires the select keyword.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
go func() {
  for {
    select {
    case v := <-ch1:
      fmt.Println(v)
    case <-ch2:
      return
    }
  }
}()

Because of select, whenever there is a new message in any of the ch1 and ch2 channels, the processing goroutine will be woken up. select also supports the default branch, which corresponds to a scenario where all case branches have no new messages, and is less used by business code, so beginners can just remember that there is such a thing. Learn later as you go.

In addition to using channels to synchronize Goroutine, Go also provides the official WaitGroup object, which the previous example can be rewritten as follows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import "sync"

func main() {
  var wg sync.WaitGroup
  for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      fmt.Println("hello")
    }()
  }
  wg.Wait()
}

After declaring wg, wg.Add(1) is called once for each Goroutine created, indicating that it has to wait for one more Goroutine. main function finally calls wg.Wait() to wait for all Goroutines to finish. After each Goroutine finishes, wg.Done() needs to be called. The wg.Wait() function will return after all Goroutines are structured, and the whole program will exit.

The new code uses defer wg.Done() to call the function, which ensures that the wg.Done() function will always be executed at the end of each Goroutine. If you don’t use defer and the Goroutine generates a panic during execution, the corresponding wg.Done() function may not be executed, so the main function will wait, resulting in a deadlock.

This is where we basically finish learning about Goroutine synchronization. Next we will learn another important topic, concurrency safety.

Concurrency safety means that multiple Goroutines are competing to read and write the same variable. A typical example of this is the channel mentioned earlier, where different Goroutines may read and write to the channel at the same time.

Because Goroutines execute concurrently, the concept of concurrency safety also comes into play. If a variable of a certain type supports multiple Goroutines reading and writing at the same time, we call it concurrently safe. Channel types are concurrency-safe. But almost all types are not concurrently safe unless otherwise specified. If multiple Goroutines read and write to a variable at the same time, it can cause a panic and the program to exit (yes, this is the simple case); in the worst case, it can destroy the data, but the program continues to run in an erroneous way, and by the time it is discovered, it is difficult to deal with it.

As an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
  a := 0
  g := sync.WaitGroup{}
  for i := 0; i < 10000; i++ {
    g.Add(1)
    go func() {
      defer g.Done()
      a++
    }()
  }
  g.Wait()
  fmt.Println(a)
}

The program declares a variable a and then starts 10,000 Goroutines to add 1 to that variable. Run it a few times and you will see that the result is not always 10,000. Run it yourself and see for yourself.

The root cause of this phenomenon is that all Goroutines operate on one variable a at the same time. Because the execution order and time of Goroutines are not fixed, when one Goroutine changes a from 0 to 1, there is a possibility that another Goroutine does not get the latest value of a 1, but still writes back to the memory corresponding to a again and again on top of 0, which may produce wrong results.

The easiest way is to use locks, where the Goroutine acquires the lock before updating a, and the other Goroutines have to wait for the current Goroutine to update because it is impossible to acquire the lock at the same time. After the current Goroutine is updated, you need to release the lock so that the other Goroutines can continue to try to get the lock and update the value of a.

The modified code is as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
  a := 0
  m := sync.Mutex{}
  g := sync.WaitGroup{}
  for i := 0; i < 10000; i++ {
    g.Add(1)
    go func() {
      defer g.Done()
      m.Lock()
      defer m.Unlock()
      a++
    }()
  }
  g.Wait()
  fmt.Println(a)
}

First declare m := sync.Mutex{} . Then each Goroutine tries to get a lock before updating m.Lock() . Only one Goroutine can lock successfully at a time, all other Goroutines will be hung. When the successful Goroutine is updated, the lock is released by defer m.Unlock(). At this point the hung Goroutine will be woken up again and a new round of lock competition will begin.

The essence of locks is queuing, where all Goroutines update the value of a in the order in which they acquire the lock, so that no concurrency problems arise. However, locks come at a cost. Goroutines that do not grab a lock will be hung, and Goroutines that compete with each other for locks will also put a burden on the operating system. So it is better to write less code that may generate locks.

The easiest way to reduce locks is to not share memory (variables). Without sharing there is no data race, everyone does their own thing and does not affect each other. So we should try to avoid shared variables, pointers, slices and bytes, which are passed by reference and different Goroutines may operate on the same section of memory, creating a race. the Go language itself also encourages passing values and copying memory, which has some performance loss, but it is not worth mentioning compared to the bugs caused by concurrency.

Another way is to use concurrency-safe data types. For example, dictionaries can use the sync.Map proxy built-in map. But using concurrent versions of types without thinking can also lead to performance problems, as most cases do not encounter data races.

1
2
3
4
5
6
7
8
m := sync.Map{}
m.Store("a", 1)
v := m.Load("a") // return interface{}
v.(int) // Convert to int

m.Range(func(k,v interface{}) bool {
 // Iterate through each item.
})

Because it is not a built-in type, sync.Map does not support the range keyword and can only be traversed by Range().

There is a class of concurrency-safe types called atomic types, which are concurrency-safe by the underlying hardware and have almost no performance loss. You may prefer to use them. The related types are encapsulated in the package sync/atomic.

The previous example can be rewritten as follows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import "sync/atomic"
func main() {
  a := &atomic.Int32{}
  g := sync.WaitGroup{}
  for i := 0; i < 10000; i++ {
    g.Add(1)
    go func() {
      defer g.Done()
      a++
      a.Add(1)
    }()
  }
  g.Wait()
  fmt.Println(a.Load())
}

In order to reduce the cost of locking, read and write locks were invented. Simply put most variables are read more and write less. There is no problem with multiple Goroutines reading a variable at the same time, as long as no one modifies the variable during the reading process.

First create a read-write lock.

1
rwm := sync.RWMutex{}

Goroutines performing reads need to acquire a read lock, and Goroutines performing writes need to acquire a write lock.

1
2
rwm.RLock() // Get Read Lock
rwm.Lock()  // Get Write Lock

Because reads do not modify data, multiple Goroutines are allowed to acquire read locks at the same time, i.e. read the contents of variables concurrently. At this point, if a small number of Goroutines want to modify the contents, they need to acquire a write lock. Write locks are exclusive locks and need to wait for all read locks and other write locks to be released before they can be acquired. Read locks are also called shared locks, and write locks are also called exclusive locks.

This is the end of the main concurrency-related topics. Concurrent programming is a very difficult area, regardless of the language, and the built-in Goroutine in Go only lowers the threshold for concurrent programming, but definitely not the difficulty of concurrent programming. Beginners must be careful when learning and using Goroutine, otherwise things can easily go wrong.