Skip to content

Repository files navigation

NuGet StatsBuildCode Coverage#yourfirstpr


Punchclock: A library for managing concurrent operations

Punchclock is the low-level scheduling and prioritization library used by Fusillade to orchestrate pending concurrent operations.

What even does that mean?

Ok, so you've got a shiny mobile phone app and you've got async/await. Awesome! It's so easy to issue network requests, why not do it all the time? After your users one-:star2: you for your app being slow, you discover that you're issuing way too many requests at the same time.

Then, you try to manage issuing less requests by hand, and it becomes a spaghetti mess as different parts of your app reach into each other to try to figure out who's doing what. Let's figure out a better way.

Key features

  • Bounded concurrency so only a fixed number of operations run at once
  • Priority scheduling where higher numbers run first when a slot opens
  • Key-based serialization so related work runs one-at-a-time
  • Task and IObservable<T> APIs over the same queueing engine
  • Cancellation via CancellationToken or an observable signal
  • Pause/resume with reference counting
  • Runtime concurrency changes with SetMaximumConcurrent
  • Shutdown that waits for queued and in-flight work to finish
  • V7.0.0+ Built on ReactiveUI.Primitives for signals, disposables, RxVoid, and sequencing
  • Public API tracking for every target framework

Install

  • NuGet: dotnet add package Punchclock

Punchclock currently targets modern .NET (net8.0, net9.0, net10.0, net11.0) and .NET Framework (net462, net472, net48, net481).

Punchclock v7.0.0 moves the queue internals and observable examples onto ReactiveUI.Primitives. ReactiveUI.Primitives v5 keeps System.Reactive interop explicit: the base ReactiveUI.Primitives package provides the lean Primitives API and optional generated R3/R3Async bridges, while System.Reactive compatibility lives in the .Reactive package variants such as ReactiveUI.Primitives.Reactive, ReactiveUI.Primitives.Async.Reactive, and ReactiveUI.Primitives.Extensions.Reactive.

For a v6-style System.Reactive migration, keep the Rx package while you move code across the boundary. Punchclock returns standard BCL IObservable<T> values, so System.Reactive LINQ can still compose queue results directly:

dotnet add package Punchclock --version 7.0.0
dotnet add package System.Reactive
# Optional when your own code needs Primitives APIs with System.Reactive Unit or IScheduler:
dotnet add package ReactiveUI.Primitives.Reactive

Then keep System.Reactive at the application edge while Punchclock schedules the work:

usingPunchclock;usingSystem;usingSystem.Net.Http;usingSystem.Reactive;usingSystem.Reactive.Linq;usingSystem.Reactive.Subjects;usingvarqueue=newOperationQueue(maximumConcurrent:2);usingvarhttp=newHttpClient();usingvarcancelFromLegacyRx=newSubject<Unit>();IObservable<string>rxFriendlyResult=queue.EnqueueObservableOperation(priority:5,key:"legacy:refresh",cancel:cancelFromLegacyRx,asyncCalculationFunc:()=>Observable.FromAsync(()=>http.GetStringAsync("https://example.com/legacy")));usingvarsubscription=rxFriendlyResult.Timeout(TimeSpan.FromSeconds(10)).Subscribe(
value =>Console.WriteLine(value),
error =>Console.Error.WriteLine(error));cancelFromLegacyRx.OnNext(Unit.Default);

R3 and R3Async bridge adapters are still generated from the base ReactiveUI.Primitives package in the ReactiveUI.Primitives.R3Bridge namespace when the consuming project references R3 or R3Async. There is no generated System.Reactive bridge namespace in ReactiveUI.Primitives v5; use the .Reactive packages when your own Primitives-facing code must expose System.Reactive Unit or IScheduler.

Most application code only needs this:

usingPunchclock;

Observable examples also use the ReactiveUI.Primitives signal helpers:

usingReactiveUI.Primitives;usingReactiveUI.Primitives.Concurrency;usingReactiveUI.Primitives.Signals;

Quick start

usingPunchclock;usingSystem.Net.Http;usingvarqueue=newOperationQueue(maximumConcurrent:2);usingvarhttp=newHttpClient();// Fire a bunch of downloads. Only two will run at a time.vart1=queue.Enqueue(1,()=>http.GetStringAsync("https://example.com/a"));vart2=queue.Enqueue(1,()=>http.GetStringAsync("https://example.com/b"));vart3=queue.Enqueue(10,()=>http.GetStringAsync("https://example.com/urgent"));awaitTask.WhenAll(t1,t2,t3);

