Threading, Tasks, Channels, and Timers

Understand how familiar System.Threading APIs map to native threads, eager tasks, blocking channels, and timer callbacks.

Four componentsNative lifecycleSource-backed

Four components, several execution models

Sharp Runtime groups familiar concurrency names into four physical components, but they do not sit on one CLR scheduler. Native threads, eager tasks, blocking channel operations, and timer callbacks each have their own ownership and shutdown rules. Select the narrow CMake target and design around the implementation behind it.

ComponentCMake targetPrimary surfaceDependency boundary
ThreadingSharpRuntime::ThreadingThread, locks, monitors, wait primitives, cancellation, ThreadPool, System::Threading::Timer, PeriodicTimerPublic dependency closure: Core.Base, TimeZone
Threading.TasksSharpRuntime::Threading.TasksTask, TaskT, continuations, factories, completion sources, value-task shapes, parallel loopsPublic dependency closure adds Threading and Core.Base
Threading.ChannelsSharpRuntime::Threading.ChannelsBounded, unbounded, rendezvous, and prioritized producer/consumer channelsHeader-only interface component; public closure adds Threading.Tasks and Core.Base
TimersSharpRuntime::TimersSystem::Timers::Timer and elapsed-event typesPublic: ComponentModel/Core.Base; private: Threading
A namespace is not a scheduler guarantee

Task does not queue onto ThreadPool, channel waits do not suspend a coroutine, and System::Timers::Timer does not marshal onto a UI synchronization context. Treat each type’s documented execution path as part of its contract.

Thread lifecycle and ownership

System::Threading::Thread wraps std::thread. Construction validates and stores a parameterless callable but does not start it. Start() may be called exactly once; a second start or a join before start throws ThreadStateException. Join() blocks, while the timeout overload polls completion on a steady clock and then joins when the worker has finished.

The destructor detaches a still-joinable thread rather than joining it. The implementation keeps its own finished/background/id state in a shared heap object captured by the worker, so destroying the Thread wrapper no longer makes those internal writes use freed memory. That repair does not extend the lifetime of references, pointers, or object addresses captured by the caller’s function. Use Join as the normal ownership boundary whenever work touches state owned by the initiating scope.

Member or propertyCurrent behaviorImportant difference
Start()Moves the stored callable into one native threadThe wrapper cannot be restarted
Start(void*)Starts the same parameterless callable; the supplied pointer is currently ignoredThere is no implemented parameterized-callback constructor behind this overload
Join(-1)Waits indefinitely and joinsInterrupt() is a no-op, so it cannot wake an infinite sleep
Name, PriorityStored and returned by the wrapperNeither value is applied to the operating-system thread
IsBackgroundStored in shared run stateIt does not reproduce CLR foreground/background process-lifetime policy
CurrentThread()Returns the assigned ID/background state for a Thread-started workerMain and externally created threads use the fallback managed ID 1
Apartment stateUnknown; setters are inertNo COM apartment model

The worker entry point does not catch exceptions. An exception escaping the supplied function reaches the native thread boundary and terminates the process. Catch and publish failures inside the worker, or use Task when storing and rethrowing a failure at Wait is the desired model.

Locks, monitors, and waits

Lock is an owning recursive timed mutex with owner/depth tracking. EnterScope() returns an RAII guard, making it the clearest choice when a lexical critical section is under your control. Exit rejects a caller that does not own the lock, and timed entry treats -1 as an infinite wait.

Monitor solves a different porting problem. A CLR associates a sync block with every managed object; C++ objects have no equivalent header. Sharp Runtime therefore uses the caller-supplied pointer as a key in a process-global registry of recursive timed mutex/condition-variable states. It provides real reentrant Enter, Exit, TryEnter, Wait, Pulse, and PulseAll behavior, but registry entries are never reclaimed. The pointer must be a stable identity; this is not a universal lock automatically embedded in every C++ object.

At the selected source pin, Monitor::Wait releases the complete recursion depth before sleeping and restores it after reacquiring. Older code released only one recursive level, which could leave the mutex held and prevent the signaling thread from entering to pulse. That former deadlock is fixed and must not remain as a current warning.

NeedRepresentative typeBoundary to remember
Lexical mutual exclusionLock, Mutex, SpinLockPrefer RAII; spin-based types are for very short, measured critical sections
Object-identity-style wait setMonitorGlobal pointer-keyed registry, not a CLR object header
Shared/exclusive accessReaderWriterLockSlimChoose and respect its recursion policy rather than mixing lock families
Counting accessSemaphore, SemaphoreSlimRelease beyond the configured maximum is rejected
Manual/automatic signalsManualResetEvent, AutoResetEvent, slim variantsThese are blocking waits, not coroutine suspension
Phases and countdownBarrier, CountdownEventDisposal and participant/count invariants are explicit
Multiple handlesWaitHandle::WaitAll/WaitAnyWaitAll waits sequentially; WaitAny polls, rather than atomically multiplexing native handles

