Make the error pool and status registry thread safe
Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
UPGRADING.md
105
UPGRADING.md
@@ -1,3 +1,100 @@
|
||||
# Upgrade notice: thread safety (2.0.0)
|
||||
|
||||
2.0.0 makes the library thread safe. Every entry point may be called from any
|
||||
thread, `akerr_init()` runs exactly once however many threads race into it, and
|
||||
the error pool and the status registry are serialized.
|
||||
|
||||
This is an ABI break. Rebuild libakerror and everything that includes its
|
||||
header; the soname moved to `libakerror.so.2` so the two cannot be mixed by
|
||||
accident.
|
||||
|
||||
What moved at the ABI:
|
||||
|
||||
* `__akerr_last_ignored` is thread-local storage. An ignored error is a fact
|
||||
about the thread that ignored it, and one shared slot had two threads
|
||||
overwriting each other's. The `IGNORE` macro expands at *your* call site, so
|
||||
your objects reference the symbol under whichever storage model your header
|
||||
said — which is why this cannot be mixed.
|
||||
* `akerr_next_error()` returns a context that **already holds one reference**.
|
||||
Finding a free slot and claiming it has to be one operation under the pool
|
||||
lock, or two threads scanning at once are handed the same slot.
|
||||
`ENSURE_ERROR_READY` therefore no longer increments the count. Code compiled
|
||||
against a 1.x header and linked against 2.x would count every reference twice
|
||||
and never return a slot to the pool.
|
||||
|
||||
What changed in the API: nothing you call, unless you call `akerr_next_error()`
|
||||
yourself. If you do, you now own a reference and must release it — the same
|
||||
thing you were doing already if you were using the context for anything.
|
||||
|
||||
New build options, both on libakerror itself:
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `AKERR_THREADS` | `auto` | Threading backend: `auto`, `pthread`, or `none` |
|
||||
| `AKERR_SANITIZE` | (empty) | Sanitizers for the library and its tests, e.g. `thread` |
|
||||
|
||||
`auto` takes POSIX threads and **fails the configure** when it cannot find them,
|
||||
rather than quietly building a library that reports itself thread safe and is
|
||||
not. `-DAKERR_THREADS=none` is how you say you meant it: no locking, no
|
||||
thread-local storage, undefined if you then use more than one thread.
|
||||
|
||||
The generated header records which one you built, as `AKERR_THREAD_SAFE` (`1` or
|
||||
`0`), so a consumer can test what it linked against and cannot disagree with the
|
||||
library about it.
|
||||
|
||||
## What thread safety here does and does not mean
|
||||
|
||||
Safe from any thread, with no coordination on your part:
|
||||
|
||||
* Raising, catching, handling, passing, ignoring and releasing errors.
|
||||
* `akerr_reserve_status_range()` and `akerr_register_status_name()`. Two threads
|
||||
reserving overlapping ranges cannot both succeed: one gets `NULL`, the other
|
||||
gets `AKERR_STATUS_RANGE_OVERLAP` naming the winner.
|
||||
* `akerr_name_for_status(status, NULL)` lookups, concurrently with each other
|
||||
and with registrations of *other* statuses.
|
||||
* `akerr_init()`, from any number of threads at once.
|
||||
|
||||
Still yours to coordinate:
|
||||
|
||||
* **One error context is owned by one thread.** The library hands it to the
|
||||
thread that raised it. Handing it to another thread is your synchronization.
|
||||
* **`akerr_log_method` and `akerr_handler_unhandled_error`** are read on every
|
||||
error and written by nobody but you. Set them during startup, before spawning.
|
||||
* **Renaming a status while another thread looks it up.**
|
||||
`akerr_name_for_status()` returns a pointer into the registry — stable for the
|
||||
life of the process, which is what makes it usable from a stack trace — and
|
||||
registering a second name for the same status overwrites that buffer in place.
|
||||
Register names during initialization. This is the one registry operation the
|
||||
lock cannot make safe, because the reader is outside the lock by the time it
|
||||
reads the string.
|
||||
* **Which unhandled error wins.** Two threads reaching `FINISH_NORETURN` with
|
||||
unhandled errors at the same instant both print a complete stack trace (the
|
||||
buffer belongs to the context, and each line is a single `akerr_log_method`
|
||||
call) and both call `akerr_handler_unhandled_error`. The process exits with
|
||||
whichever status got there first.
|
||||
|
||||
## Cost
|
||||
|
||||
One recursive lock covers both the pool and the registry, so error
|
||||
*construction* is serialized process-wide. Errors are the exceptional path and
|
||||
correctness there is worth more than throughput, but a program that raises
|
||||
errors in a hot loop will feel it.
|
||||
|
||||
The per-thread last-ditch context is a whole `akerr_ErrorContext` (tens of
|
||||
kilobytes) in thread-local storage, allocated per thread on first use of the
|
||||
library from that thread.
|
||||
|
||||
## Proving it
|
||||
|
||||
`tests/err_threads_init.c`, `tests/err_threads_pool.c` and
|
||||
`tests/err_threads_registry.c` assert the properties directly and run in the
|
||||
normal suite. The run that proves there is no data race underneath them is
|
||||
ThreadSanitizer:
|
||||
|
||||
```sh
|
||||
scripts/thread_test.sh
|
||||
```
|
||||
|
||||
# Upgrade notice: custom status codes (1.0.0)
|
||||
|
||||
Version 1.0.0 replaces the consumer-sized status-name array with a private
|
||||
@@ -202,7 +299,7 @@ Exhausting either table raises an error to the caller; it is never silent.
|
||||
|
||||
## Thread safety
|
||||
|
||||
The registry is process-global mutable state with no locking. Reserve ranges and
|
||||
register names during single-threaded initialization, before spawning threads.
|
||||
Lookups (`akerr_name_for_status(status, NULL)`) are safe to call concurrently
|
||||
once registration has finished.
|
||||
As of 2.0.0 the registry is serialized: reservations, registrations and lookups
|
||||
are all safe to call concurrently. See "What thread safety here does and does
|
||||
not mean" at the top of this document for the two things that are still yours to
|
||||
coordinate.
|
||||
|
||||
Reference in New Issue
Block a user