Goroutines

goroutines is a lightweight thread of execution.

package main
import (
 "fmt"
 "time"
)
 
func f(from string) {
 for i := range 3 {
  fmt.Println(from, ": ", i)
 }
}
 
func main() {
 // Call `f` in a common, synchronous way.
 f("directly")
 
 // Call `f` in a goroutine,
 // it will execute concurrently with the one above.
 go f("goroutine")
 
 // Starting a goroutine for an anonymous function is fine.
 go func(msg string) {
  fmt.Println(msg)
 }("going")
 
 time.Sleep(time.Second) // Use `WaitGroup` is recommended.
 fmt.Println("Done.")
}
 
/*
directly :  0
directly :  1
directly :  2
going
goroutine :  0
goroutine :  1
goroutine :  2
Done.
*/

Channels

Channels are the pipes connect concurrent goroutines.

package main
import "fmt"
 
func main() {
 // Creating a new channel.
 // Channels are typed by the value they convey.
 message := make(chan string)
 
 // Send a message into a channel via syntax `channel <- value`
 go func() { message <- "ping" }()
 
 // Receive a message from a channel via syntax `<- channel`
 msg := <-message
 fmt.Println(msg)
}
 
/*
ping
*/

By default, sends and receives block until both the sender and receiver are ready.

This property allowed us to wait at the end of our program for the message
without having to use any other synchronization.

It also allows us to use channel as signal, lock and so on.

Channel with Buffers

package main
import "fmt"
 
func main() {
 // Define a channel with specific buffer size.
 // It can received the limited number of inputs
 // without a corresponding receiver.
 message := make(chan string, 2)
 // Note that make(chan string), which is no buffered,
 // equivalent to make(chan string, 0).
 
 message <- "something"
 message <- "not blocked"
 
 // We can received the message later,
 // FIFO
 fmt.Println(<-message)
 fmt.Println(<-message)
}
/*
something
not blocked
*/

Channel Synchronization

package main
import (
 "fmt"
 "time"
)
 
func work(done chan bool) {
 fmt.Println("Do something")
 time.Sleep(time.Second)
 fmt.Println("Done")
 
 done <- true
}
 
func main() {
 done := make(chan bool, 1)
 // With buffer, as defensive,
 // preventing main give up waiting and goroutine leaks.
 
 go work(done)
 
 <-done // Block until receive notification from worker.
}
 
/*
Do something
Done
*/

Channel with Direction

When using channels as function parameters,
we can specify if a channel is meant to only send or receive values.

It improves the type-safety for programming.

package main
import "fmt"
 
// This function accepts a channel for sending message
func ping(pings chan<- string, msg string) {
 pings <- msg
}
 
// This function accepts a channel for receiving(pings)
// and a channel for sending(pongs)
func pong(pings <-chan string, pongs chan<- string) {
 msg := <-pings
 pongs <- msg
}
 
func main() {
 pings := make(chan string, 1)
 pongs := make(chan string, 1)
 
 ping(pings, "Hello")
 pong(pings, pongs)
 
 fmt.Println(<-pongs)
}
/*
Hello
*/

Select

Go’s select allows us wait on multiple channels.

package main
import (
 "fmt"
 "time"
)
 
func main() {
 c1 := make(chan string)
 c2 := make(chan string)
 
 startTime := time.Now()
 
 go func() {
  time.Sleep(time.Second)
  c1 <- "The first is done"
 }()
 
 go func() {
  time.Sleep(2 * time.Second)
  c2 <- "The second is done"
 }()
 
 // We can use `select` to await both channels spontaneously,
 // rather than wait one then another.
 for range 2 {
  select {
  case msg := <-c1:
   fmt.Println("Received:", msg)
  case msg := <-c2:
   fmt.Println("Received:", msg)
  }
 }
 
 elapsed := time.Since(startTime)
 fmt.Printf("Total time cost: %v", elapsed)
}
/*
Received: The first is done
Received: The second is done
Total time cost: 2.001103981s
*/

Timeout

Implementing timeouts in Go is easy, owing select.

package main
import (
 "fmt"
 "time"
)
 
func main() {
 // Non-blocked, preventing goroutine leaks in case the channel is never read(timeout).
 c1 := make(chan string, 1)
 go func() {
  time.Sleep(2 * time.Second)
  c1 <- "The first is done"
 }()
 
 // We use `select` to implements a timeout.
 select {
 case msg := <-c1:
  fmt.Println("Received: ", msg)
 case <-time.After(time.Second): // Await a value to be sent after 1s from `time.After`
  fmt.Println("Task 1 is timeout")
 }
 
 c2 := make(chan string, 1)
 go func() {
  time.Sleep(2 * time.Second)
  c2 <- "The second is done"
 }()
 
 select {
 case msg := <-c2:
  fmt.Println("Received: ", msg)
 case <-time.After(3 * time.Second):
  fmt.Println("Task 2 is timeout")
 }
}
/*
Task 1 is timeout
Received:  The second is done
*/

