进入临界区导致死锁

3

一个多线程日志应用出现死锁情况。我的主应用程序有4-6个运行线程,其中主线程负责监视各种事务的健康状况、更新GUI界面等。然后我有一个发送线程和一个接收线程,发送和接收线程用于与物理硬件通信,由于数据的时序性,我有时需要调试发送和接收线程看到的数据,即在不中断它们的情况下将其打印到控制台。顺便说一下,这些数据在USB总线上。

由于应用程序的线程性质,我希望创建一个调试控制台,可以从其他线程发送消息到该控制台。调试控制台作为低优先级线程运行,并实现了环形缓冲区,当您将消息打印到调试控制台时,该消息将被快速存储到环形缓冲区并设置事件。调试控制台的线程会等待来自入站消息的SingleObject事件。当检测到事件时,控制台线程将使用该消息更新GUI显示。简单明了吧?打印调用和控制台线程使用临界区来控制访问。

注意:如果发现消息丢失,可以调整环形缓冲区大小(至少这是想法)。

在测试应用程序中,如果我通过鼠标点击缓慢地调用其Print方法,则该控制台非常有效。我有一个按钮可以按下以将消息发送到控制台,它也能正常工作。但是,如果我加入任何负载(多次调用Print方法),则一切都会死锁。当我跟踪死锁时,IDE的调试器跟踪到EnterCriticalSection并卡住了。

注意:如果我删除Lock/Unlock调用并只使用Enter/LeaveCriticalSection(请看代码),有时候会工作,但仍会陷入死锁状态。为排除对堆栈push/pops的死锁,我现在直接调用Enter/LeaveCriticalSection,但这并没有解决我的问题...出了什么问题?

这里是一个Print语句,使我能够将简单整数传递到显示控制台。

void TGDB::Print(int I)
{
    //Lock();
    EnterCriticalSection(&CS);

    if( !SuppressOutput )
    {
        //swprintf( MsgRec->Msg, L"%d", I);
        sprintf( MsgRec->Msg, "%d", I);
        MBuffer->PutMsg(MsgRec, 1);
    }

    SetEvent( m_hEvent );
    LeaveCriticalSection(&CS);
    //UnLock();
}

// My Lock/UnLock methods
void TGDB::Lock(void)
{
    EnterCriticalSection(&CS);
}

bool TGDB::TryLock(void)
{
    return( TryEnterCriticalSection(&CS) );
}

void TGDB::UnLock(void)
{
        LeaveCriticalSection(&CS);
}

// This is how I implemented Console's thread routines

DWORD WINAPI TGDB::ConsoleThread(PVOID pA)
{
DWORD rVal;

         TGDB *g = (TGDB *)pA;
        return( g->ProcessMessages() );
}

DWORD TGDB::ProcessMessages()
{
DWORD rVal;
bool brVal;
int MsgCnt;

    do
    {
        rVal = WaitForMultipleObjects(1, &m_hEvent, true, iWaitTime);

        switch(rVal)
        {
            case WAIT_OBJECT_0:

                EnterCriticalSection(&CS);
                //Lock();

                if( KeepRunning )
                {
                    Info->Caption = "Rx";
                    Info->Refresh();
                    MsgCnt = MBuffer->GetMsgCount();

                    for(int i=0; i<MsgCnt; i++)
                    {
                        MBuffer->GetMsg( MsgRec, 1);
                        Log->Lines->Add(MsgRec->Msg);
                    }
                }

                brVal = KeepRunning;
                ResetEvent( m_hEvent );
                LeaveCriticalSection(&CS);
                //UnLock();

            break;

            case WAIT_TIMEOUT:
                EnterCriticalSection(&CS);
                //Lock();
                Info->Caption = "Idle";
                Info->Refresh();
                brVal = KeepRunning;
                ResetEvent( m_hEvent );
                LeaveCriticalSection(&CS);
                //UnLock();
            break;

            case WAIT_FAILED:
                EnterCriticalSection(&CS);
                //Lock();
                brVal = false;
                Info->Caption = "ERROR";
                Info->Refresh();
                aLine.sprintf("Console error: [%d]", GetLastError() );
                Log->Lines->Add(aLine);
                aLine = "";
                LeaveCriticalSection(&CS);
                //UnLock();
            break;
        }

    }while( brVal );

    return( rVal );
}

