As you likely know, Perl 5 is fundamentally single threaded. While threads.pm allows for concurrency, it creates "heavy" threads that clone the entire interpreter state, leading to slow startup times and high memory consumption. For high-concurrency systems, the preferred primitive is the fiber or green threads.
In this chapter, we dissect Acme::Parataxis, a fiber system built with Affix that implements true fibers by performing low-level surgery on the Perl interpreter's internal state. My early goal for this was to demonstrate messing around with perl's internals from an affix'd shared library with the help of Affix::Build.
The Recipe
Interfacing with a fiber system requires binding to custom C code that understands the Perl C API.
use v5.40;
use Acme::Parataxis qw[:all];
$|++;
async {
say 'Main task started';
# 'fiber' is a shorter alias for 'spawn'
my $f1 = fiber {
say ' Task 1: Sleeping...';
await_sleep(1000);
return 42;
};
my $f2 = fiber {
say ' Task 2: Performing I/O...';
# ...
return 'I/O Done';
};
# 'await' works on fibers and futures
say 'Result 1: ' . await($f1);
say 'Result 2: ' . await($f2);
};
How It Works
Acme::Parataxis is perhaps the most extreme example of what is possible with Affix. It doesn't just wrap a library; it redefines the execution flow of the Perl interpreter itself.
1. The Dual-Layer Switch
A fiber switch in Parataxis is a two-stage process performed in a custom C library bound via Affix.
Stage A: The OS Context Switch
Affix binds to OS-specific primitives like SwitchToFiber (Windows) or swapcontext (Unix). This saves the CPU registers, including the Instruction Pointer and the Hardware Stack Pointer.
Stage B: The Perl Context Switch
The C core defines a para_fiber_t struct that holds the entire state of a Perl interpreter thread. Acme::Parataxis uses Affix to bind a function that manually swaps over 20 global pointers found in the Perl core (PL_stack_sp, PL_markstack, PL_scopestack, etc.).
2. Architectural Inspiration: The Wren Model
The design of Acme::Parataxis is heavily inspired by the Wren scripting language. In Wren, fibers are the primary unit of execution and are designed to be...
- Cooperative: They only switch when explicitly yielding.
- Deterministic: Control flow is predictable, eliminating many "race condition" bugs common in preemptive threading.
- Lightweight: Each fiber has its own stack (512KB by default in Parataxis), allowing you to spawn thousands of them where you could only spawn dozens of OS threads.
Real-World Efficiency: Non-blocking HTTP
The true power of fibers is making asynchronous I/O look like simple synchronous code. By subclassing HTTP::Tiny and replacing its internal socket checks with await_read() and await_write(), we can perform multiple requests in parallel on a single thread:
async {
my $http = My::CooperativeHTTP->new();
my @urls = qw[http://perl.org http://metacpan.org];
# Spawn concurrent requests
my @futures = map { spawn { $http->get($_) } } @urls;
# Wait for all to finish
my @results = map { await $_ } @futures;
};
A functioning example using HTTP::Tiny in such a system comes bundled with the Acme::Parataxis dist.
Benchmarks: The Speed of Cooperation
Context switching in Acme::Parataxis happens at the nanosecond scale because the "trampoline" generated by Affix is optimized for your specific CPU architecture.
Benchmark: The Stress of 1,000 Fibers
This test measures the scheduler's ability to handle many short-lived fibers performing rapid yield/resume cycles.
use v5.40;
use Acme::Parataxis qw[:all];
use Time::HiRes qw[time];
no warnings 'recursion';
async {
my $total_fibers = 1000;
my $iterations = 5;
say "Starting Stress Test: $total_fibers fibers, $iterations iterations...";
my $start_time = time();
for my $iter ( 1 .. $iterations ) {
my $iter_start = time();
my @futures;
# Spawn a batch of 1,000 fibers
for my $i ( 1 .. $total_fibers ) {
push @futures, fiber {
# Perform a tiny bit of CPU work
my $val = 0;
$val += $_ for 1 .. 100;
# Cooperatively yield control halfway through
yield() if $i % 2 == 0;
return $val;
};
}
# Await the completion of the entire batch
await $_ for @futures;
my $elapsed = time() - $iter_start;
say sprintf ' Iteration %2d completed in %.4fs', $iter, $elapsed;
}
my $total_elapsed = time() - $start_time;
say sprintf 'Total time: %.4fs', $total_elapsed;
say 'Average time per fiber lifecycle: ' . ( $total_elapsed / ( $total_fibers * $iterations ) ) . 's';
};
In terms of throughput, fibers can outperform threads.pm for many tasks because they avoid the kernel overhead of OS-level scheduling.
Real-World Demo: Threaded I/O Offloading
The following demonstration shows how Acme::Parataxis handles blocking operations (like sleep or read) by offloading them to a native C thread pool while the main Perl thread continues to schedule other fibers.
use v5.40;
use Acme::Parataxis qw[:all];
use Time::HiRes qw[time];
async {
say 'Main: Initial thread pool size is ' . Acme::Parataxis::get_thread_pool_size();
my @futures;
for my $id ( 1 .. 5 ) {
push @futures, fiber {
my $sleep_time = int( rand(1000) ) + 500;
say " [Fiber $id] Will sleep for ${sleep_time}ms in background pool...";
my $start = time();
# This suspends the fiber, the C pool sleeps, and we are resumed when done.
await_sleep($sleep_time);
my $elapsed = int( ( time() - $start ) * 1000 );
say " [Fiber $id] Woke up after ${elapsed}ms!";
return "Result $id";
};
}
say 'Main: All fibers spawned. Total pool size: ' . Acme::Parataxis::get_thread_pool_size();
# Wait for all workers to finish
my @results = map { await $_ } @futures;
say 'Main: All fibers finished!';
};
Kitchen Reminders
- Hybrid Power:
Acme::Parataxisuses a "Hybrid" model. Perl code runs on one thread, butawait_*calls offload blocking work to a native C thread pool. - The
Pointer[SV]Responsibility: When using Affix to pass raw Perl variables (SV*) to C fibers, you must manually manage refcounts. A single mistake here leads to leaks that standard Perl profilers cannot see. - Cooperative Preemption: If a fiber runs a tight loop for too long, it can "starve" other fibers. Use
maybe_yield()to allow the scheduler to breathe.