-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Description
Background and Motivation
Native frameworks that support multi-threaded programming and need to do
thread-specific resource management are not currently well-supported by the .NET
ThreadPool asynchronous programming model.
While the ExecutionContext makes it possible to capture state that must follow an asynchronous work item as it runs on different threads, and the SynchronizationContext makes it possible for work items to run on threads with access to special capabilities, there is currently no mechanism for a framework to endow threadpool work items with resources for the duration of their execution and to take them away when the work item is done.
For example, in Cocoa programming (using Xamarin.iOS and Xamarin.Mac), in order to return reference counted resources from a callee that creates the resource to a caller that may or may not use the resource in a leak-proof way, the
thread is endowed with an NSAutoreleasePool.
The callee allocates the resource and then send it the autorelease message. If the caller wants to keep the resource alive, it sends the resource a retain message (that increments the reference count). If the caller doesn't retain the resource, it will be automatically released when the autorelease pool is deallocated. A common pattern when writing Cocoa code is to write functions returning resources as: (in C#) return new NSObject().autorelease().
To use autorelease pools in asynchronous programming it is necessary to instrument the execution of threadpool work items to create and release autorelease pools. (Xamarin.iOS also hooks into the runtime's native thread creation and
destruction to provide an autorelease pool backing entire managed threads, but since threadpool worker threads are relatively long-lived, those pools may accumulate an unreasonable amount of dead objects.)
It is insufficient to create Tasks in a framework-specific way. Since asynchronous programming is ubiquitous in C#, third party libraries may provide async methods that return a Task<T> or ValueTask<T> that are created outside of any single framework's control. It is necessary to instrument the dispatch of work items, not just their creation.
Proposed API
namespace System.Threading
{
public class ThreadPool
{
+ /// Registers a delegate that will be called to perform additional work when a queued work item is about to be
+ /// executed by a worker thread.
+ /// The handler delegate must call the action that is passed to it. It may perform
+ /// additional work before and after calling the action.
+ public static void RegisterWorkItemHandler (Action<Action> handleDispatchWorkItem);
}
}Usage Examples
Then it could be used like this
ThreadPool.RegisterWorkItemHandler((executeTask) => {
using (var pool = NSAutoreleasePool()) {
executeTask();
}
});
A possible implementation is:
namespace System.Threading
{
partial class ThreadPool
{
private static Action<Action> _dispatchHandler;
public static void RegisterWorkItemHandler(Action<Action> handleDispatchWorkItem)
{
Action<Action> prevHandler = _dispatchHandler;
if (prevHandler == null) {
_dispatchHandler = handleDispatchWorkItem;
} else {
_dispatchHandler = new Action<Action>((executeTask) => prevHandler(() => handleDispatchWorkItem(executeTask)));
}
}
}
}Which could be used like this:
ThreadPool.RegisterWorkItemHandler((executeTask) => {
Console.WriteLine("handler 1 before");
executeTask();
Console.WriteLine("handler 1 after");
});
ThreadPoolSupport.RegisterWorkItemHandler((executeTask) => {
Console.WriteLine(" handler 2 before");
executeTask();
Console.WriteLine(" handler 2 after");
});
Task.Run (() => Console.WriteLine (" I'm a task");With output:
handler 1 before
handler 2 before
I'm a task!
handler 2 after
handler 1 after
The disadvantage of this approach is that it will use stack proportional to the
number of task dispatched delegates. On the other hand if the number of
delegates is small, that may be acceptable in exchange for a simpler model for
the clients and less memory used (no need for a thread local variable to own
resources - a normal using block is sufficient) compared to an alternative
design, below.
Refinements
Place the method in a System.Runtime.InteropServices.ThreadPoolSupport class
We could put the method in the InteropServices namespace to hint that it is not intended for general purpose use.
Throw InvalidOperationException if called after tasks are running
We could disallow registering a handler after startup is over.
The threadpool would throw an exception if the Register method is called after the threadpool ran at least one task.
For the NSAutoreleasePool use-case, we only need to register handlers at startup before any user code is running.
On the other hand, it's possible that in other scenarios it's difficult to find the right time. Also in third-party libraries it is natural to call the Register method from a static constructor - which would not be able to guarantee that it is called before some other static constructor executes a Task.Run operation.
Implementation Notes
The runtime could use this mechanism to re-implement the worker tracking feature (controlled by ThreadPool.EnableWorkerTracking) as a handler.
The point where the handlers would be called are exactly at the call site of DispatchWorkItemWithWorkerTracking
Alternative Designs
Alternative 1: event pair
We could use a pair of events that fire just before and just after a work item
is executed on a threadpool thread.
namespace System.Threading
{
public class ThreadPool
{
+ /// Called before a work item is executed by a threadpool thread
+ public event EventHandler? WorkItemDispatching;
+ /// Called after a work item finishes executing on a threadpool thread
+ public event EventHandler? WorkItemDispatched;
}
}This alternative design has a risk/mismatch with Cocoa in that Cocoa
NSAutoreleasePool examples are all block scoped, as is the Objective-C
language extension (@autoreleasepool {} blocks). For maximum compatibility,
the autorelease pool creation and release should be in the same stack frame.
Usage Examples
The event pair could be used to set up an NSAutoreleasePool.
internal class TaskLocalAutoReleasePool
{
[ThreadLocal]
internal static NSAutoreleasePool taskPool;
}
ThreadPool.WorkItemDispatching += (_sender, _args) => {
TaskLocalAutoReleasePool.taskPool = new NSAutoreleasePool();
}
ThreadPool.WorkItemDispatched += (_sender, _args) => {
var pool = TaskLocalAutoReleasePool.taskPool;
TaskLocalAutoReleasePool.taskPool = null;
pool.Dispose ();
}Alternative 2: Extensible Task creation
In this alternative, instead of (in effect) augmenting QueueUserWorkItem we
could instead enhance AsyncTaskMethodBuilder<T> (and other related classes)
to instrument individual tasks.
However this is inadequate and not composable because third-party libraries would need to
explicitly opt into using the framework-specific method builders.
Alternative 3: Extensible builder in TaskFactory
This alternative would allow frameworks to register a custom builder for TaskFactory. This would not be composable (only one framework could register a builder). It also will not work with ValueTask<T>.
Alternative 4: Port ThreadPool to use the native system threadpool.
From the Apple "Dispatch Queues" documentation:
If your block creates more than a few Objective-C objects, you might want to enclose parts of your block’s code in an @autorelease block to handle the memory management for those objects. Although GCD dispatch queues have their own autorelease pools, they make no guarantees as to when those pools are drained. If your application is memory constrained, creating your own autorelease pool allows you to free up the memory for autoreleased objects at more regular intervals
So one alternative is for the runtime to use the underlying Grand Central Dispatch threadpool for async work for ios and Mac workloads. From the description it's unclear how frequently the GCD dispatch queues' pools are drained - we may need to drain after every managed user work item anyway (although this could be done as part of the runtime threadpool backend)
Alternative 5: Objective C specific interop support
Instead of adding a general mechanism to the threadpool, consider the NSAutoreleasePool support as a specific part of support for Objective C interop. We may end up adding more low-level APIs to the runtime to support Objective C interop, conceptually similar to low-level APIs that we have added for WinRT interop support in .NET (System.Runtime.InteropServices.ComWrappers). This can be one of the APIs in this set.
namespace System.Runtime.InteropServices.Interop
{
public static class ObjectiveCSupport
{
/// Work items executed on threadpool worker threads are wrapped with an NSAutoreleasePool
/// that drains when the work item completes. See https://developer.apple.com/documentation/foundation/nsautoreleasepool
public static void EnableAutoReleasePoolsForThreadPool();
}
}Risks
Running code around each executing task will have a negative impact on performance.
A public API means any third party library can instrument threadpool threads
and degrade performance for everyone.