Используя ASP.NET Web API, мой ExecutionContext не течет в асинхронных действиях

Мне трудно понять механику ExecutionContext.

Из того, что я читал в Интернете, контекстно-зависимые элементы, такие как безопасность (Thread Principal), культура и т.д., должны проходить через асинхронные потоки в пределах рабочей единицы выполнения.

Я встречаюсь с очень запутанными и потенциально опасными ошибками. Я замечаю свою нить CurrentPrincipal теряется в процессе асинхронного выполнения.


Вот пример сценария веб-API ASP.NET:

Сначала настройте простую конфигурацию Web API с двумя обработчиками делегирования для целей тестирования.

Все, что они делают, это выписывать отладочную информацию и передавать запрос/ответ, за исключением первого "DummyHandler", который устанавливает директиву потоков, а также часть данных, которые должны быть разделены по контексту (идентификатор корреляции запросов).

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.MessageHandlers.Add(new DummyHandler());
        config.MessageHandlers.Add(new AnotherDummyHandler());

        config.Routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "api/{controller}/{id}",
            defaults: new { id = RouteParameter.Optional }
        );
    }
}

public class DummyHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        CallContext.LogicalSetData("rcid", request.GetCorrelationId());
        Thread.CurrentPrincipal = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));

        Debug.WriteLine("Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("RCID: {0}", CallContext.LogicalGetData("rcid"));

        return base.SendAsync(request, cancellationToken)
                   .ContinueWith(task =>
                       {
                           Debug.WriteLine("Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
                           Debug.WriteLine("User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
                           Debug.WriteLine("RCID: {0}", CallContext.LogicalGetData("rcid"));

                           return task.Result;
                       });
    }
}

public class AnotherDummyHandler : MessageProcessingHandler
{
    protected override HttpRequestMessage ProcessRequest(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Debug.WriteLine("  Another Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("  User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("  RCID: {0}", CallContext.LogicalGetData("rcid"));

        return request;
    }

    protected override HttpResponseMessage ProcessResponse(HttpResponseMessage response, CancellationToken cancellationToken)
    {
        Debug.WriteLine("  Another Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("  User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("  RCID: {0}", CallContext.LogicalGetData("rcid"));

        return response;
    }
}

Прост достаточно. Затем добавьте один ApiController для обработки HTTP POST, как если бы вы загружали файлы.

public class UploadController : ApiController
{
    public async Task<HttpResponseMessage> PostFile()
    {
        Debug.WriteLine("    Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("    User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("    RCID: {0}", CallContext.LogicalGetData("rcid"));

        if (!Request.Content.IsMimeMultipartContent())
        {
            throw new HttpResponseException(HttpStatusCode.UnsupportedMediaType);
        }

        try
        {
            await Request.Content.ReadAsMultipartAsync(
                new MultipartFormDataStreamProvider(
                    HttpRuntime.AppDomainAppPath + @"upload\temp"));

            Debug.WriteLine("    Thread: {0}", Thread.CurrentThread.ManagedThreadId);
            Debug.WriteLine("    User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
            Debug.WriteLine("    RCID: {0}", CallContext.LogicalGetData("rcid"));

            return new HttpResponseMessage(HttpStatusCode.Created);
        }
        catch (Exception e)
        {
            return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, e);
        }
    }
}

После запуска теста с Fiddler, это результат, который я получаю:

Dummy Handler Thread: 63
User: dgdev
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

  Another Dummy Handler Thread: 63
  User: dgdev
  RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

    Thread: 63
    User: dgdev
    RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

    Thread: 77
    User:                                     <<<  PRINCIPAL IS LOST AFTER ASYNC
    RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

  Another Dummy Handler Thread: 63
  User:                                       <<<  PRINCIPAL IS STILL LOST
  RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

Dummy Handler Thread: 65
User: dgdev                                   <<<  PRINCIPAL IS BACK?!?
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

Чтобы сделать вещи более запутанными, когда я добавляю следующие строки async:

await Request.Content.ReadAsMultipartAsync(
    new MultipartFormDataStreamProvider(..same as before..))
.ConfigureAwait(false); <<<<<<

Теперь я получаю этот вывод:

Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2

  Another Dummy Handler Thread: 40
  User: dgdev
  RCID: 8d944500-cb52-4362-8537-dab405fa12a2

    Thread: 40
    User: dgdev
    RCID: 8d944500-cb52-4362-8537-dab405fa12a2

    Thread: 65
    User: dgdev                               <<<  PRINCIPAL IS HERE!
    RCID: 8d944500-cb52-4362-8537-dab405fa12a2

  Another Dummy Handler Thread: 65
  User:                                       <<<  PRINCIPAL IS LOST
  RCID: 8d944500-cb52-4362-8537-dab405fa12a2

Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2

Дело здесь в этом. Код, следующий за async my, фактически вызывает мою бизнес-логику или просто требует, чтобы контекст безопасности был правильно установлен. Существует проблема с потенциальной целостностью.

Может ли кто-нибудь помочь пролить свет, что происходит?

Спасибо заранее.

Ответ 1

У меня нет всех ответов, но я могу помочь заполнить некоторые пробелы и угадать проблему.

По умолчанию поток ASP.NET SynchronizationContext будет протекать, но то, как он передает идентификатор, немного странно. Он фактически течет HttpContext.Current.User, а затем устанавливает для него Thread.CurrentPrincipal. Поэтому, если вы просто установите Thread.CurrentPrincipal, вы не увидите, что он работает правильно.

На самом деле вы увидите следующее поведение:

  • С момента установки Thread.CurrentPrincipal в потоке этот поток будет иметь тот же самый главный, пока он не войдет в контекст ASP.NET.
  • Когда какой-либо поток входит в контекст ASP.NET, Thread.CurrentPrincipal очищается (потому что он установлен в HttpContext.Current.User).
  • Когда поток используется вне контекста ASP.NET, он просто сохраняет все Thread.CurrentPrincipal, которые были установлены на нем.

Применяя это к исходному коду и выводить:

  • Первые 3 все синхронно сообщаются из потока 63 после того, как его CurrentPrincipal был явно установлен, поэтому все они имеют ожидаемое значение.
  • Thread 77 используется для возобновления метода async, тем самым введя контекст ASP.NET и очистив любой CurrentPrincipal, который он мог иметь.
  • Резьба 63 используется для ProcessResponse. Он возвращается в контекст ASP.NET, очищая его Thread.CurrentPrincipal.
  • Тема 65 интересна. Он работает за пределами контекста ASP.NET(в ContinueWith без планировщика), поэтому он просто сохраняет все CurrentPrincipal, которые имели место раньше. Я предполагаю, что его CurrentPrincipal просто оставлен после более раннего тестового прогона.

Обновленный код изменяет PostFile, чтобы запустить свою вторую часть за пределами контекста ASP.NET. Таким образом, он поднимает поток 65, который просто имеет набор CurrentPrincipal. Поскольку он вне контекста ASP.NET, CurrentPrincipal не очищается.

Итак, мне кажется, что ExecutionContext течет отлично. Я уверен, что Microsoft протестировала ExecutionContext поток из wazoo; в противном случае каждое приложение ASP.NET в мире будет иметь серьезную проблему безопасности. Важно отметить, что в этом коде Thread.CurrentPrincipal просто ссылаются на текущие претензии пользователя и не представляют фактического олицетворения.

Если мои догадки верны, то исправление довольно просто: в SendAsync измените эту строку:

Thread.CurrentPrincipal = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));

:

HttpContext.Current.User = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));
Thread.CurrentPrincipal = HttpContext.Current.User;

Ответ 2

Я понимаю, что повторный вход в контекст синхронизации ASP.NET приведет к тому, что для Thread.CurrentPrincipal будет установлено значение HttpContext.Current.User. Но я все еще не вижу поведение, которое я ожидал. Я не ожидал, что при каждом ожидаемом вызове цепочки будет установлен Thread.CurrentPrincipal = HttpContext.Current.User. Я вижу, что это даже выходит за пределы обработчика событий async void, в котором я запустил цепочку async/await. Это поведение других людей? Я ожидал, что вызовы в цепочке будут использовать их захваченный контекст для продолжения, но они демонстрируют реентерабельное поведение.

Я не использую .ContinueAwait(false) ни в одном из ожидаемых звонков. У нас есть targetFramework = "4.6.1" в файле web.config, который, помимо прочего, устанавливает UseTaskFriendlySynchronizationContext = true, среди прочего. Сторонний API-клиент вызывает реентерабельное поведение в нижней части цепочки async/await.