Mahdi.Kh
September 21, 2026
We get familiar with the concepts of concurrency and parallel processing, and learn how to run and control statements concurrently or in parallel using goroutines and channels.
In the simplest case, tasks can run sequentially, meaning if we have three tasks, the CPU runs them one after another.
Often, a program is waiting for some event to happen, and during that time it isn't using the CPU. For example, a program might be waiting for a disk read or write (I/O) operation to complete. In such cases, the program's execution can be paused temporarily, another program can run on the CPU, and once the waited-for operation finishes, the original program's execution resumes.
Here are some examples of operations that cause programs to wait:
These tasks usually fall into the category of I/O tasks.
To reduce how much time the CPU sits idle, we can run programs concurrently. So concurrency gives us the ability to schedule several programs that have pauses onto a single CPU.
For example, imagine we have three programs, A, B, and C, each doing the following tasks:
Program A:
Program B:
Program C:
If we want to run these three programs sequentially on one CPU, they'll run as follows:
Now, if we run these programs concurrently, the CPU will sit idle less, and the result will run as follows:
While an I/O operation is happening, we take the program off the CPU and run another program on the CPU. This is called context switching. During context switching, the information related to the program's execution is placed in RAM, and to continue the program, this information is loaded back onto the CPU.
In the example above, while one program is running on the CPU, the I/O operation for another program is carried out by some part of the computer (for example, the disk or network card), and once that I/O operation finishes, the original program resumes.
If we have several CPUs, we can run programs in parallel. For example, if we have three CPUs, we can run each program on its own CPU.
When we run a program, the operating system creates a process for it. So a process is a running program together with all the resources it needs.
A process usually consists of the following parts:
Every process has its own separate memory in RAM. So different processes have separate memories.
A thread is the smallest unit of execution inside a process that the operating system can run or stop. If we think of each process as a factory, threads are like that factory's workers. This means the process holds the resources, and the thread executes the code.
Every thread usually has the following parts:
A process's memory is shared with its threads. So all the threads belonging to one process share memory. So the following are shared among threads:
But every thread has its own separate stack, so every thread has its own local variables, which the other threads can't access. Stack memory is usually used when calling functions.
The operating system schedules and runs threads on the CPU, and the CPU uses the Program Counter to know which instruction comes next. So a thread is an execution context that determines which instruction of the program the CPU should continue from, and in what state.
In more detail, context switching means that when a pause occurs within a thread, the operating system puts it into RAM, and later, when it's ready to continue, loads it back onto the CPU to resume.
Creating a thread is an expensive operation, because the operating system has to do a certain amount of work, and also because the stack allocated for a thread is large — for example, the default stack size for a thread on Linux is 8MB! So if you have 16GB of RAM, you can create at most around 200 threads.
Functions called with the `go` statement are called goroutines. Go manages these goroutines and distributes them across threads that run on the CPU's cores. Compared to threads, goroutines are much more lightweight, which is why you can create hundreds or even thousands of goroutines.
Goroutines inherently run concurrently, and if there are several CPUs available, they can also run in parallel.
To create a goroutine, we just need to use the go statement to call a function:
In the example above, we called two functions inside separate goroutines. So the first three calls, made using the go statement, run independently, and finally the call say("Hello stranger!") happens.
Why did we use the time.Sleep statement? Let's look at the example above once without time.Sleep.
Goroutines run independently, so once the say("Hello Stranger!") statement runs, the main function's work is done, and the program exits before the other two goroutines get a chance to run!
The main function is actually a goroutine too, but it runs implicitly when the program starts. So in this case, we have three goroutines:
maingo say("I love Go")say("Hello strager!")The only point is that when main finishes running, the entire program stops, and as a result, all the other goroutines end too. So we can use the time.Sleep statement to give Go a chance to schedule the goroutines onto threads so they get run by the CPU.
sync.WaitGroupUsing time.Sleep to wait for goroutines to finish isn't a good idea, because we can't precisely predict how long the goroutines will take to run. A better approach is to use sync.WaitGroup. Inside sync.WaitGroup there's a counter that increases when the Add method is called and decreases when the Done method is called. The Wait function waits as long as the counter isn't zero.
We can rewrite the previous example's code as follows:
In the example above, for each goroutine, we increase the counter in the wg variable by one by calling Add(1), and then, once each goroutine's work is done, we decrease the counter by one by calling Done. Finally, by calling Wait, we make the program wait for all the goroutines to finish. In this case, the program keeps running until all the goroutines have finished their work.
In Go version 1.25, the WaitGroup.Go function was introduced. This function has code similar to the following:
The previous example's code can be simplified using waitGroup.Go, as follows:
In Go, goroutines can pass values to each other through channels. A channel is like a pipe: one goroutine can put something into it, and another goroutine can receive it from the other end.
Every channel has a data type that determines what kind of data it can carry. To declare a channel, we can use chan together with the data type that should be transferred:
We use the <- operator to send and receive data on a channel. When it points toward the channel, a message is sent to the channel:
And when it points away from the channel, a message is received from the channel:
When a goroutine wants to send a value to a channel or receive a value from it, execution stops — or blocks — at that point until the operation completes. During a send (messages <- "ping"), the sending goroutine waits until another goroutine receives that value from the channel. During a receive (<-messages), the receiving goroutine waits until a value becomes available to read from the channel.
In other words, a channel is the point where goroutines connect to each other, and their execution stops until the operation is complete. This is exactly the concept of blocking during sends and receives.
A send and a receive on a channel are really a pair of synchronized operations. A send isn't complete without a matching receive (and vice versa), and only once the receive happens can both goroutines continue their execution.
To understand goroutines more deeply, I'd suggest also checking out the resources below:
Previous part: Generating Code with go generate | Golang basics
Next part: Channels | Golang basics