вторник, 17 марта 2009 г.

Многопоточность в Windows Forms. Control.Invoke().

Человек даже немного поработав с Windows Forms наверняка сталкивался с замечательным исключением System.InvalidOperationException с таким описанием: {"Cross-thread operation not valid: Control 'textBox1' accessed from a thread other than the thread it was created on."} Этим сообщением среда выполнения недвусмысленно дает понять, что обращаться к элементам управления пользовательского интерфейса можно только из того потока, который создал этот элемент управления. Такое поведение обусловлено деталями реализации, в частности использованием однопоточной модели аппартаментов (Single-threaded Apartment, STA) и механизма обработки оконных сообщений. Вот простенький пример кода, который приводит к генерации такого исключения:

public partial class Form1 : Form

{

    public Form1()

    {

        InitializeComponent();

        //на форме расположен TextBox и Button.

        //В TextBox будет выводиться количество сработок таймера,

        //таймер запускается по нажатию на кнопку Button

        timer = new Timer(AsyncHandler);

    }

 

    private void button1_Click(object sender, EventArgs e)

    {

        timer.Change(1000, 1000);

    }

    private void AsyncHandler(object data)

    {

        tickCount++;

        textBox1.Text = tickCount.ToString();

    }

 

    private readonly System.Threading.Timer timer;

    private int tickCount;

}

Существует вполне простой и понятный способ решения этой проблемы, путем проверки свойства элемента управления InvokeRequired с последующим вызовом метода Invoke.
Вот самый простой и примитивный способ:

private void AsyncHandler(object data)

{

    tickCount++;

    Action<int> action = DoChangeTicks;

    if (InvokeRequired)

    {

        Invoke(action, tickCount);

    }

    else

    {

        action(tickCount);

    }

 

}

private void DoChangeTicks(int count)

{

    textBox1.Text = tickCount.ToString();

}

Способ простой, но не совсем удобный. В данном случае вспомогательный метод только увеличивает связность и усложняет понимает решаемой задачи. Если воспользоваться новшествами C#3.0 в виде лямбда-выражений, можно сделать несколько более удобную реализацию следующего вида:

private void AsyncHandler(object data)

{

    tickCount++;

    Action action = () => textBox1.Text = tickCount.ToString();

    if (InvokeRequired)

    {

        Invoke(action);

    }

    else

    {

        action();

    }

}

Уже лучше. Мы избавились от необязательного метода, который неразрывно связан с тем действием, которое выполняет метод AsyncHandler. Но, все же, еще есть над чем подумать.
Следующий вариант решения был честно подсмотрен в неплохой книжке: Bill Wagner "More Effective C#".

/// <summary>

/// Расширения облегчающие работу с элементами управления в многопоточной среде.

/// </summary>

public static class ControlExtentions

{

    /// <summary>

    /// Вызов делегата через control.Invoke, если это необходимо.

    /// </summary>

    /// <param name="control">Элемент управления</param>

    /// <param name="doit">Делегат с некоторым действием</param>

    public static void InvokeIfNeeded(this Control control, Action doit)

    {

        if (control.InvokeRequired)

            control.Invoke(doit);

        else

            doit();

    }

    /// <summary>

    /// Вызов делегата через control.Invoke, если это необходимо.

    /// </summary>

    /// <typeparam name="T">Тип параметра делегата</typeparam>

    /// <param name="control">Элемент управления</param>

    /// <param name="doit">Делегат с некоторым действием</param>

    /// <param name="arg">Аргумент делагата с действием</param>

    public static void InvokeIfNeeded<T>(this Control control, Action<T> doit, T arg)

    {

        if (control.InvokeRequired)

            control.Invoke(doit, arg);

        else

            doit(arg);

    }

}

С помощью этого вспомогательного класса, реализация метода, взаимодействующего с элементами управления из других потоков, будет следующей:

private void AsyncHandler(object data)

