While numerous libraries exist to abstract away the complexities of asynchronous and concurrent programming, developers still need to drop down to lower thread-handling logic from time to time. Continuing our API changes for .NET 6 series, we look at some new tricks for multi-threading.
Async Parallel.ForEach
When the Parallel class was created, C# didn’t have language support for asynchronous programming. While there was the IAsyncResult pattern from .NET 1.1, it wasn’t widely used and the vast majority of code was designed to be executed synchronously.
This has become a problem as the focus has shifted to asynchronous code using async/await. Currently there is no built-in support to start a Parallel.ForEach
operation and asynchronously wait for the result. GSPP writes,
I am very active on Stack Overflow and I see people needing this all the time. People then use very bad workarounds such as starting all items in parallel at the same time, then WhenAll them. So they start 10000 HTTP calls and wonder why it performs so poorly. Or, they execute items in batches. This can be much slower because as the batch completes item by item the effective DOP [degrees of parallelism] decreases. Or, they write very awkward looping code with collections of tasks and weird waiting schemes.
To address this concern, a set of Parallel.ForEachAsync functions were created. Each take either an IEnumerable or IAsyncEnumerable. Parallel options and a cancellation token may also be provided.
public static Task ForEachAsync<TSource>(IEnumerable<TSource> source, Func<TSource, CancellationToken, ValueTask> body)
public static Task ForEachAsync<TSource>(IEnumerable<TSource> source, CancellationToken cancellationToken, Func<TSource, CancellationToken, ValueTask> body)
public static Task ForEachAsync<TSource>(IEnumerable<TSource> source, ParallelOptions parallelOptions, Func<TSource, CancellationToken, ValueTask> body)
public static Task ForEachAsync<TSource>(IAsyncEnumerable<TSource> source, Func<TSource, CancellationToken, ValueTask> body)
public static Task ForEachAsync<TSource>(IAsyncEnumerable<TSource> source, CancellationToken cancellationToken, Func<TSource, CancellationToken, ValueTask> body)
public static Task ForEachAsync<TSource>(IAsyncEnumerable<TSource> source, ParallelOptions parallelOptions, Func<TSource, CancellationToken, ValueTask> body)
Parallel.ForEachAsync
is one of the rare cases where ValueTask
is being used instead of Task
. The basic idea here is since values are going to be processed in a tight loop, the extra overhead of creating a full Task
object isn’t justified. Stephen Toub writes,
I think the funcs should return
ValueTask
rather thanTask
. The primary concern withValueTask
is that it'll be consumed incorrectly, but here the consumer is the method we're implementing, and we'll just make sure to do it right ;) And returningValueTask
is more accomodating:Task
can be converted to aValueTask
very cheaply, but it requires an allocation to convert aValueTask
(if it's not already wrapping aTask
) into aTask
.
While overall this feature was well received, one point of contention was whether or not developers should be required to provide a degree of parallelism value. This roughly equates to the number of threads that will be assigned to the parallel operation.
It was decided since most developers would not know the ideal degree of parallelism for their workload, expecting them to provide one would be counter-productive. The default selected is Environment.ProcessorCount
.
Thread.ManagedThreadId Deprecated
The property Environment.CurrentManagedThreadId
was introduced in .NET 4.5 to be a more efficient alternative to Thread.ManagedThreadId
property. However, this was never communicated in the documentation and developers continue to use Thread.ManagedThreadId
.
In order to guide developers towards the better option, a code analysis warning has been added for Thread.ManagedThreadId
.
While this effectively means Thread.ManagedThreadId
is deprecated, it is not being marked as obsolete. Developers may continue to use it in the foreseeable future even though Environment.CurrentManagedThreadId
is now preferred.
Thread.UnsafeStart
The new function for starting threads is called “unsafe” because it does not capture the execution context. David Fowler explains,
We added UnsafeStart in this PR #46181 because we needed to lazily create thread pool threads and the timer thread on the default execution context. UnsafeStart avoids capturing the current execution context and restoring it when the thread runs. There are other places where we create threads that could use similar logic
This function is already seeing use in numerous places including,
FileSystemWatcher
for OS XSocketAsyncEngine
for UnixCounterGroup
in the Tracing APIsThreadPoolTaskScheduler
when the task is marked asLongRunning
When running in the browser, UnsafeStart
will throw a PlatformNotSupportedException
.
Periodic Timer
The PeriodicTimer
class was originally called AsyncTimer because it is designed to be used in an asynchronous context. As you can see in the example below, an await must be used between each tick of the timer.
var second = TimeSpan.FromSeconds(1);
using var timer = new AsyncTimer(second);
while (await timer.WaitForNextTickAsync())
{
Console.WriteLine($"Tick {DateTime.Now}")
}
Fowler explains the design benefits of the PeriodicTimer,
This API only makes sense for timers that fire repeatedly, timers that fire once could be Task based (we already have Task.Delay for this).
The timer will be paused while user code is executing and will resume the next period once it ends.
The timer can be stopped using a CancellationToken provided to stop the enumeration.
The execution context isn't captured.
The timer can also be stopped by calling Stop
or Dispose
, even while a WaitForNextTickAsync
call is currently being executed.
There are already five other timers in .NET, but none of them have this particular set of features. As part of the documentation, a new guide for choosing which timer to use is being planned.
Trivia: The PeriodicTimer
class will be the first timer to share a namespace with another timer. Previously each timer was placed in a separate namespace:
System.Timers.Timer
System.Threading.Timer
System.Windows.Forms.Timer
System.Web.UI.Timer
System.Windows.Threading.DispatcherTimer
For our previous reports in the series, see the links below: