💾 Archived View for yujiri.xyz › software › guide › concurrency.gmi captured on 2023-06-16 at 16:56:26. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-06-14)
-=-=-=-=-=-=-
Learn programming for good instead of profit
Concurrency or parallelism means doing multiple things at once. Normally, programs execute their whole code sequentially, only ever one thing at a time. There are a few different ways you can make it run concurrently.
Let's get this out of the way because it sort of only halfway counts but it is the most basic. Just spawn multiple processes. Like a command shell does when you pipe (`ls | wc` spawns `ls` and `wc` as separate processes, so they run at the same time), you can spawn new processes to do the things you want to be done at the same time. Disadvantages:
This is pretty similar to multiple processes but a lot better. A thread is like a part of a process that can run at the same time as another part. Compared with separate processes:
"Green" threads are similar to "real", OS-managed threads, but they're managed by the process itself instead of by the operating system.
To use green threads, a program sets up a small number of OS threads that actually run things, and any number of green threads which represent all the things it wants to do concurrently, and then it shares the OS threads across the green threads so every green thread gets a turn to run, just like how the kernel shares a few CPU cores across all the running processes (or OS threads).
The point of doing this is that switching between green threads is cheaper than switching between OS threads, since it doesn't involve the kernel.
And before you ask: you don't have to implement this scheduling complexity yourself to use green threads. Usually if you're using green threads it's because you're using a language or library that provides them for you, such as the Go language. In Go, you just use the `go` keyword to spawn a new green thread. The compiler will automatically insert the code for managing these green threads into every executable.
Asynchronous programming is a style of programming meant for when all the things you want to do concurrently are *IO-bound* rather than *CPU-bound* (that is, you want to listen for incoming data on two sockets at the same time and respond to whichever comes in first, but don't need to do two math problems at the same time). Asynchronous programming doesn't necessarily rely on threads at all.
Generally, asynchronous programming involves an event loop, which is a data structure that keeps track of all the tasks that are running concurrently and runs whichever of them is not stuck waiting on IO.
Asynchronous programming is a natural way to write certain kinds of applications, like network servers or GUI programs.
Threads generally introduce the need for synchronization. I touched on this in the chapter on inter-process communication, but now I'll explain it in more detail.
Writing to memory is a volatile thing. If you have one thread trying to read from a memory address, and another thread trying to write to it at the same time, what happens? Does the reader get the old value, the new value, or some corrupted mixture of the two? Does the write succeed at all? Does the program crash? There is no reliable answer. This situation is called a "data race", and the actual outcome can depend on the hardware and such. So, for software purposes, it's called "undefined behavior", because it's not defined what will happen in this situation.
So, when you have two threads or two processes sharing memory, you generally have to take steps to ensure that access is synchronized: that even if the threads are running at the same time, they won't both touch the same memory location at the same time. Specifically:
I'll talk about two programming techniques used to accomplish this synchronization:
Processors support instructions for *atomically* reading or writing memory, which guarantees the read or write happens all at once and not at the same time as anything else, and no bizarre corrupted value appears. These aren't the default ways of reading and writing memory because they have more overhead and aren't needed for most cases (anything where only one thread is accessing it at a time).
Different programming languages have various ways of offering you this functionality. For example, in Zig there is a special thing called `@atomicStore` (store meaning write) which atomically stores a value in a variable, unlike the normal `variable = value` syntax which is not atomic.
Atomic reads/writes have a limitation though: they only work with primitive operations like reading or writing a number to memory. If you need to synchronize something more complex, you need to use locks.
Locks are a feature provided by operating system kernels meant for ensuring that only one thread is accessing something at the same time. Two threads can share a lock, and before each one tries to access the shared memory, they request to acquire the lock, and:
It's important for a thread that uses the lock to release it when it's done, otherwise no other thread will be able to access the memory afterward.
Locks are very difficult to work with in most languages because there's so many ways you can go wrong with them:
Deadlocks are hard to debug because instead of any error message, the program just freezes, leaving you to search your entire source code for the problem.
Data races are even nastier because they're non-deterministic, so they might for example happen on a user's machine while the program works fine on the developer's machine, or they might happen on the developer's machine but only sporadically, with no reliable way to reproduce the bug, and thus even less information about where the problem might be.
Rust is the only language where locks are actually easy to work with, because it exposes their functionality in such a way that you *can't* access lock-protected data without acquiring the lock, and also can't forget to release the lock when you're done.
There are also multiple kinds of locking: there's exclusive locking, which excludes all other threads from having the lock at the same time, and there's shared locking, which allow other threads to have it at the same time as long as none of them need to have it exclusively. If a thread is only going to be reading from the shared memory, it can just acquire a shared lock so it doesn't needlessly block other readers.
Exclusive locks are also called mutexes (MUTual EXclusion).