Skip to content
Fibers and Channels

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.