Gleam Concurrency Patterns

Gleam's process model enables powerful concurrency patterns without the complexity of threads, locks, or shared state. This topic covers practical patterns — worker pools, pub/sub, fan-out, and request-reply — that solve common concurrent programming challenges.

Pattern 1: Worker Pool

A worker pool distributes jobs across multiple processes running in parallel. Each worker handles one job at a time.


Worker Pool Diagram
──────────────────────────────────────────────────
                Job Queue
               [J1, J2, J3, J4, J5, J6]
                    │
           ┌────────┼────────┐
           ▼        ▼        ▼
        Worker1  Worker2  Worker3
        (J1)     (J2)     (J3)
           │        │        │
           ▼        ▼        ▼
        Result   Result   Result
import gleam/otp/actor
import gleam/erlang/process
import gleam/list

type Job { Job(input: Int, reply_to: process.Subject(Int)) }

fn worker_handler(job: Job, state: Nil) -> actor.Next(Job, Nil) {
  let result = job.input * job.input   // do the work
  process.send(job.reply_to, result)
  actor.continue(Nil)
}

pub fn start_pool(size: Int) -> List(process.Subject(Job)) {
  list.range(1, size + 1)
  |> list.map(fn(_) {
       let assert Ok(worker) = actor.start(Nil, worker_handler)
       worker
     })
}

pub fn submit_job(workers: List(process.Subject(Job)), input: Int) -> Int {
  let reply = process.new_subject()
  // Round-robin: pick worker by index (simplified)
  let worker = list.first(workers) |> result.unwrap(panic as "no workers")
  actor.send(worker, Job(input, reply))
  let assert Ok(result) = process.receive(reply, 5000)
  result
}

Pattern 2: Fan-Out / Fan-In

Fan-out sends the same work to multiple processes simultaneously. Fan-in collects all results when they finish.


Fan-Out / Fan-In Diagram
──────────────────────────────────────────────────
                 Input
                   │
         ┌─────────┼─────────┐
         ▼         ▼         ▼
      Task A     Task B    Task C    (run in parallel)
         │         │         │
         └─────────┼─────────┘
                   ▼
            Collect Results
import gleam/erlang/process
import gleam/list

pub fn parallel_map(items: List(a), work: fn(a) -> b) -> List(b) {
  let reply = process.new_subject()

  // Spawn one process per item
  let count = list.length(items)
  list.each(items, fn(item) {
    let _ = process.start(fn() {
      process.send(reply, work(item))
    }, linked: False)
  })

  // Collect all results (order may differ from input)
  list.range(1, count + 1)
  |> list.map(fn(_) {
       let assert Ok(result) = process.receive(reply, 5000)
       result
     })
}

pub fn main() {
  let results = parallel_map([1, 2, 3, 4, 5], fn(n) { n * n })
  // [1, 4, 9, 16, 25]  — computed in parallel
}

Pattern 3: Request-Reply

A process sends a request to another process and waits for a typed reply. This is the standard way to query a stateful actor.


Request-Reply Flow
──────────────────────────────────────────────────
Caller                          Server Actor
  │                                 │
  │── send(Request(reply_chan)) ───▶│
  │                            handle request
  │◀── send(reply_chan, Result) ─ ──│
  │                                 │
  │  process.receive(reply_chan)    │
  ▼                                 │
Result ready
type StoreMsg {
  Set(key: String, value: String)
  Get(key: String, reply_to: process.Subject(Result(String, Nil)))
}

fn store_handler(msg: StoreMsg, state: Dict(String, String)) -> actor.Next(StoreMsg, Dict(String, String)) {
  case msg {
    Set(k, v) ->
      actor.continue(dict.insert(state, k, v))

    Get(k, reply) -> {
      let result = dict.get(state, k)
      process.send(reply, result)
      actor.continue(state)
    }
  }
}

pub fn get_value(store: process.Subject(StoreMsg), key: String) -> Result(String, Nil) {
  let reply = process.new_subject()
  actor.send(store, Get(key, reply))
  let assert Ok(result) = process.receive(reply, 1000)
  result
}

Pattern 4: Pub/Sub

A publisher sends events to a registry, which forwards them to all subscribed processes.


Pub/Sub Diagram
──────────────────────────────────────────────────
Publisher ──event──▶ Registry ──broadcast──▶ Subscriber A
                         │
                         └────────────────▶ Subscriber B
                         │
                         └────────────────▶ Subscriber C
type PubSubMsg(event) {
  Subscribe(process.Subject(event))
  Publish(event)
}

fn pubsub_handler(msg: PubSubMsg(e), subs: List(process.Subject(e))) -> actor.Next(PubSubMsg(e), List(process.Subject(e))) {
  case msg {
    Subscribe(sub) ->
      actor.continue([sub, ..subs])

    Publish(event) -> {
      list.each(subs, fn(sub) { process.send(sub, event) })
      actor.continue(subs)
    }
  }
}

Pattern 5: Timeout and Retry

import gleam/erlang/process

pub fn with_retry(
  operation: fn() -> Result(a, e),
  max_attempts: Int,
  delay_ms: Int
) -> Result(a, e) {
  do_retry(operation, max_attempts, delay_ms, 1)
}

fn do_retry(op, max, delay, attempt) -> Result(a, e) {
  case op() {
    Ok(v) -> Ok(v)
    Error(e) ->
      case attempt >= max {
        True  -> Error(e)
        False -> {
          process.sleep(delay)
          do_retry(op, max, delay, attempt + 1)
        }
      }
  }
}

// Usage:
let result = with_retry(fn() { fetch_data() }, max_attempts: 3, delay_ms: 500)

Retry Timeline
──────────────────────────────────────────────────
Attempt 1: Error → wait 500ms
Attempt 2: Error → wait 500ms
Attempt 3: Ok(data) → return Ok(data)

Pattern 6: Periodic Tasks

import gleam/erlang/process

pub fn start_periodic(interval_ms: Int, task: fn() -> Nil) -> Nil {
  let _ = process.start(fn() {
    loop_forever(interval_ms, task)
  }, linked: False)
}

fn loop_forever(interval_ms: Int, task: fn() -> Nil) -> Nil {
  task()
  process.sleep(interval_ms)
  loop_forever(interval_ms, task)
}

// Usage: run a cleanup task every 60 seconds
start_periodic(60_000, fn() {
  cleanup_expired_sessions()
})

Key Points


Concurrency Patterns Summary
──────────────────────────────────────────────────
Worker Pool   → distribute jobs across N parallel workers
Fan-Out/In    → run N tasks in parallel, collect results
Request-Reply → query stateful actors safely with typed reply
Pub/Sub       → broadcast events to multiple subscribers
Retry         → retry failed operations with delay
Periodic      → run a task on a repeating schedule

All patterns use:
  process.start()         → spawn a process
  process.new_subject()   → create a typed channel
  process.send()          → send a message
  process.receive()       → wait for a reply
  actor.start()           → managed stateful process

These patterns cover the most common concurrent programming needs. Because each pattern uses isolated processes and typed channels, combining them is safe — no shared state means no data races, and the type system ensures messages are always the right shape.

Leave a Comment

Your email address will not be published. Required fields are marked *