Mastering Go Concurrency: Goroutines, Scheduler, Race Detection & Channels

This article explains Go's concurrency model, detailing how goroutines are scheduled onto logical processors, how to create and run them, handle race conditions with atomic operations, mutexes, and the race detector, and share data safely using unbuffered and buffered channels with practical code examples.

MaGe Linux Operations
MaGe Linux Operations
MaGe Linux Operations
Mastering Go Concurrency: Goroutines, Scheduler, Race Detection & Channels

1. Using Goroutine to Run Programs

1. Go Concurrency and Parallelism

Go's concurrency capability allows a function to run independently of others. When a goroutine is created, the function becomes a work unit scheduled by the runtime scheduler onto an available logical processor. The scheduler manages all goroutines, allocates execution time, and binds OS threads to logical processors.

Manages all created goroutines and assigns them execution time.

Binds OS threads to language runtime logical processors.

The OS schedules OS threads on physical CPUs, while the Go runtime schedules goroutine on logical processors. Three roles are involved:

M: the OS thread, the actual kernel thread.

P: the logical processor, providing scheduling context for goroutines on an M.

G: the goroutine, with its own stack and instruction pointer, scheduled by a P.

Each P maintains a global runqueue of ready goroutines. When go func starts a goroutine, it is appended to the runqueue; at the next scheduling point, P picks a goroutine from the runqueue to execute.

If an OS thread M blocks (e.g., a goroutine performs a blocking system call), the P can bind to another OS thread M, allowing other goroutines to continue. The scheduler ensures enough threads; the default limit is 10,000 threads, adjustable via runtime/debug.SetMaxThreads.

Go can achieve concurrency on a single logical processor P; to achieve parallelism, multiple logical processors are required. The scheduler distributes goroutines evenly across logical processors, which run on separate OS threads when multiple physical CPUs are available.

2. Creating Goroutine

Use the go keyword to launch a goroutine and let all goroutines execute.

//example1.go
package main

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

var (
   wg sync.WaitGroup
)

