Threading, Tasks, Channels, and Timers
Understand how familiar System.Threading APIs map to native threads, eager tasks, blocking channels, and timer callbacks.
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.
| Component | CMake target | Primary surface | Dependency boundary |
|---|---|---|---|
Threading | SharpRuntime::Threading | Thread, locks, monitors, wait primitives, cancellation, ThreadPool, System::Threading::Timer, PeriodicTimer | Public dependency closure: Core.Base, TimeZone |
Threading.Tasks | SharpRuntime::Threading.Tasks | Task, TaskT, continuations, factories, completion sources, value-task shapes, parallel loops | Public dependency closure adds Threading and Core.Base |
Threading.Channels | SharpRuntime::Threading.Channels | Bounded, unbounded, rendezvous, and prioritized producer/consumer channels | Header-only interface component; public closure adds Threading.Tasks and Core.Base |
Timers | SharpRuntime::Timers | System::Timers::Timer and elapsed-event types | Public: ComponentModel/Core.Base; private: Threading |
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 property | Current behavior | Important difference |
|---|---|---|
Start() | Moves the stored callable into one native thread | The wrapper cannot be restarted |
Start(void*) | Starts the same parameterless callable; the supplied pointer is currently ignored | There is no implemented parameterized-callback constructor behind this overload |
Join(-1) | Waits indefinitely and joins | Interrupt() is a no-op, so it cannot wake an infinite sleep |
Name, Priority | Stored and returned by the wrapper | Neither value is applied to the operating-system thread |
IsBackground | Stored in shared run state | It does not reproduce CLR foreground/background process-lifetime policy |
CurrentThread() | Returns the assigned ID/background state for a Thread-started worker | Main and externally created threads use the fallback managed ID 1 |
| Apartment state | Unknown; setters are inert | No 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.
| Need | Representative type | Boundary to remember |
|---|---|---|
| Lexical mutual exclusion | Lock, Mutex, SpinLock | Prefer RAII; spin-based types are for very short, measured critical sections |
| Object-identity-style wait set | Monitor | Global pointer-keyed registry, not a CLR object header |
| Shared/exclusive access | ReaderWriterLockSlim | Choose and respect its recursion policy rather than mixing lock families |
| Counting access | Semaphore, SemaphoreSlim | Release beyond the configured maximum is rejected |
| Manual/automatic signals | ManualResetEvent, AutoResetEvent, slim variants | These are blocking waits, not coroutine suspension |
| Phases and countdown | Barrier, CountdownEvent | Disposal and participant/count invariants are explicit |
| Multiple handles | WaitHandle::WaitAll/WaitAny | WaitAll 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.
| Operation | Current execution | Result/fault rule |
|---|---|---|
Task::Run / task constructor | One eager std::async worker | Stores fault for direct rethrow at Wait |
ContinueWith | Inline on completion/registration thread | Returned task records continuation outcome |
WhenAll | One task waits for every input | First fault in input order wins; it does not aggregate all faults |
WhenAny | Registers inline completion callbacks; no watcher thread per input | Wrapper succeeds with the first completed task, whose own outcome remains separate |
Task::Delay | One task sleeps | -1 is accepted but currently returns through negative sleep_for, not an infinite delay |
TaskCompletionSource | Bridges a promise through one async observer | Atomic 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 form | Capacity behavior | Ordering |
|---|---|---|
| Unbounded | Writes succeed until completion; memory growth is caller-controlled | FIFO |
Bounded, Wait | Async write blocks until a reader frees space | FIFO |
| Bounded drop modes | Drop incoming, newest buffered, or oldest buffered item as selected; the handled write reports success | FIFO among retained items |
| Capacity zero | Rendezvous: a wait-mode write proceeds only while a reader is parked; drop modes discard without buffering | Direct producer/consumer handoff |
| Prioritized unbounded | Writes do not block before completion | Comparer 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
| Type | Execution | Teardown and exception boundary |
|---|---|---|
System::Threading::Timer | One dedicated thread per timer; Change reschedules, -1 pauses | Shared 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::PeriodicTimer | WaitForNextTick blocks the caller on a steady-clock deadline | No callback thread; Dispose wakes a pending wait, which returns false |
System::Timers::Timer | Wraps System::Threading::Timer and raises Elapsed on its background thread | Handler 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 expectation | Sharp Runtime / C++ | Porting consequence |
|---|---|---|
| GC keeps callback targets alive | Captures obey C++ value/reference/pointer ownership | Retain owners explicitly and join/complete before release |
| Foreground/background thread affects process exit | Background is a reported flag only | Design native process shutdown explicitly |
| Object monitor lives in CLR object header | Pointer-keyed global monitor registry | Supply stable identities and accept process-lifetime registry entries |
| ThreadPool reuses bounded workers | New detached thread per queued item | Do not use reported min/max values as a concurrency limiter |
Task.Run uses the default scheduler | Eager dedicated std::async work | High task counts can mean high native thread counts |
| Continuation placement follows options/scheduler/context | Inline on the completing thread; only outcome filters matter | Keep continuations short and do not assume UI/thread affinity |
await suspends without blocking a thread | No C# await machinery; waits and channel async shapes use blocking native work | Choose native coroutines/event loops separately when scalability requires them |
| Cancellation may be linked, delayed, or context-aware | Shared flag plus synchronous callbacks and cooperative checks | Build deadline/linking policies in application code |
| Channel tuning flags select specialized implementations | One mutex/condition-variable family; only capacity/full mode/comparer alter behavior | Treat single-reader/writer flags as non-operative in this revision |
| Timer disposal offers framework-specific completion options | Threading timer detaches; event timer retains a raw-owner callback hazard | Add an application-owned callback completion barrier |