65.9K
CodeProject 正在变化。 阅读更多。
Home

使 Windows Forms 线程安全

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (15投票s)

2005年2月15日

5分钟阅读

viewsIcon

154659

downloadIcon

1733

旨在简化多线程 Windows Forms 应用程序。

引言

编写 Windows Forms 用户界面相当直接,只要你不使用多线程。但是,当你的应用程序需要执行一些实际工作时,为了确保 UI 的响应能力,使用多线程就变得必要了。这时,Windows Forms 编程就会变得相当复杂。

问题所在

你知道,Windows Forms 通常不是线程安全的。例如,除了处理消息队列的线程之外,从任何其他线程获取或设置 Windows.Forms 控件的属性都是不安全的。绝对有必要只从消息队列线程修改你的 Windows Forms 控件。

标准解决方案

当然,有一个处理这种情况的机制。每个 Windows Forms 控件都有 InvokeRequired 属性,如果当前线程是消息队列线程,它会返回 false。还有 Invoke 方法,它使得将带有参数的委托排队到控件的消息队列成为可能。

由于委托直接从消息队列调用,不会出现线程问题。但这种编程风格可能非常繁琐。仅仅是为了设置文本属性或启用/禁用控件这样的简单操作,你就必须定义一个具有匹配委托的单独方法。

示例:随机字符串

为了说明这种方法,我编写了一个生成随机字符串的简单 Windows Forms 程序。这是代码的一个片段,展示了工作线程和消息循环线程之间的同步是如何完成的。

char PickRandomChar(string digits) 
{ 
    Thread.Sleep(100); 
    return digits[random.Next(digits.Length)]; 
} 
delegate void SetBoolDelegate(bool parameter); 
void SetInputEnabled(bool enabled)
{
    if(!InvokeRequired)
    {
        button1.Enabled=enabled; 
        comboBoxDigits.Enabled=enabled; 
        numericUpDownDigits.Enabled=enabled;
    } 
    else 
        Invoke(new SetBoolDelegate(SetInputEnabled),new object[] {enabled}); 
} 
delegate void SetStringDelegate(string parameter);
void SetStatus(string status) { 
    if(!InvokeRequired)
        labelStatus.Text=status; 
    else 
        Invoke(new SetStringDelegate(SetStatus),new object[] {status});
} 
void SetResult(string result) {
    if(!InvokeRequired)
        textBoxResult.Text=result; 
    else
        Invoke(new SetStringDelegate(SetResult),new object[] {result});
} 
delegate int GetIntDelegate(); 
int GetNumberOfDigits()
{
    if(!InvokeRequired) 
        return (int)numericUpDownDigits.Value;
    else 
        return (int)Invoke(new GetIntDelegate(GetNumberOfDigits),null);
} 
delegate string GetStringDelegate(); 
string GetDigits()
{
    if(!InvokeRequired) 
        return comboBoxDigits.Text; 
    else
        return (string)Invoke(new GetStringDelegate(GetDigits),null);
}
void Work() 
{
    try 
    {
        SetInputEnabled(false);
        SetStatus("Working");        
        int n=GetNumberOfDigits();
        string digits=GetDigits();
        StringBuilder text=new StringBuilder();
        for(int i=0;i!=n;i++)
        {
            text.Append(PickRandomChar(digits));
            SetResult(text.ToString());
        }
        SetStatus("Ready");
    }
    catch(ThreadAbortException) 
    {
        SetResult("");
        SetStatus("Error");
    }
    finally 
    {
        SetInputEnabled(true);
    }
}
void Start() 
{
    Stop();
    thread=new Thread(new ThreadStart(Work));
    thread.Start();
}
void Stop() 
{
    if(thread!=null) 
    {
        thread.Abort();
        thread=null;
    }
}

我使用了 Thread.Abort,因为在这种情况下它是最简单的解决方案。如果你做的事情在任何情况下都不应该中断,你应该用一个标志来信号化线程。

这有很多简单但非常重复的代码。请注意,你总是必须检查 InvokeRequired,因为在消息队列创建之前调用 Invoke 可能导致错误。

生成线程安全的包装器

