65.9K
CodeProject 正在变化。 阅读更多。
Home

使用工作线程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (119投票s)

2000年5月17日

viewsIcon

1176998

了解如何在应用程序中创建和使用工作线程。

引言

工作线程是解决许多并发处理问题的优雅方案;例如,在进行计算时保持GUI活跃的需求。本文讨论了使用工作线程的一些问题。一篇配套文章讨论了处理用户界面线程的一些技术。

为什么要使用工作线程?

考虑一个简单程序的实现。对我们来说,程序有一个任务,即反转图像中每个像素的颜色,这仅仅是因为我们有时需要一个具体的例子来阐述技术。为了我们的例子,我们将假设正在处理的图像是10百万像素的24位彩色图像。

GUI有一个菜单项或其他方式来说明“立即反转”。这会调用视图类上的doInvert方法

void CMyView::doInvert()
    {
     for(int x=y = 0; y < image.height; y++)
          for(int x = 0; x < image.width; x++)
              changePixel(x, y);
    }

这是一个完全正确的程序。它遍历所有10百万像素,愉快地更改像素,直到完成。但这不是一个的实现。

为什么不是?因为整个GUI在操作完成之前都是无响应的。这意味着,在一段时间内,用户被迫等待操作进行,而对此无能为力。如果用户决定转换是错误的,并想停止它,好吧,太糟糕了。它反正要完成。

用过时且困难的方式完成

一种解决方案,过时且很少使用(但仍在使用,因为它“众所周知”)的16位Windows解决方案是使用PeekMessage,这是一个在没有消息时不会阻塞的API调用。

void CMyView::doInvert()
    {
     running = TRUE; 
     for(int x=y = 0; running && y < image.height; y++)
          for(int x = 0; running && x < image.width; x++)
              { /* change it */
               MSG msg;
               if(PeekMessage(&msg, AfxGetMainWnd()->m_hWnd,
                              0, 0, PM_REMOVE))
                   { /* handle it*/
                    TranslateMessage(&msg);
                    DispatchMessage(&msg);
                   } /* handle it */
               changePixel(x, y);
              } /* change it */
    }

这有几个原因是不好的。最重要的是,它在时间关键的主循环中放置了一个开销很大的函数。现在,算法可能需要原来的几分钟的显著倍数才能完成。但它运行时,GUI仍然活跃。你甚至可以,如果你不小心,启动另一个线程将每个绿色像素涂成紫色。这不应该这样做!

性能技巧很简单:只偶尔轮询。例如,如果我们假设图像大致是矩形的,我们可以将代码更改为

void CMyView::doInvert()
    {
     running = TRUE; 
     for(int y = 0; running && y < image.height; y++)
         { /* do one row */
          MSG msg;
          if(PeekMessage(&msg, AfxGetMainWnd()->m_hWnd,
                         0, 0, PM_REMOVE))
             { /* handle it*/
              TranslateMessage(&msg);
              DispatchMessage(&msg);
             } /* handle it */
 
          for(int x = 0; running && x < image.width; x++)
              { /* change it */
               changePixel(x, y);
              } /* change it */
         } /* do one row */
    }

这样它每行只测试一次,如果行很短,这可能太频繁,如果行很长,则不够频繁。泛化,将测试更改为

if(x % 10 == 0 && PeekMessage(...))

如果行太短,将会起作用。

还有一些问题没有解决;例如,如果有一个非模态对话框处于活动状态,你会注意到那里没有IsDialogMessage调用来处理它。哦。同样,也没有处理ActiveX事件通知的代码。哦。而且它假定你正在使用纯C语言,并且自己进行分派。当需要支持MFC的消息映射和控件范式时,生活变得更加复杂。哦,平方。

但是,既然有更好的方法,为什么还要费力呢?

线程解决方案

几乎总有办法使用线程来更轻松地完成工作。这并非没有一定的成本和风险,但最终它是更好的方法。

下面是如何处理反转一切的解决方案。请注意,我们必须从纯C域(我们通过静态方法进行接口)转移到MFC域。

向类(在此示例中,为CView派生类)添加以下声明

static UINT run(LPVOID p);
void run();
volatile BOOL running;

要启动线程,你的处理程序执行

void CMyView::doInvert()
    {
     running = TRUE;
     AfxBeginThread(run, this);
    }
 
UINT CMyView::run(LPVOID p)
    {
     CMyView * me = (CMyView *)p;
     me->run();
     return 0;
    }
 
void CMyView::run()
   {
     for(int x=y = 0; running && y < image.height; y++)
          for(int x = 0; running && x < image.width; x++)
              changePixel(x, y);
    running = FALSE;
   }

停止线程的命令非常简单

void CMyView::OnStop()
   {
    running = FALSE;
   }

就是这样!

好吧,差不多。请继续阅读。

例如,上面的代码假定线程不会尝试访问视图的任何成员变量,除非它确定它们存在。这包括同步原语的句柄或指向可能用于同步线程与其视图之间交互的CRITICAL_SECTION的指针。这需要一个更优雅的关闭机制

注意,running变量的声明包括volatile修饰符。这是因为在某些优化下,编译器会发现循环体内没有任何东西会改变running标志,因此,它会巧妙地避免在每次循环时进行测试。这意味着,尽管你在另一个线程中更改了该值,但更改永远不会被看到。通过添加volatile修饰符,你告诉编译器,即使循环中没有代码可以更改变量,也不能假设变量在循环执行期间不会被修改。

工作线程与GUI I:启用控件

问题是,当你的工作线程运行时,可能有很多事情你不应该做。例如,启动一个线程进行计算。然后你会有两个线程在运行相同的或相似的计算,这样就会导致疯狂(我们暂时假设这确实一件坏事)。

幸运的是,这很容易。考虑你的ON_UPDATE_COMMAND_UI处理程序

void CMyView::OnUpdateInvertImage(CCmdUI * pCmdUI)
    {
     pCmdUI->Enable(!running && (whatever_used_to_be_here));
    }

将其泛化以涵盖其他菜单项留给读者练习。但是,请注意,这解释了为什么在上面的线程处理例程的末尾会有赋值running = FALSE;:它显式地强制running标志反映线程的运行状态。(好吧,如果你非常挑剔,请注意,如果当前线程没有快速返回测试running标志,则有可能在当前线程完成之前启动另一个线程,因此你可能希望使用一个单独的布尔变量来指示线程状态。在线程开始之前设置它,只在线程循环完成后清除它。对于大多数线程用法,一个running标志通常就足够了。)

工作线程与GUI II:不要触摸GUI

没错。工作线程不得触摸GUI对象。 这意味着你不应该查询控件的状态,向列表框添加内容,设置控件的状态,等等。

为什么?

因为你可能会陷入严重的死锁情况。一个经典的例子发布在一个讨论板上,它描述了我去年发生的事情。情况是这样的:你启动一个线程,然后决定等待线程完成。与此同时,线程执行了看似无害的操作,例如向列表框添加内容,或者在帖子中的示例中,调用FindWindow。在这两种情况下,进程都急剧停止,因为所有线程都死锁了。

让我们详细分析这两种情况,以便你了解发生了什么。

在我看来,列表框通过SendMessage向其父窗口发送了一个通知。这意味着消息发送到了其父线程。但是父线程被阻塞了,等待线程完成。但是线程无法完成,直到它可以运行,你猜怎么着:SendMessage是跨线程的SendMessage,它直到被处理才会返回。但是唯一可以处理它的线程被阻塞了,直到线程完成才能运行。欢迎来到死锁的世界。

FindWindow问题非常相似。程序员指定查找具有特定标题的窗口。这意味着运行FindWindow的线程必须SendMessage一个WM_GETTEXT消息到它刚刚通过EnumWindows找到句柄的窗口。此消息直到拥有窗口的线程能够执行才能被处理。但是它不能,因为它被阻塞,等待线程完成。死锁。所以请注意,尽管你不应该显式地触摸GUI线程,但你也不能隐式地触摸它,通过看似无害的操作,如FindWindow,否则你可能会,而且将会,遇到死锁。在这些情况下,顺便说一句,没有恢复。你可以终止进程,它会显式地终止所有线程,但这既不优雅,也没有像警告框告诉我们的那样,necessarily safe。

你如何解决这些问题?

在我看来,我只是太粗心了。我actually知道得更多,并且已经写了相关的文章。所以,关于成为专家就到此为止。在几乎所有情况下,变通方法是你绝不能向GUI对象发送SendMessage。在极少数情况下,你可以使用PostMessage,尽管这通常无效,因为你需要传递指向字符串的指针,并且无法知道操作何时完成,因此你不知道何时可以释放字符串。任何传递的字符串都必须分配在静态存储(作为常量)或堆上。如果它分配在可写静态存储或堆上,你需要知道何时可以重用或释放该值。PostMessage不允许你这样做。

因此,唯一的解决方案是使用用户定义的的消息,并将其发布到主GUI线程(通常是主框架,但通常是CView派生对象)。主GUI线程然后处理发送到GUI对象的SendMessage,并因此知道操作何时完成,从而能够释放任何资源。

你不能在PostMessage调用中发送指向堆栈分配对象的指针。到操作开始执行时,对象很可能已经被从堆栈中移除了。这会导致一个不好的结果。

本文将不详细介绍如何管理用户定义的消息。这在我的消息管理文章中有介绍。我们将假设有一些用户定义的消息,名称类似于UWM_ADDSTRING,它们已经定义好了。

以下代码演示了如何将窗口名称添加到列表框