{

    tickCount++;

    this.InvokeIfNeeded(

        () => textBox1.Text = tickCount.ToString()

            );

}

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

  1. Прикольно! Блин, как много нужно прочитать...

    ОтветитьУдалить
  2. Хорошей альтернативой которую можна использовать не только в Windows Forms а всюда где надо маршалить вызовы между потоками будет SynchronizationContext .

    ОтветитьУдалить
  3. Конечно, согласен. Но, как и во многих других вопросах, специализированные решения оказываются более простые в рамках одной задачи.
    Здесь тоже самое. Способ, описанный выше - один из самых удобных способов работы с многопоточностью в Windows Forms, но при этом он не универсален.
    Хотя знание общих подходов и принципов также весьма полезно.

    ОтветитьУдалить
  4. Боюсь, что для того чтобы использовать SynchronizationContext в других сферах кроме WinForms и WPF придется изрядно попотеть, т.к. сам по себе класс SynchronizationContext не предоставляет нужную функциональность для маршалинга вызовов в определенный поток. В случае Send() он использует ThreadPool.QueueUserWorkItem, а в случае Post() вызывает метод в том же потоке. Поэтому если хочется иметь возможность делать маршалинг вызова в определенный поток, то придется этот механизм реализовывать вручную. В .net есть только 2 готовых реализации - для WinForms (WindowsFormsSynchronizationContext) и для WPF (DispatcherSynchronizationContext). Кстати, WindowsFormsSynchronizationContext реализован именно через механизм Control.BeginInvoke.

    ОтветитьУдалить
  5. Как вариант, мне больше нравится:
    this.Invoke(new EventHandler(delegate
    {
    textBox1.Text = tickCount.ToString()
    }));
    Подсмотрено на http://www.codeproject.com/KB/threads/ThreadingDotNet2.aspx

    Из плюсов нет необходимости в дополнительных классах а логика программы так-же прозрачна

    ОтветитьУдалить
  6. Ну, твой код ничем не отличается от приведенного:
    Action action = () => textBox1.Text = tickCount.ToString();
    if (InvokeRequired)
    {
    Invoke(action);
    }
    else
    {
    action();
    }
    Только используется синтаксис анонимных делегатов из C# 2.0, а этот вариант использует синтаксис C# 3.0.
    Причем, если рассмотреть приведенный мною вариант без проверки на InvokeRequired, то код будет таким:

    Action action = () => textBox1.Text = tickCount.ToString();
    textBox.Invoke(action);

    Что, как по мне, короче, понятнее и красивее:)

    2Денис: чем тебе не нравится новый синтаксис анонимных делегатов в C#3.0?

    ОтветитьУдалить
  7. "чем тебе не нравится новый синтаксис анонимных делегатов в C#3.0?"

    Тем, что это синтаксис 3.0 ! FrameWork 3.0 не поддерживается на Windows 2000, поэтому приходится писать на FrameWork 2.0

    ОтветитьУдалить
  8. 2Drakosha: а откуда Вы знаете под какую винду Денис пишет код?
    Я же не говорил абстрактно, я задавал вопрос конкретному человеку, который, насколько я помню пишет именно под .net 3.5 :)

    ОтветитьУдалить
  9. У меня вопрос. Уже во втором посте я вижу, что вы пытаетесь заменить отдельные функции лямбдами. Я же читал (честно - не помню где), что более "православно" разделять функционал на как можно более мелкие функции и стремиться к тому, чтобы код фукции влазил на один экран. Лично мне тоже проще понять, что происходит в фукции, если она маленькая.

    Учитывая это, могли бы вы объяснить свой подход?
    Спасибо.

    ОтветитьУдалить
  10. Так как Control.InvokeRequired возвращает false не только при вызове из контекста GUI, но и если у контрола не создан дескриптор, не лучше ли писать как-то так:

    if(control.InvokeRequired)
    {
    control.Invoke(doit);
    }
    else if(!control.IsHandleCreated)
    {
    throw new InvalidOperationException();
    }
    else
    {
    doit();
    }

    Или я не прав?

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