MyTest1和MyTest2只是我在按下按钮时调用的两个测试函数。无论我点击按钮的速度有多快,MyTest1都不会引起任何问题。然而,几乎每次都会出现死锁问题。

// No Dead Lock
void TTest::MyTest1()
{
    if(gdb)
    {
        // else where: gdb = new TGDB;
        gdb->Print(++I);
    }
}


// Causes a Dead Lock
void TTest::MyTest2()
{
    if(gdb)
    {
        // else where: gdb = new TGDB;
        gdb->Print(++I);
        gdb->Print(++I);
        gdb->Print(++I);
        gdb->Print(++I);
        gdb->Print(++I);
        gdb->Print(++I);
        gdb->Print(++I);
        gdb->Print(++I);
    }
}

更新: 发现了我的环形缓冲实现中的一个错误。在负载较重时,当缓冲区包装时,我没有正确检测到已满的缓冲区,因此缓冲区没有返回。我相信这个问题现在已经解决了。一旦我修复了环形缓冲区问题,性能得到了很大的提升。然而,如果我减少iWaitTime,我的死锁(或者冻结)问题就会再次出现。
因此,在进行更多的测试后,针对更重的负载,似乎我的死锁并未消失。在超级重载下,我的应用程序仍然死锁或至少冻结,但没有之前那样严重了,因为我解决了环形缓冲问题。如果我在MyTest2中增加Print调用的数量,我很容易每次都会锁定……
另外,我的更新代码已经反映在上面。我现在确保我的Set和Reset事件调用位于关键部分调用内。

离题:我想知道为什么代码没有高亮显示?在编辑视图中是可以的...不过有时候也会,反正就是这样。=/ - Zsub
请为我强调一下...也许尝试清除浏览器缓存并重新启动? - Eric
你可以在调试器中运行它,对吧?当它“死锁”时,线程在哪里? - Chris Becke
是的,我可以在调试器中运行它,也可以在我的主代码库中运行它,只要我不遇到任何负载;即在for循环或连续调用TDGB :: Print方法时。请查看我的两个测试函数(MyTest1和MyTest2)。在调试器中,我到达EnterCriticalSection并挂起。 - Eric
3个回答

4

针对这些选项,我想询问关于“Info”对象的问题。它是一个窗口吗?它的父窗口是哪个?以及它是在哪个线程上创建的?

如果Info或其父窗口是在另一个线程上创建的,则可能会发生以下情况:

控制台线程正在处理消息的关键部分。 主线程调用Print()并在关键部分上阻塞,等待控制台线程释放锁。 控制台线程调用Info上的函数(Caption),导致系统向窗口发送消息(WM_SETTEXT)。 SendMessage被阻塞,因为目标线程不处于消息警报状态(没有在调用GetMessage / WaitMessage / MsgWaitForMultipleObjects时阻塞)。

现在你陷入了死锁。

每当你将阻塞例程与任何与Windows交互的东西混合使用时,就可能会发生这种类型的问题。在GUI线程上使用的唯一适当的阻塞函数是MSGWaitForMultipleObjects,否则对线程上托管的窗口的SendMessage调用很容易死锁。

避免这种情况有两种可能的方法:

  • 从不在工作线程中进行任何GUI交互。只使用PostMessage将非阻塞UI更新命令分派到UI线程,或
  • 使用内核事件对象+ MSGWaitForMultipleObjects(在GUI线程上)来确保即使你在阻塞资源上,仍在分派消息。

