Skip to content

Chapter 12: Concurrency

Why Concurrency Matters

Your CPU has multiple cores. Your program probably uses one. That's wasteful.

Concurrency lets you do multiple things at once. Download files while processing data. Handle multiple web requests simultaneously. Compute results in parallel.

Done wrong, concurrency is a nightmare of race conditions, deadlocks, and bugs that only appear in production. Done right, it's straightforward and safe.

Wyn makes it straightforward.

The spawn Keyword

Create a concurrent task with spawn:

wyn
fn worker() -> int {
    print("Worker running")
    return 0
}

fn main() -> int {
    spawn worker()
    
    print("Main running")
    
    // Wait a bit for the worker to finish (milliseconds)
    Time::sleep(100)
    
    return 0
}

spawn worker() starts worker() running concurrently. The main function continues immediately-it doesn't wait for the worker to finish.

This is like goroutines in Go. Lightweight, cheap to create, managed by the runtime.

There are two ways to use spawn. Fire-and-forget (as above) runs the task on the cooperative coroutine scheduler-great for I/O. Keeping the result (var f = spawn compute() and awaiting it later) runs on a thread pool and gives you a future. Since v1.19, futures are memoized: awaiting the same future twice returns the same value.

Channels: Communication Between Tasks

Tasks need to communicate. Channels are how they do it:

wyn
fn worker(ch: int) {
    Task.send(ch, 42)
}

fn main() -> int {
    var ch = channel(1)
    
    spawn worker(ch)
    
    var value = ch.recv()
    print(value)  // 42
    
    return 0
}

A channel is a typed pipe. One task sends, another receives. The recv() call blocks until a value is available. Inside main you use method style (ch.send, ch.recv); in helper functions the channel arrives as a plain handle, so use the Task namespace (Task.send(ch, v), Task.recv(ch)).

This is synchronization through communication. No locks, no mutexes, no shared memory.

Producer-Consumer Pattern

One task produces data, another consumes it:

wyn
fn producer(ch: int) {
    for (var i = 0; i < 10; i = i + 1) {
        Task.send(ch, i)
    }
    Task.send(ch, -1)  // Sentinel: no more data
}

fn consumer(ch: int) {
    while (true) {
        var value = Task.recv(ch)
        if (value == -1) {
            break  // Producer is done
        }
        print("Received: ${value}")
    }
}

fn main() -> int {
    var ch = channel(16)
    
    spawn producer(ch)
    consumer(ch)
    
    return 0
}

The producer sends values. The consumer receives them. When the producer is done, it sends a sentinel value (-1 here). The consumer detects it and stops.

Worker Pool Pattern

Distribute work across multiple workers:

wyn
fn worker(id: int, jobs: int, results: int) {
    while (true) {
        var job = Task.recv(jobs)
        if (job == -1) {
            break  // No more jobs
        }
        // Process job
        Task.send(results, job * 2)
    }
}

fn main() -> int {
    var jobs = channel(16)
    var results = channel(16)
    
    // Send jobs, then one stop signal per worker
    for (var j = 1; j <= 9; j = j + 1) {
        jobs.send(j)
    }
    for (var i = 0; i < 3; i = i + 1) {
        jobs.send(-1)
    }
    
    // Start 3 workers
    for (var i = 0; i < 3; i = i + 1) {
        spawn worker(i, jobs, results)
    }
    
    // Collect results
    for (var k = 0; k < 9; k = k + 1) {
        var result = results.recv()
        print(result)
    }
    
    return 0
}

Three workers process nine jobs. Each worker takes a job, processes it, and sends the result. The work is distributed automatically.

Parallel Computation

Compute multiple things at once:

wyn
fn compute_sum(numbers: [int], result: int) {
    var sum = 0
    for (var i = 0; i < numbers.len(); i = i + 1) {
        sum = sum + numbers[i]
    }
    Task.send(result, sum)
}

fn main() -> int {
    var data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    
    // Split into two halves
    var first_half = data[0..5]
    var second_half = data[5..10]
    
    var ch1 = channel(1)
    var ch2 = channel(1)
    
    spawn compute_sum(first_half, ch1)
    spawn compute_sum(second_half, ch2)
    
    var sum1 = ch1.recv()
    var sum2 = ch2.recv()
    
    var total = sum1 + sum2
    print(total)  // 55
    
    return 0
}

Two tasks compute sums in parallel. The main task combines the results. This is the divide-and-conquer pattern.

Collecting Results with await_all

