C# 中的线程安全异步代码

Posted

技术标签:

【中文标题】C# 中的线程安全异步代码【英文标题】:Thread-safe asynchronous code in C# 【发布时间】:2011-04-06 07:52:06 【问题描述】:

几周前我问了以下问题。现在,在查看我的问题和所有答案时,一个非常重要的细节跳入了我的视野:在我的第二个代码示例中,DoTheCodeThatNeedsToRunAsynchronously() 不是在主(UI)线程中执行的吗?计时器不只是等待一秒钟然后将事件发布到主线程吗?这意味着需要异步运行的代码根本就不是异步运行的?!

原问题:


我最近多次遇到一个问题,并以不同的方式解决它,总是不确定它是否是线程安全的:我需要异步执行一段 C# 代码。 (编辑:我忘了说我使用的是 .NET 3.5!

那段代码作用于由主线程代码提供的对象。 (编辑:假设对象本身是线程安全的。)我将向您展示我尝试过的两种方法(简化)并有这四个问题

    实现我想要的最佳方式是什么?是这两种方法中的一种还是另一种方法? 两种方式之一是不是线程安全的(我担心两者都...)吗?为什么? 第一种方法创建一个线程并在构造函数中将对象传递给它。这就是我应该传递对象的方式吗? 第二种方法使用不提供这种可能性的计时器,所以我只使用匿名委托中的局部变量。这是安全的,还是理论上有可能变量中的引用在委托代码评估之前发生变化? (每当使用匿名代表时,这是一个非常通用的问题)。在 Java 中,您必须将局部变量声明为 final(即一旦分配就不能更改)。在 C# 中没有这种可能性,是吗?

方法一:线程

new Thread(new ParameterizedThreadStart(
    delegate(object parameter)
    
        Thread.Sleep(1000); // wait a second (for a specific reason)

        MyObject myObject = (MyObject)parameter;

        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();

    )).Start(this.MyObject);

这种方法有一个问题:我的主线程可能会崩溃,但由于僵尸线程,进程仍然存在于内存中。


方法 2:计时器

MyObject myObject = this.MyObject;

System.Timers.Timer timer = new System.Timers.Timer();
timer.Interval = 1000;
timer.AutoReset = false; // i.e. only run the timer once.
timer.Elapsed += new System.Timers.ElapsedEventHandler(
    delegate(object sender, System.Timers.ElapsedEventArgs e)
    
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
    );

DoSomeStuff();
myObject = that.MyObject; // hypothetical second assignment.

局部变量myObject 就是我在问题4 中所说的。我添加了第二个赋值作为示例。想象一下计时器在第二次分配之后过去,委托代码会在this.MyObjectthat.MyObject 上运行吗?

【问题讨论】:

在方法 2 的示例代码中,局部变量“myObject”永远不会重新分配,因此它永远不会在您的计时器代码主体中具有意外值。 @Kirk,很抱歉造成混乱。我添加了一行示例代码。错过它的原因是我实际上并没有在我的现实生活中重新分配它,但我想利用这个机会通过在我的示例中“嵌入”它来提出这个问题。 【参考方案1】:

这两段代码是否安全与MyObject 实例的结构有关。在这两种情况下,您都在前台和后台线程之间共享myObject 变量。在后台线程运行时,没有什么可以阻止前台线程修改myObject

这可能安全也可能不安全,取决于MyObject 的结构。但是,如果您没有专门为此计划,那么它肯定是不安全的操作。

【讨论】:

另外值得注意的是,myObject local 在第二种情况下是封闭的,这意味着对引用本身的赋值(大概是在匿名函数定义之后的后续代码行中添加的)代码执行时会被观察到。 嗨 LBushkin,您能详细说明一下吗? “封闭”和“被观察”是什么意思?我在示例代码中添加了一个假设的第二个任务。 你说得对!为了将我的问题缩小到这些代码部分,我们假设 MyObject 是线程安全的。【参考方案2】:

我建议使用Task 对象,并重组代码以便后台任务返回其计算值,而不是更改某些共享状态。

我有a blog entry 讨论了五种不同的后台任务方法(TaskBackgroundWorkerDelegate.BeginInvokeThreadPool.QueueUserWorkItemThread),并各有优缺点。

