Skip to content

Instantly share code, notes, and snippets.

@sgorozco
Last active May 18, 2022 07:07
Show Gist options
  • Save sgorozco/c6efcff1718c822102d5ece277bb891b to your computer and use it in GitHub Desktop.
Save sgorozco/c6efcff1718c822102d5ece277bb891b to your computer and use it in GitHub Desktop.
Helper class to instantiate a JoinableTaskFactory singleton
using Microsoft.VisualStudio.Threading;
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
namespace JoinableTaskTools
{
/// <summary>
/// Helper class to instantiate a <see href="https://bit.ly/3roFkog">JoinableTaskFactory</see> singleton.
/// </summary>
public static class TaskTools {
private static object s_initializeLock;
private static ManualResetEventSlim s_fakeMainThreadKeepAliveEvent;
private static Thread s_fakeMainThread;
private static JoinableTaskFactory s_taskFactory;
static TaskTools()
{
s_initializeLock = new object();
}
private static bool CallMadeFromMainThread()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("BROWSER")))
{
// Unfortunately, at this time (18/01/22), Blazor WebAssembly is single-threaded. JoinableTaskTools assumes
// that multiple threads are available. Any sort of sync-over-async logic MUST be avoided, otherwise the
// application will deadlock
throw new PlatformNotSupportedException();
}
if (!Thread.CurrentThread.IsBackground && !Thread.CurrentThread.IsThreadPoolThread)
{
MethodInfo correctEntryMethod = Assembly.GetEntryAssembly().EntryPoint;
// If we are in the main thread, we must be able to find the Assembly's entry point in the current stack
// trace
StackTrace trace = new StackTrace();
StackFrame[] frames = trace.GetFrames();
for (int i = frames.Length - 1; i >= 0; i--)
{
MethodBase method = frames[i].GetMethod();
if (correctEntryMethod == method)
{
return true;
}
}
}
return false;
}
/// <summary>
/// Initializes our JoinableTaskFactory singleton
/// </summary>
/// <exception cref="InvalidOperationException">If this method was not called from the main thread</exception>
/// <exception cref="PlatformNotSupportedException">If this method is called from a Blazor WebAssembly app
/// </exception
/// <remarks>
/// For technical documentation regarding Joinable tasks, see
/// <see href="https://bit.ly/3fCPyMi"> here </see>
/// </remarks>
public static JoinableTaskFactory Initialize()
{
lock (s_initializeLock)
{
if (s_taskFactory == null)
{
if (!CallMadeFromMainThread())
{
throw new InvalidOperationException($"Call to {nameof(Initialize)} must be made from main thread!");
}
s_taskFactory = new JoinableTaskFactory(new JoinableTaskContext());
}
}
return s_taskFactory;
}
/// <summary>
/// Initializes our JoinableTaskFactory singleton from an Asp.net process hosted on IIS
/// This is mainly for compatibility with libraries that interact with JoinableTaskFactory.
/// </summary>
public static void InitializeFromIISHostedAsp()
{
lock (s_initializeLock)
{
if (s_taskFactory != null)
{
return;
}
// Pass a fake 'main' thread to the JoinableTaskContext (instead of the worker thread that is invoking
// this call - ASP page initialization also takes place on a worker thread).
// Vanilla Asp has a synchronization context that uses threads from the pool, (when the context
// synchronizes, instead of scheduling the continuation work to a particular thread, it takes a thread from
// the pool and restores the properties of the original thread, such as the culture, etc. This implies that
// it should be safe from Deadlocking on sync-over-async
// We provide a fake "main thread" to avoid falsly flagging a worker thread as the main thread
s_fakeMainThreadKeepAliveEvent = new ManualResetEventSlim();
s_fakeMainThread = new Thread(() => s_fakeMainThreadKeepAliveEvent.Wait(), 4 * 1024)
{
IsBackground = true
};
s_fakeMainThread.Start();
s_taskFactory = new JoinableTaskFactory(
new JoinableTaskContext(s_fakeMainThread, SynchronizationContext.Current)
);
}
}
public static void InitializeFromMSTestHarness() => InitializeFromIISHostedAsp();
/// <summary>
/// Provides the JoinableTaskFactory (Microsoft's) singleton for a process. It gives a safe context to invoke
/// sync-over-async code that minimizes the risk of a deadlock
/// </summary>
public static JoinableTaskFactory JoinableTaskFactory
{
get
{
if (s_taskFactory == null)
{
throw new InvalidOperationException(
$"{nameof(TaskTools)} has not been initialized. " +
$"Make sure {nameof(TaskTools.Initialize)}() is called from the MAIN thread or " +
"if configuring a service, call " +
$"IServiceCollection.AddSingleton<JoinableTaskFactory>({nameof(TaskTools.Initialize)}())"
); ;
}
return s_taskFactory;
}
}
}
@sgorozco
Copy link
Author

