воскресенье, 27 ноября 2011 г.

Сессия ASP.NET и проблема с параллельными запросами

Эта статья будет интересна прежде всего тем, кто, явно или неявно, использует сессию ASP.NET в своих приложениях. Тем же, кто принципиально не использует сессию, можно почитать для общего развития – вдруг пригодится на будущее.
mcdonalds_queue
И, поверьте мне, она будет невероятно интересна разработчикам таких приложений, которые могут обрабатывать несколько небыстрых одновременных запросов от одного пользователя. Показательный пример – одновременное скачивание нескольких больших файлов. Это реальная проблема, с которой столкнулись мои коллеги и на отладку которой было потрачено неадекватно много времени.

Проблема состоит, в первую очередь, в неочевидности следующего факта: в рамках одной сессии (одного SessionID) параллельные запросы выполняются по очереди, если обработчики поддерживают сессию. При этом обращений к сессии в обработчике может не быть вообще – важна только поддержка сессии и её предварительная инициализация. В нашем примере это означает то, что пока не закачается один файл, закачивание других не начнется.

В принципе, ситуация с конкурентными запросами в рамках одной сессии описана в статье MSDN ASP.NET Session State Overview. Но есть нюансы. Во-первых, это один неприметный абзац в конце статьи (вместо выделенного блока в начале). Во-вторых, MSDN не раскрывает многих неочевидных моментов. И, что важно, ни один из моих знакомых ASP.NET разработчиков не знал об этой особенности.

Заинтересовались? Тогда продолжаем. Я сформулирую несколько правил работы сессии и покажу на примере, что они верны.

Несколько запросов и сессия ASP.NET

MSDN глаголет следующее:
Access to ASP.NET session state is exclusive per session, which means that if two different users make concurrent requests, access to each separate session is granted concurrently. However, if two concurrent requests are made for the same session (by using the same SessionID value), the first request gets exclusive access to the session information. The second request executes only after the first request is finished. (The second session can also get access if the exclusive lock on the information is freed because the first request exceeds the lock time-out.)
То есть, получаем основные правила:
  • У разных сессий с одновременными запросами все в порядке.
  • Если в одной сессии будет несколько параллельных запросов, выполнятся они будут по очереди. Кто успел первым – тот выполнится до конца (или до остановки по таймауту), и только потом начнется обработка второго.
Несколько легче ситуация с обработчиками запросов которые помечены относительно сессии как “только для чтения”. Далее для простоты буду называть это режимом ReadOnly (не путать с session mode – InProc, StateServer и т.п.). Они не блокируют друг друга, однако могут быть заблокированы запросом, который может писать в сессию:
However, read-only requests for session data might still have to wait for a lock set by a read-write request for session data to clear.
И повторюсь, потому что это важно – если обработчик запроса не помечен особым образом, блокируются любые запросы, даже те, которые не обращаются к сессии. Для воспроизведения блокировки параллельных запросов важно только то, что хранилище для сессии должно быть предварительно инициализировано.

Экспериментальное подтверждение правил

Чтобы не было вопросов по поводу возможных трактовок ситуации, имеет смысл написать приложение, которое воспроизводит вышеописанное поведение. А заодно можно показать, что без обращения к сессии подобных проблем не возникает.

Для иллюстрации режима “только для чтения” можно реализовать HttpHandler с маркерным интерфейсом IReadOnlySessionState вместо IRequiresSessionState. Мне показался более быстрым в реализации вариант со страницами директивой @Page.

Далее подразумевается работа в Microsoft Visual Studio 2010 и .NET 4.0. Детали реализации в других версиях могут незначительно отличаться. Достаточно создать проект “ASP.NET Empty Web Application”, но вы можете добавить страницы и в существующий проект.