具体回答您的问题:

    实现我想要的最佳方式是什么?它是两种方法之一还是另一种方法? 最好的解决方案是使用Task 对象而不是特定的Thread 或计时器回调。请参阅我的博客文章了解所有原因,但总而言之:Task 支持 returning a result、callbacks on completion、proper error handling,并与 .NET 中的 universal cancellation system 集成。 这两种方法中的一种不是线程安全的吗(我担心两者都...),为什么? 正如其他人所说,这完全取决于MyObject.ChangeSomeProperty 是否是线程安全的。在处理异步系统时,当每个异步操作改变共享状态,而是返回一个结果时,更容易推理线程安全。 第一种方法创建一个线程并在构造函数中将对象传递给它。这就是我应该传递对象的方式吗? 就我个人而言,我更喜欢使用 lambda 绑定,它的类型更安全(无需强制转换)。 第二种方法使用不提供这种可能性的计时器,所以我只使用匿名委托中的局部变量。这是安全的吗?或者理论上变量中的引用在委托代码评估之前发生变化是可能的吗? Lambdas(和委托表达式)绑定到变量,而不是values,所以答案是肯定的:在委托使用之前,引用可能会发生变化。如果引用可能发生变化,那么通常的解决方案是创建一个单独的局部变量,仅由 lambda 表达式使用,

这样:

MyObject myObject = this.MyObject;
...
timer.AutoReset = false; // i.e. only run the timer once.
var localMyObject = myObject; // copy for lambda
timer.Elapsed += new System.Timers.ElapsedEventHandler(
  delegate(object sender, System.Timers.ElapsedEventArgs e)
  
    DoTheCodeThatNeedsToRunAsynchronously();
    localMyObject.ChangeSomeProperty();
  );
// Now myObject can change without affecting timer.Elapsed

ReSharper 等工具会尝试检测绑定在 lambda 中的局部变量是否可能发生变化,并在检测到这种情况时发出警告。

我推荐的解决方案(使用Task)如下所示:

var ui = TaskScheduler.FromCurrentSynchronizationContext();
var localMyObject = this.myObject;
Task.Factory.StartNew(() =>

  // Run asynchronously on a ThreadPool thread.
  Thread.Sleep(1000); // TODO: review if you *really* need this   

  return DoTheCodeThatNeedsToRunAsynchronously();   
).ContinueWith(task =>

  // Run on the UI thread when the ThreadPool thread returns a result.
  if (task.IsFaulted)
  
    // Do some error handling with task.Exception
  
  else
  
    localMyObject.ChangeSomeProperty(task.Result);
  
, ui);

请注意,由于 UI 线程是调用 MyObject.ChangeSomeProperty 的线程,因此该方法不必是线程安全的。当然,DoTheCodeThatNeedsToRunAsynchronously 仍然需要是线程安全的。

【讨论】:

+1:非常有见地和有用的答案,谢谢!我会阅读您的博客并考虑使用您所说的Task 哦,我明白了,Task 是 .NET 4 的一项功能。不幸的是我忘了提到我有.NET 3.5。还是很好的参考答案。 Task 适用于 .NET 3.5 here。 @Hi Stephen:很抱歉不接受你的回答,只是因为我有.NET 3.5,而Heinzi 的代码是我现在采用的。我已经阅读了您的评论,但对于我的情况来说,使用已有的东西是最简单的方法。如果可以的话,我会加倍支持你的答案 ;-) 所以它会出现在 Heinzi 的答案之后,作为所有可能仍然对 Task 事物感兴趣的未来读者的参考。【参考方案3】:

“线程安全”是一个棘手的野兽。对于您的两种方法,问题在于您的线程正在使用的“MyObject”可能会被多个线程修改/读取,从而使状态看起来不一致,或者使您的线程的行为与实际状态不一致。

例如,假设您的 MyObject.ChangeSomeproperty() 必须在 MyObject.DoSomethingElse() 之前调用,否则它会抛出异常。使用您的任何一种方法,在将调用 ChangeSomeProperty() 的线程完成之前,没有什么可以阻止任何其他线程调用 DoSomethingElse()

或者,如果 ChangeSomeProperty() 恰好被两个线程调用,并且它(内部)改变了状态,线程上下文切换可能发生在第一个线程正在工作的中间,最终结果是实际的两个线程之后的新状态都是“错误的”。

但是,就其本身而言,您的两种方法本身都不是线程不安全的,它们只需要确保更改状态是序列化的,并且访问状态始终给出一致的结果。

就个人而言,我不会使用第二种方法。如果您在使用“僵尸”线程时遇到问题,请将线程上的 IsBackground 设置为 true。

【讨论】:

【参考方案4】:

您的第一次尝试非常好,但是即使在应用程序退出后线程仍然存在,因为您没有将 IsBackground 属性设置为 true... 这是一个简化(和改进)的版本你的代码:

