Parallelism, concurrency, and asynchrony are first-class concerns in gab. Two foundational primitives are provided: fibers and channels.
Fibers
A fiber is a lightweight unit of execution, similar to a goroutine in Go or a process on the BEAM (Erlang/Elixir). Fibers are cheap: gab’s runtime is designed to support hundreds of thousands of them running concurrently.
You spawn a fiber by passing a block to Fibers.make:
Fibers.make () :: do
'Hello from a fiber!'.println
end
The block runs concurrently. The fiber is scheduled by gab’s runtime — you don’t manage threads or thread pools.
Here’s a more complete example that spawns 20,000 fibers:
spawn_task := (i) :: do
Fibers.make () :: do
'Hello from fiber $!'.sprintf(i).println
end
end
Ranges.make(0, 20000).each spawn_task
Channels
Fibers communicate with each other through channels. A channel is the only way for two fibers to exchange data, or synchronize.
Create a channel with Channels.make:
ch := Channels.make
Send a value into a channel with the <! operator:
ch <! 'a message'
Receive a value from a channel with the >! operator:
value := ch >!
gab’s channels are unbuffered: a send blocks until a receiver is ready, and a receive blocks until a sender is ready. This keeps communication explicit and synchronised.
Note
Channels actually accept tuples instead of just single values. You can send as many values as you want, and they will be transferred as a group.
Putting it together
Here is a pipeline where many fibers produce values, and a single consumer reads them all:
print_chan := Channels.make
Ranges.make(0, 10000).each i :: do
Fibers.make () :: do
print_chan <! 'Hello from fiber $!'.sprintf(i)
end
end
print_chan.each (msg) :: msg.println
Each fiber sends one message into the channel, then exits. The each message reads values from the channel and passes each one to the block.
Zero-copy message passing
A reoccurring cost in concurrent systems is copying: when you send data to another thread, the runtime must deeply-copy the entirely payload in order to keep both sides memory-safe.
gab eliminates this cost.
Because all of gab’s data structures are immutable, a value cannot change after it is created. This means it is always safe to share a reference to a value across fiber boundaries — no copying is needed. In practice, passing a large record between 10,000 fibers is no more expensive than passing an integer. This is a deliberate design choice that makes gab’s concurrency both safe and fast.
Channels are immutable too
Even gab\channel is immutable. A channel reference can be passed freely between fibers without any synchronisation overhead. The runtime handles the scheduling of sends and receives internally.
Case Study: Atoms
Channels and fibers are sufficient to implement any concurrency abstraction you might need. So lets implement one!
Here is a sample implementation of an atom. Think of this as a single identity whose value can be read and updated safely from any fiber, similar to Clojure’s atom.
Design
For our atom, a dedicated fiber will hold the current state privately in its own local scope.
Other fibers send commands to it over a channel, where each command is a tuple including a reply channel as well as a function to apply to the current value in the atom. The dedicated fiber applies the function, sends the new state back on the reply channel, and recurses into itself with the updated state.
Tip
Because all reads and writes go through a single fiber, there is a single, continous trace of updates to the value over time.
Atom := gab\atom: # Our atom module
t: .def (Atom \{ chan: }) # Define the t: message for the module.
make: .def (Atom, (initial) :: do
ch := Channels.make # Create the channel
loop := (state) :: do # Define a loop block
(reply, f) := ch >! .unwrap # Read the reply channel and block to apply
new_state := f.(state) # Resolve new state by applying f
reply <! new_state # Reply with new state
self.(new_state) # Call self with the new state
end
Fibers.make () :: loop.(initial) # Spawn dedicated fiber
{ chan: ch }
end)
[Atom.t] .defmodule { # Define our api
deref: () :: do
reply := Channels.make
self.chan <! (reply, (x) :: x)
reply >!
end
swap: (f) :: do
reply := Channels.make
self.chan <! (reply, f)
reply >!
end
reset: (val) :: self.swap(() :: val)
}
Usage:
counter := Atom.make(0)
counter.deref # 0
counter.swap((n) :: n + 1)
counter.swap((n) :: n + 1)
counter.deref # 2
counter.reset(100)
counter.deref # 100
Takeaways
Recursive blocks via self
loop calls self.(new_state) to recurse. When a block is invoked directly rather than as a message specialization, self refers to the block itself.
Tuples can be sent over channels
ch <! (reply, f) sends both the reply channel and the function as a single tuple. The atom fiber receives and destructures them in one step with (reply, f) := ch >! .unwrap.
t: provides the shape for defmodule.
Atom.t returns the shape of atom records <gab\shape chan:>. This is a convention, and makes it simpler for developers to define new messages for your module types.
deref is implemented with swap
There’s no separate read mechanism. The same path handles both reads and writes, which guarantees that a deref sees all preceding swap calls.