Отмечу также, что некоторые браузеры (например, Opera) создают разные сессии для нескольких страниц одного пользователя, поэтому пример лучше проверять с помощью Internet Explorer (в моем случае IE9).
Страница инициализации сессии
Кстати, есть еще одна особенность сессии, которая может быть вам интересна. Начиная с .NET 2.0, хранилище для сессии не инициализируется до первого обращения к ней.
[UPDATE] Еще один способ инициализации сессии – добавить метод Session_Start в Global.asax (спасибо Юрию за комментарий).

По этой причине, чтобы показать независимость блокировок от обращений к сессии, создадим страницу InitSession.aspx со следующим скриптом:
<script runat="server">
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        Session["0"] = ((int?)Session["0"] ?? 0) + 1;
        Response.Write(Session["0"]);
        Response.Write("<br/>");
        Response.Write(Session.SessionID);
    }
</script>
Можете провести эксперимент – сначала закомментировать обращение к сессии и оставить только вывод идентификатора сессии (который не инициализирует хранилище). В этом случае он будет каждый раз разным.
Медленная страница
Добавим к проекту страницу SlowPage.aspx, в явном виде зададим доступ к сессии только для чтения и напишем обработчик для Load:
<%@ Page Language="C#" EnableSessionState="ReadOnly" %>
 
<script runat="server">
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        System.Threading.Thread.Sleep(30*1000);
    }
</script>
Это гарантирует задержку в обработке запроса на полминуты, чтобы заметить блокировку других запросов в той же сессии. Обращение к сессии добавим позже.
Тестовая страница
Добавим к проекту страницу TestPage.aspx и напишем в ней следующий код:
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        Response.Write(Session.SessionID);
    }
</script>
Как вы видите, от Slow.aspx её отличает только отсутствие режима ReadOnly и Thread.Sleep.
Проверка режима ReadOnly
Теперь откроем в IE InitSession.aspx, затем SlowPage.aspx, а в другой вкладке TestPage.aspx. При обновлении по F5 страницы TestPage.aspx (пока грузится SlowPage.aspx) никаких блокировок не происходит.

Что произойдет, если перенести установку режима ReadOnly из SlowPage.aspx в TestPage.aspx? Теперь, после открытия SlowPage.aspx, TestPage.aspx будет блокироваться в течении 30 секунд. Это подтверждает сказанное ранее – ReadOnly запросы блокируются обычным запросом поддерживающим сессию.
Проверка обычного режима
Если убрать установку режима ReadOnly из всех страниц, то получим то же поведение, что и в варианте после переноса - блокировку. Таким образом, все правила, озвученные выше, подтверждаются.

Разумеется, при отключении поддержки сессии никаких блокировок не происходит (только нужно убрать обращение к SessionID).

Что делать?

Что же делать в ситуации, когда мы хотим использовать сессию, но не хотим блокировать некоторые параллельные операции? Учитывая нечеткую формулировку вопроса (а частных случаев слишком много), вариантов множество, я перечислю только некоторые из них:
  • Использовать вместо сессии другой механизм хранения сессионных данных (свой сервис).
  • Написать свой или использовать сторонние неблокирующие провайдеры сессии.
  • Там где сессия только читается использовать режим ReadOnly. С оговорками, упомянутыми выше.
  • Отключать сессию для тех операций, которым она не нужна. Об этом подробнее в следующий раз.

Резюме

Хотя блокировка запросов внутри одной сессии ASP.NET, разобранная в этой статье, вполне логична (можно провести аналогию с транзакцией в БД), в официальной документации это поведение описано довольно мутно.

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

Если будет время и интерес читателей, напишу еще о сессии ASP.NET – в частности, про нестандартные провайдеры сессии и варианты отладки обращений к сессии. Также расскажу подробности о режиме ReadOnly, который не всегда ReadOnly :)


Если у вас есть замечания, пожелания или новые темы – пишите в комментариях, твиттер или на olegaxenow.reformal.ru. Постараюсь учесть.

