Асинхронные веб-формы

121

Чтобы обрабатывать запрос асинхронным образом, необходимо сообщить среде ASP.NET Framework о том, что мы намерены делать, а также указать, когда поток не должен ожидать. Первая часть, объявление своего намерения среде ASP.NET, реализована в файле Default.aspx, как показано в примере ниже:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" 
    Inherits="AsyncApp.Default" Async="true" AsyncTimeout="60" %>
...

Установка атрибута Async директивы Page в true сообщает среде ASP.NET, что мы хотим использовать асинхронную обработку запросов внутри веб-формы. Эта функция должна включаться явно, т.к. установка и поддержка ресурсов, требуемых для асинхронных задач, сопряжены с определенными накладными расходами, которые не являются желательными, когда асинхронная обработка не нужна.

В атрибуте AsyncTimeout указано количество секунд, в течение которых среда ASP.NET будет ожидать завершения асинхронных задач перед тем, как возникнет тайм-аут запроса. В примере он был установлен в 60 секунд; если атрибут AsyncTimeout не указан, для него принимается стандартное значение, равное 45 секунд. Одна из причин применения асинхронной обработки запросов связана с необходимостью согласования с длительными операциями, поэтому вы должны настроить значение AsyncTimeout так, чтобы позволить выполняемой работе благополучно завершиться.

Асинхронная обработка реализована в файле отделенного кода Default.aspx.cs, содержимое которого приведено в примере ниже:

using System;
using System.Diagnostics;
using System.Net;
using System.Threading.Tasks;
using System.Web.UI;

namespace AsyncApp
{
    public class WebSiteResult
    {
        public string Url { get; set; }
        public long Length { get; set; }
        public long Blocked { get; set; }
        public long Total { get; set; }
    }

    public partial class Default : System.Web.UI.Page
    {
        private WebSiteResult result;

        protected void Page_Load(object sender, EventArgs e)
        {
            string targetUrl = "http://professorweb.ru";
            WebClient client = new WebClient();
            result = new WebSiteResult { Url = targetUrl };
            Stopwatch sw = Stopwatch.StartNew();

            // Асинхронное выполнение запроса
            RegisterAsyncTask(new PageAsyncTask(async () =>
            {
                string webContent = await client.DownloadStringTaskAsync(targetUrl);
                result.Length = webContent.Length;
                result.Total
                    = (long)DateTime.Now.Subtract(Context.Timestamp).TotalMilliseconds;
            }));

            result.Blocked = sw.ElapsedMilliseconds;
        }

        public WebSiteResult GetResult()
        {
            return result;
        }
    }
}

Изменения выглядят мелкими, но в них выполняется большой объем работ. Вскоре мы объясним все детали, а пока рассмотрим, какой получен результат. На рисунке ниже показано, что произошло после запуска приложения и запроса веб-формы Default.aspx:

Влияние асинхронной обработки запросов

Общее время, потраченное на обработку запроса, получение данных от удаленного веб-сервера и генерацию ответа слегка увеличилось по сравнения с синхронным вариантом, рассмотренным в предыдущей статье (это связано в основном с варьированием возможностей и маршрутизации в Интернете). Крупное отличие заключается в том, что поток запроса больше не блокируется во время ожидания HTTP-запроса к http://professorweb.ru для получения ответа.

За счет применения асинхронной обработки к этой веб-форме появилась возможность вернуть поток запроса в пул на период в 1.5 секунды, который был затрачен на получение ответа от сервера. В течение этого времени данный поток был доступен для обработки других запросов, что привело к улучшению общей пропускной способности приложения.

Мы получили результат, который указывает на отсутствие времени блокирования, но на самом деле это немного не так; некоторое блокирование присутствует всегда, даже если оно существует на протяжении очень короткого времени, поскольку требуется время на установку инфраструктуры, которая позаботится о выполнении необходимой фоновой работы. Пример запускался на локальном сервере, а измерения проводились с точностью до миллисекунды - с помощью более точного таймера и при реалистичной рабочей нагрузке накладные расходы будут несколько выше.

Использование асинхронного метода

