I'm still planning and framing things out but I have further ideas. I'm still digging into distributed and concurrent systems like Erlang/OTP, Clojure, and Scala's ZIO and I'm collecting notes for even more advanced ideas.
Here's (half of) what I have so far:
An Ecosystem Hook: Event Loop Integration (Mojo / IO::Async)
Right now, await_read and await_write offload to OS threads via select() which is great for zero-dependency scripts but select() maxes out at 1024 file descriptors (FD_SETSIZE) and using a whole OS thread just to wait for a socket is heavy.
As a potential solution, I could allow Parataxis to be driven by an existing CPAN event loop (like Mojolicious or IO::Async), giving it epoll/kqueue/io_uring capabilities for free. Instead of blocking in Parataxis::run( ), you'd expose a hook and when a fiber calls await_read( $fh ), Parataxis asks the underlying event loop to watch the $fh. When the loop says it's ready, the event loop callback simply calls $fiber->enqueue() and wakes the scheduler.
This should open things up to Go-like HTTP servers where users just write procedural code (my $req = $socket->await_read), but underneath it scales to 100,000 concurrent sockets using Mojo's non-blocking I/O.
Erlang-ish Supervisor Trees (Transaction healing)
I already have fail-fast concurrency with ::Nursery but long-running applications like web servers or data pipelines need heal-fast concurrency or transaction healing.
It could look like this:
my $sup = Acme::Parataxis::Supervisor->new(
strategy => OneForOne,
max_restarts => 5,
within => 60 # seconds
);
$sup->supervise( DatabaseWorker->new );
$sup->supervise( HttpListener->new );
$sup->run();
A Parataxis supervisor would allow you to define a tree actors, fibers, etc. along with a restart strategy:
OneForOne: If fiber A dies, restart fiber AOneForAll: If fiber A dies, kill and restart fibers A, B, and C (useful if they share a broken state)RestForOne: If fiber A dies, kill and restart A and everything started after A.
Software Transactional Memory (STM)
Borrowing from Haskell and Clojure here.
Mutexes are hard to compose and prone to deadlocks. What if fibers could modify shared state without explicit locks, and the runtime automatically handled conflicts?
By implementing Acme::Parataxis::TVar (Transactional Variable, I'll work on the name...), fibers could make changes inside an atomically executed block. If two fibers collide, the runtime would silently roll back the loser and retry the block.
my $account_a = Acme::Parataxis::TVar->new( value => 100 );
my $account_b = Acme::Parataxis::TVar->new( value => 100 );
Acme::Parataxis->atomically(sub {
# We are inside a transaction now!
my $bal_a = $account_a->get;
my $bal_b = $account_b->get;
$account_a->set($bal_a - 50);
$account_b->set($bal_b + 50);
});
Here's what happens behind that code:
- When
atomically(...)starts, it create a temporary, fiber-local transaction log. - When
$account_a->getis called, STM records the current value and its version number in the log's read set. - When
$account_a->setis called, it doesn't modify the real TVar. Instead, it'll write the new value into the logs write set. - When the sub finishes, the STM engine would pause, grab the global lock, and check if the version number of
$account_aor$account_bhas changed since they were last read.- If no other fiber touched them, the write set is flushed to the real TVars, their version numbers increment, and the transaction is complete.
- If there's a conflict (any other fiber modified the value), the transaction is completely discarded and the sub is executed again from the very beginning.
Create log -> Read -> Write -> Commit.
Fibers never deadlock because they never hold locks while running user code. If two fibers collide, the faster one wins, and the lower one seamlessly retries.
Compare that to our current, traditional mutex based approach:
# Traditional Mutex approach
$account_a->mutex->lock;
$account_b->mutex->lock;
$account_a->set( $account_a->get - 50 );
$account_b->set( $account_b->get + 50 );
$account_b->mutex->unlock;
$account_a->mutex->unlock;
- Fiber 1 would transfer A -> B.
- Fiber 2 would simultaneously transfer B -> A.
- Fiber 1 would lock A and fiber 2 would lock B.
And there they'll both sit forever, waiting for the other lock. To resolve this without transactional variables, the user would need to enforce a global lock ordering system (because I can't possibly predict every circumstance in Parataxis). I could expose a simple 'always lock the lowest ID first' algo but that breaks encapsulation and is crazy hard to maintain. In a large codebase, you'd probably forget that such a system existed but you'd better be prepared to deal with the behavior.
Untested implementation could be as simple as this:
sub atomically ( $code ) {
while ( 1 ) {
# Initialize a new Fiber-Local transaction log
my $tx = Acme::Parataxis::TransactionLog->new( );
Acme::Parataxis::Local::TX->set( $tx );
# Run the code, trapping special STM exceptions
try{ $code->() }
catch ($err) {
# If the user called Acme::Parataxis::STM->retry, it threw this:
if ( ref $err eq 'Acme::Parataxis::Error::STM_Retry' ) {
$tx->park_fiber_on_read_set_tvars( );
Acme::Parataxis->_park('STM retry');
next; # Try again after we wake up
}
die $err; # A normal error, bubble it up
}
# Commit Phase (Global Lock acquired here)
if ($tx->validate()) {
$tx->commit();
$tx->wake_retry_waiters();
return; # Success!
}
# Validation failed. Loop around and try again...
}
}
I'd need to document that the user's code must not have irreversible side effects because the same block might execute several times under heavy load. The only work done should be reading or writing to TVars! If you're using fibers to update an on screen status or send a particular network packet, you're going to have a bad time with STM.
...all of that means that we could easily abstract away mutex, semaphore, and condvars entirely for shared state, replacing them with a system where shared memory acts like a lock-free, atomic, ACID-compliant database.
Async Streams (Functional Reactive Programming)
The recipe for async streams is simple: we have channel (CSP) and generator (Iterators). Combining them gives you behavior like Rust's Stream trait or RxJS. We could wrap a channel in a stream object that supports chaining map, filter, throttle, and batch to bring data-pipeline superpowers to fibers.
A normal, synchronous script might deal with a list of data like this:
my @errors = grep { $_->{level} eq 'ERROR' } map { parse($_) } @raw_logs;
But what if the data doesn't exist all at once? What if it’s arriving asynchronously over a Channel forever? The use case for async streams I'm picturing would allow you to treat a Channel like an array. You define a chain of transformations, and the runtime automatically pipes the data through them as it arrives.
Let's tail a live access log, parse the JSON, filter for 500-level errors, group them into batches of 100, and write them to a database. Doing this manually with raw loops and channels is messy. With a Stream API, it would look like this:
my $raw_channel = tail_log_file( '/var/log/app.log' );
Acme::Parataxis::Stream->from_channel( $raw_channel )
->map( sub ($line) { decode_json( $line ) } )
->filter( sub ($msg) { $msg->{status} >= 500 } )
#->throttle( 100 ) # Max 100 per second
#->batch_time( 1000 ) # Group into arrays every 1000ms
->batch( 100 ) # Wait until we have 100 items queued
->consume( sub ( $batch_arrayref ) {
# This block runs every time a batch is ready
db_bulk_insert( $batch_arrayref );
});
Parataxis already has channels and fibers so, implementing this is trivial! Every step in the chain (map, filter, batch) is just a factory that:
- Creates a new output
Channel. - Spawns a background
fiberthat loops over the input channel, applies the callback, andputs to the output channel. - Returns a new
Streamobject wrapping the output channel.
Free Backpressure
Because ::Channel has a $capacity field, this pipeline is automatically safe from memory leaks. If the database gets slow, consume blocks. That means the output channel from batch gets full. That causes the batch fiber to park on put which causes the filter fiber to park... and so on, all the way back to the log reader. Backpressure would prevent fast producers from overwhelming slow consumers and crashing the server with OOM errors.
I have another handful of ideas but I need to turn random phrases into actual explanations so...
Comments