Arc
A thread can outlive the line that started it, so whatever it is given must last as long as the thread does. So far there were two ways to arrange that, and both have a catch:
- Moving a value in, as Thread did, gives it to one thread. Several threads that need the same large value would each need their own copy.
- A pointer to a local, as Atomic did, shares one value — but only for as long as the local lives, which the compiler does not check.
An Arc<T> — an atomically reference-counted handle — removes the catch. It is the Rc<T> you already know, made safe to use across threads:
import Allocator::{ Arc, SystemAllocator };
Making one
Arc::Create moves a value onto the heap, next to a count of the handles that share it, and returns the first handle. The storage comes from an allocator, so making one can fail, and it returns Arc<T> ! AllocError:
let stock = Shop { prices: [120, 250, 75, 499], sold: Atomic<uint64>(0) };
var shop = Arc::Create<Shop>(SystemAllocator(), stock) catch { else => return 1 };
The allocator must be one every thread can use. SystemAllocator is.
Copies are handles
Copying an Arc copies only the handle: it points at the same value and adds one to the count. The value itself is never duplicated, however large it is. Each till gets a copy, which moves to its thread with the rest of the till:
var tills: JoinHandle<uint64>[3] = [
Spawn(Ring, Till { orders: [1, 0, 2, 0], shop: shop }) catch { else => return 1 },
Spawn(Ring, Till { orders: [0, 3, 0, 1], shop: shop }) catch { else => return 1 },
Spawn(Ring, Till { orders: [2, 2, 2, 2], shop: shop }) catch { else => return 1 }
];
flowchart LR
m["`Main`'s handle"] --> b["count: 4<br/>the `Shop`"]
t1["till 1's handle"] --> b
t2["till 2's handle"] --> b
t3["till 3's handle"] --> bDropping a handle subtracts one, and whichever handle is last — on whichever thread — destroys the value and returns its storage. That is the one difference from Rc<T>: an Rc keeps its count with plain arithmetic, and two threads changing it at once would lose a count, just as in the race in Atomic. An Arc changes its count with atomic operations, so copies can be made and dropped on any thread at the same moment.
| Method | Returns |
|---|---|
Arc::Create(a, value) | the first handle, Arc<T> ! AllocError |
Get() | *T, the value, for reading |
GetMutable() | (*var T)?, the value for writing, only while one handle exists |
Count() | how many handles share the value right now |
SameAs(other) | whether two handles share one value |
After the joins, every till has finished and dropped its copy, so Count is back to 1.
Reading, and changing
Through an Arc<T>, the value is only read: Get returns a read-only *T. Several threads read it at once, and reading never races with reading.
Whatever must change while the value is shared needs synchronization of its own, inside the Arc. Here that is the sold counter, an Atomic<uint64> in the shared Shop:
struct Shop {
prices: uint64[4];
sold: Atomic<uint64>;
}
func Ring(till: Till) -> uint64 {
let shop = till.shop.Get();
var revenue: uint64 = 0;
for index in 0..4 {
revenue = revenue + till.orders[index] * shop.prices[index];
shop.sold.FetchAdd(till.orders[index]);
}
return revenue;
}
The only other way to write is GetMutable, which gives a *var T only while a single handle exists — when nobody else could be reading. At the end, Main holds the last handle and changes a price.
Mutex<T> and the other types of the Sync package are handles already, with a Clone of their own, so they need no Arc around them. Arc is for everything else: a table, a configuration, a struct of atomics.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// A thread outlives the line that started it, so whatever it is given must last as long as the
// thread does. Moving a value in works for one thread. Several threads that need the same large
// value would each need a copy, and a pointer to a local is only safe while that local lives.
//
// An `Arc<T>` (atomically reference-counted) puts one value on the heap and shares it between
// handles. Copying a handle only adds one to a count; the value itself is never duplicated.
// Dropping a handle subtracts one, and the last handle to go destroys the value. It is the `Rc<T>`
// of the Rc lesson with one difference: the count changes through atomic operations, so copies
// can be made and dropped on different threads at the same time.
//
// Through an `Arc<T>` the value is only read. Whatever in it must change while it is shared needs
// its own synchronization, such as an `Atomic<T>` or a `Mutex<T>` inside it.
import Allocator::{ Arc, SystemAllocator };
import Atomic::Atomic;
import Io::PrintLine;
import Thread::{ JoinHandle, Spawn };
// What every till shares: a price list nobody changes, and a counter they all add to.
struct Shop {
prices: uint64[4];
sold: Atomic<uint64>;
}
// What a till is given: its own orders, and a handle to the shop.
struct Till {
orders: uint64[4];
shop: Arc<Shop>;
}
// Prices the orders from the shared list and counts the items in the shared total. `Get` gives a
// read-only `*Shop`, so `shop.prices[0] = 110;` here is refused:
//
// error: cannot modify data through read-only pointer '*Shop'
func Ring(till: Till) -> uint64 {
let shop = till.shop.Get();
var revenue: uint64 = 0;
for index in 0..4 {
revenue = revenue + till.orders[index] * shop.prices[index];
shop.sold.FetchAdd(till.orders[index]);
}
return revenue;
}
// A copy shares the value rather than duplicating it.
func Inspect(copy: Arc<Shop>, original: &Arc<Shop>) {
PrintLine("same shop {}", copy.SameAs(original));
PrintLine("handles now {}", original.Count());
}
func Main() -> int {
// The first handle. Its storage comes from an allocator, so making it can fail.
let stock = Shop { prices: [120, 250, 75, 499], sold: Atomic<uint64>(0) };
var shop = Arc::Create<Shop>(SystemAllocator(), stock) catch { else => return 1 };
PrintLine("handles before {}", shop.Count());
// Each till gets a copy of the handle. Every copy points at the same shop, and moves to its
// thread with the rest of the till.
var tills: JoinHandle<uint64>[3] = [
Spawn(Ring, Till { orders: [1, 0, 2, 0], shop: shop }) catch { else => return 1 },
Spawn(Ring, Till { orders: [0, 3, 0, 1], shop: shop }) catch { else => return 1 },
Spawn(Ring, Till { orders: [2, 2, 2, 2], shop: shop }) catch { else => return 1 }
];
for index in 0..3 {
PrintLine("till {} took {}", index + 1, tills[index].Join());
}
// Every till has finished and dropped its copy, so only `Main`'s handle is left.
PrintLine("items sold {}", shop.Get().sold.Load());
PrintLine("handles after {}", shop.Count());
// Passing a handle by value copies it, so for as long as the call runs there are two.
Inspect(shop, shop);
// The copy went with the call, so this is the only handle again, and it may change the value.
match shop.GetMutable() {
value? => value.prices[0] = 110,
none => PrintLine("still shared")
}
PrintLine("first price {}", shop.Get().prices[0]);
return 0;
}
Besides Io, its Rux.toml lists Allocator, Atomic and Thread under [Dependencies].
Run it
cd Examples/Concurrency/Arc
rux run
handles before 1
till 1 took 270
till 2 took 1249
till 3 took 1888
items sold 15
handles after 1
same shop true
handles now 2
first price 110
Common mistakes
Try it yourself
- Add a fourth till, and check that
handles afteris still 1. - Count the revenue in the shop too: add a
revenue: Atomic<uint64>field toShop, add to it inRing, and compare it with the sum of what the tills returned. - Print
shop.GetMutable() != nonejust before the joins instead of after them. Can you predict what it prints? Why not?
Learn more
25.3 Mutex
Guard two balances that must change together with a Mutex<T>: each thread locks it, changes the value through the MutexGuard<T> it gets back, and the lock is released when the guard is dropped.
25.5 Channel
Pass values between threads through a bounded Channel<T>: one thread sends, another transforms, Main receives, and closing a channel ends the stream once what was sent has drained.