sync
Lock shared data across several steps, run set-up code once and wait for a group of threads with locks, once and wait groups.
import "std:sync"OSL already makes each statement on shared data atomic, and atomic ( ... ) groups several statements into one step (see the concurrency lesson). std:sync covers what those can't express: a lock held across code that waits or calls other functions, set-up that must run only once, and waiting until a group of threads has finished.
A lock you own#
sync.newLock() returns a lock value. lock.with(fn) takes the lock, calls fn, and releases the lock even if fn throws. It returns what fn returned. While one thread is inside with, the others wait their turn:
import "std:sync"
auto lock = sync.newLock()
string[] events = []
async work(int id) int (
lock.with(def() -> (
events.append("start " ++ id)
thread.sleep(10)
events.append("end " ++ id)
))
return id
)
*thread.Thread<int>[] jobs = []
for id in 1 to 3 (
jobs.append(work(id))
)
void thread.waitAll(jobs)
boolean paired = true
for pair in 1 to 3 (
int i = (pair * 2) - 1
string id = events[i].split(" ")[2]
if events[i + 1] != "end " ++ id (
paired = false
)
)
log events.len
log pairedWhich thread goes first varies, so the example checks the part that doesn't: every start is followed straight away by its own end, because no thread can enter the locked section while another is in it. Without the lock, the three would interleave.
A lock's value belongs with the data it protects, so keep them together in a class or next to each other. sync.newNamedLock("cache") gives a lock a name, which only appears in error messages.
Taking and releasing by hand#
lock() and unlock() are the pieces with is made of. Pair them with defer so the lock is released on every way out of the function:
import "std:sync"
auto lock = sync.newLock()
int balance = 100
def withdraw(int amount) boolean (
lock.lock()
defer lock.unlock()
if balance < amount (
return false
)
balance -= amount
return true
)
log withdraw(30)
log withdraw(100)
log balancetryLock() takes the lock only if it is free and says whether it did. It never waits:
import "std:sync"
auto lock = sync.newLock()
log lock.tryLock()
log lock.tryLock()
lock.unlock()
log lock.tryLock()
lock.unlock()The second tryLock is false because this same thread holds the lock already. Locks are not re-entrant.
Mistakes that are caught#
A thread that locks a lock it already holds would wait forever. OSL throws a DeadlockError instead. Releasing a lock that isn't held throws a LockError:
import "std:sync"
auto lock = sync.newNamedLock("counter")
lock.lock()
auto again = try(lock.lock())
log again.unwrapErr().kind
lock.unlock()
auto stray = try(lock.unlock())
log stray.unwrapErr().kindMany readers, one writer#
When data is read often and changed rarely, let readers share the lock. rlock() takes a shared read lock, runlock() releases it, and withRLock(fn) does both around a function. A writer using lock() or with() waits until no readers are left:
import "std:sync"
auto lock = sync.newLock()
string[int] prices = {"apple": 3, "pear": 4}
def price(string name) int -> lock.withRLock(def() int -> prices[name] ?? 0)
def setPrice(string name, int value) (
lock.with(def() -> (
prices[name] = value
))
)
log price("apple")
setPrice("apple", 5)
log price("apple")
log price("plum")A thread that holds a read lock can't upgrade it to a write lock, or take a second read lock. Either throws a DeadlockError: release first.
Locks by name#
For quick uses there are global locks, picked by a string. sync.withLock(name, fn) is with on the lock called name, and sync.lock, sync.unlock, sync.tryLock, sync.rlock, sync.runlock and sync.withRLock match the lock methods. Anything that uses the same name shares the lock:
import "std:sync"
int total = 0
async add(int amount) int (
sync.withLock("total", def() -> (
int current = total
thread.sleep(1)
total = current + amount
))
return amount
)
*thread.Thread<int>[] jobs = []
for n in 1 to 10 (
jobs.append(add(n))
)
void thread.waitAll(jobs)
log totalThe read, the sleep and the write happen inside the lock, so no thread's update is lost. A lock value is usually better than a name: a typo in a string name quietly makes a different lock.
Run once#
sync.once(name, fn) calls fn the first time it sees name, and returns its value. Every later call with that name returns null without calling fn. Threads that arrive while the first call is running wait until it finishes, so code after once can rely on its effects:
import "std:sync"
int setups = 0
def prepare() (
sync.once("prepare", def() -> (
thread.sleep(20)
setups++
))
)
async user(int id) int (
prepare()
return id
)
*thread.Thread<int>[] jobs = []
for id in 1 to 5 (
jobs.append(user(id))
)
void thread.waitAll(jobs)
log setups
log sync.once("greeting", def() string -> "hello") ?? "already done"
log sync.once("greeting", def() string -> "hello") ?? "already done"If the first call throws, every call with that name throws the same error.
Wait groups#
A wait group counts outstanding work. add(n) raises the count (by 1 with no argument), each finished piece of work calls done(), and wait() blocks until the count is back at 0:
import "std:sync"
import "std:thread"
auto group = sync.waitGroup()
int[] squares = []
for n in 1 to 4 (
group.add()
thread.new(def() -> (
squares.append(n * n)
group.done()
))
)
group.wait()
log group.count()
log squares.sort()Call add before starting the work it counts, not inside it, or wait may see 0 too early. done throws a StateError if the count is already 0, and add throws a ValueError if the count would go below 0.
When you have the thread handles, thread.waitAll does the same job and also gives back the results. A wait group is for work that doesn't hand a value back, or that is started from many places.
A thread calls lock.lock() and, still holding it, calls lock.lock() again. What happens?
Set up once
loadConfig is called from three places and should do its work only once, but it runs three times. Wrap its work in sync.once.
1
API reference#
22 members, generated from the std:sync source of OSL 9.3.0.
Functions
sync.lock(string name)lock takes the global lock called name, waiting while another thread holds it. Release it with sync.unlock(name), often with defer.
sync.newLock() *sync.LocknewLock returns an unnamed lock value.
sync.newNamedLock(string name) *sync.LocknewNamedLock returns a lock value whose name labels it in errors.
sync.once(string name, any fn) anyonce calls fn the first time it is called with name and returns its value; every later call with name returns null without calling its fn, so sync.once(name, fn) == null tells a later call from the first. Callers wait until the first call has finished, so code after once can rely on its effects. If the first call throws, every call with name throws that error. Throws TypeError when fn isn't a function.
sync.rlock(string name)rlock takes a shared read lock on name. Release it with sync.runlock(name). Throws DeadlockError when this thread already holds name, because read locks cannot be upgraded or taken twice.
sync.runlock(string name)runlock releases this thread's read lock on name. Throws LockError when name isn't read-locked or this thread holds no read lock on it.
sync.tryLock(string name) booleantryLock takes the global lock called name if it is free and reports whether it did. It never waits.
sync.unlock(string name)unlock releases the global lock called name. Throws LockError when name isn't locked or another thread holds it.
sync.waitGroup() *sync.WaitGroupwaitGroup returns a new WaitGroup with a count of 0.
sync.withLock(string name, any fn) anywithLock takes the global lock called name, calls fn, and releases the lock even if fn throws.
sync.withRLock(string name, any fn) anywithRLock is withLock, using a read lock.
*sync.Lock
lock.lock()lock takes this lock exclusively. Throws DeadlockError when this thread already holds it.
lock.rlock()rlock takes a shared read lock. Throws DeadlockError when this thread already holds the lock, because read locks cannot be upgraded.
lock.runlock()runlock releases this thread's read hold.
lock.tryLock() booleantryLock takes the lock if it is free and reports whether it did.
lock.unlock()unlock releases this thread's exclusive hold.
lock.with(any fn) anywith takes the lock, calls fn, releases the lock even if fn throws, and returns what fn returned.
lock.withRLock(any fn) anywithRLock is with, using a read lock.
*sync.WaitGroup
waitGroup.add(any ...delta)add raises the count by delta, a whole number that defaults to 1; a negative delta lowers it. Throws TypeError when delta isn't a whole number and ValueError when the count would drop below 0.
waitGroup.count() intcount returns the current count.
waitGroup.done()done lowers the count by 1, waking threads in wait when it reaches 0. Throws StateError when the count is already 0.
waitGroup.wait()wait blocks until the count is 0.