Non-blocking Channel Operation

Basic sends and receives on channels are blocking.

However, we can use select with a default clause to implement non-blocking sends, receives,
and even non-blocking multi-way selects.

package main
import "fmt"
 
func main() {
 message := make(chan string)
 signals := make(chan string)
 
 // Non-blocking Receive
 select {
 case msg := <-message: // If a value is available on messages then this case will be taken.
  fmt.Println("Received: ", msg)
 default: // If not it will immediately take the default case.
  fmt.Println("No message available")
 }
 
 msg := "from msg"
 // Non-blocking Send
 select {
 case signals <- msg:
  fmt.Println("Msg is sent")
 default:
  fmt.Println("No message sent")
 }
 // Here msg cannot be sent to the messages channel, because the channel has no buffer and there is no receiver.
 // The default case will be taken.
 
 // Multi-way non-blocking receiving
 select {
 case sig := <-signals:
  fmt.Println("Received from signals: ", sig)
 case msg := <-message:
  fmt.Println("Received from message: ", msg)
 default:
  fmt.Println("Nothing Happend")
 }
}
/*
No message available
No message sent
Nothing Happend
*/

Closing a Channel

Closing a channel means no more value will be set in the channel,
indicating the completion of communication.

package main
import "fmt"
 
func main() {
 jobs := make(chan int, 5)
 done := make(chan bool)
 
 go func() {
  for {
   // In this special 2-value form of receive, the more value will be false
   // if jobs is **closed** and **empty**.
   job, more := <-jobs
 
   if more {
    fmt.Println("Received Job: ", job)
   } else {
    fmt.Println("Received all jobs")
    done <- true
    return
   }
  }
 }()
 
 for i := range 3 {
  jobs <- i
  fmt.Println("Send job: ", i)
 }
 close(jobs) // No matter the channel is empty, we can always close it.
 fmt.Println("Sent all jobs")
 
 <-done
 
 // Reading from a closed channel succeeds immediately, returning the zero value of the underlying type.
 // The optional second return value is true if the value received was delivered by a successful send operation to the channel,
 // or false if it was a zero value generated because the channel is closed and empty.
 _, ok := <-jobs
 fmt.Println("Can receive more jobs: ", ok)
}
/*
Send job:  0
Send job:  1
Send job:  2
Sent all jobs
Received Job:  0
Received Job:  1
Received Job:  2
Received all jobs
Can receive more jobs:  false
*/

Iterate over Channels

As our previous notes, we can use range to iterate over a channel.

package main
import "fmt"
 
func main() {
 c1 := make(chan string, 2)
 c1 <- "1"
 c1 <- "2"
 close(c1)
 
 // This range iterates over each element as it’s received from `c1`.
 // Because we closed the channel above, the iteration terminates after receiving the 2 elements.
 // Note that if we not to close it, it will be blocked! And deadlock in this scene.
 for msg := range c1 {
  fmt.Println("Received: ", msg)
 }
}
/*
Received:  1
Received:  2
*/

Timers and Tickers

Go’ built-in timer and ticker make it easy to
execute Go code at some point in the future, or repeatedly at some interval.

Timers

Timers are for when we want to do some tasks at some point in the future.

package main
import (
 "fmt"
 "time"
)
 
func main() {
 startTime := time.Now()
 
 // It will provide a channel that will be notified at time time.
 timer1 := time.NewTimer(2 * time.Second)
 
 time.Sleep(time.Second)
 
 <-timer1.C // Blocks on the timer's channel `C` until it sends value, indicating the timer fired.
 
 duration := time.Since(startTime)
 fmt.Printf("It takes %v/n", duration)
 
 timer2 := time.NewTimer(2 * time.Second)
 go func() {
  <-timer2.C
  fmt.Println("Timer2 is fired")
 }()
 
 // One reason we use timer instead of `Sleep`,
 // we can cancel the timer before it fired.
 // If we stop it from fired, it will return `true`.
 stopped := timer2.Stop()
 if stopped {
  fmt.Println("Timer2 is stopped")
 }
 time.Sleep(2 * time.Second)
}
/*
It takes 2.000502648s
Timer2 is stopped
*/

Tickers

Tickers are for when we want to do some tasks repeatedly at regular intervals.

package main
import (
 "fmt"
 "time"
)
 
