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:
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:
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:
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:
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:
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:
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:
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:
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 valueShared.get(handle)- read the current valueShared.set(handle, value)- store a valueShared.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:
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:
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:
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:
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:
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
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
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
// 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
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
fn consumer(ch: int) {
while (true) {
var value = Task.recv(ch)
if (value == -1) {
break // Producer is done
}
process(value)
}
}Limit Concurrency
// 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
Parallel sum: Split an array into N chunks, compute sums in parallel, combine results.
Worker pool: Implement a worker pool that processes jobs from a channel. Experiment with different numbers of workers.
Timeout: Implement a function that runs a task with a timeout. If it doesn't complete in time, cancel it.
Pipeline: Create a pipeline of tasks: one reads data, one processes it, one writes results. Connect them with channels.
Rate limiter: Implement a rate limiter that allows only N operations per second using channels.
Concurrent map: Implement a function that applies a function to each element of an array concurrently.
Benchmark: Compare sequential vs concurrent processing for different workload sizes. Find the crossover point.