如何为一个实现了IDisposable接口的对象添加线程安全性?

39

假设有一个实现IDisposable接口并具有一些公共方法的类。

如果该类型的实例在多个线程之间共享,而其中一个线程可能会处理它,那么保证其他线程在已处理后不再尝试使用该实例的最佳方式是什么? 在大多数情况下,在对象被处理后,它的方法必须意识到这一点并抛出ObjectDisposedException或者可能是InvalidOperationException,或者至少通知调用代码执行了错误的操作。对于每个方法,特别是在检查它是否被处理时,我需要同步吗? 所有带有其他公共方法的IDisposable实现都需要是线程安全的吗?


下面是一个例子:

public class DummyDisposable : IDisposable
{
    private bool _disposed = false;

    public void Dispose()
    {
        _disposed = true;
        // actual dispose logic
    }

    public void DoSomething()
    {
        // maybe synchronize around the if block?
        if (_disposed)
        {
            throw new ObjectDisposedException("The current instance has been disposed!");
        }

        // DoSomething logic
    }

    public void DoSomethingElse()
    {
         // Same sync logic as in DoSomething() again?
    }
}

3
不,我不是,但既然你提到了,据我记得Finalize()是从一个独立的GC线程中调用的。然而,只有当没有存活的引用对象时才会调用Finalize(),因此线程安全性不应该是一个考虑因素。在MS最佳实践中,建议在Finalize()内部调用Dispose,或者至少使用几乎相同的逻辑 - 参见链接。这可能导致在Finalize()方法中调用线程安全意识代码。 - Ivaylo Slavov
3
当一个一次性对象不再需要时,应该由初始化这个对象的线程来进行清理。为什么要让其他线程来处理它的清理工作?那样只会给自己带来麻烦。 - vgru
2
@Groo:虽然可以将一个线程专门用于长时间运行的操作,并在每个部分完成之前等待它,然后再继续下一部分,但在许多情况下更有效的模式是使用从线程池线程触发的异步回调。如果使用“播放完成”回调来关闭音频文件,则完全有可能在打开文件时不存在的线程上关闭文件,或者打开文件的线程在播放完成并关闭文件之前就停止了。 - supercat
2
@Groo:如果中止一个对象的操作会使其变得无用,而且处理一个对象应该导致阻塞操作被中止,那么处理对象似乎是中止其操作的好方法。 - supercat
2
我知道这是一个老话题,但我有一个更正要添加到你的评论中:“当没有活着的引用指向对象时,Finalize会被调用,因此线程安全性在那时不应该成为考虑因素。”不幸的是,即使您的类在外部不需要线程安全,finalizers也意味着您必须关心线程安全。这是因为即使其类的另一个方法仍在执行,finalizer也可能运行!有关更多信息,请参见https://blogs.msdn.microsoft.com/oldnewthing/20100810-00/?p=13193/和尤其是https://blogs.msdn.microsoft.com/oldnewthing/20100813-00/?p=13153。 - relatively_random
显示剩余7条评论
6个回答

20

我倾向于将整数作为字段来存储已释放状态,而不是布尔值,因为这样可以使用线程安全的Interlocked类来测试Dispose方法是否已被调用。

可以像这样使用:

private int _disposeCount;

public void Dispose()
{
    if (Interlocked.Increment(ref _disposeCount) == 1)
    {
        // disposal code here
    }
}

这样可以确保无论调用该方法多少次,处理代码都只被调用一次,并且完全线程安全。

然后,每个方法都可以简单地使用此方法作为屏障检查:

private void ThrowIfDisposed()
{
   if (_disposeCount > 0) throw new ObjectDisposedException(GetType().Name);
}

关于同步每个方法的问题 - 你是否意味着简单的障碍检查不够用,你想阻止其他已经在实例中执行代码的线程。这是一个更复杂的问题。我不知道你的代码在做什么,但请考虑是否真的需要它 - 简单的障碍检查不行吗?

如果你只是关注于disposed检查本身 - 我上面的例子就可以了。

编辑:回答评论:"这与易失性布尔标志有什么区别?将字段命名为somethingCount并允许它仅保留0和1值有点令人困惑"

易失性关系到确保读取或写入操作是原子和安全的。它不能使分配和检查值的过程线程安全。因此,例如,尽管使用了易失性,以下内容也不是线程安全的:

private volatile bool _disposed;

public void Dispose()
{
    if (!_disposed)
    {
        _disposed = true

        // disposal code here
    }
}

问题在于,如果两个线程非常接近,第一个线程可能会检查_disposed,读取false,进入代码块并在将_disposed设置为true之前被切换出。然后第二个线程检查_disposed,看到false并且也进入了该代码块。

使用Interlocked可以确保赋值和随后的读取都是单个原子操作。


1
这和一个易失的布尔标志有什么区别?将一个名为 somethingCount 的字段允许只保留0和1值,这可能会稍微有些混淆。 - vgru
4
@Groo - 请查看编辑。很遗憾bool类型没有Interlocked重载,但我更喜欢这种线程安全的方法而不是不安全的方法。 - Rob Levine
3
如果多个线程同时尝试释放一个对象(这将非常奇怪),那么这是有意义的。但它仍然不能保证在退出ThrowIfDisposed后不会在另一个方法中间被释放。如果您将对象传递给多个线程,这将是更常见的情况。 - vgru
@Groo 同时,这样做的目的是确保Dispose()是幂等的。无论您调用它的次数(或者它是否被两个非常接近的线程调用),代码都保证您始终会得到相同有序的结果 - 执行一次处理代码。 - Rob Levine
3
有一种情况下 Dispose 可能会同时发生,那就是在终止 I/O 操作时。例如,如果一个线程正在对 Socket 进行阻塞读取,而另一个线程决定第一个线程不应再等待(例如因为用户点击了“取消”按钮),第二个线程解除第一个线程的阻塞的正确方法是将 socket 销毁(这将导致阻塞读取停止等待并立即抛出异常)。如果第一个线程打算在操作完成后销毁 socket,则两个线程可能会同时执行销毁操作。 - supercat
显示剩余5条评论

18

您可以做的最简单的事情是将私有 disposed 变量标记为 volatile 并在方法开始时检查它。如果对象已被处理,那么您可以抛出 ObjectDisposedException

这有两个注意点:

  1. 如果该方法是事件处理程序,则不应抛出 ObjectDisposedException。相反,如果可能的话,应该优雅地退出方法。原因在于存在竞态条件,即在取消订阅事件后,仍然可以触发事件。(有关详细信息,请参见 Eric Lippert 的这篇文章。)

  2. 这不能阻止在执行类方法时对类进行处理。因此,如果您的类具有无法在处理后访问的实例成员,则需要设置一些锁定行为来确保控制对这些资源的访问。

Microsoft 关于 IDisposable 的指导建议在所有方法上检查是否已经处理,但我个人认为这并非必要。问题实际上是:如果允许在处理类之后执行方法,是否会引发异常或导致意外的副作用。如果答案是肯定的,则需要做一些工作,以确保不会发生这种情况。

关于 IDisposable 类是否都应该是线程安全的问题:不应该。大多数用于可处理类的用例仅涉及被单个线程访问。

话虽如此,您可能需要调查为什么需要使可处理类线程安全,因为它会增加很多额外的复杂性。可能有一种替代实现方式,可以让您不必担心处理类中的线程安全问题。


我认为如果你想保持简单,实现单例模式或通过代理访问对象可能更明智。 - weismat
13
将其设置为易失性(volatile)不会防止任何竞态条件发生。而这里存在着几种竞态条件。 - H H
@Henk 是的,它只解决了一个竞态条件(在方法进入时获取已释放私有字段的陈旧值)。就像我在上面的#2中指出的那样,您仍然必须处理对象在方法执行的任何时候都可能被释放的事实。 - Dan Rigby
@weismat,单例可释放对象一旦被释放,就不会在应用程序生命周期内再次使用。我一直认为单例模式与释放不兼容。您能否澄清一下您是否有其他想法? - Ivaylo Slavov
根据MSDN https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose "一个Dispose方法应该是幂等的,也就是说,它可以被多次调用而不会抛出异常"。 - TheWizard

17

大多数BCL的Dispose实现都不是线程安全的。其想法是由调用Dispose的人在Disposed之前确保没有其他人正在使用该实例。换句话说,它将同步责任向上推。这是有道理的,否则,您的所有其他消费者现在都需要处理对象在使用时被Dispose的边界情况。

尽管如此,如果要创建一个线程安全的Disposable类,可以在每个公共方法(包括Dispose)周围创建锁,并在顶部检查_disposed。如果您有长时间运行的方法,不能在整个方法中持有锁,则可能会变得更加复杂。


14
最好的Dispose多线程解决方案是:不要这样做 - H H
@Henk,公平地说,如果你没有任何错误,你真的不应该看到ObjectDisposedException。然而,考虑到这是一个调试工具,我认为它没有保证线程安全行为是合理的(即,如果您在对象被Dispose的时间周围立即从多个线程使用对象,则可能会遇到其他类型的异常)。 - Dan Bryant
1
@DanBryant,这不是关于在多线程环境中处理释放的问题,而是在处理释放时执行调用。在这种情况下,我们必须抛出“ObjectDisposedException”。 - Alex Zhukovskiy
1
最佳的解决方案是正确实现线程安全,而不是懒惰或试图“将责任推卸”,这是荒谬的。Dispose 应该像所有其他方法一样是线程安全的。这意味着在 Dispose 和所有其他方法中都必须使用锁。我的天才想法是使用 ReaderWriterLock,在其中 Dispose 获取写锁,而所有其他方法获取读锁。只要类没有被处理掉,就不会争用读锁,因为一次可以持有无限多个锁。Dispose 中的写锁确保单个线程执行它。 - Triynko
2
这里的关键是,当线程安全被正确实现时,你会得到一个ObjectDisposedException异常,你可以捕获和处理它,而不是在正在被处理时使用非线程安全版本可能导致的非确定性异常。很遗憾BCL实现没有为dispose实现线程安全。 - Triynko
@Triynko,我喜欢这种方法,你应该将它写成一个答案。然而,现在在这个问题上有更多经验的眼睛看过之后,我通常会坚持使用BCL,并保持简单。对于可被多个线程访问/处理的临时资源,你的方法是非常合理的。但调用代码的开发者需要了解他/她正在处理的可丢弃资源的类型,因为他/她有责任正确地处理它。 - Ivaylo Slavov

4

我更喜欢使用整数类型来表示"disposed"或"state"变量,并使用Interlocked.ExchangeInterlocked.CompareExchange进行操作。如果能使用enum类型,我会选择使用它们,但不幸的是,Interlocked.ExchangeInterlocked.CompareExchange并不能处理这种类型。

大多数IDisposable和finalizer的讨论都未提及一个重要点,即尽管在IDisposable.Dispose()正在进行时,对象的finalizer不应该运行,但类无法阻止其类型的对象被声明为死亡并被复活。确保Dispose和finalize方法受到足够保护以确保它们不会破坏任何其他对象的状态,这通常需要对对象状态变量使用锁定或Interlocked操作。


2

您需要锁定对要处理的资源的所有访问。我还添加了我通常使用的Dispose模式。

public class MyThreadSafeClass : IDisposable
{
    private readonly object lockObj = new object();
    private MyRessource myRessource = new MyRessource();

    public void DoSomething()
    {
        Data data;
        lock (lockObj)
        {
            if (myResource == null) throw new ObjectDisposedException("");
            data = myResource.GetData();
        }
        // Do something with data
    }

    public void DoSomethingElse(Data data)
    {
        // Do something with data
        lock (lockObj)
        {
            if (myRessource == null) throw new ObjectDisposedException("");
            myRessource.SetData(data);
        }
    }

    ~MyThreadSafeClass()
    {
        Dispose(false);
    }
    public void Dispose() 
    { 
        Dispose(true); 
        GC.SuppressFinalize(this);
    }
    protected void Dispose(bool disposing) 
    {
        if (disposing)
        {
            lock (lockObj)
            {
                if (myRessource != null)
                {
                    myRessource.Dispose();
                    myRessource = null;
                }
            }
            //managed ressources
        }
        // unmanaged ressources
    }
}

1
你能解释一下为什么在析构函数中调用 GC.SuppressFinalize(this) 吗?如果 GC 已经处置了该实例,那么这样做不是太晚了吗?也许在成功处理之后调用此方法可以减轻 GC 压力? - Ivaylo Slavov
你是正确的。这是一个疏忽,应该在Dispose()里面。 - Marc Messing

1

就我个人而言,您的示例代码与我的同事和我通常处理此问题的方式相匹配。我们通常在类上定义一个私有的CheckDisposed方法:

private volatile bool isDisposed = false; // Set to true by Dispose

private void CheckDisposed()
{
    if (this.isDisposed)
    {
        throw new ObjectDisposedException("This instance has already been disposed.");
    }
}

然后我们在所有公共方法的顶部调用CheckDisposed()方法。

如果考虑到处理争用可能发生,而不是错误条件,我还会添加一个公共的IsDisposed()方法(类似于Control.IsDisposed)。


更新:针对使isDisposed成为易失性的价值的评论,需要注意的是,考虑到我如何使用CheckDisposed()方法,"fence"问题相当微不足道。它本质上是一个故障排除工具,用于快速捕捉在对象已被处理后代码调用对象的公共方法的情况。在公共方法的开头调用CheckDisposed()并不能保证该对象不会在该方法内部被处理。如果我认为这是我的类设计中固有的风险,而不是我未考虑的错误条件,那么我将使用前面提到的IsDisposed方法以及适当的锁定。

2
你还要确保 isDisposed 被标记为 volatile,否则会出现竞态条件。 - Dan Rigby
很好的观点。示例已编辑以包括isDisposed的声明。 - dgvid
3
@Dan,我认为栅栏在这里不会有太大帮助,因为指令重排并不是问题;真正的问题是,在方法体内部任何时刻都可能调用Dispose。 - Dan Bryant
1
@DanRigby:volatile只是创建了一个栅栏。如果另一个线程在if条件之后立即处理对象,则不会改变任何内容。 - vgru
@DanRigby,我认为使用Interlocked.CompareExchange()可能是更好的选择,而不是使用volatile,因为它可以处理屏障。 - Ivaylo Slavov
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接