Concurrency vs Parallelism in Go

Concurrency vs Parallelism in Go

Def

Let's skip the boring part by just getting some definitions out of the way.

thread = a schedulable unit of work whose count is not limited by your cores

That's it. Other than that you need to know that your OS has a scheduler. The Go runtime also has its own scheduler and threads that are not 1:1 with your OS threads.

The Two Schedulers

The OS scheduler is responsible for deciding which OS threads run on which CPU cores and for how long. The Go runtime scheduler sits on top of that and is responsible for deciding which goroutine runs on which OS thread.

When you call a goroutine, the Go scheduler picks it up, assigns it to an available runtime thread, which is backed by an OS thread, which the OS scheduler then puts on a core. Your goroutines go through two layers of scheduling before they actually execute.

C vs Go

Now, before I tell you about how Go handles parallelism and concurrency I'll tell you how C handles it with pthreads. I'll do this so you can understand why most people get it wrong.

So in C using the pthreads library you make a syscall which creates an OS thread which is scheduled by your OS, which is a very expensive call to make.

// Every pthread_create() makes a clone() syscall — creates a real kernel thread
pthread_t thread;
pthread_create(&thread, NULL, do_work, NULL);
pthread_join(thread, NULL);

On the other hand in Go you create a thread that is logical and handled by the Go runtime.

// No syscall — the Go runtime handles this entirely in user space
go doWork()

Since 1.5+ the Go runtime by default will create the necessary threads based on the total number of logical cores the system has. It used to be that you would need to use a runtime call to set the number of cores but that's not the case since 1.5+. After that Go will create OS threads as needed and schedule your goroutines across them.

Parallelism by Default

The Go runtime will run your goroutines in parallel if it can. So if for example you have 8 physical cores and 16 logical cores and you run 20 goroutines, the runtime scheduler will immediately run 16 goroutines, one per P, and the 4 left will sit in a run queue until one of the goroutines finishes and its P becomes free.

You have the option to set the number of cores to 1 with a runtime call so that all goroutines will be run on a single thread concurrently, but you generally want everything run in parallel for performance.

runtime.GOMAXPROCS(1)

There are use cases where you don't want some of your goroutines to run in parallel. If for example they are operating on the same data, where you want to avoid a race condition, you can use a mutex and lock the writing:

var mu sync.Mutex
var counter int

go func() {
    mu.Lock()
    counter++
    mu.Unlock()
}()

Or for example you would like to wait for some form of result from a goroutine, where you can use a channel to block execution until the channel has a value inside:

result := make(chan int)

go func() {
    result <- compute()
}()

val := <-result
fmt.Println(val)

This is what people refer to when they say Go is very good for concurrency, it has one tool for each job and it's very simple and easy to understand.