In an ASP.NET app, I'm doing a fair amount of stuff with Task.Run
and Parallel.ForEach
. At some points, I can get 2-3 levels deep:
// This is in a controller somewhere (i.e. so the Request object is in scope)
Task.Run(() => {
Parallel.ForEach(things, t => {
Task.Run(() => {
Parallel.ForEach(otherThings, t => {
// etc..
Now, that's an exaggerration (and a dumb idea; I get that), but the question is: should the Request
object be available all the way down through this?
I've been doing a lot of reading, and I'm understanding that the ExecutionContext
should "flow" down into all of this code (unless you explicitly prevent it).
The first Task.Run
should get the same ExecutionContext
as the "outer" code. It should pass this to the first Parallel.ForEach
, and so on. Based on how I understand it, the code all the way at the bottom of this stack should have access to the same data as the original, calling code.
In my situation, I'm getting some weird null errors at some levels where it's clear the block didn't get the ExecutionContext
passed to it. Things are null inside a Task.Run
, for instance, that aren't null outside of it. The errors are consistent to certain code, but I can't see how this code is different than other code which doesn't get the error.
Am I misunderstanding some nuance of ExecutionContext
flow?
Update: Based on a comment, I tested the theory that the request was ending before while the Task was still running. I think this is what is happening, because I was not using async/await. This meant that the request didn't wait for the task to complete, and the Request object disappeared.
Here's my test code THAT WORKS (meaning, in this particular test case, it keeps going forever...)
[Route("test")]
public async Task<string> Test()
{
await Task.Run(() =>
{
var counter = 0;
while (true)
{
counter++;
ServiceLocator.Current.GetInstance<IHttpContextAccessor>().HttpContext.Items.Add(counter.ToString(), "foo");
Debug.WriteLine(counter);
}
});
return "OK";
}
This does NOT work -- it throws a null reference when trying to work with HttpContext
. Since I wasn't waiting for the Task to complete, it was trying to run after the request was completed and Request was null.
[Route("test")]
public string Test()
{
Task.Run(() =>
{
var counter = 0;
while (true)
{
counter++;
ServiceLocator.Current.GetInstance<IHttpContextAccessor>().HttpContext.Items.Add(counter.ToString(), "foo");
Debug.WriteLine(counter);
}
});
return "OK";
}
ExecutionContext
flows with its bag of AsyncLocal
s normally downstream - making copies on each "flow" (under .NET Core).
HttpContext
however (accessed via IHttpContextAccessor
) is a bit different as there is a level of indirection that allows all of those downstream copies to be affected. This is from the main implementation HttpContextAccessor
:
public class HttpContextAccessor : IHttpContextAccessor
{
private static readonly AsyncLocal<HttpContextHolder> _httpContextCurrent = new AsyncLocal<HttpContextHolder>();
/// <inheritdoc/>
public HttpContext? HttpContext
{
get
{
return _httpContextCurrent.Value?.Context;
}
set
{
var holder = _httpContextCurrent.Value;
if (holder != null)
{
// Clear current HttpContext trapped in the AsyncLocals, as its done.
holder.Context = null;
}
if (value != null)
{
// Use an object indirection to hold the HttpContext in the AsyncLocal,
// so it can be cleared in all ExecutionContexts when its cleared.
_httpContextCurrent.Value = new HttpContextHolder { Context = value };
}
}
}
private sealed class HttpContextHolder
{
public HttpContext? Context;
}
}
As it states in the comments, it uses HttpContextHolder
object to effectively have a "null switch" for all the HttpContext
instances that might be accessible from the different ExecutionContexts
:
if (holder != null) {
// Clear current HttpContext trapped in the AsyncLocals, as its done.
holder.Context = null;
}
I guess the ASP.NET Core team decided to not allow fire-and-forget async tasks (such as your not awaited Task.Run()
) to capture and possibly forever hold a reference to a stale HttpContext possibly creating memory leaks.