29 апр. 2010 г.

Контекст синхронизации потоков WCF

Автор: Aaron Murrell
 Не может быть большего разочарования, чем потратить время на разработку программы и юнит-тестов только для того, что бы в последствии, уже на этапе интеграции, выяснить, что все, что прекрасно работало в NUnit и VSTS - в действительности не работает в контексте большой системы. Я думаю, не стоит говорить, что будет дальше. Ага... я пострадал от этого.

 Для начала мораль истории: не дайте контексту синхронизации потоков WCF схватить Вас за задницу; ничто не сможет заменить правильную книгу, а часто и знания, которые можно получить при прочтении такой книги.
 Вот сценарий, о котором в дальнейшем пойдет речь:
Состав:
  • Консольное приложение, в котором хостится WCF сервис (с таким же успехом это может быть хост WAS или хост Windows-сервис)
  • Клиентское приложение Windows Forms
  • WCF сервис с обратным вызовом
Схема:


 Внутри клиента Winforms я получаю ссылку на мой прокси-сервис и передаю ему экземпляр класса, который реализует контракт обратного вызова службы (часто бывает, что это сама форма) ... ничего особенного. Выглядит это примерно так:


public partial class Form1 : Form, ICalculatorServiceCallback
{
    ICalculatorService proxy;
    public Form1()
    {
        InitializeComponent();
        System.ServiceModel.Channels.Binding binding =
            new NetTcpBinding(SecurityMode.Message, true);
        EndpointAddress address =
            new EndpointAddress(@"net.tcp://localhost:4000/CalculatorService");
        InstanceContext context = new InstanceContext(this);
        DuplexChannelFactory factory =
            new DuplexChannelFactory (context, binding, address);
        proxy = factory.CreateChannel();
    }

    private void Form1_Load( sender, EventArgs e) { }

 Далee идет реализация контракта обратного вызова:


    #region ICalculatorServiceCallback Members
    public void AddInvoked()
    {
        System.Diagnostics.Debug.WriteLine("Yo!");
    }
    #endregion

 и обработчик нажатия кнопки, вызывающий метод прокси:


    private void button1_Click(object sender, EventArgs e)
    {       
        proxy.Add(23, 44);
    }

 Что касается программы на стороне сервиса, то это типичное консольное приложение, инициализирующее сервис и императивно устанавливающее синглтон-объект типа CalculatorService, который реализует контракт ICalculatorServiceContract:


class Program
{
    static void Main(string[] args)
    {
        ICalculatorService singletonCalculator = 
            new CalculatorService();
            
        ServiceHost calculatorHost = 
            new ServiceHost(singletonCalculator);

        NetTcpBinding binding = 
            new NetTcpBinding(SecurityMode.Message, true);
        Uri address = 
            new Uri(@"net.tcp://localhost:4000/CalculatorService");

        calculatorHost.AddServiceEndpoint(
            typeof(ICalculatorService), binding, address);

        calculatorHost.Open();
        Console.WriteLine(calculatorHost.State);
        Console.WriteLine
            ("CalculatorService started, press any ENTER to close...");

        Console.ReadLine();
        calculatorHost.Close();
    }
}

 Что касается сервиса, то, как Вы могли заметить, он выглядит достаточно неплохо. Важным является то, что сервис помечен атрибутом ServiceBehavior, устанавливающим InstanceContextMode в InstanceContextMode.Single, что делает наш сервис синглтоном:


[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)]
public class CalculatorService : ICalculatorService
{

    #region ICalculatorService Members
    private Dictionary memory =
        new Dictionary();

    public void Add(params float[] nums)
    {
        Console.WriteLine("The Add Method was invoked by {0}", 
        System.Threading.Thread.CurrentPrincipal.Identity.Name);
        DoCallback(nums);
    }

    private void DoCallback(float[] nums)
    {
        ICalculatorServiceCallback callback = 
        OperationContext.Current.GetCallbackChannel();
        callback.AddInvoked();
    }

 Пока ничего интересного, не так ли? На самом деле, я не собираюсь добавлять в него больше ни какой логики. Это все. Теперь пора перейти к вещам не столь очевидным. Побочным симптомам, которые появляются в Winforms приложении. Метод CalculatorService.Add вызывается по событию нажатия кнопки, после чего приложение зависает. В конце концов я получаю сообщение о превышении времени ожидания от клиентского прокси, и сразу после этого отрабатывает обратный вызов. Мистика. Взаимосвязь событий отследить сложно, так как прямой вызов отрабатывает и обратный вызов тоже. Чем дольше я думал над происходящим, тем больше убеждался, что проблема во взаимоблокировке потоков. Нужно заметить, что все это прекрасно работает в моем тестовом окружении, когда хост и клиент выполняются в одном процессе. Тут я вспомнил, что недавно читал про режимы конкуренции и мне показалось, что проблемы могут быть с настройкой ConcurencyMode по умолчанию. В двух словах,- Вы можете указать WCF, каким образом он должен распределять входящие запросы по потокам. Это делается с помощью установки свойства ConcurencyMode атрибута ServiceBehavior Вашего сервиса. Значение по умолчанию ConcurencyMode.Single.

