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

This is the Markdown version of https://osl-lang.org/packages/sync. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:sync"
```

OSL already makes each statement on shared data atomic, and `atomic ( ... )` groups several statements into one step (see the [concurrency lesson](/learn/concurrency.md)). `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:

```osl
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 paired
```

Output:

```text
6
true
```

Which 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:

```osl
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 balance
```

Output:

```text
true
false
70
```

`tryLock()` takes the lock only if it is free and says whether it did. It never waits:

```osl
import "std:sync"

auto lock = sync.newLock()

log lock.tryLock()
log lock.tryLock()
lock.unlock()
log lock.tryLock()
lock.unlock()
```

Output:

```text
true
false
true
```

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`:

```osl
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().kind
```

Output:

```text
DeadlockError
LockError
```

## Many 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:

```osl
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")
```

Output:

```text
3
5
0
```

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:

```osl
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 total
```

Output:

```text
55
```

The 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:

```osl
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"
```

Output:

```text
1
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:

```osl
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()
```

Output:

```text
0
[1,4,9,16]
```

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.

**Quiz.** A thread calls `lock.lock()` and, still holding it, calls `lock.lock()` again. What happens?

- [ ] The second call does nothing.
- [ ] The thread waits forever.
- [x] It throws a `DeadlockError`.

Answer: Locks aren't re-entrant. OSL detects the self-wait and throws instead of hanging.

### Exercise: 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`.

Starting code:

```osl
import "std:sync"

int loads = 0

def loadConfig() (
  loads++
)

loadConfig()
loadConfig()
loadConfig()
log loads
```

It should print:

```text
1
```

Hint 1: `sync.once(name, fn)` calls `fn` only the first time it sees `name`.

A solution:

```osl
import "std:sync"

int loads = 0

def loadConfig() (
  sync.once("config", def() -> (
    loads++
  ))
)

loadConfig()
loadConfig()
loadConfig()
log loads
```

## API reference

## API reference

Every member of `std:sync`, generated from the source of OSL.

### 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.Lock`

newLock returns an unnamed lock value.

#### `sync.newNamedLock(string name) *sync.Lock`

newNamedLock returns a lock value whose name labels it in errors.

#### `sync.once(string name, any fn) any`

once 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) boolean`

tryLock 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.WaitGroup`

waitGroup returns a new WaitGroup with a count of 0.

#### `sync.withLock(string name, any fn) any`

withLock takes the global lock called name, calls fn, and releases the
lock even if fn throws.

#### `sync.withRLock(string name, any fn) any`

withRLock 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() boolean`

tryLock 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) any`

with takes the lock, calls fn, releases the lock even if fn throws, and
returns what fn returned.

#### `lock.withRLock(any fn) any`

withRLock 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() int`

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