27 комментариев:

  1. Интересно, спасибо

    ОтветитьУдалить
  2. Надо обдумать, может вообще не пользоваться сессией? Она не так уж и полезна, по большому счету.

    ОтветитьУдалить
  3. To @xor:
    В принципе - это первый вариант ответа на вопрос "Что делать?" выше по тексту.
    В принципе-то сессия как общая концепция бывает полезна, а конкретная реализация - не всегда.

    ОтветитьУдалить
  4. После таких статей, я только в очередной раз убеждаюсь, что перейти в ASP.NET MVC из WebForms было правильном решением. Очень многие вещи в WebForms реализованы через жопу. Там кстати эта проблема то же есть, но решается просто заменой обычного контроллера на асинхронный контроллер.

    ОтветитьУдалить
  5. вы чо пургу несете, все правильно, несколько запрософ на одну сессию будут обрабатоватся синхронно, но есть еще и threding, так вот он работает нормально - асинхронно.
    просто надо понимать что делаете.
    Кстати, эту загадочную синхронность можно увидеть в дебагере, когда локалхост откроешь в нескольких браузерах одновременно

    ОтветитьУдалить
  6. Синхронность не загадочная и поведение вполне логично объясняется. Только почему-то не все сходу соображают, почему у клиента файлы не закачиваются параллельно, "а еще неделю назад все работало" :)

    ОтветитьУдалить
  7. Меня просто поражает тот факт, что в каждой статье про WebForms в комментариях найдётся человек, который обязательно скажет, что MVC в 100500 раз круче.

    А за статью спасибо, не знал о таком поведении сессии.

    ОтветитьУдалить
  8. Сам напарывался на это с MVC + long polling. Страница мониторинга вешала всё и сразу :) Ждала разблокировки от триггера базы. Пометил базовый контроллер для ajax запросов как ReadOnly для сессии. Асинхронные контроллеры тоже не всегда выручают http://bit.ly/svLkpB

    ОтветитьУдалить
  9. To @Игорь Чакрыгин и к остальным :)
    На всякий случай уточняю - поведение *общее для ASP.NET* как и эта статья. Если что, MVC от MS - тоже ASP.NET ;)

    ОтветитьУдалить
  10. Да, вы правы насчёт статьи. Эта вещь очень давно освещалась в одном стареньком ресурсе: http://odetocode.com/Blogs/scott/archive/2006/05/21/session-state-uses-a-reader-writer-lock.aspx

    а объяснение простое, так как использование сессии безопасно в потоках, то там просто используются lock'и, которые не освобождаются, пока реквест не обработается.

    ОтветитьУдалить
  11. To @Vest

    Эта тема и в MSDN достаточно подробно описана, но по факту я вижу, что слишком незаметно. Поэтому, собственно, и написал здесь.

    ОтветитьУдалить
  12. MS давно пора основательно взяться за серверную часть и в идеале сотворить что-то креативно-новое. А то дождутся, что все потихоньку уйдут на php или node.js

    ОтветитьУдалить
  13. Хотел еще добавить, насколько я помню, сессия инициализируется не только при прямом к ней обращении, т.е. Session[name] = value, но и при наличии в Global.asax метода Session_Start даже если он пустой.

    ОтветитьУдалить
  14. Насчёт Сессии, да, Юрий, вы правы, я сталкивался с этим, и забыл просто вспомнить :)

    А насчёт что-то нового, я думаю ничего уже не поделаешь - серверную часть сильно менять нельзя. Сколько кода написано.

    А насчёт пхп - юзайте МВЦ

    ОтветитьУдалить
  15. >Если будет время и интерес читателей, напишу еще о сессии ASP.NET

    C удовольствием будем читать!

    ОтветитьУдалить
  16. To @Юрий

    Во-первых, спасибо за комментарий про Session_Start, статью дополнил.
    Во-вторых, MVC+Razor я считаю достаточно прогрессивными и если бы не Drupal и иже с ним (Orchard'у и прочим CMS под ASP.NET до них думаю пару лет еще пилить), то эта связка была бы по всем фронтам лучше PHP (если не принимать во внимание более дорогие в среднем цены за хостинг).

    ОтветитьУдалить
  17. Олег, все по делу! Хорошая статья! Ждем еще)))

    ОтветитьУдалить
  18. Я бы с удовольствием почитал про нестандартные провайдеры сессии и варианты отладки.
    Спасибо за статью!

    ОтветитьУдалить
  19. Хочу сделать акцент на то, что описанная здесь проблема очень актуальна в контексте разработки веб приложений на основе ajax запросов, которые несут с собой куки с id сессии. Да и вообще для любого типа клиента данного сервиса, будь то .NET HttpWebRequest или Java HttpURLConnection.
    Со временем данная проблема будет только обостряться, т.к. клиенты становятся все более асинхронными, а это вызывает желание выполнять одновременно сразу несколько обращений за данными к сервису.
    И как уже было отмечено, это касается всего стека ASP.NET приложений, где включена поддержка сессий: aspx, asmx, ashx, MVC, WCF и т.д. И если не предпринимать специальных мер, то "грабли в темном углу" так и останутся.

    ОтветитьУдалить
  20. Такая реализация сессии в ASP.NET (не важно WebForms или MVC) вполне обоснована. Обращение к глобальным ресурсам должно быть потокобезопасными.

    Если веб станет асинхронным это повлечет много проблем, и большинство программистов просто не поймут концепцию веба.

    ОтветитьУдалить
  21. To @Анонимный

    То что обоснована - не вопрос. Просто вещь, как показала практика, не очень очевидная.

    Моё личное мнение - лучше чтобы большинство программистов поняли концепцию веба, чем вводить искусственные ограничения ради тех, кто её не хочет понимать ;)

    ОтветитьУдалить
    Ответы
    1. Здравствуйте, Олег.
      У меня есть задачка, ну во всяком случае для меня задачка так как уже больше недели над ней сижу.Есть два контроллера, тобиш таблицы в БД, Автор и Книга. По нажатию на кнопку создать автора в появившемся окне есть форма для внесения данных об авторе и ссылка добавить книгу, нажав на которую появляется всплывающее окно в котором ввожу данные книги. После нажатия кнопки “Ок” данные книги не отправляются в базу, а вставляются в разметку сразу под полями ввода данных инфо об авторе. Таким образом можно посредством AJAX добавить несколько книг (массив). Тут трудностей нет. Но трудность появилась вот в чем. После ввода данных автора и отправка всего этого в базу. Для временного хранения я пытался использовать сессию, но данные сессии почему-то null в методе контроллера “Автор”.
      Есть две проблемы:
      1.Каким образом передать в сессию или в любое другое временное хранилище массив JSON?
      2.Ну и соответственно как их считать в методе контроллера для сохранения в БД?
      Буду очень признателен если Вы мне поможете в решении данной задачи. Если Вы мне пришлете свой e-mail, я сброшу свой проект. Свой e-mail я отправил в twitter.

      Удалить
    2. Я бы посоветовал накапливать данные на клиенте и отправлять когда всё готово - тогда сессия не понадобится.
      Передавать можно строкой, на сервере использовать Newtonsoft.Json.

      Удалить
  22. охуенная статья!

    ОтветитьУдалить
  23. За статью спасибо, помогла в одной проблеме. Но также могу добавить, что при условии обработки двух одновременных запросов с одной сессией сразу после старта приложения (когда срабатывает событие Application_Start) правило не работает. То есть запросы выполняются параллельно :(.
    Благо что вероятность такого случая где бы это было критично мала.

    ОтветитьУдалить
    Ответы
    1. А как это обойти? У меня как раз проблема с этим.

      Удалить
    2. хм, а как часто апликуха перезапускаеться?

      Удалить