When you spawn multiple tasks and need all their results, use await_all:

wyn
fn compute(n: int) -> int {
    var sum = 0
    for i in 0..n { sum = sum + i }
    return sum
}

fn main() -> int {
    // Spawn 5 tasks and collect all results
    var futures = []
    for i in 0..5 {
        futures.push(spawn compute(i * 1000))
    }
    var results = await_all(futures)
    // results[0] = 0, results[1] = 499500, results[2] = 1999000, ...

    println("Results: ${results[0]}, ${results[1]}, ${results[2]}")
    return 0
}

await_all blocks until every future completes, then returns an array of results in the same order as the input futures.

Racing with await_any

When you want the first result from multiple tasks:

wyn
fn fetch_from_server(id: int) -> int {
    // Simulate different response times
    return id * 100
}

fn main() -> int {
    var first = await_any([
        spawn fetch_from_server(1),
        spawn fetch_from_server(2),
        spawn fetch_from_server(3)
    ])
    println("First result: ${first}")
    return 0
}

await_any returns as soon as any one future completes. Useful for timeouts, redundant requests, or racing multiple strategies.

Shared Atomic Values

For simple shared counters or flags, use Shared:

wyn
fn increment(counter: int) -> int {
    Shared.add(counter, 1)
    return 0
}

fn main() -> int {
    var counter = Shared.new(0)

    // 100 concurrent increments
    var futures = []
    for i in 0..100 {
        futures.push(spawn increment(counter))
    }
    await_all(futures)

    println("Counter: ${Shared.get(counter)}")  // Always 100
    return 0
}

Shared values are lock-free atomic integers. Available operations:

  • Shared.new(initial) - create a shared value
  • Shared.get(handle) - read the current value
  • Shared.set(handle, value) - store a value
  • Shared.add(handle, delta) - atomic add (returns old value)
  • Shared.sub(handle, delta) - atomic subtract (returns old value)

Buffered Channels

By default, channels are unbuffered-send blocks until receive happens. Buffered channels have capacity:

wyn
fn main() -> int {
    var ch = channel(3)  // Buffer size 3
    
    ch.send(1)  // Doesn't block
    ch.send(2)  // Doesn't block
    ch.send(3)  // Doesn't block
    // ch.send(4)  // Would block - buffer full
    
    print(ch.recv())  // 1
    print(ch.recv())  // 2
    print(ch.recv())  // 3
    
    return 0
}

Buffered channels decouple producers and consumers. Use them when send and receive rates don't match.

Select

Select lets you wait on multiple channels:

wyn
fn main() -> int {
    var ch1 = channel(1)
    var ch2 = channel(1)
    
    ch1.send(10)
    ch2.send(20)
    
    select {
        v = ch1.recv() => { print("From ch1: ${v}") }
        w = ch2.recv() => { print("From ch2: ${w}") }
    }
    
    return 0
}

This waits on ch1 and ch2 simultaneously. Whichever receives first, that arm executes.

One rule: at least one arm must have a possible sender. If nothing can ever send to any of the selected channels, the program errors with a deadlock message instead of hanging forever.

Race Conditions and Data Races

Concurrency is dangerous when tasks share mutable state:

wyn
var counter = 0  // Shared mutable state

fn increment() -> int {
    for (var i = 0; i < 1000; i = i + 1) {
        counter = counter + 1  // Race condition!
    }
    return 0
}

fn main() -> int {
    spawn increment()
    spawn increment()
    
    Time::sleep(1000)
    print(counter)  // Probably not 2000!
    
    return 0
}

Both tasks read and write counter simultaneously. The result is unpredictable. This is a data race.

Don't share mutable state between tasks. Use channels instead:

wyn
fn increment(ch: int) {
    var count = 0
    for (var i = 0; i < 1000; i = i + 1) {
        count = count + 1
    }
    Task.send(ch, count)
}

fn main() -> int {
    var ch1 = channel(1)
    var ch2 = channel(1)
    
    spawn increment(ch1)
    spawn increment(ch2)
    
    var count1 = ch1.recv()
    var count2 = ch2.recv()
    
    print(count1 + count2)  // Always 2000
    
    return 0
}

Each task has its own counter. They send results through channels. No shared state, no race conditions.

Performance Considerations

When Concurrency Helps

  • I/O-bound tasks: Waiting for network, disk, user input
  • CPU-bound tasks: Heavy computation that can be parallelized
  • Independent tasks: Work that doesn't depend on other work

When Concurrency Doesn't Help

  • Sequential tasks: Work that must happen in order
  • Small tasks: Overhead of spawning exceeds benefit
  • Shared state: Synchronization overhead dominates

Measuring Performance

Always measure. Concurrency can make things slower if used wrong:

wyn
fn sequential() -> int {
    var sum = 0
    for (var i = 0; i < 1000000; i = i + 1) {
        sum = sum + i
    }
    return sum
}

fn concurrent() -> int {
    // Split work across 4 tasks
    // Combine results
    // Might be slower due to overhead!
}

For small workloads, sequential is faster. For large workloads, concurrent wins. Measure to find the crossover point.

Real-World: Web Scraper

wyn
fn fetch_url(url: string) -> string {
    // Simulate HTTP request
    Time::sleep(100)
    return "Content from " + url
}

fn main() -> int {
    var urls = [
        "http://example.com/page1",
        "http://example.com/page2",
        "http://example.com/page3",
        "http://example.com/page4",
        "http://example.com/page5"
    ]
    
    // Fetch all URLs concurrently, keep the futures
    var futures = []
    for (var i = 0; i < urls.len(); i = i + 1) {
        futures.push(spawn fetch_url(urls[i]))
    }
    
    // Collect results (same order as the futures)
    var results = await_all(futures)
    for (var i = 0; i < results.len(); i = i + 1) {
        print(results[i])
    }
    
    return 0
}

Five HTTP requests happen simultaneously. Total time is ~100ms instead of ~500ms.

Real-World: Parallel File Processing

wyn
fn count_words(path: string) -> int {
    // Read file
    var contents = File::read(path)
    var lines = contents.split("\n")
    
    // Count words
    var word_count = 0
    for (var i = 0; i < lines.len(); i = i + 1) {
        word_count = word_count + lines[i].split(" ").len()
    }
    
    return word_count
}

fn main() -> int {
    var files = ["file1.txt", "file2.txt", "file3.txt"]
    
    // Process files concurrently
    var futures = []
    for (var i = 0; i < files.len(); i = i + 1) {
        futures.push(spawn count_words(files[i]))
    }
    
    // Sum word counts
    var counts = await_all(futures)
    var total = 0
    for (var i = 0; i < counts.len(); i = i + 1) {
        total = total + counts[i]
    }
    
    print("Total words: ${total}")
    
    return 0
}

Best Practices

Communicate, Don't Share

wyn
// Bad: shared mutable state
var shared_data = []
spawn modify_data()
spawn modify_data()

// Good: communicate through channels
var ch = channel(16)
spawn producer(ch)
consumer(ch)

Signal Completion

wyn
fn producer(ch: int) {
    for (var i = 0; i < 10; i = i + 1) {
        Task.send(ch, i)
    }
    Task.send(ch, -1)  // Sentinel: signal completion
}

Handle the Completion Signal

wyn
fn consumer(ch: int) {
    while (true) {
        var value = Task.recv(ch)
        if (value == -1) {
            break  // Producer is done
        }
        process(value)
    }
}

Limit Concurrency

wyn
// Bad: spawn unlimited tasks
for (var i = 0; i < 1000000; i = i + 1) {
    spawn process(i)
}

// Good: use worker pool
var workers = 10
var jobs = channel(64)
for (var i = 0; i < workers; i = i + 1) {
    spawn worker(jobs)
}

What You've Learned

Concurrency in Wyn is based on goroutines and channels. Use spawn to create concurrent tasks. Use channels to communicate between them.

Don't share mutable state. Communicate through channels instead. This prevents race conditions and makes code easier to reason about.

Common patterns: producer-consumer, worker pool, parallel computation. Each solves a different concurrency problem.

Measure performance. Concurrency isn't always faster. Use it when tasks are independent and the workload is large enough.

Exercises

  1. Parallel sum: Split an array into N chunks, compute sums in parallel, combine results.

  2. Worker pool: Implement a worker pool that processes jobs from a channel. Experiment with different numbers of workers.

  3. Timeout: Implement a function that runs a task with a timeout. If it doesn't complete in time, cancel it.

  4. Pipeline: Create a pipeline of tasks: one reads data, one processes it, one writes results. Connect them with channels.

  5. Rate limiter: Implement a rate limiter that allows only N operations per second using channels.

  6. Concurrent map: Implement a function that applies a function to each element of an array concurrently.

  7. Benchmark: Compare sequential vs concurrent processing for different workload sizes. Find the crossover point.


Next: Chapter 13: Memory Management

MIT License - v1.19.1