In 60 seconds

Create one queue near the part of your app that owns the work, then send work through it instead of letting every caller start its own request immediately.

usingPunchclock;usingvarqueue=newOperationQueue(maximumConcurrent:4);Task<string>LoadProfile(intuserId)=>queue.Enqueue(priority:5,key:$"user:{userId}",asyncOperation:()=>api.GetProfileAsync(userId));Task<string>LoadTimeline(intuserId)=>queue.Enqueue(priority:1,key:$"user:{userId}",asyncOperation:()=>api.GetTimelineAsync(userId));varprofile=LoadProfile(42);vartimeline=LoadTimeline(42);awaitTask.WhenAll(profile,timeline);

Those two operations share the same key, so they will not run at the same time. Other keys can still use the remaining concurrency slots.

Priorities

Higher numbers win. A priority 10 operation is chosen ahead of priority 1 when a slot opens.

awaitqueue.Enqueue(10,()=>http.GetStringAsync("https://example.com/urgent"));

Priorities do not cancel work that is already running. They decide which pending operation gets the next available slot.

Equal-priority operations are FIFO by default. If you want to avoid one caller always winning equal-priority tie-breaks across different keys, enable randomization:

usingvarqueue=newOperationQueue(maximumConcurrent:4,randomizeEqualPriority:true,seed:null);

Use a seed when you want deterministic randomized ordering in tests:

usingvarqueue=newOperationQueue(4,randomizeEqualPriority:true,seed:1234);

Keys: serialize related work

  • Use a key to ensure only one operation for that key runs at a time.
  • Useful to avoid thundering herds against the same resource.
  • Different keys can run together up to the queue's concurrency limit.
  • null, string.Empty, and the internal default key are treated as non-keyed work.
// These will run one-after-another because they share the same key.vark1=queue.Enqueue(priority:1,key:"user:42",asyncOperation:()=>LoadUserAsync(42));vark2=queue.Enqueue(priority:1,key:"user:42",asyncOperation:()=>LoadUserPostsAsync(42));awaitTask.WhenAll(k1,k2);

Use keys for the resource you are protecting, not for the operation type:

awaitTask.WhenAll(queue.Enqueue(priority:1,key:"file:avatar.png",asyncOperation:()=>ResizeAsync("avatar.png")),queue.Enqueue(priority:1,key:"file:avatar.png",asyncOperation:()=>UploadAsync("avatar.png")),queue.Enqueue(priority:1,key:"file:banner.png",asyncOperation:()=>UploadAsync("banner.png")));

The two avatar.png operations serialize. The banner.png operation can run beside them if a slot is available.

Cancellation

Via CancellationToken:

usingvarcts=newCancellationTokenSource(TimeSpan.FromSeconds(2));awaitqueue.Enqueue(priority:1,key:"img:1",asyncOperation:()=>DownloadImageAsync("/1"),token:cts.Token);

Via an observable cancellation signal:

usingReactiveUI.Primitives;usingReactiveUI.Primitives.Signals;varcancel=newSignal<RxVoid>();varobs=queue.EnqueueObservableOperation(priority:1,key:"slow",cancel:cancel,asyncCalculationFunc:()=>Signal.FromTask(ExpensiveAsync()));usingvarsubscription=obs.Subscribe(
value =>Console.WriteLine(value),
error =>Console.Error.WriteLine(error));cancel.OnNext(RxVoid.Default);// Cancels if pending or while observed in-flight.

An already-canceled CancellationToken returns a canceled task without queueing anything. A non-cancelable token uses a fast path with no cancellation registration.

When an observable cancellation signal fires before the operation is evaluated, the operation factory is not invoked.

Pause and resume

usingvargate=queue.PauseQueue();// Enqueue work while paused; nothing new executes yet.// ...gate.Dispose();// Resumes and drains respecting priority/keys.

Pause is reference counted. If two callers pause the queue, the queue resumes only after both returned handles have been disposed.

In-flight operations are not canceled by pausing. Pause only stops dispatching new work.

Adjust concurrency at runtime

queue.SetMaximumConcurrent(8);// increases throughput

You can increase or decrease the concurrency limit while the queue is alive. The value must be positive; constructors and SetMaximumConcurrent throw ArgumentOutOfRangeException for zero or negative values.