func main() {
 // Ticker use similar mechanism to timers.
 // It sends value via its channel every intervals.
 ticker := time.NewTicker(500 * time.Millisecond)
 done := make(chan bool)
 
 go func() {
  // We use `select` in for-loop to await the value.
  for {
   select {
   case <-done:
    return
   case t := <-ticker.C: // It sends value of the time when it ticks
    fmt.Println("Ticked at ", t)
   }
  }
 }()
 
 time.Sleep(1600 * time.Millisecond)
 ticker.Stop() // We can stop ticker, too.
 done <- true
 fmt.Println("Ticker stopped")
}
/*
Ticked at  2026-05-28 11:29:40.615295791 +0800 CST m=+0.500022420
Ticked at  2026-05-28 11:29:41.115297187 +0800 CST m=+1.000024306
Ticked at  2026-05-28 11:29:41.615297816 +0800 CST m=+1.500024516
Ticker stopped
*/

Worker Pools

We can create a worker pool easily by Goroutines and Channels

package main
import (
 "fmt"
 "time"
)
 
func worker(id int, job <-chan int, result chan<- int) {
 for j := range job {
  fmt.Println("worker id: ", id, " started job: ", j)
  time.Sleep(time.Second)
  fmt.Println("worker id: ", id, " finished job: ", j)
  result <- j * 2
 }
}
 
func main() {
 const jobNum = 5
 jobs := make(chan int, jobNum)
 results := make(chan int, jobNum)
 
 for w := range 3 {
  go worker(w, jobs, results)
 }
 
 for j := range jobNum {
  jobs <- j
 }
 
 close(jobs)
 for range jobNum {
  <-results
 }
}
/*
worker id:  2  started job:  0
worker id:  0  started job:  1
worker id:  1  started job:  2
worker id:  2  finished job:  0
worker id:  2  started job:  3
worker id:  0  finished job:  1
worker id:  0  started job:  4
worker id:  1  finished job:  2
worker id:  0  finished job:  4
worker id:  2  finished job:  3
*/

Wait Groups

To wait for multiple goroutines to finish, just like what we’ve done above, we can use a wait group.

 
package main
import (
 "fmt"
 "sync"
 "time"
)
 
func worker(id int) {
 fmt.Printf("worker %v started\n", id)
 time.Sleep(time.Second)
 fmt.Printf("worker %v finished\n", id)
}
 
func main() {
 // The zero-value of WaitGroup can be used immediately
 var wg sync.WaitGroup
 // When we want to pass it into a function, use pointer.
 /*
  func foo(wg *sync.WaitGroup) {
   return
  }
 
  foo(&wg)
 */
 
 for i := range 5 {
  wg.Go(func() {
   worker(i)
  }) // Launch goroutines using `wg.Go()` instead of `go`
 }
 
 wg.Wait() // Block until all goroutines started by it finished.
}
/*
worker 4 started
worker 2 started
worker 3 started
worker 0 started
worker 1 started
worker 0 finished
worker 2 finished
worker 3 finished
worker 4 finished
worker 1 finished
*/

Rate Limiting

Rate limiting is an important mechanism for controlling resource utilization and maintaining quality of service.

Go elegantly supports rate limiting with Goroutines, Channels, and Tickers.

package main
import (
 "fmt"
 "time"
)
 
func main() {
 requests := make(chan int, 5)
 for i := range 5 {
  requests <- i
 }
 close(requests)
 
 // This limiter channel will receive a value every 200 milliseconds.
 limiter := time.Tick(200 * time.Millisecond)
 
 for r := range requests {
  <-limiter
  // We limit ourselves to 1 request every 200 milliseconds.
  fmt.Println("Request: ", r, time.Now())
 }
 
 // We may want to allow short bursts of requests
 // in our rate limiting scheme while preserving the overall rate limit.
 // We can accomplish this by buffering our limiter channel.
 // This burstyLimiter channel will allow bursts of up to 3 events.
 burstyLimiter := make(chan time.Time, 3)
 
 for range 3 {
  burstyLimiter <- time.Now()
 }
 
 // Every 200 milliseconds we’ll try to add a new value to burstyLimiter, up to its limit of 3.
 go func() {
  for t := range time.Tick(200 * time.Millisecond) {
   burstyLimiter <- t
  }
 }()
 burstyRequests := make(chan int, 5)
 for i := 1; i <= 5; i++ {
  burstyRequests <- i
 }
 close(burstyRequests)
 // The first 3 of these will be handled without limit owing burstyLimiter.
 for r := range burstyRequests {
  <-burstyLimiter
  fmt.Println("request", r, time.Now())
 }
}
/*
Request:  0 2026-05-28 12:22:17.038991947 +0800 CST m=+0.200381055
Request:  1 2026-05-28 12:22:17.239389833 +0800 CST m=+0.400779988
Request:  2 2026-05-28 12:22:17.438724804 +0800 CST m=+0.600114959
Request:  3 2026-05-28 12:22:17.639326836 +0800 CST m=+0.800716921
Request:  4 2026-05-28 12:22:17.838851775 +0800 CST m=+1.000241372
request 1 2026-05-28 12:22:17.838971064 +0800 CST m=+1.000360731
request 2 2026-05-28 12:22:17.838983496 +0800 CST m=+1.000373582
request 3 2026-05-28 12:22:17.838994042 +0800 CST m=+1.000383709
request 4 2026-05-28 12:22:18.039238766 +0800 CST m=+1.200628921
request 5 2026-05-28 12:22:18.239595445 +0800 CST m=+1.400985531
*/

