Separate shared service ownership from thread-local logging caches

This commit is contained in:
jdb committed 2026-09-22 07:18:22 -05:00
1 parent c1c8a3c072
commit 761bd66793
6 files changed
+386 -157

No files matched your search

+76 -53
View File
@@ -201,53 +201,58 @@ func initCustomLogAppender*[T](
The `state` field allows you to explicitly pass in any data that is required
for the custom functionality.
*TODO: rethink this. I chose this to avoid GC-safety issues copying closures
across threads, but maybe I don't need this separate, explicit state field.*
> [!CAUTION] The `state` data type must support copy semantics on assignment.
> It is possible to pass a `ref` to `state` and/or data structures that include
> `ref`s, but **you must guarantee they remain valid**, either by allocating
> shared memeory, or (preferably) keeping alive a reference to them that the GC
> is aware of, either on the thread where they were initialized or by
> explicitly telling the GC about the cross-thread reference *(TODO: how?)*.
> [!CAUTION]
> Custom state is currently copied by assignment when an appender is cloned.
> Ordinary ORC `ref` values, including references nested in value types, must
> not be shared this way across threads. Keeping an original reference alive
> does not protect reference counts. A safe custom-state and captured-formatter
> ownership API is still pending; use independently owned values or an explicit
> shared-state abstraction with a synchronized lifetime and access protocol.
See [testutil][] and the unit tests in [namespaced\_logging][nsl-unit-tests]
for an example.
for a single-threaded example; the test collector's lock does not make its
reference-counted state safe to share across threads.
## Notes on Use in Multi-Threaded Applications
The loggers and appenders in this library are thread-safe and are intended to
behave more intuitively in a multi-threaded environment than
[std/logging][std-logging] while presenting a similar API. This is particularly
true in environments where the logging setup code may be separated from the
thread-management code (in an HTTP server, for example).
This library is intended to support thread-safe logging in multi-threaded
applications, and hopefully to behave more intuitively in a multi-threaded
environment than [std/logging][std-logging] while presenting a similar API. A
key difference is that logging setup can happen separately from thread
creation, such as when an HTTP server manages its own worker threads.
The *LogService* object is the main entry point for the logging system and
should be initialized on the main thread. The *LogService* contains a reference
to the "source of truth" for logging configuration and is safe to be shared
between all threads.
should be initialized via *initLogService* during application setup, usually on
the main thread. Pass a copy of that *LogService* to each worker that needs
logging. Such copies use the same logging configuration and will be updated
when there are changes (e.g. to appenders or logging thresholds). Calling
*initLogService* again creates a separate, independent configuration.
The thread which initializes a *LogService* must also be the longest-living
thread that uses that *LogService* instance. If the initializing thread
terminates or the *LogService* object in that thread goes out of scope while
other threads are still running and using the *LogService*, the global state
may be harvested by the garbage collector, leading to use-after-free errors
when other threads attempt to log (likely causing segfaults).
Within each thread, call *threadLocalRef* to obtain a *ThreadLocalLogService*,
then use its *getLogger* method to create loggers. Keep those local services and
loggers within the thread that created them. If the main thread also logs,
create a local service and loggers there too. Appenders configured during setup
are available to each thread without repeating that setup in every worker.
Individual threads should use the *threadLocalRef* proc to obtain a
*ThreadLocalLogService* reference that can be used to create *Logger* objects.
*ThreadLocalLogService* objects cache the global *LogService* state locally to
avoid expensive locks on the shared state. Instead an atomic configuration
version number is maintained to allow the thread-local state to detect global
configuration changes via an inexpensive [load][atomic-load] call and
automatically synchronize only when necessary.
Once workers have their own copies of the *LogService*, the original service
can go out of scope and the thread that initialized it can finish. Logging
remains available while any service copy, local service, or logger using that
service remains alive. Join worker threads before exiting the application so
they have an opportunity to finish logging.
This thread-local caching mechanism is the primary advantage of this logging
system over std/logging in a multi-threaded environment as it means that
the logging system itself is responsible for making sure appenders are
configured for every thread where loggers are used, even if the thread
initialization context is separated from the logging setup code.
You can change logging configuration through a service copy or a local service
in any thread. Those changes are intended to take effect automatically on
subsequent logging calls in every thread using that service, without recreating
loggers or configuring each worker separately.
> [!NOTE]
> Thread-safety work is still in progress. For now, use the built-in appenders
> with default or stateless formatters when sharing a service across threads.
> Custom appenders with shared reference-based state and formatters with
> captured state are not yet generally safe. Runtime configuration refresh
> and delivery of queued background output at application exit also have known
> failures. See [the regression tests](tests/README.md) for the remaining work.
## Architectural Design
@@ -261,18 +266,17 @@ usability in multithreaded environments. The design centers on two key types:
#### LogService (Value Type)
```nim
type LogService* = object
configVersion: int
global: GlobalLogService
appenders: seq[LogAppender]
thresholds: TableRef[string, Level]
```
The *LogService* object is intended to support uses cases such as:
- **Main thread initialization**: a mutable *LogService* supports all of the
configuration functions you would typically need when initializing logging
for an application on the main thread.
- **Cross-thread communication**: Being an `object` type, *LogService* follows
value semantics and can be safely copied between threads.
- **Cross-thread communication**: Copies of a *LogService* can be passed
between threads while keeping the shared configuration alive. Configuration
changes are intended to become visible to each associated
*ThreadLocalLogService* automatically on subsequent logging calls.
- **Service composition**: independently initialized *LogService* objects are
truly independent and multiple can be created and embedded in larger
application contexts.
@@ -283,15 +287,32 @@ The *LogService* object is intended to support uses cases such as:
#### ThreadLocalLogService (Reference Type)
```nim
type ThreadLocalLogService* = ref LogService
type ThreadLocalLogService* = ref object
shared: LogService
configVersion: int
appenders: seq[LogAppender]
thresholds: TableRef[string, Level]
```
*ThreadLocalLogService* is a reference to a thread-local copy of a *LogService*
and can be obtained via *threadLocalRef*. We purposefully use reference
*threadLocalRef* builds a fresh *ThreadLocalLogService* cache in the calling
thread, even when the configuration has not changed. We use reference
semantics within the context of a thread so that *Logger* objects created
within the same thread context share the same *ThreadLocalLogService*
reference, avoiding the need to synchronize every *Logger* individually.
*ThreadLocalLogService* is no longer an alias for `ref LogService`. To obtain a
shared handle from a local service, use `local.service` instead of `local[]`:
```nim
let handle = localLogSvc.service
# Pass handle to a worker, which calls handle.threadLocalRef() itself.
```
Configuration methods remain available on both types. Shared handles contain no
cache; `ensureFreshness(handle)` only validates initialization, and
`handle.appenders()` clones the current shared configuration. Local services
retain their per-thread caches and refresh them through `ensureFreshness(local)`.
*ThreadLocalLogService* is the object that users are expected to interact with
during regular operation and support both the configuration functions of
*LogService* and the creation of *Logger* objects.
@@ -302,30 +323,32 @@ during regular operation and support both the configuration functions of
#### GlobalLogService (Internal)
Under the hood *LogService* holds a reference to a *GlobalLogService*, a
heap-allocated object that serves as the single source of truth for logging
Under the hood *LogService* holds a pointer to a manually allocated shared
control block that serves as the single source of truth for logging
configuration. This internal type is not exposed to library users but manages:
- **Shared configuration state**: Appenders, thresholds, and root logging level
- **Synchronization primitives**: Locks and atomic variables for thread
coordination
- **Background I/O threads**: Dedicated writer threads for console and file
output
- **Ownership**: Atomic retain/release and explicit destruction of configuration
- **Configuration versioning**: Atomic version numbers for efficient change
detection
The `GlobalLogService` ensures that configuration changes are safely propagated
across all threads while maintaining high performance for logging operations.
Console and file writers remain process-wide. The shared control block owns
appender prototypes; refresh borrows them under the configuration lock and calls
`clone()` to create local instances. Clones must not retain ordinary managed
references shared across threads. That custom-state and formatter contract is
the next part of the ownership work.
### Thread Safety Model
#### Safe Cross-Thread Pattern
```nim
# Main thread setup
let logService = initLogService()
var logService = initLogService()
logService.addAppender(initConsoleLogAppender())
# Safe: value semantics allow crossing thread boundaries
# Each copy retains the shared control block atomically.
proc workerThread(ls: LogService) {.thread.} =
# Convert to thread-local reference for efficient operations
let tlls = threadLocalRef(ls)
@@ -351,7 +374,7 @@ The library uses atomic version numbers to efficiently detect configuration
changes:
```nim
proc ensureFreshness*(ls: var LogService) =
proc ensureFreshness*(ls: ThreadLocalLogService) =
# Cheap atomic check first
if ls.configVersion == ls.global.configVersion.load():
return # No changes, return immediately