[API Proposal]: Expose ThreadPool.NotifyThreadBlocked()

Open
#111,102 7 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
25/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Stale
Tech stack
csharp

Research direction

Start by reviewing the existing internal NotifyThreadBlocked and NotifyThreadUnblocked methods, plus the Task.Wait change in .NET 6 referenced by the proposal. Evaluate the proposed ThreadPool.NotifyThreadBlocked() and ThreadBlockedScope API across ThreadPool implementations; done means the public API design, behavior, and platform implications are resolved.

Written by the indexing model from the issue text.

Description

api-suggestion area-System.Threading
Background and motivation

The ThreadPool can be inappropriately starved of threads if many ThreadPool threads block. The ideal solution to that problem is for applications to write async code to avoid blocking threads. However, sometimes it is impossible for applications to write async code because of a requirement to call a sync API that has no async equivalent.

The particular scenario that I am dealing with is that my ASP.NET app frequently opens handles to files on network storage. Sometimes, the network storage appliance does not open file handles in a timely manner, and so my thread pool threads become blocked for non-trivial amounts of time, which causes the ThreadPool to starve, which causes other requests to not be served in a timely manner. There is no way to open a file handle asynchronously, so I cannot solve this problem by switching to async code.

In .NET 6, PR https://github.com/dotnet/runtime/pull/53471 modified Task.Wait() to notify the ThreadPool that it is blocking a thread, which informs the ThreadPool to inject new threads more aggressively, My goal with this proposal is to enable application developers to similarly notify the ThreadPool of a thread blocking for reasons other than waiting on a Task.

API Proposal
namespace System.Threading;

public static partial class ThreadPool
{
    public static ThreadBlockedScope NotifyThreadBlocked();
}

public ref struct ThreadBlockedScope : IDisposable
{
    public void Dispose();
}

The NotifyThreadBlocked() method is modeled after the existing internal method of the same name, but it returns a new ThreadBlockedScope ref struct that notifies the runtime of thread unblock when it is disposed, whereas the pre-existing internal method returns a bool that indicates whether a separate NotifyThreadUnblocked() method should be called upon unblock.
This aspect of the proposal exists to protect against potential misuse that could occur if the existing internal methods were exposed directly, such as erroneous additional or missing calls to NotifyThreadUnblocked().

Calling NotifyThreadBlocked() will be taken to indicate that the current thread is about to block, as opposed to incrementing a process-wide counter of the number of threads blocked. This means that nested calls on the same thread will be ignored, which is a valuable behavior to allow future .NET versions to decide to add calls to NotifyThreadBlocked() within an existing APIs (e.g., Thread.Sleep()) without concern that the blocked thread will be double counted due to application code already calling NotifyThreadBlocked() before calling that existing API. Since ThreadBlockedScope is a ref struct, disposal is guaranteed to occur on the same thread that called NotifyThreadBlocked(), similar to the recently introduced Lock.Scope type.

API Usage
public SafeFileHandle OpenNetworkFile(string networkFilePath)
{
    using (ThreadBlockedScope blockScope = ThreadPool.NotifyThreadBlocked())
    {
        return NativeMethods.CreateFileW(networkFilePath, ...);
    }
}

This example uses a call to the Win32 API CreateFileW() to open a file handle instead of using File.OpenHandle() to open a file handle. I wrote the example this way to sidestep any discussion about whether the problem is better solved by having File.OpenHandle() internally call the existing internal ThreadPool APIs. For the record, I think that File.OpenHandle() probably shouldn't call them, since most files opened by .NET applications in general are not in network storage, so making every caller pay the overhead of notifying the thread pool may not be worth it. Regardless, my ASP.NET app uses native code to open the handle to the network file, so modifying File.OpenHandle() to call the existing internal ThreadPool APIs would not solve my problem.

Alternative Designs
  1. As discussed above, relevant .NET runtime APIs such as File.OpenHandle() or Thread.Sleep() could be modified to call the existing internal ThreadPool APIs instead of exposing the ThreadPool APIs publicly for applications to call. However, this does not solve my case of opening a file handle via native code unless LibraryImportAttribute is enhanced to support calling these APIs, which I'm guessing would an undesirable entanglement of layers. Also, it's not clear that the .NET runtime can always determine when its APIs, such as File.OpenHandle(), are sufficiently likely to block to justify notifying the ThreadPool that the thread is potentially blocking.

  2. Instead of depending on apps to manually indicate with these new ThreadPool APIs that a thread is about to block, perhaps there is some way to solve thread exhaustion with changes internal to the ThreadPool thread injection algorithm, such as with automated detection of blocked threads. I don't have any practical suggestion about how this could be accomplished, though.

  3. For my particular concern about opening files always being synchronous, perhaps it is possible to open files asynchronously on Linux with io_uring. My ASP.NET app runs on Windows, though, where I believe asynchronous file opens to be impossible. Maybe the Windows team could be contacted to request this feature though :).

Risks
  1. For some ThreadPool implementations, there may be no useful implementation for these APIs, such as when the native Windows thread pool is used. Therefore, use of these APIs may increase behavioral differences between the same application running under different deployment scenarios, or different versions of .NET as ThreadPool implementations are modified. This does not seem a significant concern because the APIs are only performance hints, not guaranteeing specific behavior. Also, the behavioral differences have already been introduced via Task.Wait() in .NET 6+.

  2. It may be unclear to application developers when it is or isn't beneficial for them to call ThreadPool.NotifyThreadBlocked(). For example, Task.Wait() already calls it internally, so applications do not need to call it in that case, but the only way to know that is to read the .NET runtime source code. This could be mitigated with docs for ThreadPool.NotifyThreadBlocked() that list all the runtime APIs that already call it internally, if that list remains sufficiently small. In any case, the impact of an application adding erroneous additional calls to these APIs is probably not significant.

Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/runtime

All issues in dotnet/runtime

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.