Microsoft.VisualStudio.Threading nuget repository.

Microsoft.VisualStudio.Threading github repository.

Purpose:

If you refer to the blog article that explains how JoinableTaskFactory should be used, you will notice it makes use of a ThreadHelper static class that belongs to the non-public Visual Studio codebase. The static class in this gist pretends to be a replacement for it; instead of referring to ThreadHelper.JoinableTaskFactory, you may refer to TaskTools.JoinableTaskFactory instead.

Initialization:

In order to perform its deadlock-avoiding magic, JoinableTaskFactory needs to keep track of the main thread's managed ID (which also happens to be the UI thread in WinForms and WPF applications). As such, a JoinableTaskFactory singleton must be instantiated on the main thread (or as an alternative, the managed thread ID of the main thread must be passed to it as a constructor parameter, whenever the instance is created on a different thread).

To initialize the singleton, call this method directly, right during application startup:
TaskTools.Initialize();

After this call, you may refer to the singleton by referring to the TaskTools.JoinableTaskFactory static property.

If you prefer, and you are in an environment with Dependency Injection support and would prefer to receive the task factory as an Injected dependency rather than working with a public singleton, you may inject the factory to your class instead. For .net Core built-in dependency injection framework, the singleton could be registered like this:

builder.Services.AddSingleton<JoinableTaskFactory>(TaskTools.Initialize());

Notes:

  • Applications that don't have a synchronization context (such as a Console application) should not worry about the potential sync-over-async deadlock problem, yet you may still want to create the singleton for them. This allows you to consume reusable libraries that assume the JoinableTaskFactory exists (libraries that may be used safely from within a Windows GUI application too).

  • Traditional ASP.net apps (running as an IIS-hosted process) do not expose the managed main thread, as such there is no simple way (that I am aware of) to instantiate the factory and pass the correct managed thread ID. Yet from what I have read, the synchronization context that is used by the IIS process should be able to work with the factory without fears of inducing a deadlock. I added a simple helper method (InitializeFromIISHostedAsp) that creates the factory from within a manually-created thread (a fake main thread). From my experience this seems to be safe.

  • The Microsoft.VisualStudio.Threading nuget package will install a code analyzer that will only be happy if the use of the JoinableTaskFactory is made from the internals of a class; when writing a sync and an async version of a library method, it will favor the following pattern:

  • There is no support for Blazor WebAssembly. Blazor WASM is, at this moment, still a single threaded environment. Even the tasks that are delegated to the thread pool, end up being run by the only available thread. Using the JoinableTaskFactory.Run() methods from this environment creates a deadlock.

  1. Write public, async versions of the desired methods, for example:
public async Task FooMethodAsync() {
   // The full logic of the async method goes here...
}

public async Task<int> FooMethodAsyncB() {
   // The full logic of the async method goes here...
}
  1. Write public sync-over-async versions of the method that will call the async version by means of the JoinableTaskFactory
public void FooMethod() {
   // Simply wrap the async version in a JoinableTaskFactory.Run() call
   TaskTools.JoinableTaskFactory.Run( ()=> FooMethodAsyncB());
}

public int FooMethodB() {
   // Simply wrap the async version in a JoinableTaskFactory.Run() call
   return TaskTools.JoinableTaskFactory.Run( ()=>
}

This has the added advantage that a single codebase for both methods must be maintained, while at the same time, keeps the code analyzer happy.

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