Lowering the value does not cancel already-running operations. It limits future dispatch until the active count drops below the new limit.

Shutting down

usingReactiveUI.Primitives.Concurrency;awaitqueue.ShutdownQueue().ToTask();

ShutdownQueue starts shutdown and returns an IObservable<RxVoid> that signals when queued and in-flight operations have finished. After shutdown has started, new enqueue attempts throw InvalidOperationException.

Calling ShutdownQueue more than once is safe. Repeated calls return the same shutdown observable.

ReactiveUI.Primitives base

Punchclock now uses ReactiveUI.Primitives as its reactive foundation. The public API still feels small:

  • Task callers use queue.Enqueue(...).
  • Observable callers use queue.EnqueueObservableOperation(...).
  • Void observable signals use RxVoid instead of Unit.
  • Cancellation and examples use Signal<T>.
  • Advanced scheduling can be controlled with ISequencer.

This keeps the queue independent from any UI framework while still giving ReactiveUI-style applications a natural observable API.

usingPunchclock;usingReactiveUI.Primitives;usingReactiveUI.Primitives.Concurrency;usingReactiveUI.Primitives.Signals;usingvarqueue=newOperationQueue(maximumConcurrent:1);IObservable<string>pending=queue.EnqueueObservableOperation(priority:3,key:"refresh",asyncCalculationFunc:()=>Signal.FromTask(RefreshAsync()));stringresult=awaitpending.ToTask();

Task API

Use the Task API when your application is already written with async/await. It is the most direct API for app code.

TaskSaveAsync(Documentdocument,CancellationTokentoken)=>queue.Enqueue(priority:5,key:$"document:{document.Id}",asyncOperation:()=>repository.SaveAsync(document,token),token:token);

Non-generic operations return Task:

awaitqueue.Enqueue(priority:1,key:"cache:trim",asyncOperation:()=>cache.TrimAsync());

Generic operations return Task<T>:

Useruser=awaitqueue.Enqueue(priority:3,key:"user:42",asyncOperation:()=>api.GetUserAsync(42));

Leave the key out when the operation does not need serialization:

varresponse=awaitqueue.Enqueue(priority:1,asyncOperation:()=>http.GetStringAsync("https://example.com/status"));

Observable API

Use the observable API when you want to compose the queued operation with other observable streams.

IObservable<byte[]>image=queue.EnqueueObservableOperation(priority:2,key:"image:42",asyncCalculationFunc:()=>Signal.FromTask(DownloadImageAsync(42)));usingvarsubscription=image.Subscribe(bytes =>{Console.WriteLine($"Downloaded {bytes.Length} bytes");});

With an observable cancellation signal:

varcancel=newSignal<RxVoid>();IObservable<SearchResult>search=queue.EnqueueObservableOperation(priority:10,key:"search",cancel:cancel,asyncCalculationFunc:()=>Signal.FromTask(SearchAsync("punchclock")));usingvarsubscription=search.Subscribe(result =>Render(result));cancel.OnNext(RxVoid.Default);

Operation factory exceptions and operation observable errors flow to that operation's result. The queue still releases capacity and continues processing later work.

Custom sequencing

Most apps can use the default Sequencer.Immediate. Tests and hosts with their own execution model can pass an ISequencer.

usingReactiveUI.Primitives.Concurrency;ISequencersequencer=Sequencer.Immediate;usingvarqueue=newOperationQueue(maximumConcurrent:2,scheduler:sequencer);

The sequencer controls when scheduled operations are started after they have been selected by the queue.

API overview

OperationQueue

Constructors:

newOperationQueue();newOperationQueue(intmaximumConcurrent);newOperationQueue(intmaximumConcurrent,ISequencerscheduler);newOperationQueue(intmaximumConcurrent,boolrandomizeEqualPriority,int?seed);newOperationQueue(intmaximumConcurrent,boolrandomizeEqualPriority,int?seed,ISequencer?scheduler);

The default constructor uses maximumConcurrent: 4. Any constructor that takes maximumConcurrent requires a positive value.

Observable enqueue methods:

IObservable<T>EnqueueObservableOperation<T>(intpriority,Func<IObservable<T>>asyncCalculationFunc);IObservable<T>EnqueueObservableOperation<T>(intpriority,stringkey,Func<IObservable<T>>asyncCalculationFunc);IObservable<T>EnqueueObservableOperation<T,TDontCare>(intpriority,stringkey,IObservable<TDontCare>cancel,Func<IObservable<T>>asyncCalculationFunc);

