@jialin.huang
FRONT-ENDBACK-ENDNETWORK, HTTPOS, COMPUTERCLOUD, AWS, Docker
To live is to risk it all Otherwise you are just an inert chunk of randomly assembled molecules drifting wherever the Universe blows you

© 2024 jialin00.com

Original content since 2022

back
RSS

Concurrency in Go: From Basics to Better Practices

When you want to do more than one thing at the same time...

Newbie Naive behavior

When you first want to do multiple things at once, you might write something like this:

func doSomething() {
    fmt.Println("Doing something important...")
    time.Sleep(time.Second * 2) // wasting your time
}

func doSomethingElse() {
    fmt.Println("Doing something else that's equally important...")
    time.Sleep(time.Second * 3)  // still wasting your time
}

func main() {
    start := time.Now()
    
    fmt.Println("Starting first task...")
    doSomething()
    
    fmt.Println("Taking a long break...")
    time.Sleep(time.Second * 5)  // fake busy
    
    fmt.Println("Starting second task...")
    doSomethingElse()
    
    fmt.Println("Taking another unnecessary break...")
    time.Sleep(time.Second * 5)  // fake busy again
    
    fmt.Println("I'm finally done with all the important stuff...")
    elapsed := time.Since(start)
    fmt.Printf("All this hard work took %s\n", elapsed)
}

It's pretty dumb, but hey, we all start somewhere when coding from scratch!

Goroutines

So you've heard about these cool things called goroutines in Go. They're like lightweight threads, and you think, "Nice! I'll just add go in front of everything!"

The main program doesn't give a shit about your goroutines. It'll just say "I'm done" and peace out before your goroutines even start their engines.

func main() {
    go doSomething()
    go doSomethingElse()
    fmt.Println("I'm done")
}

directly print

go run main.go
# I'm done

WaitGroup to the Rescue

This is where WaitGroup comes in, like a responsible adult managing a bunch of hyperactive kids (goroutines).
WaitGroup tells the main program: "Hey, I'm in charge of these goroutines. You just sit tight and wait for my signal."

Here's how it works:

  1. wg.Add(): Hey, I'm starting a new task!
  1. wg.Done(): Alright, I'm finished with this task!
  1. wg.Wait(): Hold up, main program. Wait until all my tasks are done.

Basic

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // i'm done. counter--
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // counter++
        go worker(i, &wg)
    }

    wg.Wait() // "I'm waiting for all workers to finish!"
    fmt.Println("All workers done")
}

Anonymous

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Printf("Goroutine %d working\n", id)
			time.Sleep(time.Second * time.Duration(id))
		}(i)
	}

	wg.Wait()
	fmt.Println("All goroutines done")
}

Channels: When Goroutines Need to Chat

If your goroutines need to discuss their tasks, it's time to set up a channel for communication!

dualch := make(chan int)
sendersendCh := make(chan<- int)
receiverrecvCh := make(<-chan int)
func main() {
    sendch := make(chan int)
    recvch := make(<-chan int)

    go sendData(sendch)
    go receiveData(recvch)

    recvch = sendch

		// let goroutines have some time to execute
		// no WaitGroup here so...
    time.Sleep(time.Second)
}

func sendData(ch chan<- int) {
    ch <- 42
}

func receiveData(ch <-chan int) {
    fmt.Println(<-ch)
}

WaitGroup & Channel Teamwork 1

Here's a normal, correctly working example, but there's something curious about it. Why are wg.Wait() and close(result) inside a go func()? Let's take a look:

func worker(id int, wg *sync.WaitGroup, result chan<- int) {
    defer wg.Done()
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second * 2)
    fmt.Printf("Worker %d done\n", id)
    result <- id * 2
}

func main() {
    var wg sync.WaitGroup
    result := make(chan int, 3)

    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go worker(i, &wg, result)
    }

    go func() {
        wg.Wait()
        close(result)
    }()

    for r := range result {
        fmt.Printf("Received result: %d\n", r)
    }

    fmt.Println("All workers are done")
}

What Happens If We Change Things?

Try changing the channel length to 2 and remove the outer goroutine. What do you think will happen?

    result := make(chan int, 2)
    // ...
    // go func() {
        wg.Wait()
        close(result)
    // }()

This will cause:

  1. Not enough buffer space
  1. Workers get blocked trying to send results, so they can't call Done()
  1. wg.Wait() keeps waiting forever
  1. wg.Wait() is synchronous, so it blocks the main program from continuing
  1. The result channel never gets to spit out its results

And boom! You get a nice fatal error: all goroutines are asleep - deadlock!

Buffer's Too Small, But We Use go func()

But if you keep it in a go func(), even if your buffer is too small, it won't cause an error.

    result := make(chan int, 2)
    // ...
    go func() {
        wg.Wait()
        close(result)
    }()

WaitGroup & Channel Teamwork 2

Three Consumers Receive 5 Messages

func producer(ch chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        ch <- i
        time.Sleep(time.Millisecond * 100)
    }
    close(ch)
}

func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    for num := range ch {
        fmt.Printf("Consumer %d received: %d\n", id, num)
    }
}

func main() {
    var wg sync.WaitGroup
    ch := make(chan int)

    wg.Add(1)
    go producer(ch, &wg)

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go consumer(i, ch, &wg)
    }

    wg.Wait()
    fmt.Println("All done")
}

A Bit More on Better defer Practices

Check out this situation. If the file fails to open, file is nil, and there's no error handling in either the main program or the openFile function. Here's what I mean:


func openFileUnsafe(fileName string) {
	file, e := os.Open(fileName)
	fmt.Println((e))
	// no error handling
	// ...
	defer file.Close()

	fmt.Println("File opened successfully")
}

func main() {

	openFileUnsafe("non_existent_file.txt")
	fmt.Println("This line will not be reached if panic occurs")
}

When you run it, it's super weird! defer makes the error low-key LOL

WHY IT JUST FAIL SILENTLY?????

I don't know why this happens, I don't have an answer yet.

go run main.go
# open non_existent_file.txt: no such file or directory
# File opened successfully
# This line will not be reached if panic occurs

Here's a better way to write it. Even though the above didn't throw an error, the whole behavior is strange.

You should still properly handle errors everywhere, okay?

func openFileSafe(fileName string) error {
    file, err := os.Open(fileName)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()

    fmt.Println("File opened successfully")
    return nil
}

func main() {
    if err := openFileSafe("file.txt"); err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("File processing completed")
}

When you run it:

go run wg.go
Error: failed to open file: open file.txt: no such file or directory

And there you have it! That's how you can make your Go concurrency code a bit safer and more reliable. Remember, always handle your errors and be careful with defer!

EOF