MyObject myObject = this.MyObject;
Thread t = new Thread(()=>
    
        Thread.Sleep(1000); // wait a second (for a specific reason)
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
    );
t.IsBackground = true;
t.Start();

关于线程安全:当多个线程同时执行时,很难判断您的程序是否正常运行,因为您在示例中没有向我们展示任何争论点。如果您的程序在 MyObject 上存在争用,则非常可能会遇到并发问题。

Java 有final 关键字,C# 有一个对应的关键字readonly,但finalreadonly 都不能确保您正在修改的对象的状态在线程之间保持一致。这些关键字唯一要做的就是确保您不会更改对象指向的引用。如果两个线程在同一个对象上存在读/写争用,那么您应该对该对象执行某种类型的同步或原子操作以确保线程安全。

更新

好的,如果您修改myObject 指向的引用,那么您的争用现在在myObject 上。我确信我的回答不会 100% 符合您的实际情况,但是鉴于您提供的示例代码,我可以告诉您会发生什么:

您将保证修改了哪个对象:它可以是that.MyObjectthis.MyObject。无论您使用的是 Java 还是 C#,这都是正确的。调度程序可以安排您的线程/计时器在第二次分配之前、之后或期间执行。如果您指望特定的执行顺序,那么您必须做一些事情来确保执行顺序。通常,something 是线程之间以信号形式进行的通信:ManualResetEventJoin 或其他东西。

这是一个连接示例:

MyObject myObject = this.MyObject;
Thread task = new Thread(()=>
    
        Thread.Sleep(1000); // wait a second (for a specific reason)
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
    );
task.IsBackground = true;
task.Start();
task.Join(); // blocks the main thread until the task thread is finished
myObject = that.MyObject; // the assignment will happen after the task is complete

这是一个ManualResetEvent 示例:

ManualResetEvent done = new ManualResetEvent(false);
MyObject myObject = this.MyObject;
Thread task = new Thread(()=>
    
        Thread.Sleep(1000); // wait a second (for a specific reason)
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
        done.Set();
    );
task.IsBackground = true;
task.Start();
done.WaitOne(); // blocks the main thread until the task thread signals it's done
myObject = that.MyObject; // the assignment will happen after the task is done

当然,在这种情况下,甚至生成多个线程都是没有意义的,因为您不会允许它们同时运行。避免这种情况的一种方法是在启动线程后不更改对myObject 的引用,这样就不需要ManualResetEvent 上的JoinWaitOne

所以这引出了一个问题:你为什么要为myObject 分配一个新对象?这是启动多个线程以执行多个异步任务的 for 循环的一部分吗?

【讨论】:

(删除了我的评论——没有看到他关于“僵尸线程”的问题) 感谢IsBackground 的提示。关于变量,我确实是指更改 reference,因此按照您的回答,我可以将局部变量声明为 readonly 并防止引用像在 Java 中一样被更改,可以吗? 我发现readonly不能用于局部变量,另见:***.com/questions/443687/… @chiccodoro,啊,是的...我忘了说虽然 readonly 和 final 关键字做类似的事情,但在某些情况下它们仍然不同:readonly 不能用于局部变量并且不能用在方法前面。 谢谢。是的,Java 和 C# 之间存在区别:在 Java 中,您可以将变量声明为“final”,以确保它(引用)永远不会改变。事实上,如果你想在匿名内部类代码中使用它(它或多或少对应于 C# 中的匿名委托),你甚至不得不这样做。 - 你为什么要重新分配的问题:我不想,但我(特别是)对意外重新分配会发生什么以及如何防止这种情况感兴趣。【参考方案5】:

实现我想要的最佳方式是什么?是两种方法中的一种还是另一种方法?

看起来都不错,但是...

这两种方法中的一种是否不是线程安全的(我担心两者都...),为什么?

...它们不是线程安全的除非MyObject.ChangeSomeProperty() 是线程安全的。

第一种方法创建一个线程并将构造函数中的对象传递给它。这就是我应该传递对象的方式吗?

是的。使用闭包(如您的第二种方法)也很好,还有一个额外的好处是您不需要进行强制转换。

第二种方法使用不提供这种可能性的计时器,所以我只使用匿名委托中的局部变量。这是安全的,还是理论上有可能变量中的引用在委托代码评估之前发生变化? (每当使用匿名委托时,这是一个非常通用的问题)。

当然,如果你在设置timer.Elapsed之后直接添加myObject = null;,那么你线程中的代码会失败。但是你为什么要这样做呢?请注意,更改this.MyObject 不会影响线程中捕获的变量。


那么,如何使这个线程安全?问题是myObject.ChangeSomeProperty(); 可能与修改myObject 状态的其他代码并行运行。基本上有两种解决方案:

