我什么时候知道可以安全调用Dispose?

5
我有一个搜索应用程序,对于某些请求,返回结果需要一定的时间(10到15秒)。同样的请求可能同时拥有多个并发请求。目前情况下,我必须独立处理这些请求,这会导致大量不必要的处理。
我已经设计了一个方案,应该可以避免不必要的处理,但还有一个悬而未决的问题。
每个请求都有一个标识所请求的数据的键。我维护一个由请求组成的字典,用请求的键进行索引。请求对象具有一些状态信息和用于等待结果的 WaitHandle。
当客户端调用我的 Search 方法时,代码会检查字典,看看是否已经存在该键的请求。如果是,则客户端只需等待 WaitHandle。如果没有请求存在,我就创建一个请求,将其添加到字典中,并发出异步调用以获取信息。同样地,代码会等待事件。
当异步进程获取结果时,它会更新请求对象,从字典中删除请求,然后发出信号。
这一切都很顺利。除了我不知道何时处理请求对象。也就是说,由于我不知道最后一个客户端何时使用它,因此无法调用 Dispose。我必须等待垃圾收集器来清理。
以下是代码:
class SearchRequest: IDisposable
{
    public readonly string RequestKey;
    public string Results { get; set; }
    public ManualResetEvent WaitEvent { get; private set; }

    public SearchRequest(string key)
    {
        RequestKey = key;
        WaitEvent = new ManualResetEvent(false);
    }

    public void Dispose()
    {
        WaitEvent.Dispose();
        GC.SuppressFinalize(this);
    }
}

ConcurrentDictionary<string, SearchRequest> Requests = new ConcurrentDictionary<string, SearchRequest>();

string Search(string key)
{
    SearchRequest req;
    bool addedNew = false;
    req = Requests.GetOrAdd(key, (s) =>
        {
            // Create a new request.
            var r = new SearchRequest(s);
            Console.WriteLine("Added new request with key {0}", key);
            addedNew = true;
            return r;
        });

    if (addedNew)
    {
        // A new request was created.
        // Start a search.
        ThreadPool.QueueUserWorkItem((obj) =>
            {
                // Get the results
                req.Results = DoSearch(req.RequestKey);  // DoSearch takes several seconds

                // Remove the request from the pending list
                SearchRequest trash;
                Requests.TryRemove(req.RequestKey, out trash);

                // And signal that the request is finished
                req.WaitEvent.Set();
            });
    }

    Console.WriteLine("Waiting for results from request with key {0}", key);
    req.WaitEvent.WaitOne();
    return req.Results;
}

基本上,我不知道最后一个客户端将何时发布。无论我怎样分析这里的情况,都存在竞争条件。考虑以下情况:
  1. 线程A创建一个新请求,启动线程2,并等待等待句柄。
  2. 线程B开始处理请求。
  3. 线程C检测到有一个挂起的请求,然后被交换出去。
  4. 线程B完成请求,从字典中删除该项,并设置事件。
  5. 线程A的等待得到满足,返回结果。
  6. 线程C唤醒,调用WaitOne,被释放并返回结果。
如果我使用某种引用计数方式,以便“最后”客户端调用Dispose,那么在上述情况下对象将由线程A进行处理。然后当线程C尝试等待已释放的WaitHandle时,它就会死掉。
我能想到的唯一解决方法是使用引用计数方案,并使用锁保护对字典的访问(在这种情况下使用ConcurrentDictionary是没有意义的),以便每次查找都伴随着引用计数的增加。虽然这样做可以解决问题,但看起来很丑陋。
另一种解决方案是放弃WaitHandle,使用类似事件的机制和回调。但这也需要我用锁保护查找,并且我还要处理事件或裸多播委托的额外复杂性。这也似乎是一个hack。
目前可能不是问题,因为该应用程序尚未获得足够的流量,以使那些被遗弃的句柄在下一次GC通过之前累积起来,然后进行清理。也许它永远不会成为问题?但是如果我应该调用Dispose来摆脱它们,而不是将它们留给GC来清理,这让我感到担忧。
有什么想法吗?这是一个潜在的问题吗?如果是,您有一个干净的解决方案吗?

一句话就能概括:「啊!!!」 - Mitch Wheat
@Mitch:“Eeeeeek”是对什么的回应?设计还是问题? - Jim Mischel
@Jim Mischel - 您需要记住ConcurrentDictionary具有副作用。如果多个线程尝试同时调用GetOrAdd,则工厂可能会被调用多次,但只有一个线程可以成功添加。为其他线程生成的值将被丢弃,但此时计算已完成。 - Enigmativity
@Jim Mischel - 此外,您提到GC会进行清理,但是GC从不调用.Dispose() - 您始终需要在代码中显式地调用.Dispose() - Enigmativity
@Enigmativity:我知道垃圾回收器不会调用“Dispose”,但它确实会执行终结器。而“ManualResetEvent”的终结器会调用“Dispose”,释放操作系统句柄。 - Jim Mischel
@Enigmativity:ConcurrentDictionary 会针对相同的键在多个线程上调用工厂方法,这让我有点失望。我一定要重新审视解决方案的这部分。谢谢。 - Jim Mischel
2个回答

4
考虑使用Lazy<T>来代替SearchRequest.Results,但这可能需要重新设计。还没有完全考虑清楚。
但对于您的使用情况,可能几乎可以直接实现自己的Wait()Set()方法在SearchRequest中。例如:
object _resultLock;

void Wait()
{
  lock(_resultLock)
  {
     while (!_hasResult)
       Monitor.Wait(_resultLock);
  }
}

void Set(string results)
{
  lock(_resultLock)
  {
     Results = results;
     _hasResult = true;
     Monitor.PulseAll(_resultLock);
  }
}

不需要处理。 :)

我喜欢这个解决方案。需要再多考虑一下,但看起来相当不错。而且我真的不需要一个 _hasResult。我可以像 while (Result == null) 一样使用 Result - Jim Mischel

2
我认为您最好使用TPL来满足所有多线程需求,这是它擅长的领域。
关于您问题的评论,需要注意的是ConcurrentDictionary具有副作用。如果多个线程同时尝试调用GetOrAdd,则工厂可能会被所有线程调用,但只有一个线程会获胜。其他线程产生的值将被丢弃,但此时计算已完成。
由于您还说进行搜索很昂贵,因此花费时间锁定然后使用标准字典的成本将是最小的。
所以这是我建议的内容:
private Dictionary<string, Task<string>> _requests
    = new Dictionary<string, Task<string>>();

public string Search(string key)
{
    Task<string> task;
    lock (_requests)
    {
        if (_requests.ContainsKey(key))
        {
            task = _requests[key];
        }
        else
        {
            task = Task<string>
                .Factory
                .StartNew(() => DoSearch(key));
            _requests[key] = task;
            task.ContinueWith(t =>
            {
                lock(_requests)
                {
                    _requests.Remove(key);
                }
            });
        }
    }
    return task.Result;
}

这个选项能很好地运行搜索,在搜索过程中记住任务,完成后就从字典中删除。当搜索执行时请求相同的键会得到相同的任务,因此一旦任务完成,它们将得到相同的结果。

我已经测试了代码,它可以正常工作。


+1。是的,这看起来非常不错。谢谢您。我会把它放到我的项目中,看看效果如何。显然,我需要进一步学习TPL。 - Jim Mischel
1
是的,GetOrAdd 调用工厂多次的整个特性很奇怪。那个“特性”以前一直困扰着我。 - Brian Gideon
我最终使用了另一个建议,因为我已经在后台线程上执行,并且没有充分的理由再触发另一个线程。但是你的建议让我重新思考了我的应用程序设计,并且大大简化了它。感谢你的建议。 - Jim Mischel

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