Go has goroutines. These are the foundations for concurrency in Go.
I want to step back for a moment and explore the history that leads us to goroutines. In the beginning computers ran one process at a time. Then in the 60’s the idea of multiprocessing, or time sharing became popular. In a time-sharing system the operating systems must constantly switch the attention of the CPU between these processes by recording the state of the current process, then restoring the state of another. This is called process switching.
There are three main costs of a process switch. First is the kernel needs to store the contents of all the CPU registers for that process, then restore the values for another process. The kernel also needs to flush the CPU’s mappings from virtual memory to physical memory as these are only valid for the current process. Finally there is the cost of the operating system context switch, and the overhead of the scheduler function to choose the next process to occupy the CPU.
There are a surprising number of registers in a modern processor. I have difficulty fitting them on one slide, which should give you a clue how much time it takes to save and restore them.
Because a process switch can occur at any point in a process’ execution, the operating system needs to store the contents of all of these registers because it does not know which are currently in use. This lead to the development of threads, which are conceptually the same as processes, but share the same memory space.
As threads share address space, they are lighter than processes so are faster to create and faster to switch between.
Goroutines take the idea of threads a step further.
Goroutines are cooperatively scheduled, rather than relying on the kernel to manage their time sharing. The switch between goroutines only happens at well defined points, when an explicit call is made to the Go runtime scheduler. The compiler knows the registers which are in use and saves them automatically.
While goroutines are cooperatively scheduled, this scheduling is handled for you by the runtime. Places where Goroutines may yield to others are:
- Channel send and receive operations, if those operations would block.
- The Go statement, although there is no guarantee that new goroutine will be scheduled immediately.
- Blocking syscalls like file and network operations.
- After being stopped for a garbage collection cycle.
This an example to illustrate some of the scheduling points described in the previous slide.
The thread, depicted by the arrow, starts on the left in the ReadFile function. It encounters os.Open , which blocks the thread while waiting for the file operation to complete, so the scheduler switches the thread to the goroutine on the right hand side.
Execution continues until the read from the c chan blocks, and by this time the os.Open call has completed so the scheduler switches the thread back the left hand side and continues to the file.Read function, which again blocks on file IO.
The scheduler switches the thread back to the right hand side for another channel operation, which has unblocked during the time the left hand side was running, but it blocks again on the channel send.
Finally the thread switches back to the left hand side as the Read operation has completed and data is available.
This slide shows the low level runtime.Syscall function which is the base for all functions in the os package. Any time your code results in a call to the operating system, it will go through this function. The call to entersyscall informs the runtime that this thread is about to block. This allows the runtime to spin up a new thread which will service other goroutines while this current thread blocked.
This results in relatively few operating system threads per Go process, with the Go runtime taking care of assigning a runnable Goroutine to a free operating system thread.