Cancellation is cooperative and synchronous

A CancellationTokenSource owns shared cancellation state; token copies retain that state. Requesting cancellation sets the flag and invokes registered callbacks synchronously on the thread calling Cancel(), in reverse registration order. Every callback is attempted. If callbacks throw, their failures are collected and Cancel raises an AggregateException after delivery.

Registering on an already-canceled token invokes the callback synchronously before Register returns. Disposing a registration removes a pending callback; if that callback is already executing on another thread, disposal waits for it to finish. Self-disposal from the callback is detected so it does not deadlock.

Cancellation never forcibly stops a thread or native call. A task checks an already-canceled token before launching; after launch, its function must observe the copied token and ordinarily call ThrowIfCancellationRequested(). An escaping OperationCanceledException becomes the Canceled state only when that same token reports cancellation requested; otherwise it is a fault.

The current source does not implement delayed cancellation constructors/CancelAfter, linked token sources, CancelAsync, TryReset, token wait handles, synchronization-context registration overloads, or token value equality. Build those policies explicitly rather than assuming the type name supplies them.

ThreadPool is a detached-thread facade

QueueUserWorkItem does not enqueue into a persistent worker pool. Each accepted callback constructs a new std::thread and detaches it. The state overload captures the raw state pointer, and the unsafe work-item overload captures the raw IThreadPoolWorkItem*; neither extends caller-owned object lifetime. Callback exceptions are not caught at this boundary.

GetMinThreads/SetMinThreads and their maximum counterparts maintain process-global configuration and validate the relationship between the stored pairs. Those values are observable configuration only: they do not throttle or provision detached work-item threads. A burst of queued items can therefore create a burst of operating-system threads regardless of the reported maximum.

RegisterWaitForSingleObject also uses a dedicated background thread that polls the borrowed WaitHandle* in short slices. External Unregister joins that thread before returning so the caller can safely release the borrowed handle; self-unregister detaches to avoid joining itself.

Tasks: eager work, shared state, inline continuations

Task and TaskT<TResult> start work immediately with std::async(std::launch::async, ...). This is a thread-per-task design, not submission to ThreadPool and not a pluggable scheduler. TaskScheduler::Default/Current provide API-shaped objects, but tasks never consult them. Task creation flags such as fairness, parent attachment, scheduler hiding, and asynchronous-continuation forcing therefore do not alter execution.

Task copies share completion state and a shared_future, so multiple consumers can wait safely. The worker stores success, cancellation, or an exception before signaling completion. Wait() and result access rethrow the stored fault directly; unlike .NET’s blocking Task.Wait, they do not wrap it in AggregateException. Cancellation produces TaskCanceledException.

ContinueWith registers a callback in the antecedent’s shared state. It runs inline on the thread that completes the antecedent, or immediately on the registering thread if the antecedent is already complete. Only the outcome-filter bits (NotOn*/OnlyOn*) change behavior; scheduler and parent-task options are accepted no-ops. A continuation excluded by its filter completes as canceled. A throwing continuation faults its returned task.

WhenAll still waits for every input after a fault. If several inputs fault, the first exception in input order is retained rather than an aggregate of every failure. If no input faults but one is canceled, waiting on the returned task throws TaskCanceledException; because this implementation builds the coordinator as an action-backed task, that coordinator currently reports Faulted rather than Canceled.

OperationCurrent executionResult/fault rule
Task::Run / task constructorOne eager std::async workerStores fault for direct rethrow at Wait
ContinueWithInline on completion/registration threadReturned task records continuation outcome
WhenAllOne task waits for every inputFirst fault in input order wins; it does not aggregate all faults
WhenAnyRegisters inline completion callbacks; no watcher thread per inputWrapper succeeds with the first completed task, whose own outcome remains separate
Task::DelayOne task sleeps-1 is accepted but currently returns through negative sleep_for, not an infinite delay
TaskCompletionSourceBridges a promise through one async observerAtomic TrySet*; unresolved destruction cancels the bridge

A TaskCompletionSource is ordinary C++ storage. It must remain alive while another thread may call SetResult, SetException, SetCanceled, or a TrySet* method. The task handed to consumers owns its own shared state, but it does not keep the producer object alive. Use shared ownership for a source completed by a background producer.

ValueTask and ValueTaskT can represent a completed value/fault or retain an underlying task. Their “awaiter” methods are blocking compatibility calls; Sharp Runtime does not add C# await syntax or a C++ coroutine scheduler. ExecutionContext does not flow ambient state across tasks: capture returns no context, while AsyncLocal is actual thread-local storage.

The base SynchronizationContext keeps a thread-local Current pointer, executes Send inline, and implements Post through the detached-thread ThreadPool facade. Task creation and continuation do not capture or restore that context.

Channels, backpressure, and completion

