1. The Concurrency Model in Go
1.1 The Communication Model: CSP
CSP stands for Communicating Sequential Process, a model for concurrent communication. A Process can use many Channels, and a Channel does not care who is using it — it is only responsible for sending and receiving data.
In the Go community there is a very famous maxim: do not communicate by sharing memory; instead, share memory by communicating. What it means is that you should not pass pointers between Processes; you should encapsulate data into objects, drop them into a Channel, and wait for a Process to consume them.
The Process/Channel of CSP correspond to the Goroutine/Channel of the Go language, and they are the cornerstone of concurrent programming in Go. A Goroutine is used to execute tasks, and a Channel is used for communication between Goroutine tasks.
1.2 The Two-Level Thread Model
A user thread is just a pile of data inside a user program; it is the kernel thread that is the actual thread in the system. When a user thread gets scheduled, the kernel thread reads the user thread’s data and executes it.
It is therefore worth understanding the relationship between user threads and kernel threads — that is, the thread model. Based on how the two are mapped to each other, we can distinguish three scheduling models: one-to-one, many-to-one, and many-to-many. (There is currently no real-world case of a model where one user thread is bound to multiple kernel threads.)
One-to-one: one user thread is bound to one kernel thread. With the help of the kernel’s scheduling, concurrency is easy to achieve, but kernel threads switch frequently and the scheduling cost is high. Many-to-one: multiple user threads are bound to one kernel thread. Scheduling of the multiple threads is controlled through program logic, but only one kernel thread is bound, so this is concurrency only at the macro level, not true parallelism. Many-to-many: multiple user threads are bound to multiple kernel threads. This lets you fully exploit the computing performance of multiple cores.
The two-level thread model separates user scheduling from kernel scheduling. User scheduling only has to care about scheduling user threads against logical processors, while kernel scheduling only has to care about scheduling logical processors against physical processors.
1.3 The G-P-M Scheduling Model
A very important reason Go can fully exploit the multi-core performance of the CPU is that it implements the G-P-M scheduling model on top of the many-to-many thread model. The concurrency performance of some languages depends on third-party libraries, with the scheduling of user threads and coroutines implemented inside those third-party libraries. But in Go, this scheduling capability is provided directly as a language feature. The diagram below is the model of the Go scheduler:

First, let’s look at the related concepts:
- G, Goroutine
Each Goroutine corresponds to a G struct, used to store the G’s running stack and state — in other words, an execution logic.
- P, Processor
A logical processor. A G must be bound to a P before it can be scheduled. A P provides the M with context such as memory allocation state and the task queue.
- M, Machine
A physical processor. A P must be bound to an M before it can be scheduled.
The scheduling process works like this: after a G is created it all enters the Global queue, waiting to be scheduled. A P finds an idle M, and after binding, starts executing the Gs in its Local queue. If a G makes a system call and leaves the M in a blocked state, then the P will drift to another M along with its Local queue. When there is no G in a P’s Local queue, it fetches Gs from the Global queue and then from other Ps’ Local queues, until all Gs have finished executing.
A Goroutine needs only 2KB of memory to start with, so very little resource is needed to reach a very high level of concurrency. But the memory it occupies can keep growing, up to 1GB on a 64-bit machine. This guarantees both startup speed and quantity, while also accommodating scenarios with large memory consumption.
2. Goroutine and Channel in Code
A Goroutine is the entity that actually executes concurrently. Through Channel communication, Go achieves data passing and synchronization between Goroutines.
2.1 Goroutine
A Goroutine is implemented with coroutines. What is a coroutine? A coroutine is a lightweight thread, an execution logic. But this execution logic is not scheduled by the OS; in Go, these coroutines are managed and scheduled by the Goroutine scheduler.
The G-P-M model is the implementation model of the Goroutine scheduler. Let’s look at an example directly:
| |
With the go keyword, you can easily execute a function concurrently in a non-blocking way. Without time.Sleep, the main program will not wait to print 2/3 - goroutine and will simply exit.
2.2 The Lifecycle of a Channel
- Create
A Channel must be created with make; the zero value of a Channel is nil.
| |
- Write data
| |
- Read data
| |
- Close the Channel
| |
Reading and writing data here is a bit like pipe operations in the Linux system.
2.3 Channel Classification
By the direction of data flow, Channels can be divided into three kinds:
- Declare a bidirectional channel of type T
Usually the ones used are bidirectional channels.
| |
- Declare a channel that can only send type T
| |
- Declare a channel that can only receive type T
| |
By whether they have a buffer area, Channels are divided into two more kinds:
- Without a buffer, which can be seen as synchronous mode — this is the default mode
Sending and receiving happen at the same time; when one side is not ready, the other side stays in a waiting state.
| |
- With a buffer, which can be seen as asynchronous mode
While the buffer is not full, sending and receiving are asynchronous; only when the buffer is full does sending block, waiting for data to be consumed.
| |
After a Channel has been used, it needs to be closed. If you continue to send data, it will cause a Panic. But you can continue to read data from the Channel: a Channel without a buffer returns the zero value, and a Channel with a buffer also returns the zero value after the data has been fully received. This guarantees that even when a Channel is closed, the data in it is not lost and can still be handled with reliable logic.
3. Communication Examples
- Channel communication
In the G-P-M example, time.Sleep was used to wait for a Goroutine to finish. But the completion time of a Goroutine is uncertain — it could be tens of milliseconds, or it could be tens of seconds; this way of controlling things is unreliable.
An unbuffered Channel can be used for this kind of communication scenario. Here is a related example:
| |
If the Goroutine does not involve data transmission, you can also declare a boolean-type Channel here, done := make(chan bool). When the Goroutine finishes all of its execution logic, just write a boolean value into done.
- WaitGroup to control Goroutines
Go’s advantage is high concurrency, which means many Goroutines may be executing at the same time. So how do you control the concurrent behavior of multiple Goroutines? The answer is WaitGroup. Let’s look at the example below:
| |
The result of this code is different every time it runs, because the execution order of concurrent Goroutines at equal priority cannot be controlled.
WaitGroup implements concurrency control through a counter and a semaphore. On wg.Add, the counter is + 1; on wg.Done(), the counter is -1.