 ConcurencyMode.Single указывает WCF о том, что необходимо блокировать конкурентный доступ к сервису до одного потока одновременно. Входящие вызовы помещаются в очередь и выполняются последовательно в порядке поступления. Вызовы, которые ожидают в очереди дольше определенного времени, вызывают ошибку превышения времени ожидания на стороне клиента.

 Так же доступны ConcurencyMode.Multiple и ConcurencyMode.Reentrant.

 Преимущество ConcurencyMode.Singe (а я уверен, что именно поэтому он и выбран значением по умолчанию) в том, что он потокобезопасен по свой природе. Как разработчику сервиса, Вам не придется думать о блокировках и конкурентном доступе к ресурсам, поскольку все это обеспечивается на уровне WCF. Если Вы хотите управлять всем вручную, то можете выбрать ConcurencyMode.Multiple. ConcurencyMode.Reentrant - это уникальный режим и мы его сейчас рассмотрим.

 Теперь, если Вы собираетесь вызывать клиента во время выполнения операции сервиса, работающего в режиме ConcurencyMode.Singe, то этот вызов работать не будет. При попытке выполнить обратный вызов, WCF обнаруживает, что ресурсы, которые необходимы для его выполнения, уже заняты. Он ждет освобождения этих ресурсов, что приводит к взаимоблокировке процессов (deadlock). Это именно то, что происходит. Решение? Решение простое - использовать режим ConcurencyMode.Reentrant. Этот режим дает WCF право обойти существующую блокировку на стороне сервиса и выполнить обратный вызов. К моему удивлению, это не решило проблему блокировки на стороне клиента Windows Forms. На самом деле, я не заметил ни какой разницы вообще.

 Только проведя несколько часов за чтением книги Юваля Леви "Программирование WCF сервисов", я понял, что ключ к решению проблемы в использовании класса SynchronizationContext на стороне клиента.

 SynchronizationContext появился в .Net 2.0 и дает нам возможность управлять процессом синхронизации потоков. Что это означает? Один из вариантов использования - получение гарантии того, что доступ к ресурсам окна осуществляется не более чем из одного потока - потока UI. Используя контекст синхронизации я могу быть уверен в том, что .Net переключит поток выполнения на тот поток, в котором обслуживается UI.

 В WCF есть несколько механизмов управления поведением SynchronizationContext для обеспечения различных потребностей сервисов, но нас интересует один, который касается взаимодействия потока, который выполняет вызов удаленного сервиса и потока, который выполняет обратный вызов со стороны сервиса. По умолчанию обратный вызов использует контекст синхронизации потока, который его создал. В нашем случае, поток UI создал объект для обратного вызова и выполнение обратного вызова должно выполняться в потоке UI. Это создает проблему, так как диспетчер потока UI ожидает завершения выполнения метода Add. Обратный вызов не может быть выполнен до тех пор, пока поток UI не освободится. Именно поэтому он происходит сразу после того, как мы получаем сообщение о превышении времени ожидания для вызова метода Add.

 Что бы изменить это поведение, надо установить параметр UseSynchronizationContext атрибута CallbackBehavior в false, указав, что обратный вызов не должен быть связан с потоком, который создал объект обратного вызова.

 Исправленная часть нашего неработающего кода должна выглядеть так:

 Сначала меняем ConcurrencyMode на Reentrant:


[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single, 
                        ConcurrencyMode=ConcurrencyMode.Reentrant)]
public class CalculatorService : ICalculatorService
{
...

 Затем изменяем атрибут CallbackBehavior объекта реализации обратного вызова нашего сервиса:


[CallbackBehavior(UseSynchronizationContext=false)]
    public partial class Form1 : Form, ICalculatorServiceCallback
    {
        ICalculatorService proxy;
        public Form1()
        {
            InitializeComponent();
...

 Я надеюсь, что это поможет Вам сохранить много времени и нервов.


Источник: www.aaronmurrell.com
Перевод: Роман Голубин

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

  1. Спасибо! Статья помогла разобраться с дэдлоками. Выставлял ConcurrencyMode в Reentrant, а про UseSynchronizationContext забыл. Теперь все в поряде.

    ОтветитьУдалить
  2. Респект за:
    [CallbackBehavior(UseSynchronizationContext=false)]

    ОтветитьУдалить
  3. Спасибо автору! Сберегло очень много времени!

    ОтветитьУдалить
  4. 3 часа пытался решить аналогичную проблему, статья помогла!
    Спасибо автору огромное!

    ОтветитьУдалить
  5. Спасибо! Помогло

    ОтветитьУдалить