选项 1:在主 UI 标题中执行 myObject.ChangeSomeProperty()。如果ChangeSomeProperty 很快,这是最简单的解决方案。您可以使用Dispatcher (WPF) 或Control.Invoke (WinForms) 跳回UI线程,但最简单的方法是使用BackgroundWorker

MyObject myObject = this.MyObject;
var bw = new BackgroundWorker();

bw.DoWork += (sender, args) => 
    // this will happen in a separate thread
    Thread.Sleep(1000);
    DoTheCodeThatNeedsToRunAsynchronously();


bw.RunWorkerCompleted += (sender, args) => 
    // We are back in the UI thread here.

    if (args.Error != null)  // if an exception occurred during DoWork,
        MessageBox.Show(args.Error.ToString());  // do your error handling here
    else
        myObject.ChangeSomeProperty();


bw.RunWorkerAsync(); // start the background worker

选项 2:使用 lock 关键字使 ChangeSomeProperty() 中的代码线程安全(在 ChangeSomeProperty 内部以及在修改或读取相同支持字段的任何其他方法内部) .

【讨论】:

感谢 BackgroundWorker 示例,因为我似乎无法在 3.5 中使用 Task【参考方案6】:

在我看来,这里更大的线程安全问题可能是 1 秒睡眠。如果需要这样做以与其他操作同步(给它时间完成),那么我强烈建议使用适当的同步模式而不是依赖睡眠。 Monitor.Pulse 或AutoResetEvent 是实现同步的两种常用方式。两者都应该小心使用,因为很容易引入微妙的竞争条件。但是,使用 Sleep 进行同步是等待发生的竞争条件。

此外,如果您想使用线程(并且无法访问 .NET 4.0 中的任务并行库),那么 ThreadPool.QueueUserWorkItem 更适合短期运行的任务。如果应用程序死亡,线程池线程也不会挂起应用程序,只要不存在阻止非后台线程死亡的死锁。

【讨论】:

QueueUserWorkItem 听起来很有趣。对于同步,我还从您的回答中学到了一些新东西。只是,在我的情况下,我实际上必须等待 Excel 发布文件。我想在提到“Excel”这个词之后,就没有必要扩展同步了 :-) / :-(【参考方案7】:

到目前为止没有提到的一件事:线程方法的选择在很大程度上取决于 DoTheCodeThatNeedsToRunAsynchronously() 的具体作用。

不同的 .NET 线程方法适用于不同的需求。一个非常重要的问题是此方法是否会快速完成,还是需要一些时间(它是短暂的还是长期运行的?)。

一些 .NET 线程机制,例如 ThreadPool.QueueUserWorkItem(),供短期线程使用。它们通过使用“回收的”线程避免了创建线程的费用——但是它将回收的线程数量是有限的,因此长时间运行的任务不应该占用 ThreadPool 的线程。

其他要考虑的选项正在使用:

ThreadPool.QueueUserWorkItem() 是一种在 ThreadPool 线程上触发并忘记小任务的便捷方法

System.Threading.Tasks.Task 是 .NET 4 中的一项新功能,它使小型任务可以轻松地在异步/并行模式下运行。

Delegate.BeginInvoke()Delegate.EndInvoke()BeginInvoke() 将异步运行代码,但确保同时调用 EndInvoke() 以避免潜在的资源泄漏至关重要。它也基于 ThreadPool我相信线程。

System.Threading.Thread 如您的示例所示。线程提供最多的控制,但也比其他方法更昂贵 - 因此它们非常适合长时间运行的任务或面向细节的多线程。

总的来说,我个人的偏好是使用Delegate.BeginInvoke()/EndInvoke()——它似乎在控制和易用性之间取得了很好的平衡。

【讨论】:

Task 经过精心设计以取代所有这些。长时间运行的任务只需使用TaskCreationOptions.LongRunning。我最近写了a blog post 比较了所有这些方法。 Task 是明显的赢家,除了BackgroundWorker 更简单的少数情况。 QueueUserWorkItemThread 甚至没有获得荣誉奖。 ;)

以上是关于C# 中的线程安全异步代码的主要内容,如果未能解决你的问题,请参考以下文章

异步代码、共享变量、线程池线程和线程安全

使用 SerialPort 和 C# 中的线程“安全句柄已关闭”

可重入,异步信息安全,线程安全

当我从 C# 代码调用 C++ 代码时,它是线程安全的吗?

C++ 中的异步线程安全日志记录(无互斥体)

linux可重入异步信号安全和线程安全