What are Concurrency/Parallelism and its basics in Go

What are Concurrency/Parallelism and its basics in Go

Before diving deep into how Go handles concurrency in an excellent way, let us understand what we mean by these terms.

Concurrency, as per wiki, is the ability of different parts or units of a program, algorithm, or problem to be executed out of order, or in a partial order, without affecting the outcome.

If the above definition doesn’t clarify things, we can try to understand it in a simple way.

Concurrency is the ability to do multiple things at the same time within shared time slots.

Parallelism, as per wiki,  refers to the techniques to make programs faster by performing several computations at the same time. The above statement means that we should be able to run multiple tasks during the same time slots rather than having shared time slots.

Let me explain the above via a simple real-world example.

Let’s say that a person must complete the tasks below for a given day, which takes the following time.

  1. Working out in the morning => 60 minutes
  2. Listening to music for relaxation => 30 minutes
  3. Preparing breakfast => 30 minutes
  4. Driving to the office by own car => 60 minutes
  5. Listening to a tech podcast => 30 minutes
  6. Preparing slides for the upcoming work => 30 minutes

If a person does all these tasks linearly, the total time taken will be 240 minutes (4 hours), which doesn’t look optimal.

Here’s an illustration of the above with a sequential process -



Everyone wants to save time nowadays, so let’s see how we can optimize the execution of the above tasks.

One such example could be -

  • While working out, whenever you are resting, listen to music for relaxation, which could save some time. (In the real world, most people would prefer to do both together, which is parallel execution)
  • While preparing breakfast, whenever you get some spare time, you could listen to a tech podcast or maybe prepare some part of the slide, saving some time.

From the above example, the total time would reduce by a good margin if we do things in the above fashion.

Here’s an illustration of the above -


This is a great example of concurrency. There are a lot of beautiful examples on the internet which share the same idea, i.e. you are using shared resources to complete the same task in less time.

If this example was about concurrency, how could we relate this to parallelism, where there is no need for shared resources? If in some fashion we can delegate the responsibilities to other people.

Now, if we can multi-task or if, let’s say, there is one extra manpower support, we can execute things in this fashion.

  • Listening to music while working out.
  • Listening to tech podcasts while preparing breakfast.
  • Now, since we have extra manpower, we could use that and someone else can drive while we prepare the slides for work.

This could also reduce the time taken to complete these tasks drastically.

Here’s an illustration of the above -


Now, we could possibly combine both of these as well, which could result in a much more efficient way of doing tasks.

Evolution of processors and chips

Now that we have a good understanding of concurrency and parallelism let’s go back in time to understand how chips and processors have evolved with changes in software requirements.

You might have questions like — why should we understand the evolution of chips?

The reason is while writing code, we all should know that there is a limit to the hardware on which the code will run; the resources are finite and cannot extend beyond a certain point.

Let’s also understand the following while looking at chip evolution -

Clock speed

It refers to how many cycles the CPU can execute per second. Clock speed is measured in hertz (pulses per second).

If you are curious, you can find the clock speed of your system in the operating system processor details. (for mac users, it is visible when clicking on the "About this mac" option)

It also indicates how fast your computer can do jobs.