func main() {
   // Assign one logical processor to the scheduler
   runtime.GOMAXPROCS(1)
   wg.Add(2)
   fmt.Printf("Begin Coroutines
")
   go func() {
      defer wg.Done()
      for count := 0; count < 3; count++ {
         for char := 'a'; char < 'a'+26; char++ {
            fmt.Printf("%c ", char)
         }
      }
   }()
   go func() {
      defer wg.Done()
      for count := 0; count < 3; count++ {
         for char := 'A'; char < 'A'+26; char++ {
            fmt.Printf("%c ", char)
         }
      }
   }()
   fmt.Printf("Waiting To Finish
")
   wg.Wait()
}

This program uses runtime.GOMAXPROCS(1) to allocate one logical processor; the two goroutines are scheduled on that processor. Output shows the first goroutine finishes before the second.

Begin Coroutines
Waiting To Finish
A B C ... Z a b c ... z

To run the two goroutines in parallel, set two logical processors:

// Set to 2 logical processors
runtime.GOMAXPROCS(2)

Running with two processors interleaves the output of uppercase and lowercase letters.

Begin Coroutines
Waiting To Finish
A B C ... Z a b c ... z

If only one logical processor is available, you can force cooperative scheduling with runtime.Gosched() at desired points.

//example2.go
package main

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

var (
   wg sync.WaitGroup
)

func main() {
   runtime.GOMAXPROCS(1)
   wg.Add(2)
   fmt.Printf("Begin Coroutines
")
   go func() {
      defer wg.Done()
      for count := 0; count < 3; count++ {
         for char := 'a'; char < 'a'+26; char++ {
            if char == 'k' {
               runtime.Gosched()
            }
            fmt.Printf("%c ", char)
         }
      }
   }()
   go func() {
      defer wg.Done()
      for count := 0; count < 3; count++ {
         for char := 'A'; char < 'A'+26; char++ {
            if char == 'K' {
               runtime.Gosched()
            }
            fmt.Printf("%c ", char)
         }
      }
   }()
   fmt.Printf("Waiting To Finish
")
   wg.Wait()
}

Both goroutines yield when the character reaches k/K, resulting in alternating output.

Begin Coroutines
Waiting To Finish
A B C ... K L ... Z a b c ... k ... z

2. Handling Race Conditions

Concurrent programs may have unsynchronized access to shared resources, leading to race conditions when multiple goroutines read and write the same variable.

//example3.go
package main

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

var (
   counter int64
   wg      sync.WaitGroup
)

func addCount() {
   defer wg.Done()
   for count := 0; count < 2; count++ {
      value := counter
      runtime.Gosched()
      value++
      counter = value
   }
}

func main() {
   wg.Add(2)
   go addCount()
   go addCount()
   wg.Wait()
   fmt.Printf("counter: %d
", counter)
}

Running this may produce counter: 4 or counter: 2 due to races.

Solutions:

Use atomic functions (e.g., atomic.AddInt64).

Use a mutex to protect the critical section.

Use channels for synchronization.

1. Detecting Races

Go provides a race detector. Compile with -race and run the program:

go build -race example4.go ./example4

The detector reports the lines where the race occurs.

2. Using Atomic Functions

Atomic operations safely modify integers without locks.

//example5.go
package main

import (
   "sync"
   "runtime"
   "fmt"
   "sync/atomic"
)

var (
   counter int64
   wg      sync.WaitGroup
)

func addCount() {
   defer wg.Done()
   for count := 0; count < 2; count++ {
      atomic.AddInt64(&counter, 1)
      runtime.Gosched()
   }
}

func main() {
   wg.Add(2)
   go addCount()
   go addCount()
   wg.Wait()
   fmt.Printf("counter: %d
", counter)
}

Output is always counter: 4.

3. Using Mutex

//example6.go
package main

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

var (
   counter int
   wg      sync.WaitGroup
   mutex   sync.Mutex
)

func addCount() {
   defer wg.Done()
   for count := 0; count < 2; count++ {
      mutex.Lock()
      value := counter
      runtime.Gosched()
      value++
      counter = value
      mutex.Unlock()
   }
}

func main() {
   wg.Add(2)
   go addCount()
   go addCount()
   wg.Wait()
   fmt.Printf("counter: %d
", counter)
}

The mutex ensures only one goroutine updates counter at a time, yielding counter: 4.

3. Sharing Data with Channels

Go follows the CSP model, using channels ( chan) to pass messages between goroutines instead of locking shared data.

unbuffered := make(chan int) // unbuffered channel for int
buffered := make(chan string, 10) // buffered channel for string
buffered <- "hello world"
value := <-buffered

Unbuffered channels are synchronous: a send blocks until a receive is ready, and vice versa. Buffered channels allow storing multiple values before a receive.

1. Unbuffered Channels

Example simulating a tennis match where two players exchange a ball via an unbuffered channel:

//example7.go
package main

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

var wg sync.WaitGroup

func player(name string, court chan int) {
   defer wg.Done()
   for {
      ball, ok := <-court
      if !ok {
         fmt.Printf("Player %s Won
", name)
         return
      }
      n := rand.Intn(100)
      if n%13 == 0 {
         fmt.Printf("Player %s Missed
", name)
         close(court)
         return
      }
      fmt.Printf("Player %s Hit %d
", name, ball)
      ball++
      court <- ball
   }
}

func main() {
   rand.Seed(time.Now().Unix())
   court := make(chan int)
   wg.Add(2)
   go player("candy", court)
   go player("luffic", court)
   court <- 1
   wg.Wait()
}

Output shows the ball being hit back and forth until a miss occurs.

2. Buffered Channels

Buffered channels store values until they are received; sending blocks only when the buffer is full, and receiving blocks only when the buffer is empty. Closing a channel prevents further sends but allows remaining values to be received.

Summary

Goroutine execution is managed by logical processors, each with its own OS thread and runqueue.

Multiple goroutines can run concurrently on one logical processor; parallelism requires multiple logical processors.

Use the go keyword to create goroutines.

Race conditions arise when goroutines concurrently access shared resources.

Mutexes or atomic functions can prevent races.

Channels provide a preferred way to share data safely in Go.

Unbuffered channels are synchronous; buffered channels are not.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

GomutexGoroutinerace conditionChannelatomic
MaGe Linux Operations
Written by

MaGe Linux Operations

Founded in 2009, MaGe Education is a top Chinese high‑end IT training brand. Its graduates earn 12K+ RMB salaries, and the school has trained tens of thousands of students. It offers high‑pay courses in Linux cloud operations, Python full‑stack, automation, data analysis, AI, and Go high‑concurrency architecture. Thanks to quality courses and a solid reputation, it has talent partnerships with numerous internet firms.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.