一篇之前的文章中,我展示了如何自动创建类的包装器,使其“隐式”实现接口。相同的代码生成方法可以扩展到创建自动确保方法在正确线程中调用的包装器。

我将在稍后详细介绍整个过程。首先,让我们看看这个机制是如何使用的。

首先,你在窗体中公开相关属性,而不考虑线程问题。即使你不使用多线程,这可能也是你想做的事情。

public bool InputEnabled
{
    set
    { 
        button1.Enabled=value; 
        comboBoxDigits.Enabled=value; 
        numericUpDownDigits.Enabled=value;
    } 
}
public string Status 
{
    set { labelStatus.Text=value;} 
}
public int NumberOfDigits
{
    get { return numericUpDownDigits.Value; }
}
public string Digits 
{
    get { return comboBoxDigits.Text; }
}
public string Result 
{
    set { textBoxResult.Text=value; }
}

然后,你定义一个接口,其中包含你可能想从不同线程访问的所有属性和/或方法。

interface IFormState
{
    int NumberOfDigits { get; } 
    string Digits { get; }
    string Status { set; }
    string Result { set; }
    bool InputEnabled { set; }
}

现在,在工作方法中,你只需要创建一个线程安全的包装器并使用它。所有重复的代码都将为你生成。

void Work() 
{
    IFormState state=Wrapper.Create(typeof(IFormState),this);
    try 
    {
        state.InputEnabled=false;
        state.Status="Working";
        int n=state.NumberOfDigits;
        string digits=state.Digits;
        StringBuilder text=new StringBuilder();

        for(int i=0;i<n;i++) 
        {
            text.Append(PickRandomChar(digits));
            state.Result=text.ToString();
        }   
        state.Status="Ready";
    }
    catch(ThreadAbortException) 
    {
        state.Status="Error";
        state.Result=""; 
    }
    finally
    {
        state.InputEnabled=true;
    }
}

工作原理

包装器生成器使用 System.Reflection.Emit 生成一个代理类,其中包含接口所需的所有方法。这还包括具有特殊签名的属性访问器方法。

如果 InvokeRequired 返回 false,这些方法的正文将直接调用原始方法。这很重要,可以确保即使窗体尚未附加到消息处理线程,调用这些方法也能正常工作。

如果 InvokeRequired 返回 true,将创建一个指向原始方法的委托,并将其传递给窗体的 Invoke 方法。委托类型会被缓存,这样你就不会为相同的签名获得多个委托类型。

由于包装器生成器使用 ISynchronizeInvoke 接口进行同步调用,因此你也可以在非 Windows-Forms 应用程序中使用它。你所要做的就是实现该接口,并可能自己实现某种消息队列。

限制和注意事项

重要的是要理解,虽然线程安全的包装器隐藏了线程同步的开销,但它并没有消除它。因此,如果 InvokeRequiredtrue,使用线程安全包装器访问属性会比直接访问慢得多。所以,如果你需要从另一个线程对窗体进行多个复杂的更改,最好将它们全部在一个方法中完成,而不是使用单独的属性访问器调用。

另一件需要记住的事情是,并非所有类型的对象都可以安全地在没有同步的情况下从一个线程传递到另一个线程。通常,只有值类型(如 intDateTime 等)和不可变引用类型(如 string)才能安全传递。你必须非常小心地将可变引用类型(如 StringBuilder)从一个线程传递到另一个线程。如果你确定该对象在不同线程中存在引用时不会被修改,或者该对象是线程安全的,那么这样做是可以的。如果不确定,只需传递一个深层副本而不是引用。

Whidbey

Whidbey 将使多线程问题更容易,因为它对 Windows Forms 中的后台处理提供了额外支持,特别是通过支持匿名方法(它们实际上是闭包),这使得使用委托变得更加容易。

在 Whidbey 中设置属性更加简单

Invoke(delegate { labelStatus.Text="Working"; });

获取属性也一样

int n=(int)Invoke(delegate { return numericUpDownDigits.Value; });

不幸的是,Whidbey 可能会在 2025 年与《永远的毁灭公爵》一起发布。

参考文献

© . All rights reserved.