Queue control:

IDisposablePauseQueue();voidSetMaximumConcurrent(intmaximumConcurrent);IObservable<RxVoid>ShutdownQueue();voidDispose();

OperationQueue also has a protected virtual Dispose(bool isDisposing) for derived types.

OperationQueueExtensions

Task helpers are exposed as extension methods on OperationQueue:

TaskEnqueue(intpriority,Func<Task>asyncOperation);Task<T>Enqueue<T>(intpriority,Func<Task<T>>asyncOperation);TaskEnqueue(intpriority,stringkey,Func<Task>asyncOperation);Task<T>Enqueue<T>(intpriority,stringkey,Func<Task<T>>asyncOperation);TaskEnqueue(intpriority,stringkey,Func<Task>asyncOperation,CancellationTokentoken);Task<T>Enqueue<T>(intpriority,stringkey,Func<Task<T>>asyncOperation,CancellationTokentoken);

The public API baseline also records the compiler-generated static extension method entries. Consumers should call them as normal extension methods:

awaitqueue.Enqueue(1,()=>DoWorkAsync());

Behavior details

  • maximumConcurrent is the upper bound for active operations.
  • Higher priority pending operations are selected first.
  • Equal priorities are FIFO unless randomized tie-breaking is enabled.
  • Operations with the same non-empty key are serialized.
  • Operations with different keys may run concurrently.
  • Non-keyed work can run concurrently with other non-keyed work.
  • Non-keyed pending work is considered ahead of keyed pending work internally so one serialized key does not unnecessarily hold the whole pipeline back.
  • Pausing stops new dispatch only; active operations keep running.
  • Shutdown drains pending and active work, then signals RxVoid.Default and completes.
  • Enqueueing after shutdown starts throws InvalidOperationException.
  • Cancellation before evaluation prevents the factory from being invoked.
  • Factory exceptions and operation errors are delivered to that operation and do not permanently break the queue.
  • Dispose is safe to call repeatedly and cleans up pending cancellation subscriptions.

Best practices

  • Prefer Task-based Enqueue APIs in application code; use observable APIs when composing with Rx.
  • Use descriptive keys for shared resources (e.g., "user:{id}", "file:{path}").
  • Keep operations idempotent and short; long operations block concurrency slots.
  • Use higher priorities sparingly; they jump the queue when a slot opens.
  • PauseQueue is ref-counted; always dispose the returned handle exactly once.
  • For cancellation via token, reuse CTS per user action to cancel pending work quickly.
  • Treat the queue as infrastructure owned by a feature or service. Avoid creating a new queue for every operation.
  • Pick a priority scale and keep it boring: for example, 1 background, 5 user-visible, 10 urgent.
  • Include the resource identity in keys. user:42 is more useful than user.
  • Prefer ShutdownQueue for graceful application teardown and Dispose for cleanup.

Advanced notes

  • Unkeyed work is prioritized ahead of keyed work internally to keep the pipeline flowing; keys are serialized per group.
  • The semaphore releases when an operation completes, errors, or is canceled.
  • Cancellation before evaluation prevents invoking the supplied function.
  • A pause handle created after shutdown starts will not resume dispatch.
  • ShutdownQueue is idempotent; all callers observe the same shutdown signal.
  • Randomized equal-priority scheduling only affects tie-breaks. Priority still wins.
  • A seeded random queue is useful for deterministic tests.
  • ISequencer exists for advanced scheduling and test control. The default is immediate scheduling.

Real-world patterns

Request throttling

usingvarqueue=newOperationQueue(maximumConcurrent:3);Task<string>GetJsonAsync(stringurl,CancellationTokentoken)=>queue.Enqueue(priority:1,key:url,asyncOperation:()=>http.GetStringAsync(url,token),token:token);

This limits total HTTP pressure while serializing repeated calls to the same URL.

User-visible work beats background work

TaskRefreshVisibleItemAsync(intid)=>queue.Enqueue(priority:10,key:$"item:{id}",asyncOperation:()=>api.RefreshItemAsync(id));TaskWarmCacheAsync(intid)=>queue.Enqueue(priority:1,key:$"item:{id}",asyncOperation:()=>cache.WarmAsync(id));