Synchronization

There are a few other options for managing state besides channels.

Atomic Counter

package main
 
import (
 "fmt"
 "sync"
 "sync/atomic"
)
 
func main() {
 var ops atomic.Uint64
 var wg sync.WaitGroup
 
 for range 50 {
  wg.Go(func() {
   for range 1000 {
    ops.Add(1)
   }
  })
 }
 
 wg.Wait()
 // Using Load it’s safe to atomically read a value
 // even while other goroutines are (atomically) updating it.
 fmt.Println("Ops: ", ops.Load())
}
/*
Ops:  50000
*/

Mutex

For more complex state we can use a mutex to safely access data across multiple goroutines.

package main
 
import (
 "fmt"
 "sync"
)
 
// Note that mutexes must not be copied,
// so if this struct is passed around, it should be done by pointer.
type Container struct {
 mu      sync.Mutex
 counter map[string]int
}
 
func (c *Container) inc(name string) {
 c.mu.Lock()         // Lock the mutex before accessing counters
 defer c.mu.Unlock() //  Unlock it at the end of the function using a defer statement.
 c.counter[name]++
}
 
func main() {
 c := Container{
  // Note that the zero value of a mutex is usable as-is,
  // so no initialization is required here.
  counter: map[string]int{"a": 0, "b": 0},
 }
 
 var wg sync.WaitGroup
 
 doIncrease := func(name string, times int) {
  for range times {
   c.inc(name)
  }
 }
 
 wg.Go(func() {
  doIncrease("a", 10000)
 })
 
 wg.Go(func() {
  doIncrease("b", 10000)
 })
 
 wg.Go(func() {
  doIncrease("a", 10000)
 })
 
 wg.Wait()
 fmt.Println(c.counter)
}
/*
map[a:20000 b:10000]
*/

Wait… What is Defer?

Stateful Goroutines

With built-in synchronization features of goroutines and channels,
we can achieve the same result like mutex and counter above.

It show Go’s ideas of sharing memory by communicating and having each piece of data owned by exactly one goroutine.

package main
 
import (
 "fmt"
 "math/rand"
 "sync/atomic"
 "time"
)
 
type readOp struct {
 key  int
 resp chan int
}
 
type writeOp struct {
 key  int
 val  int
 resp chan bool
}
 
func main() {
 var readOps uint64
 var writeOps uint64
 
 // The reads and writes channels will be used
 // by other goroutines to issue read and write requests, respectively.
 reads := make(chan readOp)
 writes := make(chan writeOp)
 // Use channel to wrap structs containing channel,
 // which make it possible to communicate bi-directional
 
 go func() {
  state := make(map[int]int) // The only goroutines could access this map directly.
  for {
   select {
   case ro := <-reads:
    ro.resp <- state[ro.key]
   case wo := <-writes:
    state[wo.key] = wo.val
    wo.resp <- true
   }
  }
 }()
 
 for range 100 {
  go func() {
   for {
    read := readOp{
     key:  rand.Intn(5),
     resp: make(chan int),
    }
    reads <- read
    <-read.resp
    atomic.AddUint64(&readOps, 1)
    time.Sleep(time.Millisecond)
   }
  }()
 }
 
 for range 10 {
  go func() {
   for {
    write := writeOp{
     key:  rand.Intn(5),
     val:  rand.Intn(100),
     resp: make(chan bool),
    }
    writes <- write
    <-write.resp
    atomic.AddUint64(&writeOps, 1)
    time.Sleep(time.Millisecond)
   }
  }()
 }
 
 time.Sleep(time.Second)
 
 readOpsFinal := atomic.LoadUint64(&readOps)
 fmt.Println("readOps:", readOpsFinal)
 writeOpsFinal := atomic.LoadUint64(&writeOps)
 fmt.Println("writeOps:", writeOpsFinal)
}
/*
readOps: 69740
writeOps: 7366
*/

This option causes performance overhead comparing to mutex.

But when we face complex state machine or I/O and connection managers, it will be a good choice.

So…

  • Use Mutexes for protecting raw data structures.
  • Use Stateful Goroutines for managing complex, long-running processes or asynchronous event workflows.