Первое изменение, которое было внесено в файл Default.aspx.cs, касалось использования другого метода класса WebClient:

string webContent = await client.DownloadStringTaskAsync(targetUrl);

Вместо синхронного метода DownloadString() вызывается асинхронный вариант DownloadStringTaskAsync(), который возвращает объект Task<string> и может применяться с ключевым словом await. Метод DownloadStringTaskAsync() дает тот же самый результат, что и метод DownLoadString(), но работает асинхронным образом, не блокируя поток запроса.

Создание собственных асинхронных методов

Можно переписать любой метод, обеспечив возврат из него объекта Task и использование ключевого слова async, но поскольку поток по-прежнему должен выполнять этот метод, возникает риск переноса той же самой проблемы в другое место. В широком смысле существуют два исключения из этого:

Запомните в качестве эмпирического правила: если вы не имеете существенного опыта параллельного программирования, то должны применять асинхронную обработку запросов к веб-формам ASP.NET только с использованием классов .NET Framework, предоставляющих асинхронные методы, таких как WebClient.

Создание и регистрация асинхронной страничной задачи

Нам необходимо упаковать асинхронную работу, предназначенную для выполнения, чтобы ее можно было интегрировать в жизненный цикл, который определен классом Page. Это делается с применением класса PageAsyncTask, конструктор которого принимает делегат, возвращающий объект Task. Мы используем лямбда-выражение следующего вида:

// ...
new PageAsyncTask(async () =>
{
    string webContent = await client.DownloadStringTaskAsync(targetUrl);
    result.Length = webContent.Length;
    result.Total
        = (long)DateTime.Now.Subtract(Context.Timestamp).TotalMilliseconds;
})
// ...

Это расширенное применение синтаксиса лямбда-выражений, которое упрощает определение фоновой работы, не требуя создание отдельного асинхронного метода. Компилятор C# выполняет ряд "магических" действий с ключевыми словами async (которое применяется к лямбда-выражению) и await (которое используется в качестве префикса при вызове метода класса WebClient). Результатом является делегат, который возвращает объект Task и вызывает метод DownloadStringTaskAsync(), не блокируя поток запроса.

Асинхронное действие регистрируется за счет передачи объекта PageAsyncTask методу RegisterAsyncTask(), определенному в классе Page:

RegisterAsyncTask(new PageAsyncTask(async () =>
{ 
   // ...
}));

Метод RegisterAsyncTask() регистрирует асинхронный объект, но его выполнение будет инициировано только после события PreRender и перед событием PreRenderComplete.

Выполнение множества задач

Можно зарегистрировать несколько асинхронных задач и все они будут выполнены после возникновения события PreRenderComplete - однако последовательно, а не параллельно. Это не блокирует поток запроса, но не дает эффект, который большинство людей ожидают получить, когда имеют дело с множеством асинхронных операций.

Для демонстрации того, что произойдет, мы добавили новую веб-форму по имени Multiples.aspx, контент которой показан в примере ниже:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Multiples.aspx.cs" 
    Inherits="AsyncApp.Multiples" Async="true" %>

<!DOCTYPE html>
<html>
<head runat="server">
    <title></title>
    <style>
        table { border: thin solid black; border-collapse: collapse; font-family: Tahoma}
        th, td { text-align: left; padding: 5px; border: thin solid black;}
    </style>
</head>
<body>
    <table>
        <tr><th>Время начала</th><th>URL</th><th>Длина</th></tr>
        <asp:Repeater id="rep" SelectMethod="GetResults" 
                ItemType="AsyncApp.MultiWebSiteResult" runat="server">
            <ItemTemplate>
                <tr>
                    <td><%# Item.StartTime %></td>
                    <td><%# Item.Url %></td>
                    <td><%# Item.Length %></td>
                </tr>
            </ItemTemplate>
        </asp:Repeater>
    </table>
</body>
</html>

Это вариация веб-формы Default.aspx, созданной ранее. С помощью элемента управления Repeater отображается набор результатов. Кроме того, определен другой тип со свойствами, имеющими отношение к этому примеру - в частности, нас интересует время начала запроса к веб-серверу, а не промежуток времени, в течение которого поток запроса был заблокирован, или время выполнения отдельных запросов. Определения типа и класса отделенного кода представлены в примере ниже:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Web.UI;
using System.Threading.Tasks;

