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:
149
tests/err_threads.h
Normal file
149
tests/err_threads.h
Normal file
@@ -0,0 +1,149 @@
|
||||
#ifndef AKERR_TEST_THREADS_H
|
||||
#define AKERR_TEST_THREADS_H
|
||||
|
||||
/*
|
||||
* Shared helpers for the thread-safety tests.
|
||||
*
|
||||
* These tests are checkable on their own -- they assert exclusive ownership of
|
||||
* pool slots and of reserved ranges, which is a property, not a symptom -- but
|
||||
* the run that proves the absence of a data race is the one under
|
||||
* ThreadSanitizer:
|
||||
*
|
||||
* cmake -S . -B build/tsan -DAKERR_SANITIZE=thread
|
||||
* cmake --build build/tsan
|
||||
* ctest --test-dir build/tsan --output-on-failure
|
||||
*
|
||||
* Everything shared between the threads here is either read-only after the
|
||||
* threads start, or touched through __atomic builtins. Anything else would be a
|
||||
* race in the *test*, and TSan cannot tell whose bug it is reporting.
|
||||
*
|
||||
* A test body runs on AKERR_TEST_THREADS threads that meet at a barrier first,
|
||||
* so they arrive at the library together instead of in start-up order. Failed
|
||||
* checks are counted per thread rather than returned early: a thread that
|
||||
* abandoned its work would leave the others holding pool slots and turn one
|
||||
* failure into a cascade of unrelated ones.
|
||||
*/
|
||||
|
||||
#include "akerror.h"
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define AKERR_TEST_THREADS 8
|
||||
|
||||
typedef struct
|
||||
{
|
||||
int id; /* 1-based: 0 means "no thread" below */
|
||||
int failures;
|
||||
pthread_barrier_t *barrier;
|
||||
} akerr_ThreadArg;
|
||||
|
||||
#define AKERR_TCHECK(__arg, __cond) \
|
||||
do { \
|
||||
if ( !(__cond) ) { \
|
||||
fprintf(stderr, "CHECK FAILED (thread %d): %s at %s:%d\n", \
|
||||
(__arg)->id, #__cond, __FILE__, __LINE__); \
|
||||
(__arg)->failures += 1; \
|
||||
} \
|
||||
} while ( 0 )
|
||||
|
||||
/*
|
||||
* A logger that counts instead of printing. The capturing logger in
|
||||
* err_capture.h appends to a shared buffer with a shared length, which is a
|
||||
* data race the moment two threads log at once; these tests need a logger that
|
||||
* is safe to install before spawning and still shows that something was
|
||||
* reported.
|
||||
*/
|
||||
static int akerr_thread_log_count;
|
||||
|
||||
static void __attribute__((unused)) akerr_thread_logger(const char *fmt, ...)
|
||||
{
|
||||
(void)fmt;
|
||||
__atomic_fetch_add(&akerr_thread_log_count, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
|
||||
static int __attribute__((unused)) akerr_thread_logs(void)
|
||||
{
|
||||
return __atomic_load_n(&akerr_thread_log_count, __ATOMIC_RELAXED);
|
||||
}
|
||||
|
||||
/*
|
||||
* Independent bookkeeping of who holds which pool slot. The library's own
|
||||
* refcount says a slot is checked out; this says which thread it was checked
|
||||
* out to, which is the part a racing akerr_next_error() would get wrong by
|
||||
* handing one slot to two threads at once.
|
||||
*/
|
||||
static int akerr_slot_owner[AKERR_MAX_ARRAY_ERROR];
|
||||
|
||||
/* Returns 0 on success, or the id of the thread that already holds the slot. */
|
||||
static int __attribute__((unused)) akerr_slot_claim(int slot, int id)
|
||||
{
|
||||
int unowned = 0;
|
||||
|
||||
if ( __atomic_compare_exchange_n(&akerr_slot_owner[slot], &unowned, id, 0,
|
||||
__ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE) ) {
|
||||
return 0;
|
||||
}
|
||||
return unowned;
|
||||
}
|
||||
|
||||
static int __attribute__((unused)) akerr_slot_holder(int slot)
|
||||
{
|
||||
return __atomic_load_n(&akerr_slot_owner[slot], __ATOMIC_ACQUIRE);
|
||||
}
|
||||
|
||||
/*
|
||||
* Give the slot up *before* releasing the error context. The other order hands
|
||||
* the slot back to the pool while this thread still claims it, and the next
|
||||
* thread to be given it reports a violation that is the test's fault.
|
||||
*/
|
||||
static void __attribute__((unused)) akerr_slot_drop(int slot)
|
||||
{
|
||||
__atomic_store_n(&akerr_slot_owner[slot], 0, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
/*
|
||||
* Run `body` on AKERR_TEST_THREADS threads and return the total number of
|
||||
* failed checks. A thread that cannot be created is itself a failure, but the
|
||||
* ones already running still get joined.
|
||||
*/
|
||||
static int __attribute__((unused)) akerr_run_threads(void *(*body)(void *))
|
||||
{
|
||||
pthread_t threads[AKERR_TEST_THREADS];
|
||||
akerr_ThreadArg args[AKERR_TEST_THREADS];
|
||||
pthread_barrier_t barrier;
|
||||
int started = 0;
|
||||
int failures = 0;
|
||||
|
||||
if ( pthread_barrier_init(&barrier, NULL, AKERR_TEST_THREADS) != 0 ) {
|
||||
fprintf(stderr, "CHECK FAILED: pthread_barrier_init at %s:%d\n",
|
||||
__FILE__, __LINE__);
|
||||
return 1;
|
||||
}
|
||||
for ( int i = 0; i < AKERR_TEST_THREADS; i++ ) {
|
||||
args[i].id = i + 1;
|
||||
args[i].failures = 0;
|
||||
args[i].barrier = &barrier;
|
||||
if ( pthread_create(&threads[i], NULL, body, &args[i]) != 0 ) {
|
||||
fprintf(stderr, "CHECK FAILED: pthread_create for thread %d"
|
||||
" at %s:%d\n", i + 1, __FILE__, __LINE__);
|
||||
failures++;
|
||||
break;
|
||||
}
|
||||
started++;
|
||||
}
|
||||
/* An unstarted thread never reaches the barrier, so the started ones would
|
||||
* wait for it forever. Nothing to do but say so before hanging is diagnosed
|
||||
* as a deadlock in the library. */
|
||||
if ( started != AKERR_TEST_THREADS ) {
|
||||
fprintf(stderr, "only %d of %d threads started; the barrier will not"
|
||||
" release\n", started, AKERR_TEST_THREADS);
|
||||
}
|
||||
for ( int i = 0; i < started; i++ ) {
|
||||
pthread_join(threads[i], NULL);
|
||||
failures += args[i].failures;
|
||||
}
|
||||
pthread_barrier_destroy(&barrier);
|
||||
return failures;
|
||||
}
|
||||
|
||||
#endif // AKERR_TEST_THREADS_H
|
||||
Reference in New Issue
Block a user