Goroutine question

I’m learning goroutine from a book go in action and running this code:

package main

import (
	"fmt"
	"runtime"
	"sync"
)

var (
	counter int
	wg sync.WaitGroup
)

func main(){
	wg.Add(2)

	go incCounter(1)
	go incCounter(2)

	wg.Wait()
	fmt.Println("final counter: ", counter)
}

func incCounter(input int){
	defer wg.Done()
	for count := 0; count < 2; count++{
		value := counter
		//do werld thing
		runtime.Gosched()
		value ++
		counter = value
	}
}

since this two go routine overwrite each other’s work of updating package-level variable counter, so the final counter value should be always 2. I tried it and interesting that majority of time i get 2 but sometimes I get 3 or 4. any idea of this?

(base) zwang-mac:cmd1 zwang$ go run race.go
final counter:  2
(base) zwang-mac:cmd1 zwang$ go run race.go
final counter:  2
(base) zwang-mac:cmd1 zwang$ go run race.go
final counter:  3
(base) zwang-mac:cmd1 zwang$ go run race.go
final counter:  2
(base) zwang-mac:cmd1 zwang$ 

Thanks
James

2 Likes

It’s very hard to tell precisely since both incCounter(1) and incCounter(2) executions are not in sync, neither do the counter value access. We can only know the counter value can be:

  1. in the range of [2, 4]
  2. crash the entire main or do something magical on your system depending various factors due to unsafe access counter by 2 goroutines

This example, however, does show the importance of synchronization and multi-process guarantees, assuming we treat //do weird thing does not affect the process much.


Why 2, 3, 4, and not one of them

If we look at one incCounter, it first loads the value from the shared variable counter into local variable value. Then it increment the value once before saving it back to the shared variable. The process repeat itself 1 time. Since the shared variable starts at 0 (declared variable without value is always start at nil/0), the increment will pump the value from 0 --> 1 --> 2.

Now if we look at two incCounter, we got a bunch of possibilities.

1. incCounter(1) and incCounter(2) execute at the same time, access the shared variable safely, write back to shared variable safely.

Both incCounter will always load its local value at 0. Then, both increases their respective value to 2 and save it back to shared variable. This is where you can ensure that you always get 2 as minimum counter value.

2. incCounter(2) starts after incCounter(1), access the shared variable safely, write back to shared variable safely.

This is where incCounter(1) increment the value from 0 --> 2 and then incCounter(2) increment the value from 2 --> 4.

Since incCounter(2) starts after incCounter(1), we can safely say that by the time incCounter(1) updated the counter value to 2 before incCounter(2) is able to load it to its value local variable. By the time incCounter(2), its loaded value's value is 2, not 0. Hence, it pumped it to 4.

3. incCounter(2) starts later than (not after) incCounter(1), access the shared variable safely, write back to shared variable safely.

This is where incCounter(2) loads its value when incCounter(1) increases the counter from 0 --> 1. Hence, incCounter(2) starts at 1 and by the time it finishes it increments, the final value is 3. Since incCounter(2) starts later, it has the final call to write into counter shared variable. That’s how you landed with 3.

4. Regardless which incCounter start first, they either access/write the shared variable simultaneously

The outcome for this possibility is unpredictable depending on operating system, your processor design, etc.

Most of the time, the application crashes. Sometimes, it crashes the entire operating system, requiring you to restart your computer. Sometimes if the processor is designed for such usage, the circumstances I mentioned just now were forgiven.

This is what we called race condition and precisely, data race condition where 2 or more processes racing to access/write into a shared memory.

5. incCounter(1) starts after incCounter(2), access the shared variable safely, write back to shared variable safely.

Same outcome as possibility No. 2.

6. incCounter(1) starts later (not after) incCounter(2), access the shared variable safely, write back to shared variable safely.

Same outcome as possibility No. 3.


Since we are dealing with possibilities in executions itself, there is no guarantees at all. Sometimes, you get landed on possibility No.2 or No.5; Most of the times, you landed with possibility No.1; Sometimes, No.3 or No.6; Sometimes No.4. If you swap a different hardware, the possibilities changes again.

In computer science, you do not want to gamble the circumstances. That’s why we always want to make sure each goroutines (or all processes) are in sync with each others and eliminate all race conditions, be it data/execution. Only then, you can get a guaranteed result while benefiting from the multi-processes capability.

1 Like

Thanks @hollowaykeanho for such a detailed explanation! Just as a follow up, what’s the possible fix you think I can use to resolve this race data issue? and in general what’s the good practice to write goroutine for multiprocessing?

Thanks

1 sentence: plan as detailed as possible, upto the point of data access.

You know your planning is on track when you can answer the following pattern at any point:

WHO do WHAT at WHEN by HOW

1 Like

Just to back him up :stuck_out_tongue_closed_eyes:, here are some of his past explanations:

How to Plan

  1. Synchronization example [bad code] - more info about deadlock
  2. Occured incomprehensible deadlock - plan using pseudo-code
1 Like

Here is a possible fix example. It uses a mutex to ensure only on go routine at the time will increment counter.

https://play.golang.org/p/LGjQnTd78Vf

When you run it, counter will always be 4.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.