void CMyDialog::AddNameToControl(CString & name)
    {
     CString * s = new CString(name);
     PostMessage(UWM_ADDSTRING, 0, (LPARAM)s;
    }

在CMyDialog.h的类处理程序集合中,我添加了声明

afx_msg LRESULT OnAddString(WPARAM, LPARAM);

在类的MESSAGE_MAP中,我添加了

ON_REGISTERED_MESSAGE(UWM_ADDSTRING, OnAddString);

ON_MESSAGE(UWM_ADDSTRING, OnAddString)

(如果你对区别感到好奇,请阅读我的消息管理文章)。

然后处理程序看起来像这样

LRESULT CMyDialog::OnAddString(WPARAM, LPARAM lParam)
    {
     CString * s = (CString *)lParam;
     c_ListBox.AddString(*s);
     delete s;
     return 0;
    }

消息的发送者必须在堆上分配要发送的值,它通过new运算符完成。消息被发布到主GUI线程,该线程最终将其路由到OnAddString处理程序。这个特定的处理程序知道消息是发送给特定控件c_ListBox的(如果你不明白如何为控件获取控件变量,请阅读我关于该主题的文章)。请注意,我们可以将目标控件ID或目标CWnd类变量放在wParam中,从而使其更通用。处理程序调用AddString方法。当此调用完成时,就知道了字符串值不再需要(如果列表框是自绘且没有LBS_HASSTRINGS,情况会不同,但如果你已经知道如何做到这一点,解决方案应该是显而易见的)。因此,我们可以删除堆上分配的对象,在本例中是一个CString

工作线程与GUI III:对话框和消息框

你不能尝试从工作线程启动任何GUI窗口。这意味着工作线程不能调用MessageBoxDoModal、创建非模态对话框等等。处理此问题的唯一方法是向主GUI循环发布一个用户定义的消息来为你执行此服务。

如果你尝试这样做,你会得到各种奇怪的失败。你会从MFC得到ASSERT错误,对话框将无法出现,基本上你会得到应用程序的各种反常行为。如果你真的、真的需要让GUI对象从线程运行,你不能使用工作线程。你必须使用一个有消息泵的用户界面线程。我不是这些方面的专家,尽管我写了一篇简短的文章,介绍我在做一个SAPI应用程序时发现了什么,这可能有助于使用它们。

如果你必须暂停线程直到对话框或MessageBox完成,你可能应该使用Event进行同步。你所做的是创建一个例如自动重置的Event,它在Clear状态下创建。向主GUI线程发布一个用户定义的消息(请参阅我关于消息管理的文章),然后主GUI线程将启动对话框。当对话框完成时,它调用::SetEvent来设置Event。与此同时,线程在发布消息后,使用::WaitForSingleObject等待Event被设置。线程将阻塞,直到对话框完成并设置Event。

我真的应该检查VC++ 6.0的CEvent实现。VC++ 4.2的实现充满了错误,完全无法使用。你已被警告。

工作线程与GUI IV:AfxGetMainWnd

好吧,我已经展示了如何向类的窗口发布消息。但是如果你想向主窗口发布消息呢?这很明显,对吧?只需执行

AfxGetMainWnd()->PostMessage(...)

你就完成了!对吗?当然不是。现在应该很明显,如果答案不是显而易见的,我就不会问这个问题了。

会发生什么情况是ASSERT失败或访问冲突。如果你进行一些调试上的巧妙操作,你会发现主窗口的指针是NULL!但这怎么可能呢!应用程序明显有一个主窗口……

好吧,答案是,是的,应用程序有一个主窗口。但这并不是AfxGetMainWnd的定义返回的!仔细阅读文档,你会看到它说

如果AfxGetMainWnd是从应用程序的主线程调用的,它会根据上述规则返回应用程序的主窗口。如果该函数是从应用程序的辅助线程调用的,则该函数返回与调用线程关联的主窗口。[强调已添加]

工作线程没有主窗口。因此,调用将返回NULL。变通方法是通过调用主线程中的AfxGetMainWnd来获取指向应用程序主窗口的指针,并将其存储在一个(例如,类成员变量)工作线程可以找到的地方。

等待线程启动 (2001年1月27日)

我最近处理了一个新闻组消息,该消息讨论了等待线程启动的问题。提出的机制看起来像这样

BOOL waiting; // flag (this is one bug right here!)
void CMyClass::OnStartThread()
   {
    waiting = TRUE;
    AfxBeginThread(myThread, something);
    while(waiting) /* spin */ ;   // horrible!
   }

UINT CMyClass::myThread(LPVOID whatever) // *** static *** member function
   {
    ... initialization sequence here
    waiting = FALSE; // allow initiatior to continue (probably won't work)
    ... thread computations
    return 0;
   }

这段代码有几个问题。首先,它是一个糟糕的实现;它要求父线程运行直到它的时间片结束,这意味着它延迟了创建线程的启动。这本身就足以丢弃代码。但即使它可以接受,它仍然是错误的waiting变量必须声明为volatile,因为在优化编译器下,while循环可能会被执行,也可能不会被执行,如果执行了,它可能会永远不会退出。我在关于" Surviving the Release Build"的文章中更详细地讨论了这一点。但底线是,当你有任何变量可以从一个线程修改,并且其修改必须在另一个线程中检测到时,你必须将其声明为volatile

忙等待是这里严重的灾难。由于NT中的时间片或量子是200毫秒,这意味着线程可能浪费长达200毫秒,什么都不做,然后才允许子线程运行。如果子线程阻塞在某种I/O操作上,并且控制权返回给创建者,每次控制权返回给创建者时,它都会消耗另外200毫秒(内核不关心它只是轮询一个布尔变量,该变量在单处理器上轮询时永远不会改变;它只知道线程正在执行)。正如你所看到的,线程中的几次I/O操作就会给感知到的线程启动时间增加几秒钟。

正确的解决方案如下所示。在此解决方案中,使用了手动重置Event。为了简化代码,我们在需要它之前创建它,并在之后立即销毁它;如果线程可能被启动多次,将此移至成员变量的优化应该很明显。注意,将其作为成员变量表明Event将在类的构造函数中创建,并在析构函数中销毁。

class CMyClass : public CView { // or something like this...
     protected:
         HANDLE startupEvent;
 };

void CMyClass::OnStartThread()
   {
     startupEvent = ::CreateEvent(NULL, // no security attributes
                                  TRUE, // manual-reset
                                  FALSE,// initially non-signaled
                                  NULL);// anonymous
     AfxBeginThread(myThread, this);
     switch(::WaitForSingleObject(startupEvent, MY_DELAY_TIME))
         { /* waitfor */
          case WAIT_TIMEOUT:
              ... report problem with thread startup
              break;
          case WAIT_OBJECT_0:
              ... possibly do something to note thread is running
              break;
         } /* waitfor */
     CloseHandle(startupEvent);
     startupEvent = NULL; // make nice with handle var for debugging
   } 

UINT CMyClass::myThread(LPVOID me)
   {
    CMyClass * self = (CMyClass *)me;
    self->run;
    return 0;
   }

void CMyClass::run( )
   {
    ... long initialization sequence
    ::SetEvent(staruptEvent);
    ... loop computations
   }

请注意,我没有处理启动超时可能不正确且线程仍在尝试启动的事实;例如,这可以通过尝试::WaitForSingleObject线程句柄,等待时间为0来实现;如果超时,则线程正在运行;如果返回WAIT_OBJECT_0,则线程已停止。这需要你处理CWinThread对象可能在你获取句柄之前被删除的问题。不,我不会尝试编写每一行代码。

实际上,我很少会这样做。我更倾向于使用发布到主窗口的消息来建立GUI的状态:线程正在启动,线程已启动,线程已终止(注意,它可能在未启动的情况下终止)。这避免了GUI阻塞直到线程实际完成启动序列,或处理线程以某种方式在设置::SetEvent之前死亡的超时问题。

void CMyClass::OnStartThread( )
   {
    AfxBeginThread(myThread, this);
    PostMessage(UWM_THREAD_STARTING);
   }

UINT CMyClass::myThread(LPVOID me) // *** static *** member function
   {
    CMyClass * self = (CMyClass *)me;
    self->run( );
    return 0;
   }

void CMyClass::run( )
   {
    ... lengthy startup sequence
    PostMessage(UWM_THREAD_STARTED);
    ... long thread computation
    PostMessage(UWM_THREAD_STOPPING);
    ... long thread shutdown
    PostMessage(UWM_THREAD_TERMINATED);
   }

我在许多上下文中都使用了上述范例。请注意,它完全消除了同步的需要,但增加了GUI的一些复杂性。例如,设想我在GUI中(在本例中,因为我发布到视图,所以它是视图特定的状态)有一个编码当前状态的成员变量:已终止或从未启动,正在启动,正在停止,正在运行。我可能有菜单项,称为启动计算暂停计算取消计算。我会创建ON_UPDATE_COMMAND_UI处理程序,这些处理程序响应如下

void CMyClass::OnUpdateStart(CCmdUI * pCmdUI)
   {
    pCmdUI->Enable(threadstate == MY_THREAD_STOPPED);
   }
void CMyClass::OnUpdatePause(CCmdUI * pCmdUI)
   {
    pCmdUI->Enable(threadstate == MY_THREAD_RUNNING);
   }
void CMyClass::OnUpdateCancel(CCmdUI * pCmdUI)
   {
    pCmdUI->Enable(threadstate == MY_THREAD_RUNNING);
   }

如果我真的不需要等待(而我发现我很少需要),我现在已经避免了在主GUI线程中引入阻塞同步事件的需要,这可能会锁定GUI。另请注意,我可能会将取消情况更改为允许在线程启动过程中取消线程,前提是这在线程计算的上下文中是有意义的。在这种情况下,我必须在启动过程中“轮询”取消标志,例如,通过将启动拆分成一个单独的函数

void CMyClass::run( )
   {
    BOOL byCancel = FALSE;
    if(!startMe( ))
     { 
      PostMessage(UWM_THREAD_STOPPED, TRUE); // stopped by cancel
      return;
     }
    PostMessage(UWM_THREAD_STARTED);
    while(!cancelFlag)
      { /* thread loop */
    ...lengthy thread computation
      } /* thread loop */
    
    byCancel = cancelFlag;
    PostMessage(UWM_THREAD_STOPPING);
    ...lengthy thread shutdown
    PostMessage(UWM_THREAD_STOPPED, byCancel);
   }

BOOL CMyClass::startMe( )
   {
    ...do something
    if(cancelFlag)
      return FALSE;
    ...open the file on the server
    if(cancelFlag)
       {
        ...close the file on the server
        return FALSE;
       }
    ... more stuff, following above idea
    return TRUE;
   }

与往常一样,在这种情况下,重要的是要撤销你所做的一切,如果你检测到CancelFlag在启动过程中已被设置。我还定义了消息的WPARAM,其中包含一个标志,指示线程是正常停止还是由于用户取消而停止(我可能会使用它在日志中显示线程被用户请求停止)。我甚至可能会扩展到一组枚举类型,以便在线程由于某个问题而决定终止时传递错误代码。我甚至可能使用LPARAM来保存::GetLastError代码。你看,这个基本方案有很多主题和变体。

暂停线程和线程关闭 

线程可能需要出于某种原因停止和等待。也许用户点击了“暂停”复选框或按下了“停止”按钮。也许线程无事可做,正在等待某些信息,如请求包进行处理。问题是你必须在进程退出之前关闭所有线程(注意:在Windows CE中,关闭主线程会关闭进程及其拥有的所有线程。这在Win9x、Windows NT或Windows 2000中是不成立的)。一个典型的错误是,你关闭了程序,重新编译了它,然后得到了一个无法写入可执行文件的错误。为什么?因为程序仍在运行。但你在任务栏上看不到它。所以你打开任务管理器(通过Ctrl-Alt-Del),它也不在那里。但某个东西卡住了你的可执行文件!答案是,如果你在NT任务管理器中查看进程选项卡,或者使用pview查看进程,你确实会发现你的程序仍在运行。这通常是因为你未能关闭一个或多个工作线程。只要一个线程存在,即使它被阻塞了,你的进程仍然存活。当然,如果你杀死了GUI线程,那么主窗口就消失了,什么也看不见了。但它仍然潜伏在那里。当然,你可以使用NT任务管理器或pview来终止所有线程以杀死进程,但这有点像用炸药掀起汽车来换轮胎。是的,它会抬起汽车,但还有其他一些事情会出错,这些都被认为是不可取的副作用。

三个函数立即出现,用于暂停或关闭线程:SuspendThreadResumeThread方法(及其底层API调用,::SuspendThread::ResumeThread)和::TerminateThread。假设,就实际而言,除了某些非常有限的上下文外,这些函数不存在。使用它们几乎总会给你带来麻烦。

可以使用的有限上下文是

  • 线程通常可以调用SuspendThread(或::SuspendThread)来挂起自身,前提是它知道自己处于“安全”状态(例如,它没有持有它应该释放的任何同步原语)。这里的风险是你不知道你的调用者锁定了什么,并且你可能正在挂起一个调用者函数未预期的线程。所以即使在这种有限的情况下也要极其小心
  • 线程总是可以调用ResumeThread(或::ResumeThread)来恢复另一个被挂起的线程。例如,在以挂起模式创建/启动线程的情况下,你必须调用ResumeThread/::ResumeThread才能使其运行!

请注意,让一个线程调用TerminateThread来终止自身不是一个好主意,因为这意味着它将立即终止。这意味着DLL_THREAD_DETACH事件对于各种DLL将不会被执行,这可能导致你甚至不知道你在使用的DLL出现故障!(如果你不理解这意味着什么,就把它当作:绕过DLL_THREAD_DETACH是一件非常糟糕的事情)。相反,如果一个线程想杀死自己,它应该调用ExitThread,这能确保正确通知所有DLL。

请注意,你不应该用SuspendThread/ResumeThread来代替事件和互斥锁等同步对象的正确使用。

为了说明让一个线程挂起另一个线程是多么糟糕,让我们看一个简单的情况:你有一个工作线程在做某事,而某事涉及内存分配。你点击GUI上的“暂停”按钮,它立即调用SuspendThread。会发生什么?工作线程停止了。就在此时。立即。无论它在做什么。如果你的工作线程碰巧在存储分配器中,你就关闭了整个应用程序。好吧,不完全是——它不会关闭,直到下一次你尝试从GUI线程分配内存。但是MFC库相当自由地使用内存分配,所以很有可能下一个从任何线程对MFC库的调用将直接导致该线程停止。如果你的GUI线程(最有可能的),你的应用程序就会挂起。

为什么会这样?

存储分配器被设计成线程安全的。这意味着一次最多只允许一个线程执行它。它由CRITICAL_SECTION保护,每个尝试进入分配器的线程如果已经有一个线程在分配器中就会被阻塞。那么如果你执行SuspendThread会发生什么?线程会立即停止在分配器中间,并持有临界区锁。此锁将不会被释放,直到线程恢复。现在,如果碰巧你的GUI需要一个分配作为恢复线程的一部分,尝试恢复线程将阻塞,产生经典的死锁。如果你执行了::TerminateThread,那么锁将永远不会被释放。并且没有SuspendThread,就没有ResumeThread的需要。

啊,你说,但我知道我既不在工作线程也不在GUI中进行任何内存分配。所以我不需要担心这个!

你错了。

请记住,MFC库会进行你看不见的分配。分配只是MFC库中用于使其线程安全的许多临界区之一。停止在任何其中一个中都将是你的应用程序的致命错误。

忽略这些函数的存在。

那么,你如何暂停或终止线程呢?

关闭线程和暂停线程的问题密切相关,我的解决方案在两种情况下都是相同的:我使用同步原语通过挂起线程来实现暂停,并使用原语上的超时来允许我轮询关闭。

有时同步原语是一个简单的事件,例如在下面的示例中,我想暂停线程。在其他情况下,特别是当使用线程服务事件队列时,我将使用信号量等同步原语。你也可以阅读我关于GUI线程技术的文章。

通常,我会在后台使用一个工作线程,它只是轮询一些状态(如布尔值)来确定它应该做什么。例如,要暂停一个线程,它会查看说“暂停”的布尔值,当(例如)一个复选框被设置时,它就会被设置

// thread body:
while(running)
   { /* loop */
    if(paused)
       switch(::WaitForSingleObject(event, time))
          {
           case WAIT_OBJECT_0:
              break;
           case WAIT_TIMEOUT:
              continue;
          }
     // rest of thread
    } /* loop */

continue用于超时的技巧意味着线程将定期轮询running标志是否已清除,从而简化应用程序的关闭。我通常使用1000作为时间,每秒轮询一次以关闭。

我为什么在::WaitForSingleObject之前执行了对paused布尔变量的看似冗余的测试?等待对象本身不就足够了吗?

是的,paused布尔值是一个优化技巧。因为我使用了::WaitForSingleObject,每次循环都涉及一个中等强度的操作来测试是否继续。在一个高性能循环中,这会引入一个完全不可接受的性能瓶颈。通过使用一个简单的布尔值,我可以在不需要时避免这种高强度的内核调用。如果性能不是问题,你可以消除这个额外的测试。

在主GUI线程中设置这些变量的代码如下所示

CMyDialog::OnPause()
   {
    if(paused && c_Pause.GetCheck() != BST_CHECKED)
       { /* resume */
        paused = FALSE;
        SetEvent(event);
       } /* resume */
    else
    if(!paused && c_Pause.GetCheck() == BST_CHECKED)
       { /* pause */
        paused = TRUE;
        ResetEvent(event);
       } /* pause */
   }

其中event是来自::CreateEvent的句柄。避免使用CEvent——至少在我上次尝试它的时候,它非常糟糕且有bug,以至于无法使用。我没有检查VC++ 6.0的实现,所以它可能已经被修复了,但底线是MFC与同步原语的接口一直都很可疑,并且与使用实际原语相比没有任何优势。

我将在后续章节中讨论另一种稍微复杂一些的、无需轮询即可关闭线程的机制。

从视图或主框架关闭线程  

有时在关闭线程时会出现问题。如果不按正确的顺序操作,你甚至可能在工作线程仍在运行时关闭GUI线程,这可能导致各种有趣的问题。有趣,就像那句中国诅咒语一样。所以这里有一个我用来关闭线程并确保它在视图关闭之前被关闭的方法。

首先,你必须在你的视图中存储一个指向CWinThread对象的指针,所以声明

CWinThread * myWorkerThread;

在你的视图中。当你创建工作线程时,以如下方式创建它

myWorkerThread = AfxBeginThread(run, this);

你需要这个变量来同步关闭与视图终止。

void CMyView::OnClose()
    {
     // ... determine if we want to shut down
     // ... for example, is the document modified?
     // ... if we don't want to shut down the view, 
     // ... just return
  
     // If we get here, are are closing the view
     myWorkerThread->m_bAutoDelete = FALSE;
     running = FALSE;
     WaitForSingleObject(myWorkerThread->m_hThread, INFINITE);
     delete myWorkerThread;
     CView::OnClose(); // or whatever the superclass is
    }

前面函数中唯一的奇怪之处是显式地将m_bAuthoDelete标志保存为FALSE。这是因为CWinThread派生对象的删除可能会关闭线程的句柄,从而使后续的WaitForSingleObject无效。通过禁止自动删除,我们可以等待线程句柄。然后我们自己显式删除CWinThread派生对象,因为它不再有用。

特别感谢Charles Doucette指出了我最初文章中的一个缺陷,他在Doug Harrison的另一篇文章中发现了这个缺陷:存在一个竞态条件;我之前存储了句柄并关闭了线程。但是自动删除使句柄无效,这可能导致不正确的行为。

通过将句柄存储在变量中,我们可以对线程执行WaitForSingleObject。关闭操作将阻塞,直到线程终止。一旦线程终止,我们就可以通过调用我们的超类OnClose处理程序(在本例中,我们是一个CView的派生类)来继续关闭。

这里有一个警告:这假定线程将在“合理的时间内”实际终止。如果你有一个线程阻塞在I/O或同步对象上,你需要添加一个超时机制,正如我已经描述过的。请注意,这禁止使用CRITICAL_SECTION作为同步对象,因为它们没有超时功能。如果你阻塞在一个CRITICAL_SECTION上,你就永远卡住了。

当然,在一般情况下,你可能需要几种同步机制来确保线程在合理的时间内终止。AfxBeginThread机制的一个严重设计缺陷是,它不允许我创建我自己的CWinThread派生子类作为创建的对象。在这种情况下,我有时会进行CWinThread的子类化,并通过我自己的子类中的线程创建来绕过AfxBeginThread,并导出诸如CMyWinThread::shutdown之类的方法,这些方法会执行所有必要的操作,以使线程干净利落地快速关闭。

线程关闭(无轮询) (2001年1月20日)

我使用的每隔几秒轮询一次的技术确实有两个含义:它使线程每隔几秒钟就活跃一次,并且限制了关闭时的响应能力。应用程序的关闭时间受到退出线程等待操作所允许的最大时间的限制。我还有另一种实现方式,它使用第二个Event。

HANDLE ShutdownEvent;

这应该通过CreateEvent初始化。当我使用这种技术时,我将其包含在从CWinThread派生的类中,这使得线程创建稍微复杂一些。这是因为AfxBeginThread总是创建一个新的CWinThread对象,但如果你需要自己的CWinThread派生类,你就不能使用AfxBeginThread。下面所示的技术可以泛化这一点。请注意,如果我想做到非常通用,我会创建一个template类。我将其留给读者练习。

/***********************************************************************
*                                class CMyThread
***********************************************************************/
class CMyThread : public CWinThread {
     public:
        CMyThread( );
        virtual ~CMyThread( );
        static CMyThread * BeginThread(LPVOID p);
        void Shutdown( );
        enum { Error, Running, Shutdown, Timeout };
     protected: // data
        HANDLE ShutdownEvent;
        HANDLE PauseEvent;
};
/**********************************************************************
*                        CMyThread::CMyThread
* Inputs:
*        AFX_THREADPROC proc: Function to be called
*        LPVOID p: Parameter passed to proc
***********************************************************************/
CMyThread::CMyThread(AFX_THREADPROC proc, LPVOID p ) : CWinThread(proc, p)
   {
     m_bAutoDelete = FALSE;
     ShutdownEvent = ::CreateEvent(NULL,   // security
                                   TRUE,   // manual-reset
                                   FALSE,  // not signaled
                                   NULL);  // anonymous

     PauseEvent = ::CreateEvent(NULL,      // security
                                TRUE,      // manual-reset
                                TRUE,      // signaled
                                NULL);     // anonymouse
   }

/**********************************************************************
*                         CMyThread::~CMyThread
**********************************************************************/
CMyThread::~CMyThread( )
   {
    ::CloseHandle(ShutDownEvent);
    ::CloseHandle(PauseEvent);
   }

/*********************************************************************
*                        CMyThread::BeginThread
* Result: CMyThread *
*        Newly-created CMyThread object
*********************************************************************/
CMyThread * CMyThread::BeginThread(AFX_THREADPROC proc, LPVOID p)
   {
    CMyThread * thread = new CMyThread(proc, p);
    if(!thread->CreateThread( ))
        { /* failed */
         delete thread;
         return NULL;
        } /* failed */
    return thread;
   }
/*********************************************************************
*                         CMyThread::Wait
* Result: DWORD
*       WAIT_OBJECT_0 if shutting down
*       WAIT_OBJECT_0+1 if not paused
* Notes:
*       The shutdown *must* be the 0th element, since the normal
*       return from an unpaused event will be the lowest value OTHER
*       than the shutdown index
*********************************************************************/
DWORD CMyThread::Wait( )
   {
    HANDLE objects[2];
    objects[0] = ShutdownEvent;
    objects[1] = PauseEvent;
    DWORD result = ::WaitForMultipleObjects(2, objects, FALSE, INFINITE);
    switch(result)
      { /* result */
       case WAIT_TIMEOUT:
           return Timeout;
       case WAIT_OBJECT_0:
           return Shutdown;
       case WAIT_OBJECT_0 + 1:
           return Running;
       default:
           ASSERT(FALSE); // unknown error
           return Error;
      } /* result */
   }

/********************************************************************
*                        CMyThread::Shutdown
* Effect:
*        Sets the shutdown event, then waits for the thread to shut
*        down
********************************************************************/
void CMyThread::Shutdown( )
   {
    SetEvent(ShutdownEvent);
    ::WaitForSingleObject(m_hThread, INFINITE);
   }

请注意,我没有为CreateThread提供全部选项,因为我创建的线程不需要标志、堆栈大小或安全属性;如果你需要这些功能,你需要进行显而易见的扩展。

要从应用程序调用它,我执行如下操作。在线程将运行的类的声明中,例如一个视图类,我添加声明,如下所示

CMyThread * thread; // worker thread
static UINT MyComputation(LPVOID me);
void ComputeLikeCrazy( );

然后我添加方法,例如这个响应视图中的菜单项或按钮的方法

void CMyView::OnComputationRequest( )
   {
    thread = CMyThread::BeginThread(MyComputation, this);
   }

UINT CMyView::MyComputation(LPVOID me) // static method!
   {
    CMyView * self = (CMyView *)me;
    self->ComputeLikeCrazy( );
   }

下面的代码演示了如何实现“暂停”功能。或者,PauseEvent变量可以代表队列或其他同步机制上的信号量。但是请注意,如果你想等待信号量、关闭或暂停,会更复杂。在这种情况下,因为你只能等待“或”的事件,而不是更复杂的组合,所以你可能需要嵌套两个WaitForMultipleObjects,一个用于信号量或关闭组合,另一个用于暂停或关闭组合。虽然我在下面没有展示,但你也可以将此技术与超时结合使用。请注意,在下面的示例中,running标志实际上是局部的,而不是类成员变量,并且由解码ShutdownEvent的情况隐式处理。

void CMyView::ComputeLikeCrazy( )
   {
    BOOL running = TRUE;

    while(running)
      { /* loop */
       DWORD result = thread->Wait( );
       switch(result)
          { /* result */
           case CMyThread::Timeout:   // if you want a timeout...
              continue;
           case CMyThread::Shutdown:  // shutdown event
              running = FALSE;
              continue;
           case CMyThread::Running:   // unpaused
              break;
          } /* result */
       // ...
       // ... compute one step here
       // ...
      } /* loop */
   }

请注意,我为超时情况提供了支持,尽管当前实现不支持(留给读者练习)。

同步

任何时候,如果两个线程共享状态,都必须提供访问同步。我在我们的书《Win32 Programming》中对此进行了大量讨论,并且不打算在此处复制该讨论。然而,奇怪的是,对于paused这样的变量,我没有提供任何同步。我怎么能这样做呢?

答案是,只要只有一个线程修改该变量,同步就不是必需的,至少在某些受限制的情况下。在我们的示例中,主GUI线程修改变量paused,但所有其他线程,如工作线程,只读取它。确实,工作线程可能会因为一个指令而错过检测到它,但这想法是,多执行一个工作线程的循环也没有关系,因为用户可能已经错过了几十甚至几百毫秒。

有人指出,即使只有一个线程修改该变量(尽管有多个线程使用它),如果它需要一个以上的指令(或一个内存周期)来完成,那么同步是必需的。例如,如果该值是一个64位值,并且使用了两个32位指令来存储它(因为你没有为本机Pentium指令集编译),那么在第一个32位存储后(无论编译器选择先执行高位还是低位),修改线程可能被抢占,但第二个32位没有存储。事实上,这是正确的。如果编译器生成一个64位存储,可能就没有问题。Pentium总线是64位宽的,同步在硬件层面完成。但是,如果该值未对齐,使得单次内存周期无法存储它(例如,一个32位值跨越64位边界),那么就需要两次内存周期才能完成存储,这在多处理器环境中存在风险。因此,你应该小心利用这个特性。Chris Bond指出这一点,谨此致敬。

那么,关于我在AfxBeginThread之前将running设置为TRUE,并在线程退出时将其设置为FALSE的情况呢?那不是违背了我之前的说法吗?嗯,是的。但是请注意,在这种情况下同步仍然存在。线程正在终止,因此线程中剩余的任何计算即将完成。线程中将不会进行其他工作。GUI线程不会启动新线程,直到running标志为FALSE。除非你将CWinThreadm_bAutoDelete成员设置为FALSE,否则包括删除对象在内的所有清理都将自动处理。所以我们实际上可以“蒙混过关”。

如果你想完全正确和精确,唯一的有效解决方案是让另一个线程等待你的第一个工作线程,当第一个工作线程完成时,第二个工作线程启动,将running设置为FALSE,然后终止自身。这有点笨拙,本质上是多余的,但形式上是正确的。

摘要

使用线程会带来一些复杂性,但与处理PeekMessage的问题相比,它是一种更好的并行计算概念的泛化。需要付出的关注意初时令人惊讶,但在完成几个多线程应用程序后,它几乎会成为一种本能。学会使用线程。长远来看,你将受益匪浅。

历史

(2001年1月27日) 作为对几个关于ResumeThread问题的回应,我扩展了对该主题的讨论

  (2001年1月27日) 作为对新闻组中一些问题的回应,我添加了关于如何等待线程启动的讨论。

  (2001年1月20日) 一种线程关闭的替代机制——特别是,你如何在线程内部检测到它正在被关闭——现在已经文档化了(我在写Win32 Programming时本想放进去,但六个月后才想出来)。我本想在原始文章中放入这个,但忘记了。

  (2000年4月10日)从视图或主框架关闭线程的线程关闭逻辑中发现了一个缺陷;你必须显式禁止CWinThread派生对象的自动删除!

  添加了一个新章节,讨论如何关闭与视图相关的线程。

  (2000年1月28日)暂停和恢复线程的描述得到了增强,更详细地讨论了为什么必须避免使用SuspendThreadResumeThread


这些文章中表达的观点是作者的观点,不代表,也不被微软认可。

发送邮件至newcomer@flounder.com提出关于本文的问题或评论。
版权所有 © 1999CompanyLongName保留所有权利。
www.flounder.com/mvp_tips.htm
© . All rights reserved.