Separate shared service ownership from thread-local logging caches
This commit is contained in:
1 parent
c1c8a3c072
commit
761bd66793
6 files changed
+386
-157
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user