Go routine & concurrency Tutorial

A tutorial about goroutine in Golang

About this note

This note mainly discusses how we can use goroutines in basic ways, without focusing on what a goroutine is or the logic behind the mechanism.

What is a Goroutine

A goroutine is like a thread, but not exactly. More specifically, it is much lighter than a thread. Compared to threads, goroutines require less performance and cost, which makes them really efficient. A scheduler is used to manage the goroutines, switching between them during runtime.

Why Goroutine

In traditional methods of coding for concurrency, we need to use mutex keys to keep the memory safe. We care about the shared memory and about whether "we locked a mutex signal," "Is the mutex signal now OK in a specific line of code?" If you have ever tried to code concurrency in C, you probably know what I mean. If not, just imagine you need to think about a variable being shared by 10 files, with every file interacting with that variable at some lines of code. You have to carefully consider the current state of the variable and its future state.

Goroutines solve this problem by using channels. The main idea is that "channels care about data transformation." In other words, we don’t modify or check data directly; we send or receive notifications that data has changed. You can learn more by reading the following notes.

Create Goroutine Codes

To create a goroutine

func main() {
	// use go keyword to create a goroutine
	go func() {
		... // the code will be execute in another go routine
	}() // call the func immediately
}

To create a channel, push data into a channel, get data from a channel. A channel is like a queue, follows the FIFO rule.

func main() {
	c := make(chan struct) // make(chan [type])
	worker(c)
	time.sleep(5 * time.second)
	c <- struct{}{} // push a empty struct instance to channel
	time.sleep(5 * time.second) // wait for the worker finish printing
	return
}

func worker(c chan struct{}) { // the parameter type is "chan struct"
	fmt.println("worker waiting for data")
	// get data from c, <- means pop the first data from c,
	// we could get the value like this: var1 := <-c, but now we ignore the return
	// value since it is only a empty struct, we don't care, so we write <-c only
	<-c
	fmt.println("worker finished")
}

Exit Goroutine in Several ways

Detached mode

Detached mode means “The goroutine who create the current goroutine, doesn’t care about the current goroutine” As the current goroutine finish all the codes inside the function, the current goroutine die. There are 2 common usages:

  1. One-time task: In this example, we want the goroutine do something once, and we don’t care how it exit or what value it will return.

    func OneTimeTask() {
    	fmt.Println("main goroutine started")
    	c := make(chan int)
    	go func(c chan int) {
    		fmt.Println("new goroutine started")
    		select {
    		case val := <-c:
    			// do something...
    			fmt.Println(val)
    		case <-time.After(2 * time.Second):
    			// timeout, do something...
    		}
    		// no codes here, so the goroutine exit
    	}(c)
    	fmt.Println("main goroutine created another goroutine")
    	time.Sleep(time.Second)     // sleep 1 sec
    	c <- 1                      // push 1 to channel
    	time.Sleep(5 * time.Second) // sleep to make sure the goroutine we created exit
    	fmt.Println("main goroutine ends")
    }
    
    // main goroutine started
    // main goroutine created another goroutine
    // new goroutine started
    // 1
    // main goroutine ends
    
  2. Background tasks: Like monitor or timer that trigger some kind of methods based on some kind of situations over and over again.

    func AddMonitorToTasks() {
    	// suppose we have some tasks
    	tasks := []int{1,2,3,4,5}
    	for i := range tasks {
    		go func(i int) {
    			time.Sleep(2 * time.Second)
    			fmt.Println("monitoring:", i)
    			// you can add a for loop here to monitor the task after a period of time
    		}(i)
    		// do not use closure here, because all go routinue will share the same varibale if we use closure
    		// now, we are passing the variable inside the function, which is a copy of the variable
    	}
    	time.Sleep(5 * time.Second)
    }
    
    // note: your result might be different from the result below
    // monitoring: 2
    // monitoring: 1
    // monitoring: 0
    // monitoring: 4
    // monitoring: 3
    

Join Mode

If the parent goroutine needs information from its children goroutines, we can call it “join mode” which means parent should wait for its children, or the parent cares about the children’s results. There are several ways of “joining”:

Join Mode isn’t a well known name. This term comes from a book which listed in the reference part.

  1. Wait a single goroutine

    func WaitSingle() {
    	c := make(chan struct{})
    	go func() {
    		// do some work here
    		time.Sleep(2 * time.Second)
    		// work done
    		c <- struct{}{} // push a signal to the channel
    	}()
    	fmt.Println("parent started waiting")
    	<-c // the program will stop here until recieving the signal
    	fmt.Println("parent got result")
    }
    
    // parent started waiting
    // parent got result
    
  2. Get the status from the exited goroutine

    func GetStatus() {
    	c := make(chan error) // we make a error channel here
    	go func() {
    		// do some work here
    		time.Sleep(2 * time.Second)
    		err := errors.New("there is an error") // simulate error
    		if err != nil {
    			c <- err
    			return
    		}
    		// work done
    		c <- nil // push a signal to the channel
    	}()
    	fmt.Println("parent started waiting")
    	err := <-c // the program will stop here until recieving the signal
    	fmt.Println("parent got result")
    	fmt.Println("err is:", err.Error())
    }
    
    // parent started waiting
    // parent got result
    // err is: there is an error
    
  3. Wait multiple goroutines

    func WaitMutiple() {
    	c := make(chan struct{})
    	var wg sync.WaitGroup
    	fmt.Println("assigning tasks")
    	for i := 0; i < 10; i++ {
    		wg.Add(1) // add a task to the wait group
    		go func(i int) {
    			time.Sleep(3 * time.Second)
    			fmt.Println("task", i, "done")
    			wg.Done()
    		}(i)
    	}
    	// we use another goroutine to wait for all tasks to be done
    	// wait until all tasks are done, then send a signal to the channel
    	go func() {
    		wg.Wait()
    		c <- struct{}{}
    	}()
    	<-c
    	fmt.Println("tasks end")
    }
    
    // assigning tasks
    // task 2 done
    // task 7 done
    // task 5 done
    // task 1 done
    // task 0 done
    // task 3 done
    // task 4 done
    // task 8 done
    // task 6 done
    // task 9 done
    // tasks end
    