Channel<T> returns shared reader and writer endpoints over a shared mutex/condition-variable state. The ordinary factories use a FIFO deque; the prioritized unbounded factory uses a multiset and removes the smallest item according to its comparer. The implementation is deliberately lock-based, not lock-free.

Channel formCapacity behaviorOrdering
UnboundedWrites succeed until completion; memory growth is caller-controlledFIFO
Bounded, WaitAsync write blocks until a reader frees spaceFIFO
Bounded drop modesDrop incoming, newest buffered, or oldest buffered item as selected; the handled write reports successFIFO among retained items
Capacity zeroRendezvous: a wait-mode write proceeds only while a reader is parked; drop modes discard without bufferingDirect producer/consumer handoff
Prioritized unboundedWrites do not block before completionComparer order, smallest first

TryRead/TryWrite are immediate. The async-shaped methods construct tasks whose native workers block on condition variables; they do not suspend a coroutine. Each outstanding ReadAsync, WriteAsync, WaitToReadAsync, WaitToWriteAsync, or completion wait can therefore occupy a task thread.

Factory endpoints and backing state use shared_ptr. Default reader/writer async methods also call shared_from_this to retain the endpoint for the operation, so custom endpoint subclasses using those defaults must themselves be created under shared ownership. This keeps channel internals alive; values or references captured inside T still follow their own C++ lifetime rules.

Completion stops future writes but lets readers drain buffered items. Clean closure makes availability waits return false after the drain. Error completion faults WaitToReadAsync, WaitToWriteAsync, and the completion task with the producer error; ReadAsync/WriteAsync instead report ChannelClosedException with that error retained as the inner exception. There is no ReadAllAsync, async-enumerable integration, or cancellation-token overload. SingleReader, SingleWriter, and AllowSynchronousContinuations exist on option objects but the current factories do not use them to select an optimized or different execution path.

Timer selection and teardown

TypeExecutionTeardown and exception boundary
System::Threading::TimerOne dedicated thread per timer; Change reschedules, -1 pausesShared internal state avoids a dangling timer-state pointer, but Dispose signals then detaches instead of joining; an in-flight callback may continue, and an escaping callback exception terminates the process
System::Threading::PeriodicTimerWaitForNextTick blocks the caller on a steady-clock deadlineNo callback thread; Dispose wakes a pending wait, which returns false
System::Timers::TimerWraps System::Threading::Timer and raises Elapsed on its background threadHandler exceptions are caught and discarded, but the callback captures raw this; Close/destruction does not prove an in-flight callback has finished

System::Timers::Timer does not derive from the full component/designer model and has no SynchronizingObject marshalling. Event sender is currently null. Keep the object alive across callback dispatch and add an external shutdown barrier if destruction can race a tick. Stopping prevents fresh cookie-matched delivery; it is not a join for work already inside the callback.

Platforms and deliberate non-goals

On ordinary native targets these components are built primarily from the C++ standard threading library. Single-threaded Emscripten explicitly rejects task construction, task delay, thread-pool queuing, parallel loops, and System::Threading::Timer. Immediate channel operations are header-only synchronization code, but their async-shaped waits/reads/writes require the task path and therefore cannot run in that configuration; System::Timers::Timer inherits the lower timer dependency. An Emscripten pthread build enables the native paths but requires whole-program -pthread configuration and the browser isolation needed for Web Workers/shared memory.

Processor-ID discovery has Win32 and Linux implementations, with zero as the Emscripten/other fallback. Thread name, priority, apartments, and CLR execution-context flow are not platform abstractions in this revision. Current build/runtime evidence by toolchain is tracked separately on Platforms and portability.

C#/.NET comparison

C# / .NET expectationSharp Runtime / C++Porting consequence
GC keeps callback targets aliveCaptures obey C++ value/reference/pointer ownershipRetain owners explicitly and join/complete before release
Foreground/background thread affects process exitBackground is a reported flag onlyDesign native process shutdown explicitly
Object monitor lives in CLR object headerPointer-keyed global monitor registrySupply stable identities and accept process-lifetime registry entries
ThreadPool reuses bounded workersNew detached thread per queued itemDo not use reported min/max values as a concurrency limiter
Task.Run uses the default schedulerEager dedicated std::async workHigh task counts can mean high native thread counts
Continuation placement follows options/scheduler/contextInline on the completing thread; only outcome filters matterKeep continuations short and do not assume UI/thread affinity
await suspends without blocking a threadNo C# await machinery; waits and channel async shapes use blocking native workChoose native coroutines/event loops separately when scalability requires them
Cancellation may be linked, delayed, or context-awareShared flag plus synchronous callbacks and cooperative checksBuild deadline/linking policies in application code
Channel tuning flags select specialized implementationsOne mutex/condition-variable family; only capacity/full mode/comparer alter behaviorTreat single-reader/writer flags as non-operative in this revision
Timer disposal offers framework-specific completion optionsThreading timer detaches; event timer retains a raw-owner callback hazardAdd an application-owned callback completion barrier