The visible refresh gets the next slot before lower-priority cache warming. The shared key still prevents both operations from touching the same item at the same time.

One queue, many keys

vartasks=documents.Select(document =>queue.Enqueue(priority:2,key:$"document:{document.Id}",asyncOperation:()=>SyncDocumentAsync(document)));awaitTask.WhenAll(tasks);

Every document sync is isolated by key, but unrelated documents can still run in parallel.

Observable refresh

varrefresh=queue.EnqueueObservableOperation(priority:5,key:"dashboard",asyncCalculationFunc:()=>Signal.FromTask(LoadDashboardAsync()));usingvarsubscription=refresh.Subscribe(model =>Render(model));

Full examples

Image downloader with keys and priorities

usingPunchclock;usingvarqueue=newOperationQueue(3);usingvarhttp=newHttpClient();TaskDownload(stringurl,stringdest,intpri,stringkey)=>queue.Enqueue(priority:pri,key:key,asyncOperation:async()=>{varbytes=awaithttp.GetByteArrayAsync(url);awaitFile.WriteAllBytesAsync(dest,bytes);});vartasks=new[]{Download("https://example.com/a.jpg","a.jpg",1,"img"),Download("https://example.com/b.jpg","b.jpg",1,"img"),queue.Enqueue(priority:5,asyncOperation:()=>Task.Delay(100)),// higher priority misc work};awaitTask.WhenAll(tasks);

Graceful shutdown

usingReactiveUI.Primitives.Concurrency;usingvarqueue=newOperationQueue(2);varupload=queue.Enqueue(priority:5,key:"upload:1",asyncOperation:()=>UploadAsync("1"));varcache=queue.Enqueue(priority:1,asyncOperation:()=>WarmCacheAsync());awaitqueue.ShutdownQueue().ToTask();awaitTask.WhenAll(upload,cache);

Pause while batching

usingvarqueue=newOperationQueue(4);using(queue.PauseQueue()){foreach(varidinids){_=queue.Enqueue(priority:1,key:$"item:{id}",asyncOperation:()=>RefreshAsync(id));}}// The queue resumes here and drains according to priority and key.

Performance and behavior

Punchclock is intended for application-level operation scheduling: network requests, disk work, cache refreshes, API calls, and other async operations where too much parallelism hurts more than it helps.

It is not a CPU work-stealing scheduler, a job runner, or a durable background queue. If you need persistence, retries across process restarts, distributed workers, or cron-style scheduling, pair Punchclock with a tool designed for that job.

The queue is thread-safe for normal enqueue/control usage. Keep the operation body itself thread-safe too, especially when multiple keys can run together.

FAQ

Does priority interrupt running work?

No. Priority decides the next pending operation when a concurrency slot opens.

Do keys limit global concurrency?

No. Keys serialize work for the same key. The queue still uses the global maximumConcurrent limit across all active work.

What should I use for a "void" observable?

Use ReactiveUI.Primitives.RxVoid. Emit RxVoid.Default.

Should I create one queue or many queues?

Usually one queue per feature, subsystem, or external resource is enough. Too many queues make global concurrency harder to reason about.

What happens when an operation throws?

The error is delivered to that operation's task or observable. The queue releases capacity and continues processing later operations.

Troubleshooting

  • Nothing runs? Ensure you didn't leave the queue paused. Dispose the token from PauseQueue.
  • Starvation? Check if you assigned very high priorities to long-running tasks.
  • Deadlock-like behavior with keys? Remember keyed operations are strictly serialized; avoid long critical sections.
  • InvalidOperationException when enqueueing? Shutdown has already started.
  • ArgumentOutOfRangeException from the constructor or SetMaximumConcurrent? Use a value greater than zero.
  • Observable shutdown does not appear to finish? Make sure the returned IObservable<RxVoid> is subscribed, or convert it with ToTask().

Contribute

Punchclock is developed under an OSI-approved open source license, making it freely usable and distributable, even for commercial use. Because of our Open Collective model for funding and transparency, we are able to funnel support and funds through to our contributors and community. We ❤ the people who are involved in this project, and we’d love to have you on board, especially if you are just getting started or have never contributed to open-source before.

So here's to you, lovely person who wants to join us — this is how you can support us:

About

Make sure your asynchronous operations show up to work on time

Topics

Resources

Code of conduct

Stars

264 stars

Watchers

14 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages