Arc.Threading
0.56.0
dotnet add package Arc.Threading --version 0.56.0
NuGet\Install-Package Arc.Threading -Version 0.56.0
<PackageReference Include="Arc.Threading" Version="0.56.0" />
<PackageVersion Include="Arc.Threading" Version="0.56.0" />
<PackageReference Include="Arc.Threading" />
paket add Arc.Threading --version 0.56.0
#r "nuget: Arc.Threading, 0.56.0"
#:package Arc.Threading@0.56.0
#addin nuget:?package=Arc.Threading&version=0.56.0
#tool nuget:?package=Arc.Threading&version=0.56.0
Arc.Threading
Arc.Threading is a .NET 10 library for managing the lifetime of threads and tasks. It arranges threads, tasks, and groups in a cancellable tree, so you can start, signal, terminate, and await whole subtrees. It also provides a pooled job worker and small synchronization primitives designed for low allocation.
- Quick start
- Execution trees
- Execution stacks
- Reusable jobs
- Pulse events
- Locks
- Other utilities
- Performance and allocation notes
- Upgrade notes
- NativeAOT
- Build, test, and coverage
Quick start
Install the package in a .NET 10 project:
dotnet add package Arc.Threading
Create a root, attach work, then request and await shutdown (a top-level program with implicit usings):
using Arc.Threading;
using var root = new ExecutionRoot();
// Starts immediately on a dedicated long-running task.
_ = new TaskCore(root, async core =>
{
while (await core.TryDelay(100)) // false once termination is requested
{
Console.WriteLine("Working");
}
});
await Task.Delay(500);
root.RequestTermination(); // Cancel the work attached to the root.
await root.WaitForTerminationAsync(); // Wait until it has exited.
Termination is cooperative: delegates must check CanContinue or observe their cancellation token. Dispose() requests termination but does not wait, so await shutdown before disposing the root or resources that the work uses. Disposing the root terminates everything left in the tree.
Execution trees
A tree starts at an ExecutionRoot. Every execution belongs to one root and has at most one parent group. Termination requests, waits, and signals flow down the tree.
Execution types
| Type | Purpose |
|---|---|
ExecutionCore |
A cancellable node derived from CancellationTokenSource. An instance of exactly this type disposes itself when terminated. |
ExecutionGroup |
A container for child executions, with no thread or task of its own. |
ExecutionRoot |
The tree root. It owns BaseGroup and IndependentGroup, which are both independent. |
ThreadCore |
Runs an Action<ThreadCore> on a dedicated foreground thread. |
TaskCore / TaskCore<TSelf> |
Runs an asynchronous delegate on a dedicated long-running task. The generic form passes the derived instance as TSelf. |
TaskCompletionCore |
A core with a CompletionTask that completes when SetCompleted() is called. |
TaskCompletionGroup |
A group with a CompletionTask. It always belongs to an execution stack. |
ReusableJobWorker<TJob> |
A TaskCore that processes pooled jobs. See Reusable jobs. |
ExecutionCoreOptions |
Effect on thread/task cores |
|---|---|
None |
Start immediately. Dispose the core when its delegate exits, or when a start is skipped because the core was already canceled. |
DelayedStart |
Do not start until ExecutionSignal.Start is received. |
NoDisposeOnCompletion |
Never dispose the core automatically. |
TerminationOptions.None (the default) skips independent descendants; TerminationOptions.IncludeIndependent includes them.
Ownership and groups
- Constructors attach the new execution to
parent. A disposed parent throwsObjectDisposedException. Work attached to a terminated parent is terminated immediately. IsIndependent(a constructor argument or property) makes recursive termination and waiting skip that execution and its subtree unlessIncludeIndependentis set. Requests made directly on it still apply, and signals still reach it.GetOrAddGroup(isIndependent, name)returns the direct child group with that name (ordinal comparison), or creates it. It throwsInvalidOperationExceptionif the existing group has a different independence setting. The returned group may already be terminated; dispose it so that the next call creates a new one.ExecutionRoot.GetOrAddUnitGroup(name)does the same for independent groups underIndependentGroup.- Set
Parentor callAddChild()to move an execution within the same root. Self-parenting, cycles, and cross-root moves throwInvalidOperationException; disposed executions throwObjectDisposedException.Parent = nulldetaches the execution, so ancestors no longer terminate or await it. FindChild(id)andTryGetChildCancellationToken(id, out token)search direct children only.GetChildren()returns a cached snapshot; treat it as read-only.ChildCountis advisory while membership changes.Iddefaults to a process-wide unique, nonzero number (the root uses 0) and is reset to 0 on disposal. You can assign it, but uniqueness is then not enforced.Nameis a display name.core.CancellationToken(orcore.Token) is a token whose source is the core itself.token.AsExecutionCore()andtoken.AsExecution<T>()recover the execution, or returnnullfor any other token.core.ToCancellationToken()converts the other way.
Startup and signals
Thread/task cores start as soon as they are constructed, unless DelayedStart is set. A derived core whose delegate reads fields set in its constructor should use the protected delayStart constructor and start itself when ready:
sealed class CounterCore : TaskCore<CounterCore>
{
public CounterCore(ExecutionGroup parent, int intervalMilliseconds)
: base(parent, Process, ExecutionCoreOptions.None, delayStart: true)
{
this.IntervalMilliseconds = intervalMilliseconds;
this.SendSignal(ExecutionSignal.Start); // Fields are ready.
}
public int IntervalMilliseconds { get; }
private static async Task Process(CounterCore core)
{
while (await core.TryDelay(core.IntervalMilliseconds))
{
Console.WriteLine("Tick");
}
}
}
- Pass
DelayedStartwhen the owner decides when to start, then callcore.SendSignal(ExecutionSignal.Start). SendingStartto a group or the root starts every delayed core below it, including independent ones. - A core starts at most once; later
Startsignals are ignored. - If a core is canceled before it starts,
Startabandons it (the delegate never runs) and disposes it unlessNoDisposeOnCompletionis set. As a result, a non-delayedTaskCore,ThreadCore, orReusableJobWorker<TJob>created under a terminated parent is disposed inside its constructor:ParentisnullandTaskCore.Taskis already completed. An instance of a derived type is not disposed there, because its own constructor has not run yet; it stays terminated until its owner disposes it. A delayed core that is canceled and never receivesStartalso stays in the tree, terminated, until it or an ancestor is disposed. ThreadCoreuses a foreground thread, which keeps the process alive until the delegate returns. For a background thread, create the core withDelayedStart, setcore.Thread.IsBackground = true, then sendStart.- Thread/task cores react only to
Start.CancelandTerminateare application-defined notifications with no built-in effect; callRequestTermination()to cancel. - Groups forward every signal to all current children, including independent ones. If a child throws, forwarding continues; afterwards a single exception is rethrown unchanged, and several are thrown as an
AggregateException. - An
ExecutionSignalHandlerpassed to theExecutionCoreorTaskCompletionGroupconstructor, or toExecutionStack.PushNew(), replacesOnSignalReceived(), including group forwarding. Derived types can overrideOnSignalReceived()instead; call the base method to keepStarthandling and forwarding.
For full control, a derived type can create its own task with the protected TaskCore(parent, options) constructor, CreateLongRunningTask(core, Func<Task>), and Initialize(task, startAutomatically).
Termination and completion
| Member | Behavior |
|---|---|
RequestTermination(options) |
Cancels this execution and its descendants (independent ones only with IncludeIndependent). Idempotent, and ignored after disposal. Work attached during the request is canceled too. Exceptions from cancellation callbacks are ignored. |
CanContinue |
false once cancellation is requested. |
IsTerminated |
Thread/task cores: the delegate has exited, or the core was canceled before it started. Other cores and groups: cancellation was requested. |
WaitForTerminationAsync(timeout, options, ct) |
Returns true once the target has terminated, or false on timeout or cancellation. It does not request termination. |
TryDelay(delay, ct) |
Returns true after the delay, or false as soon as this execution or ct is canceled. |
SetCompleted() |
Completes the CompletionTask of a completion core or group. Completion and termination are independent. |
- Termination is cooperative. The inherited
Cancel()andCancelAfter()cancel only that core's token and do not traverse the tree. - Thread/task cores dispose themselves when the delegate exits, unless
NoDisposeOnCompletionis set. - An
OperationCanceledExceptionthat escapes the delegate after termination was requested is a normal exit:TaskCore.Taskcompletes successfully, and aThreadCoreends quietly. Any other exception, including anOperationCanceledExceptionthrown before termination was requested, faultsTaskCore.Taskand is unhandled on aThreadCore, which terminates the process. - Do not start
TaskCore.Taskyourself. It is already completed if the core was terminated when the task was created, and it stays unstarted if a delayed core is canceled beforeStart. UseWaitForTerminationAsync()to observe shutdown. - A group is only a container: waiting on it waits for its non-group descendants (independent ones only with
IncludeIndependent). A group with no such descendants counts as terminated even if it is not canceled. Waits check every 10 ms. ExecutionRoot.WaitForTerminationAsync()first requests termination of the wholeBaseGroupsubtree (including independent descendants) and waits for it. It then waits for the rest of the tree within the remaining timeout. It does not cancel the root's other children, so callroot.RequestTermination()first.IndependentGroupis waited for only withIncludeIndependent, and the wait never cancels it.CompletionTaskruns continuations asynchronously. Cancellation and disposal do not complete it.
A graceful shutdown of the whole tree:
root.RequestTermination(TerminationOptions.IncludeIndependent);
var terminated = await root.WaitForTerminationAsync(TimeSpan.FromSeconds(5), TerminationOptions.IncludeIndependent);
root.Dispose();
Disposal
Dispose()requests termination, removes the execution from its parent and stack, and resetsIdto 0. It does not join threads or tasks, and once disposed, the execution is no longer awaited by its ancestors. Repeated calls have no effect.- Disposing a group first detaches it from its parent and stack, so lookups such as
GetOrAddGroup()andFindChild()no longer return it. It then cancels its whole subtree, repeating until nothing attached is left uncanceled. Work attached to a descendant during disposal, for example by a cancellation callback or another thread, is therefore terminated as well. Finally, the non-independent descendants are detached from their parents and stacks. They are canceled but not disposed; thread/task cores still dispose themselves when their delegates exit. - Independent children of a disposed group are detached without being canceled. They keep running outside the tree, so terminate them yourself.
- Disposing an
ExecutionRootterminates the whole tree, includingBaseGroup,IndependentGroup, unit groups, and all independent descendants. It does not wait; for a graceful shutdown, awaitWaitForTerminationAsync()first. - A disposed group rejects new children with
ObjectDisposedException.
Execution stacks
An ExecutionStack tracks executions of one root separately from the parent-child tree.
var stack = new ExecutionStack(root);
using var operation = stack.PushNew(root.BaseGroup);
operation.SetCompleted();
await operation.CompletionTask;
PushNew(parent, signalHandler)creates a non-independentTaskCompletionGroupunderparentand adds it to the stack.TryPush(core)adds an existing execution. It returnstrueif the execution is now (or already) on this stack, andfalseif it is on another stack or has been disposed. An execution from a different root throwsInvalidOperationException.- An execution belongs to at most one stack. It leaves the stack when it, or a group that contains it, is disposed.
FirstCore,LastCore,Count,IsEmpty, andFind(id)inspect the stack. Counts are advisory while membership changes.
Reusable jobs
Worker
ReusableJobWorker<TJob> is a TaskCore that queues jobs and pools the job objects, so a steady stream of work allocates little. Process jobs with a JobProcessor delegate (synchronous), or override ProcessJobAsync(job, cancellationToken) (asynchronous); the token is the worker's own token.
using var worker = new ReusableJobWorker<PrintJob>(root, (_, job) =>
{
Console.WriteLine(job.Message);
});
worker.MaxConcurrentTasks = 4;
var job = worker.Rent();
job.Message = "Hello";
worker.Add(job);
await job.WaitAsync();
Console.WriteLine(job.State); // Completed or Aborted
worker.Return(job);
await worker.WaitForCompletionAsync();
public record class PrintJob : ReusableTaskJob
{
public string Message { get; set; } = "";
}
| Member | Behavior |
|---|---|
| Constructor | (parent, processJob = null, poolCapacity = 32, options = None). poolCapacity limits the idle jobs kept for reuse. |
Rent(options) |
Returns a pooled or new job in the Initial state. |
Add(job) |
Queues an Initial job (it becomes Pending); other states throw InvalidOperationException. A stopped or disposed worker aborts the job. |
Return(job) |
Pools a Completed or Aborted job; jobs in other states are ignored. |
WaitForCompletionAsync(timeout, ct) |
Waits until the worker is idle. See Waiting for jobs. |
MaxConcurrentTasks |
The maximum number of concurrent processors (default 1, minimum 1). |
IsIdle / PendingJobCount |
Whether no job is pending or running / the number of queued jobs. |
OnJobFinished(job) |
Override hook. Runs after a job completes or aborts, before its waiters are released. |
OnTerminated() |
Override hook. Runs when the worker loop exits, after all active processors finish. |
Job types
| Job type | Wait primitive |
|---|---|
ReusableTaskJob |
Task / WaitAsync(); recommended for asynchronous callers. Uses a new completion source for each rental. |
ReusableBlockingJob |
Wait(), backed by one reusable ManualResetEventSlim. |
ReusableJob |
None; for fire-and-forget work. |
Derive a record class from one of these types and add your own properties. Rent with ReusableJobOptions.ReturnToPoolOnCompletion to have the worker return the job automatically when it finishes (fire-and-forget).
Job lifecycle
Initial (rented) → Pending (added) → Running → Completed or Aborted → Pooled (returned).
- A job is
Abortedwhen the worker is stopping, when processing throws, or whenOnJobFinished()throws. The exception is not reported anywhere else, so log failures inside the processor. A failing job does not stop the worker or other jobs. Statebecomes final beforeOnJobFinished()runs. Waiters are released after it, even if it throws.- When the worker is terminated (by
RequestTermination()on it or an ancestor), it aborts pending jobs, lets running jobs finish (they see the canceled token), waits for all processors, callsOnTerminated(), and then disposes itself unlessNoDisposeOnCompletionis set. Dispose()cancels the worker and aborts pending jobs immediately. It does not wait for running jobs.- A
DelayedStartworker that is terminated beforeStartaborts its queued jobs when canceled, and never callsOnTerminated().
Waiting for jobs
- A job wait completes for both
CompletedandAborted, so inspectState. Canceling or timing out a wait never cancels the job. ReusableTaskJob.WaitAsync(ct)throwsOperationCanceledExceptionon cancellation;WaitAsync(timeout, ct)also throwsTimeoutExceptionon timeout.ReusableBlockingJob.Wait(timeout, ct)returnsfalseon timeout and throwsOperationCanceledExceptionon cancellation.- Waiting on a returned job throws
InvalidOperationException. AReusableTaskJobreused from the pool and rented withReturnToPoolOnCompletionhas no completion source, so itsTaskandWaitAsync()throw as well. worker.WaitForCompletionAsync()returnstrueas soon as no job is pending or running, including aborted jobs, so it does not mean every job succeeded. It returnsfalseon timeout, on cancellation, if the worker is disposed, or if it stops with jobs outstanding. The wait is event-driven (with a 1-second safety check), not a fixed-interval poll. It observes idleness and is not a barrier against laterAdd()calls.
Concurrency and threading
- Set
MaxConcurrentTasksbefore submitting work. Raising it adds processors as jobs arrive; lowering it does not interrupt active processors. - With a limit above 1, the
JobProcessordelegate,ProcessJobAsync(), andOnJobFinished()run concurrently and must be thread-safe. Jobs are dequeued in FIFO order but may finish in any order. - The main processing loop is hosted by the worker's long-running task; additional processors run on the thread pool.
- All jobs run under the
ExecutionContextcaptured when the worker was constructed.AsyncLocalvalues of the thread that callsAdd()do not flow into jobs. OnJobFinished()may run on any thread, including the thread that callsAdd(),Dispose(), orRequestTermination()when pending jobs are aborted.
Pooling rules
- Return a job only to the worker that rented it, and only after processing and all waits have finished. A final
Stateis not enough: the worker may still be runningOnJobFinished()and releasing waiters. - Do not touch a job after returning it.
Return()does not reset your properties, so reset them before reuse. - Jobs rented with
ReturnToPoolOnCompletionare fire-and-forget: do not await, access, or return them afterAdd(). - The pool keeps up to
poolCapacityidle jobs; extra returned jobs are discarded. - Jobs use reference equality. A copy made with
withis a new job in theInitialstate, with default options and its own wait primitive; your properties are copied. The compiler still generatesGetHashCode()from the properties of your derived record, so override it (public override int GetHashCode() => base.GetHashCode();) if you use jobs as hash keys.ToString()is safe on pooled jobs.
Pulse events
AsyncPulseEvent supports one waiter at a time. A pulse that arrives while no one is waiting is retained by default, and several retained pulses coalesce into one. Pass retainPulseIfNoWaiter: false to drop such pulses instead.
var pulse = new AsyncPulseEvent();
var waiting = pulse.WaitAsync(TimeSpan.FromSeconds(5), root.CancellationToken);
pulse.Pulse();
bool signaled = await waiting;
WaitAsync(timeout, ct)returnstruefor a pulse andfalseon timeout or cancellation; it throws for neither. Continuations of pending waits run asynchronously.- A second concurrent wait throws
InvalidOperationException. - An already canceled token returns
falsewithout consuming a retained pulse. A zero timeout only checks for a retained pulse. - A timeout must be infinite or between 0 and
int.MaxValuemilliseconds. Pulse()is lock-free and can be called from any thread.
Locks
SemaphoreLock is a non-reentrant exclusive lock for synchronous and asynchronous callers.
var mutex = new SemaphoreLock();
using (mutex.EnterScope())
{
Console.WriteLine("Synchronous access");
}
using (await mutex.EnterScopeAsync())
{
await Console.Out.WriteLineAsync("Asynchronous access");
}
| Member | Behavior |
|---|---|
Enter() / EnterScope() |
Blocks until the lock is acquired. Throws ThreadInterruptedException if the thread is interrupted, without leaving a queued waiter behind. |
TryEnter() |
Acquires the lock only if it is free. |
EnterAsync() / EnterScopeAsync() |
Waits asynchronously and always acquires the lock. |
EnterAsync(timeout), EnterAsync(ct), EnterAsync(timeout, ct) |
Returns false on timeout or cancellation. TimeSpan.Zero only tries once. |
Exit() |
Releases the lock. Throws SynchronizationLockException if it is not held. |
IsLocked |
Whether the lock is held. |
- Pair each successful
Enter(),EnterAsync(), orTryEnter()with exactly oneExit(), normally infinally. Scopes release automatically. - Ownership is not tracked: entering again from the same flow deadlocks, and any thread may call
Exit()(for example, after anawait). - Keep the instance private, because it locks itself internally (
lock (this)). - An already canceled token returns
falseimmediately, even if the lock is free. An invalid timeout (negative other than -1 ms, or above 4,294,967,294 ms) throwsArgumentOutOfRangeExceptionbefore the lock or its queue changes. - If acquisition wins a race with cancellation, the result is
trueand you must callExit(). - Continuations of asynchronous waiters never run inline inside
Exit()orCancellationTokenSource.Cancel().
| Type | Purpose |
|---|---|
ILockable / IAsyncLockable |
Lock interfaces (Enter(), Exit(), IsLocked, EnterAsync()) with default EnterScope() / EnterScopeAsync() helpers. |
MonitorLock |
A reentrant Monitor wrapper. It is thread-affine: release it on the acquiring thread and never hold it across await. IsLocked reports ownership by the current thread. |
LockScope |
A disposable struct that releases its lock once. IsLocked tells whether it holds the lock. Do not copy it: each copy has its own ownership flag. |
ILockProvider |
Exposes a System.Threading.Lock through LockObject. |
Other utilities
| API | Behavior |
|---|---|
DelayedTaskExecutor |
Coalesces Request() calls into one delayed run of an asynchronous action. |
SingleTask |
TryRun() runs work on the thread pool when idle, and returns null while busy. RunningTask is the current task. |
UniqueWork |
Run() starts the work on the thread pool, or returns the task already in progress. |
MicroSleep |
A blocking sleep specified in microseconds, using native timers. |
ExecutionId.Get() |
Returns an ambient long ID for the current asynchronous flow. |
CancellationTokenPool |
Rent() and Return() pooled CancellationTokenSource instances (up to PoolCapacity, 256). |
EstimateSize |
Struct<T>() returns the managed size of T (pointer size for reference types). Class<T>() and Constructor(factory) return the average bytes allocated per construction on the current thread. |
Task.TryDelay(delay, ct) |
A C# 14 static extension on Task that returns false on cancellation instead of throwing. |
AbortOrComplete |
A result enum: Abort or Complete. |
PanicException |
An application-defined fatal error. Throwing it does not terminate the process by itself. |
DelayedTaskExecutor (
new DelayedTaskExecutor(action, delay, cancellationToken)): the delay starts at the first request; later requests do not restart it. Requests during the delay coalesce. Requests during a run schedule at most one more run, which happens even if the current run fails. A zero delay still runs asynchronously.Request()returnstrueif it changed the state. Cancellation drops pending runs. Handle exceptions inside the action, becauseRequest()does not expose the background task.SingleTask and UniqueWork: failures and cancellation propagate through the returned task (to every caller sharing a
UniqueWorkrun). A new run can start once the current one completes.UniqueWorkalways starts work on the thread pool, never inline under its lock.MicroSleep: uses
nanosleepon Unix and a high-resolution waitable timer on Windows, falling back totimeBeginPeriod(1)andThread.Sleep(rounded up to whole milliseconds).CurrentModeshows which is used.Sleep(int microseconds)rejects negative values and throwsObjectDisposedExceptionafter disposal; an interrupted Unix sleep resumes for the remaining time. It is not thread-safe, and precision depends on the OS. Dispose it to release native resources (a finalizer also releases the fallback timer period).using var sleeper = new MicroSleep(); sleeper.Sleep(microseconds: 500);ExecutionId: assigned on the first call and then returned within the flow, including flows started from it afterwards. An ID first assigned in a child flow (such as an awaited method) does not flow back to the caller, so call
Get()at the start of the logical operation. IDs are unique only within the process.CancellationTokenPool: a rented source is yours alone. Return it once, after its registrations finish and its old tokens are no longer used. Canceled sources cannot be reset and are disposed. Linked and derived sources are disposed too, because a linked source stays subscribed to its parents. Returning a disposed plain source throws
ObjectDisposedException.EstimateSize: includes allocations made inside the constructor or factory. One untimed warm-up call excludes one-time costs such as static constructors, so the factory runs 1,001 times.
Performance and allocation notes
- Allocation-free paths: consuming a retained pulse, uncontended
SemaphoreLock.EnterAsync()andEnterScopeAsync()(one cached task per lock),TryDelay()that returns immediately (zero delay or already canceled),WaitForTerminationAsync()on a target that has already terminated,FindChild(), andGetChildren()while membership is unchanged.TaskCompletionCoreandTaskCompletionGroupcreate their completion source only whenCompletionTaskis first read. - Pending waits allocate. A cancelable wait adds a token registration, and a timed pulse wait also rents a pooled
CancellationTokenSource.SemaphoreLock.EnterAsync(ct)registers directly on the token, without a timer or an async state machine. WaitForTerminationAsync()polls every 10 ms using onePeriodicTimerper call, so expect up to about 10 ms of latency.- Each running
TaskCore, including each worker, occupies a dedicated thread that hosts its asynchronous delegate. Use one worker for a large stream of jobs instead of one core per job. - Workers use pooled async state machines for job processing, start additional processors without capturing the caller's context, and wake
WaitForCompletionAsync()by event instead of polling. ReusableTaskJoballocates a completion source per rental (none for auto-returned pooled jobs),ReusableBlockingJobreuses its event, andReusableJobhas no wait primitive.
Upgrade notes
Several public members were renamed; see Arc.Threading Name Changes. For example, the parameter of MicroSleep.Sleep() is now microseconds. These behavior changes may surprise existing code:
- Disposing
ExecutionRootterminates the whole tree, includingBaseGroup,IndependentGroup, and unit groups. (Disposal) - Disposing a group also terminates work attached to its subtree during disposal. (Disposal)
- A thread/task core that is canceled before it starts is disposed when
Startarrives. A non-delayed core of a library type created under a terminated parent is disposed inside its constructor; derived types are not. (Startup and signals) - An
OperationCanceledExceptionthrown after termination was requested completesTaskCore.Tasksuccessfully, and no longer crashes the process from aThreadCore. (Termination and completion) ThreadCoreruns on a foreground thread. (Startup and signals)- Groups forward a signal to every child even if one throws, and rethrow afterwards. (Startup and signals)
- Default
Idvalues come from a process-wide counter instead of random numbers, and disposal resetsIdto 0. (Ownership and groups) ReusableJobWorker.WaitForCompletionAsync()is event-driven and returns as soon as the worker is idle. (Waiting for jobs)- All jobs run under the
ExecutionContextcaptured when the worker was constructed, not the producer's. (Concurrency and threading) - Jobs use reference equality, and
withcopies start fresh in theInitialstate. A pooledReusableTaskJobrented withReturnToPoolOnCompletionhas noTask. (Pooling rules) CancellationTokenPool.Return()disposes linked and derived sources instead of pooling them. (Other utilities)DelayedTaskExecutorstill performs a rerun that was requested during a failed run. (Other utilities)
NativeAOT
The library enables IsAotCompatible. NativeAotSmokeTest exercises execution trees, generic cores and workers, pooling, token conversion, synchronization, ambient IDs, allocation helpers, and native sleep. It fails when run on a JIT (dynamic-code) runtime, so run the published executable.
The smoke project roots the whole library for trimming analysis and treats compiler, trimming, and AOT warnings as errors. CI publishes and runs it for Windows x64 and Linux x64. Install the NativeAOT prerequisites before publishing.
dotnet publish NativeAotSmokeTest/NativeAotSmokeTest.csproj -c Release -r win-x64 -o artifacts/nativeaot/win-x64
./artifacts/nativeaot/win-x64/NativeAotSmokeTest.exe
dotnet publish NativeAotSmokeTest/NativeAotSmokeTest.csproj -c Release -r linux-x64 -o artifacts/nativeaot/linux-x64
./artifacts/nativeaot/linux-x64/NativeAotSmokeTest
Build, test, and coverage
dotnet build Arc.Threading.slnx -c Release
dotnet test --project xUnitTest/xUnitTest.csproj -c Release
dotnet test --project xUnitTest/xUnitTest.csproj -c Release --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --results-directory artifacts/coverage
The test project uses Microsoft.Testing.Platform (selected in global.json) and its code coverage extension. Reports are written under artifacts/coverage. The Build and Test workflow runs the tests with coverage on Windows and Linux, and runs the NativeAOT smoke tests; OS-specific native paths are exercised only on the matching OS.
Tests cover execution ownership and shutdown, delayed startup, worker concurrency and pooling, lock interruption and contention, cancellation races, and allocation-sensitive operations. Coverage percentages describe executed lines and branches; they do not prove the absence of concurrency bugs.
The 2026-10-15 Windows x64 Release run passed 183 tests: line coverage was 94.81% (1,170/1,234) and branch coverage was 94.77% (634/669). Before this review, 96 tests covered 93.22% of lines and 92.87% of branches. Remaining gaps are mainly the Unix and fallback paths of MicroSleep, which cannot run on Windows, and rare race-retry branches.
Run the allocation benchmarks (BenchmarkDotNet with MemoryDiagnoser) with:
dotnet run --project Benchmark/Benchmark.csproj -c Release -- --filter '*HotPathBenchmark*'
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Arc.Collections (>= 2.1.1)
NuGet packages (7)
Showing the top 5 NuGet packages that depend on Arc.Threading:
| Package | Downloads |
|---|---|
|
ValueLink
ValueLink is a C# Library for creating and managing multiple links between objects. |
|
|
Netsphere
Netsphere is a network protocol for Lp. |
|
|
BigMachines
A source-generated state-machine library for .NET. |
|
|
Arc.Unit
Arc.Unit is an independent unit of function and dependency (like .Net Generic Host). |
|
|
CrystalData
CrystalData is a simple and versatile storage engine for C#. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.56.0 | 89 | 10/15/2026 |
| 0.55.1 | 115 | 10/6/2026 |
| 0.55.0 | 298 | 9/25/2026 |
| 0.54.0 | 346 | 9/12/2026 |
| 0.53.0 | 237 | 9/5/2026 |
| 0.52.0 | 462 | 9/3/2026 |
| 0.51.0 | 243 | 8/31/2026 |
| 0.50.0 | 367 | 8/20/2026 |
| 0.49.1 | 412 | 5/23/2026 |
| 0.49.0 | 147 | 5/23/2026 |
| 0.48.4 | 360 | 5/11/2026 |
| 0.48.2 | 351 | 5/10/2026 |
| 0.48.1 | 143 | 5/10/2026 |
| 0.48.0 | 141 | 5/10/2026 |
| 0.47.2 | 243 | 5/8/2026 |
| 0.47.1 | 387 | 5/7/2026 |
| 0.47.0 | 139 | 5/7/2026 |
| 0.46.1 | 145 | 5/7/2026 |
| 0.46.0 | 146 | 5/7/2026 |
| 0.45.2 | 209 | 4/27/2026 |