windbg调试常见线程错误
调试“资源竞争”多线程会在写代码、维护和调试并行执行单元的时候带来很多挑战,尤其是共享资源的时候,例如多线程程序中使用全局变量和线程环境上下文状态,
Broken invariants introduced by the parallel nature of a program and the relative order in which its threads get scheduled by the
operating system are called race conditions.
翻译过来,即: 线程由于系统计划执行的顺序不确定性和并行性而造成对程序变量值得破坏,称为资源竞争(race condition)
这种错误情况很难复制,因为导致程序失败的线程计划执行顺序相对来说是不确定的,最好的阻止这类bug产生的方式是在检查设计和编码阶段仔细检查。
多线程程序设计不仅对普通人,即使对最优秀的工程师来说也很难,要避免这类bug,第一步是认识他们,主要有3种方式可能产生资源竞争:
在需要正确同步的多个线程中修改共享内存。这种情况经常产生的逻辑错误是内部代码变量被未同步的线程访问破坏,这种情况极为棘手,通常不会产生程序崩溃之类很明显的现象,而是仅仅产生错误结果。
某个共享变量的生命期超过使用它们的工作线程的生命期。变量在释放后,会导致线程访问无效内存地址。
工作线程中的某模块代码在卸载后被程序执行,例如执行dll模块时,同样这种情况会产生访问冲突。
资源竞争c++实例:
#include <windows.h>
#include <stdio.h>
DWORD WINAPI PrintProc1(LPVOID lpParameter)
{
char* buffer=(char*)lpParameter;
for(int i=0;i<64;i++)
{
printf("Thread 1:%c\n",buffer);
}
return 0;
}
DWORD WINAPI PrintProc2(LPVOID lpParameter)
{
char* buffer=(char*)lpParameter;
for(int i=0;i<64;i++)
{
printf("Thread 2:%c\n",buffer);
}
return 0;
}
void main()
{
char buffer;
int i;
for(i=0;i<64;i++)
{
buffer=i%('z'-'0')+'0';
}
LPTHREAD_START_ROUTINE procs={PrintProc1,PrintProc2};
HANDLE lpHandles={NULL,NULL};
for(i=0;i<2;i++)
{
lpHandles=CreateThread(NULL,1024,procs,(LPVOID)buffer,0,NULL);
}
WaitForMultipleObjects(2,lpHandles,true,INFINITE);
for(i=0;i<2;i++)
{
CloseHandle(lpHandles);
}
}
在windbg中可以通过~f和~u命令冻结线程,控制线程执行顺序,从而模拟资源竞争的出现。
下面是个C#例:
class Program
{
private static void ThreadProc(
object data
)
{
string hashValue = Convert.ToBase64String(
g_hashFunc.ComputeHash(data as byte[]));
Console.WriteLine("Thread #{0} done processing. Hash value was {1}",
Thread.CurrentThread.ManagedThreadId, hashValue);
}
private static void Main(
string[] args
)
{
int n, numThreads;
Thread[] threads;
numThreads = (args.Length == 0) ? 1 : Convert.ToInt32(args);
threads = new Thread;
g_hashFunc = new SHA1CryptoServiceProvider();
//
// Start multiple threads to use the shared hash object
//
for (n = 0; n < threads.Length; n++)
{
threads = new Thread(ThreadProc);
threads.Start(Encoding.UTF8.GetBytes("abc"));
}
//
// Wait for all the threads to finish
//
for (n = 0; n < threads.Length; n++)
{
threads.Join();
}
}
private static HashAlgorithm g_hashFunc;
}
C:\book\code\chapter_09\HashRaceCondition\Bug>test.exe 2
Thread #3 done processing. Hash value was qZk+NkcGgWq6PiVxeFDCbJzQ2J0=
Thread #4 done processing. Hash value was qZk+NkcGgWq6PiVxeFDCbJzQ2J0=
C:\book\code\chapter_09\HashRaceCondition\Bug>test.exe 2
Thread #3 done processing. Hash value was +MHYcAb79+XMSwJsMTi8BGiD3HE=
Thread #4 done processing. Hash value was AAAAAAAAAAAAAAAAAAAAAAAAAAA=
通常解决上面错误的方式有2种:1.给每个线程分配独立的hash对象。2.给全局变量加锁这2种方式走了2个极端
前者需要在每个线程中控制hash对象生命周期,创建和销毁对象。后者摒弃了多核带来的性能优势。
一个更好的解决方式是使用线程池设计模式。
池对象是一系列对象的集合,池可以对对象进行检查并分配给线程完成一定操作,线程使用之后会将对象返回给池,并设置为可重用状态。
这种设计模式会重置对象状态为可重用状态,这种操作的代价显然比重新创建一个对象来的小,同时也适应并行机制。
使用池对象时,通常会将该池中填充满对象。典型池对象定义入下:
class ObjectPool<T>
where T : class, new()
{
public ObjectPool(
int capacity
)
{
...
m_objects = new Stack<T>(capacity);
//
// objects will be lazily created only as needed
//
for (n = 0; n < capacity; n++)
{
m_objects.Push(null);
}
m_objectsLock = new object();
m_semaphore = new Semaphore(capacity, capacity);
}
public T GetObject()
{
T obj;
m_semaphore.WaitOne();
lock (m_objectsLock)
{
obj = m_objects.Pop();
}
if (obj == null)
{
obj = new T(); // delay-create the object
}
return obj;
}
public void ReleaseObject(
T obj
)
{
...
lock (m_objectsLock)
{
m_objects.Push(obj);
}
//
// Signal that one more object is available in the pool
//
m_semaphore.Release();
}
private Stack<T> m_objects;
private object m_objectsLock;
private Semaphore m_semaphore;
}
再给先前c#代码使用了对象池后,结果会正常:
Thread #3 done processing. Hash value was qZk+NkcGgWq6PiVxeFDCbJzQ2J0=
Thread #4 done processing. Hash value was qZk+NkcGgWq6PiVxeFDCbJzQ2J0=
Test completed in 16 ms
共享状态生命周期管理bug
相对于使用共享状态的线程来说,工作线程中访问该共享状态时需要很小心,无论该状态是显式线程上下文还是变量。
这类竞争条件是就本地代码而言。
线程上下文和引用计数:
当你在C/C++中手动创建了一个新线程是,你可以使用一个通用指针变量共享新线程的上下文状态。如果这样做的话,你需要明确
在新线程回调例程结束之前该变量是否已经销毁,如果不清楚这一点的话,程序可能会在变量销毁后崩溃,下面的代码:
static
HRESULT
MainHR(
VOID
)
{
CAutoPtr<CThreadParams> spParameter;
CHandle shThreads;
int n;
ChkProlog();
spParameter.Attach(new CThreadParams());
ChkAlloc(spParameter);
ChkHr(spParameter->Init(L"Hello World!"));
//
// Create new worker threads with non-ref counted shared state
//
for (n = 0; n < ARRAYSIZE(shThreads); n++)
{
shThreads.Attach(::CreateThread(
NULL, 0, WorkerThread, spParameter, 0, NULL));
ChkWin32(shThreads);
}
//
// Do not Wait for the worker threads to exit
// DWORD nWait = WaitForMultipleObjects(ARRAYSIZE(shThreads),
// (HANDLE*)shThreads, TRUE, INFINITE);
// ChkWin32(nWait != WAIT_FAILED);
//
Sleep(10);
ChkNoCleanup();
}
上面的代码很可能会像预期的那样正常运行很多次,但是存在一个严重的资源竞争问题,spParameter内存地址绑定的智能指针会在MainHR退出时摧毁,所以在工作线程回调例程中使用是不安全的。如果运行过足够多次程序,可以看到打印的信息暴漏了一些垃圾字符,说明有内存崩溃。
Test message... Hello World!
Test message... Hello World!
Test message... Hello World!
Test message... Hello World!
Test message... Äello World!
Test message... Äello World!
Success.
Test message... okkkk
有2种方式可以解决上面代码的资源竞争:
第一个解决方法是取消后面代码的注释,在MainHR函数结束之前等待工作线程
如果不想等待工作线程,可以使用引用计数,这种技术普遍应用于管理那些被多个逻辑主体使用的共享资源,例如com,
就是应用了这种技术,很好的在多线程程序设计中发挥作用,该例中可以这样实现:
static
DWORD
WINAPI
WorkerThread(
__in LPVOID lpParameter
)
{
//
// The reference count is automatically decremented when the callback exits
// thanks to the CComPtr ATL smart pointer calling Release in its destructor
//
CComPtr<CThreadParams> spRefCountedParameter;
spRefCountedParameter.Attach(reinterpret_cast<CThreadParams*>(lpParameter));
wprintf(L"Test message... %s\n", spRefCountedParameter->m_spMessage);
return EXIT_SUCCESS;
}
static
HRESULT
MainHR(
VOID
)
{
CThreadParams* pRefCountedParameter;
CComPtr<CThreadParams> spRefCountedParameter;
CHandle shThreads;
DWORD dwLastError;
int n;
ChkProlog();
spRefCountedParameter.Attach(new CThreadParams());
ChkAlloc(spRefCountedParameter);
pRefCountedParameter = static_cast<CThreadParams*>(spRefCountedParameter);
ChkHr(spRefCountedParameter->Init(L"Hello World!"));
//
// Create new worker threads with reference-counted shared state
//
for (n = 0; n < ARRAYSIZE(shThreads); n++)
{
pRefCountedParameter->AddRef();
shThreads.Attach(::CreateThread(
NULL, 0, WorkerThread, pRefCountedParameter, 0, NULL));
dwLastError = ::GetLastError();
if (shThreads == NULL)
{
pRefCountedParameter->Release();
}
ChkBool(shThreads, HRESULT_FROM_WIN32(dwLastError));
}
ChkNoCleanup();
}
class CRefCountImpl
{
public:
//
// Declare a virtual destructor to ensure that derived classes
// will be destroyed properly
//
virtual
~CRefCountImpl() {}
CRefCountImpl() : m_nRefs(1) {}
VOID
AddRef()
{
InterlockedIncrement(&m_nRefs);
}
VOID
Release()
{
if (0 == InterlockedDecrement(&m_nRefs))
{
delete this;
}
}
private:
LONG m_nRefs;
};
class CThreadParams :
CZeroInit<CThreadParams>,
public CRefCountImpl
{
...
工作线程中的全局变量和进程的结束过程:
Global Variables in Worker Threads and the Process Rundown Sequence
工作线程是NT线程池、远程过程调用(RPC)或COM运行时代码之类的系统组件创建的,由于此时调用者不能显式创建线程或显式调用回调函数,因此经常会用到全局(静态)变量,这意味着你不能为他们提供状态对象,这时进程结束过程中的同步和线程终止事件相关原理有助于理解全局变量是什么时候绑定到工作线程的。后面会有例子揭示c运行时库在进程结束时期是如何释放全局变量内存的。
所有DLL和EXE的全局C++对象和静态C++对象都会在进程退出之前由C运行时库自动销毁。如果在此之后该进程存在线程试图访问这些共享状态,就会产生访问无效内存的情况。最简单的解决方法时在进程退出前等待所有工作线程结束回调执行完毕。然而这种做法并不令人满意,尤其是对于那些回调函数无法立刻撤销的工作线程。在一些情况下,工作线程可能执行到一个阻塞的、处于同步状态的第三方网络API函数中,它不支持撤销操作,然而你又不希望进程等待他执行完毕。如果你决定无论如何也要放弃线程正在进行的工作使进程退出,就必须理解c运行时库是如何结束进程的,并且做出预防措施防止工作线程访问已经被释放的全局对象和静态对象。
使用C运行库时u,Visual C++编译器会将你的入口点wmain封装为C运行库提供的入口点__wmainCRTStartup。dll模块中的入口点DllMain也是这样。这些C运行库入口点处函数负责在运行到你自己的入口点之前初始化模块中的C++全局对象和静态对象,C运行库入口点还在用户main函数退出时进行一些操作,这是由msvcrt!exit完成的,他会结束运行的任务。
step1:msvcrt!exit ->
step2:C运行库销毁exe中C++全局对象 ->
step3:ntdll!NtTerminateProcss(NULL)结束进程中所有工作线程 ->
step4:销毁DLL模块中的全局状态变量(CRT),调用DllMain(DLL_PROCESS_DETACH),卸载DLL ->
step5:ntdll/nt!NtTerminateProcess(-1) 最终退出当前进程,此时该调用不会返回到用户模式
后三步是在kernel32!ExitProcess和ntdll!RtlExitUserProcess调用时发生的。可见DLL和EXE的全局C++对象销毁的时间点不同,下面2点需要注意:
main中全局和静态C++对象实在工作线程结束前销毁的,这意味着此时不能存在工作线程试图访问这些全局变量。你需要写代码阻止工作线程在进程结束阶段已经开始时访问EXE的全局状态。此外还需要在程序中加入代码(例如通过全局布尔变量和InterlockedExchage)通知工作线程进程要退出。对于DLL模块的全局和静态C++对象则要安全一些,因为DLL模块的全局状态对象实在工作线程结束后才销毁的。此时进程中只有主线程且他不会试图使用DLL模块中的任何状态对象。然而,由于所有工作线程都突然结束,DLL模块的C++析构函数被调用时(step3)可能并不知道这些对象都处于何种状态,幸运的是,这种bug在写的比较好的C++析构函数中并不很常见。下面是例子:
class CMainApp
{
private:
static
DWORD
WINAPI
WorkerThread(
__in LPVOID lpParameter
)
{
CThreadParams* pParameter =
reinterpret_cast<CThreadParams*>(lpParameter);
wprintf(L"Test message... %s\n", pParameter->m_spMessage);
return EXIT_SUCCESS;
}
public:
static
HRESULT
MainHR()
{
CHandle shThreads;
int n;
ChkProlog();
ChkHr(g_params.Init(L"Hello World!"));
for (n = 0; n < ARRAYSIZE(shThreads); n++)
{
shThreads.Attach(::CreateThread(
NULL, 0, WorkerThread, &g_params, 0, NULL));
ChkWin32(shThreads);
}
//
// Do not Wait for the worker threads to exit...
//
ChkNoCleanup();
}
private:
static const NUM_THREADS = 64;
static CThreadParams g_params;
};
DLL模块生命期管理错误
如果工作线程执行的代码存在于DLL,那么DLL模块不能在线程回调函数执行结束之前被卸载,这么做的原因是很显然的,然而你并不见得能很好的控制第三方DLL模块。例如你可能写了一个API给其他开发者使用,调用者可以动态加载该API的DLL模块,如果该API启动了一个新工作线程异步执行,而调用代码调用过API后卸载了DLL,此时该工作线程就会在未加载的内存中执行,导致非法访问。下面是例子:
static
HRESULT
MainHR(
__in int argc,
__in_ecount(argc) WCHAR* argv[]
)
{
HMODULE hModule = NULL;
PFN_AsynchronousCall pfnAsynchronousCall;
ChkProlog();
...
hModule = ::LoadLibraryW(argv);
ChkWin32(hModule);
pfnAsynchronousCall = (PFN_AsynchronousCall) GetProcAddress(
hModule, "AsynchronousCall");
ChkBool(pfnAsynchronousCall, E_FAIL);
ChkHr(pfnAsynchronousCall());
ChkCleanup();
if (hModule)
{
FreeLibrary(hModule);
}
ChkEpilog();
}
因为FreeLibrary卸载了DLL,然而异步线程pfnAsynchronousCall还在执行,因此该程序会产生非法访问。
解决DLL生命周期管理bug:
最好的方式是将API中异步的部分在设计时体现出来,这样调用者才知道该如何正确使用。同时可以使用引用计数来修复这个问题。
跟踪DLL模块自身的引用计数,在调用LoadLibrary后执行API时每创建一个线程就增加计数,当工作线程回调例程结束时就通过
FreeLibraryAndExitThread减少计数,这样做,并不等同于将调用释放动态链接库和将退出线程作为同一个原子性事务处理,
就像FreeLibrary之后ExitThread一样,这样做控制权永远不能返回给回调函数,即使通过FreeLibrary将引用计数降为0 卸载DLL模块。
That being said, there is a way to fix the problem even if you were really bent on keeping the API
signature unchanged. The idea behind this fix, again, is to use reference counting—only this time,
you keep track of references to the DLL module itself. You increment the reference count of the
DLL module by calling LoadLibrary every time a new background thread is created by your API, and
you then have the worker-thread callback routine decrement it upon its exit by using the atomic
FreeLibraryAndExitThread
Win32 API call. It’s critical that you use this atomic call to free the library
and exit the thread as part of the same transaction, as opposed to calling FreeLibrary followed by
ExitThread. This way, control is never returned to the callback even if the backing DLL module gets
unloaded
from memory as its reference count drops down to 0 from the FreeLibrary call!
在这种设计模式下,需要注意以下几点:
FreeLibraryAndExitThread调用之后不要有任何代码运行,尤其对于c++的析构函数在线程回调函数体结束处自动销毁智能指针变量这种情况。因此,需要保证在卸载动态链接库、退出线程之前必要的清理工作。
在API调用返回调用者之前需要同时固定DLL在内存中的数据,这意味着增加DLL引用计数不能放在工作线程回调函数中,因为此时已经太晚了,调用者有可能在模块存在于内存时卸载DLL,会导致非法访问。
这个模式只用于手动创建线程时,如果线程由NT线程池管理,那么你不需要操心。win32线程池api简化了操作,只要他们在执行,内存数据就不会变动,你只需要简单滴调用SetThreadPoolCallbackLibrary,线程池实在ntdll中实现的,用来管理dll引用计数。
DLL模块生命期管理错误
如果工作线程执行的代码存在于DLL,那么DLL模块不能在线程回调函数执行结束之前被卸载,这么做的原因是很显然的,然而你并不见得能很好的控制第三方DLL模块。例如你可能写了一个API给其他开发者使用,调用者可以动态加载该API的DLL模块,如果该API启动了一个新工作线程异步执行,而调用代码调用过API后卸载了DLL,此时该工作线程就会在未加载的内存中执行,导致非法访问。下面是例子:
static
HRESULT
MainHR(
__in int argc,
__in_ecount(argc) WCHAR* argv[]
)
{
HMODULE hModule = NULL;
PFN_AsynchronousCall pfnAsynchronousCall;
ChkProlog();
...
hModule = ::LoadLibraryW(argv);
ChkWin32(hModule);
pfnAsynchronousCall = (PFN_AsynchronousCall) GetProcAddress(
hModule, "AsynchronousCall");
ChkBool(pfnAsynchronousCall, E_FAIL);
ChkHr(pfnAsynchronousCall());
ChkCleanup();
if (hModule)
{
FreeLibrary(hModule);
}
ChkEpilog();
}
因为FreeLibrary卸载了DLL,然而异步线程pfnAsynchronousCall还在执行,因此该程序会产生非法访问。
解决DLL生命周期管理bug:
最好的方式是将API中异步的部分在设计时体现出来,这样调用者才知道该如何正确使用。同时可以使用引用计数来修复这个问题。
跟踪DLL模块自身的引用计数,在调用LoadLibrary后执行API时每创建一个线程就增加计数,当工作线程回调例程结束时就通过
FreeLibraryAndExitThread减少计数,这样做,并不等同于将调用释放动态链接库和将退出线程作为同一个原子性事务处理,
就像FreeLibrary之后ExitThread一样,这样做控制权永远不能返回给回调函数,即使通过FreeLibrary将引用计数降为0 卸载DLL模块。
That being said, there is a way to fix the problem even if you were really bent on keeping the API
signature unchanged. The idea behind this fix, again, is to use reference counting—only this time,
you keep track of references to the DLL module itself. You increment the reference count of the
DLL module by calling LoadLibrary every time a new background thread is created by your API, and
you then have the worker-thread callback routine decrement it upon its exit by using the atomic
FreeLibraryAndExitThread
Win32 API call. It’s critical that you use this atomic call to free the library
and exit the thread as part of the same transaction, as opposed to calling FreeLibrary followed by
ExitThread. This way, control is never returned to the callback even if the backing DLL module gets
unloaded
from memory as its reference count drops down to 0 from the FreeLibrary call!
在这种设计模式下,需要注意以下几点:
FreeLibraryAndExitThread调用之后不要有任何代码运行,尤其对于c++的析构函数在线程回调函数体结束处自动销毁智能指针变量这种情况。因此,需要保证在卸载动态链接库、退出线程之前必要的清理工作。
在API调用返回调用者之前需要同时固定DLL在内存中的数据,这意味着增加DLL引用计数不能放在工作线程回调函数中,因为此时已经太晚了,调用者有可能在模块存在于内存时卸载DLL,会导致非法访问。
这个模式只用于手动创建线程时,如果线程由NT线程池管理,那么你不需要操心。win32线程池api简化了操作,只要他们在执行,内存数据就不会变动,你只需要简单滴调用SetThreadPoolCallbackLibrary,线程池实在ntdll中实现的,用来管理dll引用计数。
页:
[1]