namespace AsyncApp
{
    public class MultiWebSiteResult
    {
        public string Url { get; set; }
        public long Length { get; set; }
        public long StartTime { get; set; }
    }

    public partial class Multiples : System.Web.UI.Page
    {
        private ConcurrentQueue<MultiWebSiteResult> results;

        protected void Page_Load(object sender, EventArgs e)
        {
            string[] targetUrls
                = { "http://asp.net", "http://professorweb.ru", "http://google.com" };
            results = new ConcurrentQueue<MultiWebSiteResult>();

            foreach (string targetUrl in targetUrls)
            {
                MultiWebSiteResult result = new MultiWebSiteResult { Url = targetUrl };
                results.Enqueue(result);

                RegisterAsyncTask(new PageAsyncTask(async () =>
                {
                    result.StartTime
                            = (long)DateTime.Now.Subtract(Context.Timestamp).TotalMilliseconds;
                    string webContent = await new WebClient().DownloadStringTaskAsync(targetUrl);
                    result.Length = webContent.Length;
                    rep.DataBind();
                }));
            }
        }

        public IEnumerable<MultiWebSiteResult> GetResults()
        {
            return results;
        }
    }
}

Мы запрашиваем три URL и назначаем запросы объектам PageAsyncTask. Для просмотра результатов запустите приложение и перейдите на URL вида /Multiples.aspx:

Множество последовательных запросов

Вызов метода DataBind() гарантирует отображение результатов внутри элемента управления Repeater. Причина связана со способом интеграции элементов управления, отображающих данные, в жизненный цикл запроса Page.

По информации в столбце "Время начала" видно, что запросы выполнялись последовательно, т.е. запрос к google.com не запускался, пока не прошло около 2.3 секунд после его получения средой ASP.NET.

Это может оказаться приемлемым в вашем приложении, но в большинстве приложений требуется помещать работу в очередь, чтобы отдельные запросы можно было выполнять параллельно при наличии достаточного числа доступных потоков. Достичь такого результата проще всего с помощью библиотеки TPL - в примере ниже показано, как это сделано в файле отделенного кода Multiples.aspx.cs:

// ...
protected void Page_Load(object sender, EventArgs e)
{
    string[] targetUrls
        = { "http://asp.net", "http://professorweb.ru", "http://google.com" };
    results = new ConcurrentQueue<MultiWebSiteResult>();

    RegisterAsyncTask(new PageAsyncTask(async () =>
    {
        List<Task> tasks = new List<Task>();
        foreach (string targetUrl in targetUrls)
        {
            tasks.Add(Task.Factory.StartNew(() =>
            {
                MultiWebSiteResult result = new MultiWebSiteResult { Url = targetUrl };
                result.StartTime
                    = (long)DateTime.Now.Subtract(Context.Timestamp).TotalMilliseconds;
                Task<string> innerTask
                    = new WebClient().DownloadStringTaskAsync(targetUrl);
                innerTask.Wait();
                result.Length = innerTask.Result.Length;
                results.Enqueue(result);
            }));
        }
        await Task.WhenAll(tasks);
        rep.DataBind();
    }));
}
// ...

Мы регистрируем только один объект PageAsyncTask и управляем параллельными задачами самостоятельно с применением класса Task. Как упоминалось в предыдущей статье, библиотека TPL является отдельной крупной темой, и мы не собираемся здесь подробно объяснять этот код. Отметим только, что в результате обеспечивается помещение в очередь отдельных запросов к контенту веб-сайта, что позволит выполнить их параллельно:

Выполнение нескольких запросов параллельно

Как видите, все три запроса были запущены почти в одно и то же время. Это не гарантированно произойдет, т.к. в очереди могут находиться другие работы, а все потоки TPL могут быть заняты, но если есть свободные системные ресурсы, то время, затраченное на обработку запросов, будет намного меньше.

Пройди тесты
Лучший чат для C# программистов