Notify-and-wait Mode

In the previous part, we discussed about how the main goroutine wait for children goroutines end. Now, we are going to discuss how we can notify a child goroutine end.

  1. Notify a goroutine

    func NotifyOne() {
    	quit := make(chan string)
    	go func() {
    		for {
    			select {
    			case <-time.After(time.Second):
    				fmt.Println("work after 1 sec")
    			case <-quit:
    				quit <- "ok"
    				return
    			}
    		}
    	}()
    	time.Sleep(5 * time.Second)
    	quit <- "end" // sending a signal to the channel, doesn't has to be "end"
    	status := <-quit // a bi-directional channel, the internal goroutine send a signal back
    	fmt.Println(status)
    }
    
    // work after 1 sec
    // work after 1 sec
    // work after 1 sec
    // work after 1 sec
    // ok
    
  2. Notify multiple goroutines

    Here, a point is that when we closing a channel, it will send a signal to all the goroutines that waiting for the channel. You can check the variable ok in the go func , which will be false if the channel is closed. We can use this characteristic to notify multiple goroutines.

    func NotifyMutiple() {
    	job := make(chan int)
    	var wg sync.WaitGroup
    	// create 10 goroutines
    	for i := 0; i < 10; i++ {
    		wg.Add(1)
    		go func(i int) {
    			for {
    				num, ok := <-job
    				if !ok {
    					wg.Done()
    					fmt.Println("Goroutine", i, "done")
    					return
    				}
    				fmt.Println("Gorouinte", i, "is working on job", num)
    			}
    		}(i)
    	}
    	// adding tasks to job channel
    	for i := 0; i < 10; i++ {
    		job <- i
    	}
    
    	time.Sleep(5 * time.Second) // wait for jobs to be done
    	close(job) // closing a channel will notify all the goroutines that waiting for the channel
    	wg.Wait()
    	fmt.Println("job done")
    }
    
    // Gorouinte 8 is working on job 9
    // Gorouinte 0 is working on job 0
    // Gorouinte 5 is working on job 3
    // Gorouinte 1 is working on job 1
    // Gorouinte 7 is working on job 6
    // Gorouinte 4 is working on job 2
    // Gorouinte 2 is working on job 5
    // Gorouinte 3 is working on job 7
    // Gorouinte 6 is working on job 4
    // Gorouinte 9 is working on job 8
    // Goroutine 8 done
    // Goroutine 4 done
    // Goroutine 9 done
    // Goroutine 2 done
    // Goroutine 6 done
    // Goroutine 1 done
    // Goroutine 7 done
    // Goroutine 5 done
    // Goroutine 3 done
    // Goroutine 0 done
    // job done
    

Pipeline

We can have multiple different tasks running at the same time, which is shown in the codes below. Every function is handled by a goroutine. After a function is finished, it will send the result to the next goroutine. Finally, the last function send the result to the end channel.

func Pipeline() {
	quit := make(chan struct{})
	add1 := func(i int) int {
		return i + 1
	}
	times2 := func(i int) int {
		return i * 2
	}
	square := func(i int) int {
		return i * i
	}
	doTask := func(c <-chan int, f func(int) int) <-chan int {
		out := make(chan int)
		go func() {
			defer close(out) // Ensure the channel is closed when the goroutine exits
			for {
				select {
				case num, ok := <-c:
					if !ok {
						return // Exit the goroutine if the input channel is closed
					}
					fmt.Println("calling function", f)
					resultNum := f(num)
					out <- resultNum
				case <-quit:
					return
				}
			}
		}()
		return out
	}

	c := make(chan int) // initial channel where we put data in
	resultChannel := doTask(doTask(doTask(c, add1), times2), square) // calling 3 functions in a pipeline
	go func() {
		for {
			select {
			case result, ok := <-resultChannel:
				if !ok {
					return // Exit the goroutine if the result channel is closed
				}
				fmt.Println("result:", result)
			case <-quit:
				return
			}
		}
	}()
	// simulate some tasks
	c <- 1
	c <- 2
	c <- 0
	close(c) // Close the input channel after sending all values
	time.Sleep(5 * time.Second)
	close(quit)
}

// calling function 0xc75b40
// calling function 0xc75b40
// calling function 0xc75b60
// calling function 0xc75b60
// calling function 0xc75b80
// calling function 0xc75b80
// calling function 0xc75b40
// calling function 0xc75b60
// result: 16
// result: 36
// calling function 0xc75b80
// result: 4

Conclusion

Hope you know some ways of writing goroutines after reading! In this note, we discussed how to create goroutines, exit goroutines in different ways, and utilize channel and go routines to make a pipeline. However, we didn’t discuss a lot about channel. In the future, I will share more about some strategies of using channels.

Contact

- LinkedIn
- GitHub
- Email