你知道的,这可能非常正确!我已经进行了大量关于多线程的工作,在其他地方都没有遇到问题;只有在调用Windows UI时遇到了问题。顺便说一下,UI是Embarcadero VCL组件。我怀疑核心线程阻塞了像你建议的SendMessage。现在去测试一下... - Eric
是的,确实如此。我只需禁用GUI调用并转储环形缓冲区,或者在等待超时时注释掉所有GUI调用,现在一切都可以正常工作了。感谢你一直帮忙解决这个问题,Chris!我对Ben Voigt的评论非常感兴趣,我想进行调查。我找到了一个代码项目演示,提供了一个参考实现。同时我也会查看MSGWaitForMultipleObjects - 这是一个新的API调用对我来说。 - Eric

2
不知道死锁在哪里,这段代码很难弄清楚。然而有两点需要注意:
  • 既然是c++,应该使用Auto对象来执行锁定和解锁操作。以防万一Log抛出异常不会造成灾难。

  • 你在响应WAIT_TIMEOUT时重置了事件。这留下了一个小的机会窗口,让第二个Print()调用在工作线程从WaitForMultiple返回之后但进入临界区之前设置事件。这将导致事件在实际存在数据时被重置。

但你确实需要调试它并找出它“死锁”的地方。如果一个线程卡在EnterCriticalSection上,那么我们就可以找出原因。如果没有任何一个线程卡住,那么不完整的打印只是事件丢失的结果。


这些信息对我很有帮助,与核心问题无关。 我发现根本原因在我的环形缓冲区内。 环形缓冲区存在一个错误,导致它没有返回,因此按定义来说死锁并不是真正的死锁。 我简单地解决了环形缓冲区的bug,一切都开始正常工作。 我将其标记为正确答案,因为它指出了WAIT_TIMEOUT情况下潜在的问题。 谢谢! - Eric
这是一个针对有兴趣的人的提示。我的环形缓冲区接收数据非常快,导致一个边缘情况:当缓冲区已经包裹了一圈,我的读写指针(读指针和写指针用于跟踪环中的位置)变得相等,当R/W指针相等时,环形缓冲区代码在新写入时看起来像一个满缓冲区。同样,写操作看起来像一个空缓冲区。因此,读取满或写入空会在这种边缘情况下导致缓冲区死锁。一旦纠正,我的“死锁”问题就解决了。 - Eric
啊,糟糕... 经过进一步的测试,我的死锁问题似乎并没有解决。在超重负载下,我的应用程序仍然会死锁,或者至少会冻结,但是自从我解决了环形缓冲区问题以来,情况已经好多了。 - Eric

2

我强烈建议使用无锁实现。

这不仅可以避免潜在的死锁问题,而且调试工具是绝对不能占用锁的一个场景。在多线程应用程序中格式化调试消息的影响已经足够糟糕了......如果由于加入调试代码而同步你的并行代码,那么调试就变得毫无意义。

我建议采用基于SList的设计(Win32 API提供了一个SList实现,但是您可以轻松地使用InterlockedCompareExchange和InterlockedExchange构建一个线程安全的模板)。每个线程将拥有一个缓冲池。每个缓冲区将跟踪它来自哪个线程,在处理完缓冲区后,日志管理器将把缓冲区发布到来源线程的SList以供重用。希望写入消息的线程将会向记录器线程发布一个缓冲区。这也防止任何线程耗尽其他线程的缓冲区。当队列中放置了一个缓冲区时,唤醒记录器线程的事件完成了设计。


有趣!!!为了辩护,这个调试控制台通常是不活动的。它需要一个特殊的命令行开关来打开,然后只有通过一个隐藏窗口记录特定的日志内容。这是一个仅在我需要从临时代码、启用隐藏 UI 选择的代码等打印时才会调用的日志系统...但非常感兴趣听听您对我在其他领域使用关键部分的想法。您是否有好的资源推荐阅读? - Eric
在其他领域使用临界区是可以的,如果你理解的话。但是在调试代码中使用临界区(或任何类型的锁,包括嵌入在分配器或I/O层中的锁)是一场灾难(类似于海森堡的不确定性原理的软件版本)。至于更多学习资源,我建议您浏览comp.programming.threads档案。在微软关闭MS公共新闻组之前,我从一些同样的专家那里学到了很多东西。 - Ben Voigt

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