Ruby currently uses OS provided abstractions for implementing Fibers. Unfortunately, these often perform poorly and have tricky semantics. We present a native implementation of coroutines and demonstrate improvements to performance.
Existing Implementations
The most common implementation on UNIX systems involves manipulating the state of makecontext()
and swapcontext()
. These function calls have been deprecated and removed from the POSIX standard, but still exist in most UNIX implementations for backwards compatibility. As well as being poorly implemented and supported, the documented semantics of these functions requires certain system calls which limit their performance.
Another typical undocumented and unsupported approach is to abuse setjmp()
and longjmp()
. It is possible to manipulate the jmp_buf
to change the return address and stack pointer, so it is possible to jump between fibers.
Windows provides native APIs for fibers and these are pretty decent. They work as expected, but depending on the situation, may do more than required at the expense of performance.
Improving Performance
A native implementation of coroutines using assembly can significantly improve performance of the Ruby's Fibers. Even thought some assembly is required, the net semantic complexity is reduced and existing hacks can be removed.
x64 Implementation
The state that is required per coroutine - typically just the stack pointer, but it is possible to augment this with other per-coroutine data:
// The fiber context (stack pointer).
typedef struct
{
void **stack_pointer;
} coroutine_context;
The initialization function prepares the stack so that when we transfer to it, it will return to the given start address:
inline void coroutine_initialize(
coroutine_context *context,
coroutine_start start,
void *stack_pointer,
size_t stack_size
) {
/* Force 16-byte alignment */
context->stack_pointer = (void**)((uintptr_t)stack_pointer & ~0xF);
if (!start) {
assert(!context->stack_pointer);
/* We are main coroutine for this thread */
return;
}
*--context->stack_pointer = NULL;
*--context->stack_pointer = (void*)start;
context->stack_pointer -= COROUTINE_REGISTERS;
memset(context->stack_pointer, 0, sizeof(void*) * COROUTINE_REGISTERS);
}
The destroy function essentially just nullifies the stack pointer:
inline void coroutine_destroy(coroutine_context * context)
{
context->stack_pointer = NULL;
}
The transfer function switches between coroutines. It essentially saves the caller state onto its stack, swaps the stack to another coroutine, and then returns.
coroutine_transfer:
# Save caller state
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
# Save caller stack pointer
movq %rsp, (%rdi)
# Restore callee stack pointer
movq (%rsi), %rsp
# Restore callee stack
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
# Put the first argument into the return value
movq %rdi, %rax
# We pop the return address and jump to it
ret
Keep in mind that this is the simplest possible implementation and doesn't preserve things like sigmask, FPU state, etc. They essentially remain per-thread with this specific implementation.
Performance
Generally speaking, the overhead of a fiber context switch is not much more than a standard C fast call (pass as many arguments in registers as possible). On x64, the standard (and only) ABI is fast call, and so it's efficent by default.
Further Reading
The code is available here and the Ruby bug report has more details. There is a PR tracking changes.
The goal of these improvements is to improve the performance of async. I've measured a 5% improvement to async-http.