-
Notifications
You must be signed in to change notification settings - Fork 7
Concurrency
Serena OS offers a very rich set of concurrency mechanisms and tools that make it easy to write code that takes advantage of concurrency.
There are virtual processors, dispatch queues and processes. A virtual processor is the lowest level form of a thread of execution. It is very similar to threads in traditional operating systems, however a virtual processor has a much more fluid relationship to its process compared to a thread. A thread is something that a process creates and owns until it is explicitly terminated. A virtual processor on the other hand is a thread of execution that is dynamically acquired when needed, used for some time and automatically relinquished when not needed anymore. In a sense a virtual processor belongs to the kernel and is dynamically assigned and removed from a process as needed. In fact, a virtual processor may move between processes if the kernel deems this to be necessary.
Virtual processors can be organized in groups. Each group is identified by a virtual processor group id and groups can be expanded and reduced in size at any time and as needed. All virtual processors in a group work in parallel. Additionally a virtual processor group can serve as the target of a signal.
The user space interface to virtual processor and groups is defined in the sys/vcpu.h header. It is recommended however that you use dispatch queues whenever possible since they make it much easier to implement concurrent algorithms.
Dispatch queues (see dispatch.h) are an abstraction layer on top of a virtual processor groups. Each dispatch queue is backed by its own private virtual processor group. A dispatch queue has a minimum and a maximum concurrency level. The concurrency level of a dispatch group determines how many virtual processors a dispatch group uses to achieve the desired level of concurrency. A dispatch queue with a concurrency level of 1 is known as a "serial dispatch queue" because at most one operation can execute at any given time. A dispatch queue with a concurrency level greater one is also known as a "concurrent dispatch queue" because multiple operations may execute in parallel.
Both virtual processors and dispatch queues are "process private" concurrency mechanisms because they are not visible outside the boundaries of a process. So no other process can directly access or address a virtual processor, virtual processor group or dispatch queue that belongs to your process. There is an indirect mechanism available though: you can set up signal routes to ensure that a signal that is sent to your process, is automatically routed to a single virtual processor, virtual processor group or dispatch queue inside of your process. This routing also works with dispatch queues because a dispatch queue allows you to retrieve its virtual processor group id. You can then use this id to set up a route that routes a process-level signal to the dispatch queue for further processing.
A process is a collection of virtual processor groups, an address space and a set of resources. Resources are things like ope files, pipes, terminal connections, etc. All these things are connected to the process by an I/O channel. The address space keeps a record of all memory that a process has allocated and it ensures that the memory will be automatically returned to the system if the process terminates.
The system provides a number of synchronization primitives that virtual processors can use to implement concurrency safe operations. There are spinlocks, mutexes, condition variables and wait queues.
Spinlocks (see sys/spinlock.h) are the lowest level of synchronization primitive. They are so named because they do not use a wait queue to dynamically suspend a virtual processor if the lock is contended. Instead they simply yield for a short amount of time. This kind of lock is appropriate for situations where very little contention is expected.
Mutexes (see sys/mtx.h) provide a full and fair locking implementation. The virtual processor is suspended while the lock is in use by some other virtual processor and it is only woken up once the lock becomes available.
Condition variables (see sys/cnd.h) can be used to implement solutions to producer-consumer style problems.
Finally wait queues (see sys/waitqueue.h) serve as a fundamental building block for synchronization primitives. A wait queue allows a virtual processor to efficiently wake up a set of waiting virtual processors. You can use them in your code to build your own kind of synchronization primitive if none of the provided ones are suitable for your problem.
The atomic primitives represent the lowest level of building blocks that can be used to implement highly efficient atomic solutions and synchronization primitives in user space code. These functions avoid system and blocking calls as much as possible and try to do as much work as possible using special CPU instructions in user space. However, please note, that sometimes it is necessary for some of these functions to execute a special system call because the only way to provide atomicity is to execute code in kernel space. Whether this overhead is necessary or not depends on the CPU type and hardware platform on which your code is running.
The first group of atomic primitives are the atomic integer and flags primitives (see ext/atomic.h). These primitives are a subset of the C23 atomic macros and functions as defined in the stdatomic.h header file. Atomic flags are used to implement boolean variables that allow atomic setting, clearing and reading. Atomic integers are used to implement 32bit sized integer variables that allow atomic addition, subtraction and bit-wise operations.
The second group of atomic primitives are the atomic reference counting functions (see ext/rc.h). These functions make it possible to implement a highly efficient reference counting system in user space that guarantees atomicity.