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:
99
README.md
99
README.md
@@ -4,12 +4,14 @@ This library provides a TRY/CATCH style exception handling mechanism for C.
|
||||
|
||||

|
||||
|
||||
## Upgrading from a pre-1.0.0 release
|
||||
## Upgrading
|
||||
|
||||
1.0.0 replaced the consumer-sized status-name array with a private, ownership-
|
||||
enforced registry. That is a source and ABI break: see
|
||||
[UPGRADING.md](UPGRADING.md) for what was removed, how to migrate, the capacity
|
||||
limits and how to raise them, and the thread-safety rules.
|
||||
2.0.0 makes the library thread safe. That is an ABI break — `__akerr_last_ignored`
|
||||
became thread-local storage and the pool now takes its own reference — so
|
||||
everything built against a 1.x header must be rebuilt. 1.0.0 replaced the
|
||||
consumer-sized status-name array with a private, ownership-enforced registry.
|
||||
See [UPGRADING.md](UPGRADING.md) for both, what was removed, how to migrate, the
|
||||
capacity limits and how to raise them, and the thread-safety rules.
|
||||
|
||||
|
||||
# Why?
|
||||
@@ -114,6 +116,90 @@ functions raise, the capacity limits and how to raise them, and thread-safety
|
||||
rules.
|
||||
|
||||
|
||||
# Thread safety
|
||||
|
||||
The library is thread safe as built by default. Every entry point may be called
|
||||
from any thread at any time, including the first one: `akerr_init()` runs
|
||||
exactly once no matter how many threads race into it.
|
||||
|
||||
What that covers:
|
||||
|
||||
* **The error pool.** Finding a free slot in `AKERR_ARRAY_ERROR` and taking its
|
||||
reference is one operation under a lock, so two threads can never be handed
|
||||
the same context. A context is then owned by the thread that raised it, all
|
||||
the way through `CATCH`, `HANDLE`, and release.
|
||||
* **The status registry.** Reservations and name registrations are serialized
|
||||
against each other and against lookups. Two threads reserving the same range
|
||||
cannot both win — exactly one gets `NULL` and the other gets
|
||||
`AKERR_STATUS_RANGE_OVERLAP` naming the winner.
|
||||
* **Per-thread state.** The context behind `IGNORE` (`__akerr_last_ignored`) and
|
||||
the last-ditch context used to report `akerr_release_error(NULL)` are
|
||||
thread-local, so one thread's ignored error is never another's.
|
||||
|
||||
What it does not cover, and cannot:
|
||||
|
||||
* **Sharing one error context between threads.** The library hands a context to
|
||||
one thread; passing it to another is your synchronization to do.
|
||||
* **`akerr_log_method` and `akerr_handler_unhandled_error`.** Set them during
|
||||
startup, before you spawn threads. They are read on every error and the
|
||||
library never writes them after initialization, so setting one while other
|
||||
threads are raising errors is a race the library cannot mediate.
|
||||
* **Renaming a status that other threads are looking up.**
|
||||
`akerr_name_for_status(status, NULL)` returns a pointer into the registry,
|
||||
valid for the life of the process; registering a *second* name for the same
|
||||
status overwrites that buffer in place. Register names during initialization.
|
||||
Registering a *new* status concurrently is fine.
|
||||
* **Which unhandled error terminates the process.** An error that reaches
|
||||
`FINISH_NORETURN` unhandled prints its stack trace and calls
|
||||
`akerr_handler_unhandled_error`, which by default calls `exit()`. Each
|
||||
thread's trace is whole — the buffer belongs to its context, and each line is
|
||||
one call to `akerr_log_method` — but if two threads get there at the same
|
||||
instant, both traces print and the exit status is whichever one won.
|
||||
|
||||
There is one lock, it is recursive, and it covers both the pool and the
|
||||
registry. That means error construction is serialized across threads: raising an
|
||||
error is the exceptional path, and correctness there is worth more than
|
||||
throughput. A program that raises errors on its hot path will feel it.
|
||||
|
||||
`AKERR_THREAD_SAFE` in the generated header is `1` for a thread-safe build, so a
|
||||
consumer can check what it linked against:
|
||||
|
||||
```c
|
||||
#if AKERR_THREAD_SAFE
|
||||
/* ... start worker threads ... */
|
||||
#endif
|
||||
```
|
||||
|
||||
## Building single threaded
|
||||
|
||||
The threading backend is chosen when libakerror is configured. `auto` (the
|
||||
default) takes POSIX threads, and **fails the configure** if it cannot find
|
||||
them rather than quietly producing a library that says it is thread safe and is
|
||||
not. To mean it:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build -DAKERR_THREADS=none
|
||||
```
|
||||
|
||||
That builds with no locking and no thread-local storage, stamps
|
||||
`AKERR_THREAD_SAFE 0` into the header, and calling the library from more than
|
||||
one thread is then undefined.
|
||||
|
||||
## Proving it
|
||||
|
||||
The thread tests (`tests/err_threads_*.c`) assert the properties above directly:
|
||||
exclusive ownership of pool slots, exactly one winner for a contested range,
|
||||
every registered name readable back. They run in the normal suite. The run that
|
||||
proves the *absence* of a data race underneath them is ThreadSanitizer:
|
||||
|
||||
```sh
|
||||
scripts/thread_test.sh
|
||||
```
|
||||
|
||||
which configures `build/tsan` with `-DAKERR_SANITIZE=thread`, builds the library
|
||||
and every test with it, and runs the suite. Under that build a sanitizer report
|
||||
fails the test rather than being printed and passed over.
|
||||
|
||||
# Installation
|
||||
|
||||
```bash
|
||||
@@ -132,7 +218,8 @@ The build process relies upon `scripts/generrno.sh` which performs the following
|
||||
|
||||
## Dependencies
|
||||
|
||||
This library depends upon `stdlib`. If you don't want to link against stdlib, you must modify the library code to include headers and link against a library that provides the following:
|
||||
This library depends upon `stdlib`, and upon POSIX threads unless it is built
|
||||
with `-DAKERR_THREADS=none` (see "Thread safety" above). If you don't want to link against stdlib, you must modify the library code to include headers and link against a library that provides the following:
|
||||
|
||||
- `memset` function
|
||||
- `strncpy` function
|
||||
|
||||
Reference in New Issue
Block a user