(One more important point to note, with an increase in the number of CPU clock cycles, the power generated would be more, eventually generating more heat, one of the reasons that most of the current computer's CPU ranges between 2.4–2.6 GHz).

This is one of the standard diagrams which shows the evolution of transistors and CPU clock speed with power consumption.

Interestingly we can see how the CPU Clock speed increased exponentially at the start, and then it became flat. The CPU power consumption also increased, i.e. generation of more heat.

What are the different kinds of processes?

Processes also play a vital role in deciding whether we need concurrency or not.

Now, Let’s look at them -

CPU Intensive

Operations/Computations where the CPU needs a lot of calculations

Imagine running a nested For loop of O(N³), which is doing some complex mathematical calculations inside the loop (the value of N is 10⁹).

IO-bound (Input/Output) Process

What are the different kinds of processes? The IO process involves Input and Output operations.

Example => Reading and writing information from the disk and files.

The IO process might not require a lot of CPU since most of the time is spent getting the data.

Independent process

These are separate independent processes that do not need anything to complete their task. They do not share any data or communication with any other task/process.

Co-operative process

Co-operative processes are used to speed up executions when there are a lot of tasks. They share data and communicate with other processes for faster completion of tasks.

There are various ways to achieve cooperation (message passing, sharing data, variables, files, etc.)

What is Context Switching?

While running parallel tasks, one important point that needs consideration is context switching, which often gets missed by us.

It is a mechanism by which a process saves the process ID of the current task while switching to another task. Once it completes the other task, it will use the saved id to continue the older task.

Now, imagine if we are running thousands of threads. Then how many times will context switching happen? We need to understand the cost of context switching before making everything in the world run in parallel.

The reason I am specifically talking about all of the above, rather than directly jumping on how Go solves concurrency, is that we all understand when we need concurrency and when we could avoid it and go sequential. But we should also be aware of the impact a process can have on the CPU.

A Sword can not do the work done by a needle.

Once you are designing enterprise-level software, always think about whether you need concurrency/parallelism or both or none while understanding the overall cost implication.

You can consider the types of processes you encounter before making these decisions.

How do we handle concurrency in go?

This section assumes that you are already aware of Go.

In case you are new to Go, there are standard tutorials you can read and follow to understand the basics.

Go achieves concurrency using go-routines.

Go-routines => These are very lightweight threads that help in running a function independently.

Example of a Go-routine

A simple Go-routine function call looks like this -

package main
import"fmt"
func main() {
fmt.Println("Inside main function")
gofunc() {
fmt.Println("Inside go routine")
}()
}

So, specifying the Go keyword before a function call can help you run that function as a Go-routine.

Once you execute this program, you will notice that the first print statement gets printed and the one inside the Go routine doesn’t. Why is that so?

The answer is that our main function also runs as a Go-routine, so if we do not tell our main function to wait for other go-routines to finish, it will exit.

Solution for the above using WaitGroup

package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
fmt.Println("Inside main function")
wg.Add(1)
gofunc() {
fmt.Println("Inside go routine")
defer wg.Done()
}()
wg.Wait()
}

The explanation for the above -

  • First, we would be declaring a wait group which will help in completing the go routine, also wait group helps in waiting for a set of Go routines to be completed.
  • wg.Add; it adds a counter to the wait group, so we know how many go routines to wait for.
  • wg.Done; reduces the counter and waits till the Go routine is not finished.
  • Also, we are using defer statements to make sure that wg.Done() function executes before the function scope is finished, and control goes out of this function.
  • wg.Wait; it will block the main function from exiting until the WaitGroup counter is not Zero.

Solving using channels

Channels - A mechanism provided by Go that helps in communications across different go routines.

Channels are of the following types -

Buffered channels -

  • It has a capacity that can be defined during initialization.
  • In the case of a buffered channel, the sender and receiver don’t need to be ready at the same time.

Un-buffered channels -

  • It doesn’t have the capacity and is blocking in nature by default.
  • These types of channels, due to it’s blocking nature are synchronous
  • The sender and receiver need to be ready at the same time

Using un-buffered channel -

package main
import (
"fmt"
)
func print(channel chan bool) {
fmt.Println("Inside go routine")
<-channel
close(channel)
}
func main() {
channel := make(chanbool)
fmt.Println("Inside main function")
goprint(channel)
}

The explanation for the above -

  • We would be creating a channel of type boolean.
  • Go keyword is used for invoking the go routine function call print.
  • We are passing the channel inside the Go routine, which would help us block the call.
  • <-channel, this operator means we are receiving value from the channel; here, we are just using this to block the function and wait for the function to complete.
  • As mentioned above, the send and receive operations are blocked by default.
  • Close (channel), this is used for closing the channel when there is no data.

Similarly, we can use a Buffered channel where we can provide size to the channel and then further read or write in the same fashion.

Example -

package main
import (
"fmt"
)

func print(channel chan bool) {
fmt.Println("Inside go routine")
channel <- true
msg := <-channel
fmt.Println("Value inside the channel ", msg)
close(channel)
}

func main() {
channel := make(chanbool, 1)
fmt.Println("Inside main function")
goprint(channel)
}

Here you can see inside the main function the channel is getting created the size of the channel is 1.

Once the channel is initialised we are inserting the true value in the channel using the <- operator and then print it on the console.

Using a buffered channel too, we can block/unblock a go routine function call.

Conclusion

These are some standard ways that can help us solve concurrency. The above examples are basic examples that are only for understanding these concepts.

Finally, I would want to call out that concurrency and parallelism are great only if they are used for valid cases, i.e. when required. If we start using concurrency/parallelism for everything, it could negatively impact software performance in many cases.

Also, in my future series, I will talk about mutex, synchronization, selects and other aspects of writing concurrent programs in go, which solves many enterprise-level software problems.