Concurrency · Lesson 25.4

Arc

Source
Share one value between threads with Arc<T>, a counted handle whose copies may be made and dropped on any thread, and change what must change inside it through an Atomic<T>.
You'll need: Rc, Thread, Atomic

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"] --> b

Dropping 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.

MethodReturns
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.

Src/Main.rux
// 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

  1. Add a fourth till, and check that handles after is still 1.
  2. Count the revenue in the shop too: add a revenue: Atomic<uint64> field to Shop, add to it in Ring, and compare it with the sum of what the tills returned.
  3. Print shop.GetMutable() != none just before the joins instead of after them. Can you predict what it prints? Why not?

Learn more

  • Allocator — the package in the API reference, with Rc and Arc
  • Atomics — the operations the count is kept with
  • Rc — the same handle for one thread
  • Mutex — a shared value every thread may change