Skip to content

#204: Use default scheduler for worker tasks#205

Merged
atanzu merged 2 commits into
DataDog:masterfrom
George-Shaw:gshaw/issue-204
Jun 10, 2026
Merged

#204: Use default scheduler for worker tasks#205
atanzu merged 2 commits into
DataDog:masterfrom
George-Shaw:gshaw/issue-204

Conversation

@George-Shaw

Copy link
Copy Markdown
Contributor

Fix for issue #204.

AsynchronousWorker called Task.Factory.StartNew without a TaskScheduler argument. The default behaviour inherits the task scheduler from the parent task. If the parent task was explicitly scheduled in a particular synchronisation context (e.g. the main thread), the worker tasks would use that same context. This was incorrect - worker tasks should always be in background threads.

To correct this, I am explicitly specifying TaskScheduler.Default for scheduling the worker tasks, so they always run on thread pool threads.

This is the most minimal fix for the issue. It would probably be better to use Task.Run instead of Task.Factory.StartNew.

@cit-pr-commenter

Copy link
Copy Markdown

PR Security Update

All commits in this PR up to and including 625c149 have been reviewed and marked safe by SDLC security. For any questions, please reach out to #ci-for-external-contributors-collab on Slack.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes issue #204 by ensuring AsynchronousWorker does not inherit the caller’s TaskScheduler when starting its long-lived worker tasks, preventing worker loops from being scheduled onto UI/main-thread synchronization contexts.

Changes:

  • Explicitly schedules worker tasks using TaskScheduler.Default (instead of inheriting the ambient scheduler).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

for (int i = 0; i < workerThreadCount; ++i)
{
_workers.Add(Task.Factory.StartNew(() => Dequeue(), TaskCreationOptions.LongRunning));
_workers.Add(Task.Factory.StartNew(() => Dequeue(), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@George-Shaw thanks for the fix. quick question on this codex comment: the description says workers will "always run on thread pool threads", but TaskCreationOptions.LongRunning with TaskScheduler.Default actually spins up a dedicated background thread rather than using the pool. the fix itself is still correct for the issue you're solving (keeping work off a caller-supplied scheduler like a UI SynchronizationContext), it's just the wording that's a bit off.

a couple things we wanted to check with you:

  1. was the intent to keep the existing dedicated-thread-per-worker behavior and just stop inheriting the caller's scheduler? that's what the code does today and what this patch preserves, which seems right to us.
  2. would you be open to rewording the PR description so it says something like "always run on background threads, off the caller's scheduler" instead of "thread pool threads"? that way the description matches the runtime behavior and avoids confusion for future readers.

also, separately, the line in your description suggesting Task.Run would be better: agreed it reads more cleanly, but Task.Run doesn't accept TaskCreationOptions.LongRunning, so swapping to it would change behavior (workers would run on pool threads instead of dedicated ones, with potential pool-starvation risk at higher workerThreadCount). probably worth either dropping that line or moving it to a "future work" note so it doesn't read as part of this PR's scope.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Yes, my intent was to keep all existing behaviour except for inheriting the caller's scheduler.
  2. I've updated the PR description with your corrections. You are quite right about the effect LongRunning has on the default scheduler and that it cannot be used with Task.Run.

I'll also update the commit message in a little bit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit message now updated to remove the reference to "thread pool threads".

I also rebased the commit on the latest master while I was at it.

for (int i = 0; i < workerThreadCount; ++i)
{
_workers.Add(Task.Factory.StartNew(() => Dequeue(), TaskCreationOptions.LongRunning));
_workers.Add(Task.Factory.StartNew(() => Dequeue(), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@George-Shaw on this codex comment about a regression test: worth adding, since this is the kind of bug that's easy to silently reintroduce later (someone "simplifies" the StartNew call back to the shorter overload and nothing visibly breaks until a UI app hits it again).

a minimal version could be: construct a custom TaskScheduler that records every call to QueueTask, run new AsynchronousWorker(...) inside a task pinned to that scheduler via Task.Factory.StartNew(..., yourScheduler), then assert the recorded queue never received the worker tasks. that exercises the exact inheritance path from issue #204 without needing a real SynchronizationContext or UI host.

do you have bandwidth to add it to this PR? if not, no worries, we'll take care of it on our end. just let us know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, a regression test would be a good idea here. The test you describe should do the job.

Unfortunately, I've recently come back from holiday to a large backlog of work, so I'm unlikely to have time to do this anytime soon. Sorry for the inconvenience!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no worries at all, thanks for letting us know. just pushed the regression test as commit d6d0b11 on top of your branch (tests/StatsdClient.Tests/Worker/AsynchronousWorkerTests.cs::WorkerTasksDoNotInheritCallerScheduler).

it builds a TrackingTaskScheduler that counts QueueTask invocations, then constructs AsynchronousWorker from a task pinned to that scheduler. with the fix the queue count is 1 (just the outer task); without the fix it's 1 + workerThreadCount. confirmed locally that it passes on the current branch and fails when the TaskScheduler.Default argument is removed.

also wanted to say thanks for updating the description and commit message, the new wording reads great. ptal when you have a sec and let us know if anything looks off about the test, otherwise we'll move to get this merged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test looks good to me. I'm impressed with how quickly you did that. I guess that's an advantage to already being familiar with the test suite...

@duncanista duncanista left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the fix. left a couple of comments inline on the codex notes (PR description wording, and a regression test). nothing blocking, just want to align on the wording and figure out who picks up the test before this merges.

@duncanista duncanista requested a review from a team as a code owner June 4, 2026 15:13
@atanzu

atanzu commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@George-Shaw could you please sign your commit? Unfortunately due to the repo rules I can't merge it as-is. Please have a look at this.

George-Shaw and others added 2 commits June 9, 2026 14:03
AsynchronousWorker called Task.Factory.StartNew without a TaskScheduler
argument. The default behaviour inherits the task scheduler from the
parent task. If the parent task was explicitly scheduled in a particular
synchronisation context (e.g. the main thread), the worker tasks would
use that same context. This was incorrect - worker tasks should always
be in background threads.

To correct this, I am explicitly specifying TaskScheduler.Default for
scheduling the worker tasks, so they always run on background threads,
off the caller's scheduler.
Verifies that AsynchronousWorker does not inherit a caller-supplied
TaskScheduler. The test pins construction to a TaskScheduler that
counts QueueTask invocations; if the worker tasks were to inherit the
caller's scheduler, they would be queued there too. With the fix in
place, only the outer construction task is queued.
@George-Shaw

Copy link
Copy Markdown
Contributor Author

@atanzu: I've re-pushed the commits with signatures. I also signed @duncanista's commit that added the regression test, since that was on top of my commit. (If the repo rules require the original author to sign rather than the committer, @duncanista will need to re-sign that commit.)

@atanzu atanzu merged commit 43706d1 into DataDog:master Jun 10, 2026
28 checks passed
@atanzu

atanzu commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@George-Shaw @duncanista thank you a lot for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants