有没有办法解决由第三方库引起的OS加载程序锁定死锁?

时间:2012-03-02 04:41:42

标签: c# windows multithreading com deadlock

我有一个有趣的问题,我在其他任何地方都没有记录(至少不是这个特定的问题)。

这个问题是COM,VB6和.NET的结合,使它们发挥得很好。

以下是我所拥有的:

  • 旧版VB6 ActiveX DLL(由我们编写)
  • 用C#编写的多线程Windows服务,通过网络处理来自客户端的请求并发回结果。它通过创建一个新的STA线程来处理每个请求。每个请求处理程序线程实例化一个COM对象(在ActiveX DLL中定义)来处理请求并获得结果(传入一串XML,然后返回一个XML字符串),显式释放COM对象,以及退出。然后,该服务将结果发送回客户端。
  • 使用异步网络(即线程池线程)处理所有网络代码。

是的,我知道这首先是一件冒险的事情,因为VB6开始时并不是非常友好的多线程应用程序,但不幸的是,这就是我的意思。暂时坚持下去。

我已经修复了许多导致代码死锁的事情(例如,确保COM对象实际上是从一个单独的STA线程创建和调用的,确保在之前显式释放COM对象线程退出以防止垃圾收集器和COM Interop代码之间发生的死锁等,但是有一个死锁场景我似乎无法解决。

在WinDbg的帮助下,我能够找出正在发生什么,但我不确定如何(或者是否)解决这个特定的死锁问题。

发生了什么

如果一个请求处理程序线程正在退出,并且另一个请求处理程序线程同时启动,则可能会发生死锁,因为VB6运行时初始化和终止例程似乎有效。

在以下情形中发生死锁:

  • 正在启动的新线程正在创建(VB6)COM对象的新实例以处理传入请求。此时,COM运行时正在调用以检索对象的类工厂。类工厂实现在VB6运行时本身( MSVBVM60.dll )。也就是说,它调用VB6运行时的 DllGetClassObject 函数。反过来,这会调用内部运行时函数(MSVBVM60!CThreadPool::InitRuntime),该函数获取互斥锁并进入临界区以执行其部分工作。此时,它正在调用 LoadLibrary oleaut32.dll 加载到进程中,同时保留此互斥锁。所以,现在它持有这个内部VB6运行时互斥锁并等待OS加载程序锁定。

  • 正在退出的线程已经在加载程序锁定内运行,因为它已完成执行托管代码并在 KERNEL32!ExitThread 函数内执行。具体来说,它正在处理该线程上 MSVBVM60.dll DLL_THREAD_DETECH消息,而该线程又调用一个方法来终止线程上的VB6运行时({ {1}})。现在,这个线程试图获取与初始化的其他线程已经具有的互斥锁相同的互斥锁。

经典的僵局。线程A具有L1并且想要L2,但是线程B具有L2并且需要L1。

问题(如果您已经跟我这么做了)是我无法控制VB6运行时在其内部线程初始化和拆卸例程中所做的事情。

理论上,如果我可以强制VB6运行时初始化代码在内部运行OS加载程序锁定,我会阻止死锁,因为我相当肯定VB6运行时所持有的互斥锁是具体的仅在初始化和终止例程中使用。

要求

  • 我无法从单个STA线程进行COM调用,因为这样服务就无法处理并发请求。我也无法长时间运行请求阻止其他客户端请求。这就是我为每个请求创建一个STA线程的原因。

  • 我需要在每个线程上创建一个COM对象的新实例,因为我需要确保每个实例在VB6代码中都有自己的全局变量副本(VB6为每个线程提供了自己的副本全局变量)。

我尝试过的解决方案

将ActiveX DLL转换为ActiveX EXE

首先,我尝试了明显的解决方案并创建了一个ActiveX EXE(进程外服务器)来处理COM调用。最初,我编译它,以便为每个传入请求创建一个新的ActiveX EXE(进程),我也尝试使用线程每对象编译选项(创建一个流程实例,并创建它ActiveX EXE中新线程上的每个对象。

这解决了与VB6运行时相关的死锁问题,因为VB6运行时永远不会被正确加载到.NET代码中。但是,这导致了一个不同的问题:如果并发请求进入服务,ActiveX EXE往往会随机发生MSVBVM60!CThreadPool::TerminateRuntime错误。我假设这是因为COM编组和/或VB6运行时无法处理ActiveX EXE中的并发对象创建/销毁或并发方法调用。

强制VB6代码在OS加载程序锁中运行

接下来,我切换回使用ActiveX DLL作为COM类。为了强制VB6运行时在OS加载程序锁内运行其线程初始化代码,我创建了一个本机(Win32)C ++ DLL,其代码用于处理 DllMain 中的RPC_E_SERVERFAULTDLL_THREAD_ATTACH代码调用 CoInitialize 然后实例化虚拟VB6类以强制加载VB6运行时并强制运行时初始化例程在线程上运行。

当Windows服务启动时,我使用 LoadLibrary 将此C ++ DLL加载到内存中,以便该服务创建的任何线程都将执行该DLL的DLL_THREAD_ATTACH代码。

问题是此代码运行服务创建的每个线程,包括.NET垃圾收集器线程和异步网络代码使用的线程池线程,这些线程没有结束好(这似乎导致线程永远不会正常启动,我想在GC上初始化COM和线程池线程通常只是一个非常糟糕的主意)。

  

附录

     

我刚才意识到为什么这是一个坏主意(也可能是它不起作用的部分原因):当你持有时,调用 LoadLibrary 是不安全的。装载机锁。请参阅此MSDN文章中的备注部分:http://msdn.microsoft.com/en-us/library/ms682583%28VS.85%29.aspx,具体为:

     

DllMain中的线程持有加载程序锁定,因此无法动态加载或初始化其他DLL。

有没有办法解决这些问题?

所以,我的问题是,是否有任何方法来解决原始的死锁问题?

我唯一能想到的是创建自己的锁对象并包围在.NET DLL_THREAD_ATTACH块中实例化COM对象的代码,但是我没办法(我知道)在(操作系统的)线程退出代码周围放置相同的锁。

这个问题是否有更明显的解决方案,或者我在这里运气不好?

5 个答案:

答案 0 :(得分:2)

只要所有模块都在一个进程中工作,就可以通过用包装器替换一些系统调用来挂钩Windows API。然后,您可以将调用包装在一个关键部分以避免死锁。

有几个库和样本可以实现这一目标,该技术通常被称为绕行:

http://www.codeproject.com/Articles/30140/API-Hooking-with-MS-Detours

http://research.microsoft.com/en-us/projects/detours/

当然,包装器的实现应该在本机代码中完成,最好是C ++。 .NET绕道也适用于高级API函数,例如 MessageBox ,但如果您尝试在.NET中重新实现 LoadLibrary API调用,那么您可能会遇到循环依赖问题,因为.NET运行时在执行期间内部使用 LoadLibrary 函数,并经常执行此操作。

所以解决方案对我来说是这样的:一个单独的.DLL模块,它在应用程序的最开始加载。该模块通过使用您自己的包装器修补几个VB和Windows API调用来修复死锁问题。所有包装器都做一件事:在关键部分包装调用并调用原始API函数来完成实际工作。

答案 1 :(得分:1)

编辑: 回想起来,我认为这不会奏效。问题是死锁可能在Win32线程退出的任何时候发生,并且由于Win32线程没有将1:1映射到.NET线程,我们不能(在.NET内)强制Win32线程获取锁之前退出。除了正在退出的.NET线程被切换到不同的OS线程的可能性之外,可能有OS线程与任何.NET线程(垃圾收集等)无关,它们可能随机启动和退出。

  
    

我唯一能想到的是创建自己的锁对象     并包围在.NET锁中实例化COM对象的代码     阻止,但是我没办法(我知道)把同样的锁     (操作系统的)线程退出代码。

  
     

这听起来像是一种很有前景的方法。我从中收集到你   能够修改服务的代码,并说出每个线程   在退出之前显式释放COM对象,所以大概是你   可以在此之前声明锁定,或者在明确之前   释放COM对象或之后。秘诀是选择一个   一旦线程持有它就会隐式释放的锁类型   退出了,例如Win32 mutex

     

Win32互斥对象可能直到被放弃   该线程已完成所有DLL_THREAD_DETACH调用,但我没有   知道这种行为是否有记录。我不熟悉   在.NET中锁定,但我的猜测是它们不太适合,   因为即使存在正确的锁,也很可能   一旦线程到达结尾,就被视为放弃   托管代码部分,即在调用DLL_THREAD_DETACH之前。

     

如果Win32互斥对象不起作用(或者如果你非常合理的话)   你可能需要不喜欢不依赖于无证件的行为   自己实施锁定。一种方法是使用   OpenThread获取当前线程的句柄并将其保存在您的   锁定对象,以及事件或类似对象。如果锁有   已经声明,你想等待它可用,使用   WaitForMultipleObjects要等到线程句柄或者   事件发出信号。如果事件发出信号,则表示锁具有   已经明确发布,如果线程句柄发出信号则是   由线程退出隐式释放。显然正在实施   这涉及许多棘手的细节(例如:当一个线程   显式释放锁,你无法关闭线程句柄   因为另一个线程可能正在等待它,所以你必须关闭   当锁定是下一次声明时,但它不应该是   难以解决这些问题。

答案 2 :(得分:1)

我没有看到任何原因导致您无法在启动代码中加载ActiveX控件的额外实例,只是挂起引用。 Presto,因为VB6运行时永远不会关闭,所以不会出现加载器锁定问题。

答案 3 :(得分:0)

由于我仍然在探索我的选择,为了简单起见,我还想看看我是否可以在不使用任何本机代码的情况下在纯.NET代码中实现解决方案。我不确定这是否是一个万无一失的解决方案,因为我还在试图弄清楚它是否真的让我得到了我需要的互斥,或者看起来是不是这样。

欢迎任何想法或意见。

代码的相关部分如下。一些说明:

  • 从远程客户端收到新邮件时,从线程池线程调用HandleRpcRequest方法
  • 这会触发一个单独的STA线程,以便它可以安全地进行COM调用
  • DbRequestProxy是一个围绕我正在使用的实际COM类的瘦包装类
  • 我使用ManualResetEvent_safeForNewThread)来提供互斥。基本思想是,如果任何一个特定线程即将退出(因此可能即将终止VB6运行时),此事件将保持无信号(阻止其他线程)。仅在当前线程完全终止后(Join调用完成后)再次发出该事件的信号。这样,除非现有线程退出,否则多个请求处理程序线程仍可以并发执行。

到目前为止,我认为这段代码是正确的,并保证两个线程不再在VB6运行时初始化/终止代码中死锁,同时仍然允许它们在大多数执行时并发执行时间,但我可能会在这里遗漏一些东西。

public class ClientHandler {

    private static ManualResetEvent _safeForNewThread = new ManualResetEvent(true);

    private void HandleRpcRequest(string request)
    {

        Thread rpcThread = new Thread(delegate()
        {
            DbRequestProxy dbRequest = null;

            try
            {
                Thread.BeginThreadAffinity();

                string response = null;

                // Creates a COM object. The VB6 runtime initializes itself here.
                // Other threads can be executing here at the same time without fear
                // of a deadlock, because the VB6 runtime lock is re-entrant.

                dbRequest = new DbRequestProxy();

                // Call the COM object
                response = dbRequest.ProcessDBRequest(request);

                // Send response back to client
                _messenger.Send(Messages.RpcResponse(response), true);
                }
            catch (Exception ex)
            {
                _messenger.Send(Messages.Error(ex.ToString()));
            }
            finally
            {
                if (dbRequest != null)
                {
                    // Force release of COM objects and VB6 globals
                    // to prevent a different deadlock scenario with VB6
                    // and the .NET garbage collector/finalizer threads
                    dbRequest.Dispose();
                }

                // Other request threads cannot start right now, because
                // we're exiting this thread, which will detach the VB6 runtime
                // when the underlying native thread exits

                _safeForNewThread.Reset();
                Thread.EndThreadAffinity();
            }
        });

        // Make sure we can start a new thread (i.e. another thread
        // isn't in the middle of exiting...)

        _safeForNewThread.WaitOne();

        // Put the thread into an STA, start it up, and wait for
        // it to end. If other requests come in, they'll get picked
        // up by other thread-pool threads, so we won't usually be blocking anyone
        // by doing this (although we are blocking a thread-pool thread, so
        // hopefully we don't block for *too* long).

        rpcThread.SetApartmentState(ApartmentState.STA);
        rpcThread.Start();
        rpcThread.Join();

        // Since we've joined the thread, we know at this point
        // that any DLL_THREAD_DETACH notifications have been handled
        // and that the underlying native thread has completely terminated.
        // Hence, other threads can safely be started.

        _safeForNewThread.Set();

    }
}

答案 4 :(得分:0)

大约20年前,我已经使用VB6,VC6编写了一个相当复杂的代码,我需要将其移植到visual studio.net。 我只是简单地将函数与编写的头文件一起纠正了所有编译错误(很多),然后尝试加载。收到“ loaderlock关闭” 然后,我决定重做所有文件,从其他几个文件所依赖的文件开始,然后按我的方式进行,直到我去了,我只包含了该特定文件所需的头文件。现在加载的结果很好。不再关闭loaderlock。 对我来说,教训是,在绝对cpp文件中不要包含超出绝对必要的头文件。 希望对您有帮助

来自一个非常快乐的露营者!

大卫