目录
1、线程与线程函数基础知识
1.1、创建线程的函数返回时不代表代码执行到线程函数中了
1.2、创建线程的函数返回后要调用CloseHandle将线程句柄(引用计数)释放掉
1.3、线程何时退出并结束?
2、线程函数的几个细节
3、回调函数运行在主调线程中,不能发生堵塞
4、多线程之间在操作共享资源时要做同步
4.1、两个线程同时对一个整型的全局变量进行自加操作
4.2、一个线程在遍历STL列表、另一个线程在删除STL列表元素或者向列表中添加元素
5、多线程死锁问题
5.1、发生死锁的场景说明
5.2、锁的类型
5.3、多线程死锁的排查实例
6、最后
VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931C++软件分析工具从入门到精通案例集锦(专栏文章正在更新中...)https://blog.csdn.net/chenlycly/article/details/131405795C/C++基础与进阶(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_11931267.html开源组件及数据库技术(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12458859.html网络编程与网络问题分享(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_2276111.html 为了提高业务处理的效率,软件中会频繁地使用多线程,作为基础编程技能的多线程编程技术包含了多个细节问题。本文在多年的项目实践与排查问题经验的基础上,从C++软件调试实战的角度去讲解多线程编程中的若干知识点及常见问题,希望能给大家提供一个借鉴或参考。
1、线程与线程函数基础知识
创建线程时会指定一个线程函数,最终这个线程是跑在这个线程函数中的。线程函数退出了,线程也就结束了。
1.1、创建线程的函数返回时不代表代码执行到线程函数中了
以Windows平台为例,当我们调用CreateThread或者_beginthreadex去发起线程创建请求,当CreateThread或者_beginthreadex函数返回时,并不代表代码已经跑到线程函数里了。有可能已经跑到线程函数中了,也有可能还没运行到线程函数中。
以前我们排查过这样一个问题:定义了如下的线程函数ThreadFunc,线程函数中访问了一个类指针对象m_pVideoCap:
unsigned int ThreadFunc(void* pParam)
{
... // 代码省略
if ( m_pVideoCap != NULL )
{
m_pVideoCap->GetFrameData();
}
... // 代码省略
}
创建线程的代码如下:
// 创建线程
HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, NULL);
if ( hThread != NULL )
{
CloseHandle(hThread);
}
// 将指针变量m_pVideoCap初始化为NULL
m_pVideoCap = NULL;
我们在主线程中调用系统函数_beginthreadex创建一个新的线程,将线程的线程函数指定为ThreadFunc,然后在创建线程的函数返回之后去初始化指针变量m_pVideoCap。
上述这段代码会时不时崩溃,崩溃在线程函数ThreadFunc中,具体是崩溃在使用m_pVideoCap调用函数GetFrameData处。但崩溃不是必现的,只是偶尔才会出现。这是一个我之前在项目中排查的问题实例。
上述代码将指针变量m_pVideoCap初始化为NULL的操作,放到调用_beginthreadex创建线程之后。是否会崩溃,主要有以下两个场景:
1)不崩溃的场景:如果_beginthreadex返回时,线程还没走到线程函数中,紧接着将m_pVideoCap初始化为NULL,代码就不会崩溃。
2)发生崩溃的场景:如果_beginthreadex返回时,线程就执行到线程函数中,指针变量m_pVideoCap还没来得及初始化为NULL,线程函数中就访问了,即访问了未初始化的指针变量,所以产生了崩溃。
对于发生崩溃的场景,为啥会发生崩溃呢?可能有人会说,不是已经判断m_pVideoCap是否为NULL了吗?为啥还会发生崩溃呢?原因是这样的,对于Visual Studio编译出来的程序,未初始化的变量的内存会被自动填充成0xCCCCCCCC(该变量是在栈上分配内存的)或0xCDCDCDCD(该变量是在堆上分配内存的),所以在执行到if判断m_pVideoCap指针是否为空时,m_pVideoCap还没初始化,其值是自动填充的0xCDCDCDCD,所以还是执行到if语句内部的m_pVideoCap->GetFrameData()这行代码上了。
关于0xCCCCCCCC、0xCDCDCDCD、0xFEEEFEEE等常见异常值的说明,可以参见我之前写的文章:
内存中常见异常值的说明(0xcccccccc、0xcdcdcdcd、0xfeeefeee和0xdddddddd 等)https://blog.csdn.net/chenlycly/article/details/128285918 至于m_pVideoCap->GetFrameData()这个函数调用为什么会发生崩溃,可能是以下这两个原因之一:
1)可能GetFrameData函数内部访问了其所在类的数据成员,读变量的值就是访问数据成员内存中的内容,因为所在类对象的地址是个异常值,通过这个异常值去访问类的数据成员(内存)(类的成员变量的内存首地址就是相对于当前类对象首地址的偏移值),会触发内存访问违例。
2)可能GetFrameData函数是虚函数,虚函数的调用会涉及到二次寻址,因为当前类对象的首地址为异常值,所以二次寻址时会访问不该访问的内存,引发内存访问违例。
1.2、创建线程的函数返回后要调用CloseHandle将线程句柄(引用计数)释放掉
此外,在创建线程的函数返回后,要调用CloseHandle将线程句柄(引用计数)释放一次,否则会导致线程句柄泄漏。如果频繁地创建线程不释放线程句柄,就会导致进程的总句柄值累积,可能会达到进程总句柄数的上限,导致后面创建线程会失败。
进程默认的总句柄上限值是10000个,包括文件句柄、线程句柄、进程句柄等,在注册表中可以查看到,也可以修改:(一般不建议修改这些系统的默认值)
关于进程句柄上限的说明,可以参见我之前写的文章:
从注册表看Windows系统进程GDI句柄及进程句柄数上限https://blog.csdn.net/chenlycly/article/details/80076343深入探究 C++ 编程中的资源泄漏问题https://blog.csdn.net/chenlycly/article/details/133631728
1.3、线程何时退出并结束?
当线程函数执行完退出时,线程就跟着退出了。在部分异常的场景下,我们可能要强制结束线程,可以调用ExitThread或者TerminateThread去强制结束线程。但一般不推荐使用这类强制结束线程的做法,可能会导致部分资源没有释放,应该让线程“优雅的”退出,即让线程函数执行完自行退出。
让线程函数正常执行完后返回,可以确保以下的清理工作都能被执行:
- 线程函数中创建的所有 C++对象都通过其析构函数被正确销毁。
- 操作系统正确释放线程栈使用的内存。
- 操作系统把线程的退出代码(在线程的内核对象中维护)设为线程函数的返回值。
- 系统递减少线程的内核对象的使用计数。
比如我们在线程函数中设置了一个while循环,去循环地处理事务,我们可以设置一个BOOL控制变量m_bRunning:
unsigned int ThreadFunc(void* pParam)
{
while ( m_bRunning )
{
... // do something
}
}
在需要线程及时退出时将BOOL变量m_bRunning 置为FALSE,让While循环结束,让线程函数尽快退出,线程函数退出了,线程也就结束了。
下面我们来看看调用ExitThread和TerminateThread的区别:(这段内容可以参看《Windows核心编程》相关章节)
1)调用ExitThread强制退出线程
ExitThread函数需要在线程函数中执行,只能退出当前线程,不能终止其他进程。该函数将终止线程的运行,操作系统会清理该线程使用的所有操作系统资源。
2)调用TerminateThread强制终止线程
调用TerminateThread可以直接去终止其他线程,只要能获取到要终止的线程句柄,将线程句柄传给TerminateThread函数就可以终止线程了。
如果通过调用ExitThread函数的方式来终止一个线程的运行,该线程的堆栈也会被销毁。但如果使用的是 TerminateThread,那么除非拥有此线程的进程终止运行(进程退出或终止),否则系统不会销毁这个线程的堆栈。Microsoft 故意以这种方式来实现TerminateThread。否则,假如其他还在运行的线程要引用被“杀死”的那个线程的堆栈上的值,就会引起访问违规。让被“杀死”的线程的堆栈保留在内存中,其他的线程就可以继续正常运行。
TerminateThread 函数是异步的。也就是说,它告诉系统你想终止线程,但在函数返回时,并不保证线程已经终止了。如果需要确定线程已终止运行,还需要调用WaitForSingleObject或类似的函数,并向其传递线程的句柄。相关代码如下所示:
HANDLE hThread = NULL;
... // 代码省略,已经获取到要终止的线程句柄,并保存到hThread中
TerminateThread( hThread, 0 );
WaitForSingleObject( hThread, INFINITE); // 参数值INFINITE表示无限等到,直到线程退出,也可以设置指定的超时时间,比如30ms
此外,DLL动态链接库通常会在线程终止运行时收到通知。但如果线程是用TerminateThread强行“杀死”的,则DLL不会收到这个通知,其结果是不能执行正常的清理工作(这点可以参照《Windows核心编程》第20章DLL高级技术中查看详细说明)。
2、线程函数的几个细节
有时为了不阻塞主线程,我们需要把一些比较耗时的操作放到一个线程中去执行,待事务处理完线程就退出了。
有时我们需要在线程中设置一个循环,去循环执行相关的业务,比如需要持续地处理底层上来的,或者服务器过来的消息及数据。对于后面这种循环执行业务操作的场景,一般我们需要人为地在循环体加上一小段延时,不让线程一直持续的运行,如果线程一直在不间歇的运行,则会占用大量的CPU时间片,会导致进程占用CPU较高的问题。
一旦线程调用Sleep或WaitForSingleObject时,线程就会挂起,不再消耗CPU时间片,待Sleep时间到或者wait到对象了,所在线程就退出挂起状态,进入待运行状态,然后系统就会给该线程重新分配时间片,线程得以继续运行。
之前排查的多个CPU占用高的问题,就是线程函数中代码一直在不间歇的运行导致的,有可能是发生了死循环,也有可能是在某些场景下没有人为地加Sleep导致的,相关案例可以查看我之前写的文章:
使用Process Explorer/Process Hacker和Windbg高效排查软件高CPU占用问题https://blog.csdn.net/chenlycly/article/details/134180480使用Process Explorer查看线程的函数调用堆栈去排查程序高CPU占用问题https://blog.csdn.net/chenlycly/article/details/132830803
3、回调函数运行在主调线程中,不能发生堵塞
特别是在业务分层的软件中,下层模块的消息与数据是通过上层给下层设置的回调函数回调给上层的。回调函数是上层模块实现的,将回调函数的地址设置给下层模块,相关示例代码如下:
// 1、回调函数声明
typedef void (__stdcall *PMsgCallBackFunc)( DWORD dwEvent, const char* pMsgBody, DWORD dwMsgLen );
// 2、设置回调函数(底层模块提供的接口)
void __stdcall SetMsgCallBack( PMsgCallBackFunc pMsgCallBackFunc );
// 3、回调函数实现(上层模块实现的回调函数)
void __stdcall MsgCallBackFunc( DWORD dwEvent, const char* pMsgBody, DWORD dwMsgLen )
{
... // 代码省略
}
下层模块在向上层模块投递消息及数据时会调用回调函数。所以回调函数虽然是上层模块实现的,但实际上回调函数是运行在下层模块的线程中的。
所以一般消息及数据的处理不能放在回调函数中处理,因为消息与数据的处理可能会比较耗时,直接在回调函数中处理会导致回调函数的堵塞,会直接导致调用回调函数的下层模块中的线程的堵塞,这样就会直接影响下层模块线程的业务处理。一般会采用如下的处理方式:
在回调函数中将消息及数据先缓存起来,然后通知上层模块的线程到缓存中取消息及数据,上层线程从缓存中拿到消息及数据后再去处理,这样回调函数能尽快地返回,不会堵塞底层模块的线程,不会影响到底层模块线程的业务处理。
注意,这套逻辑中涉及到两个线程的交互,一个回调函数所在的线程(底层模块的线程),一个是上层模块的线程,两个线程在操作缓存时要加锁做同步。
4、多线程之间在操作共享资源时要做同步
多个线程在同时操作共享资源时要做同步,所谓同步就是在访问共享资源时加锁,防止出现一个线程在读、另一个线程在写的冲突,或者两个线程在同时写的冲突,以避免出现unexpected不可预知的异常。
下面我举两个典型的问题场景来说明问题,可能问题本身比较简单,但都很有代表性,都很能说明问题:
1)实例1:两个线程同时对一个整型的全局变量进行自加操作的问题;
2)实例2:两个线程在同时操作同一个STL列表,一个线程在遍历STL列表,另一个线程在从STL列表中删除元素或者向STL列表中添加元素的问题。
4.1、两个线程同时对一个整型的全局变量进行自加操作
这是来自《Windows核心编程》一书第8章第1节中的一个典型实例,之前我在讲了解汇编的好处的话题时也引用了这个实例。要完全理解这个实例,需要从汇编代码的角度去看。
该例子中定义了一个long型的全局变量,然后创建了两个线程,线程函数分别是ThreadFunc1和ThreadFunc2,这两个线程函数中均对g_x变量进行自加操作(在访问共享变量g_x时未加锁同步),相关代码如下:
// define a global variable
long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam)
{
g_x++;
return 0;
}
DWORD WINAPI ThreadFunc2(PVOID pvParam)
{
g_x++;
return 0;
}
这里有个问题,当这两个线程函数执行完后,全局变量g_x的值会是多少呢?一定会是2吗?
实际上,在两个线程函数执行完后,g_x的值不一定为2。这个实例需要从汇编代码的角度去理解,从C++源码看则很难搞懂,这是一个从汇编代码角度去理解代码执行细节的典型实例。
熟悉汇编代码,不仅可以辅助排查C++软件异常,还可以理解很多高级语言无法理解的代码执行细节。
有些人可能觉得,代码中就是一个自加的操作,一下子就执行完了,中间应该不会被打断。会不会被打断,其实要看汇编代码的,这行C++源码对应三行汇编代码,对g_x变量的自加这句C++代码,对应的汇编代码如下:
MOV EAX, [g_x] // 将g_x变量的值读到EAX寄存器中
INC EAX // 将EAX中的值执行自加操作
MOV [g_x], EAX // 然后将EAX中的值设置到g_x变量内存中
看C++代码:g_x++,只能保证CPU执行某条汇编指令时不会被打断(汇编指令是CPU执行的最小粒度),但3条汇编指令,指令与指令之间是可能被打断的。
为什么说两个线程执行完成后g_x变量的值是不确定的呢?比如可能存在两种场景:
1)场景1(最终结果g_x=2)
假设线程1先快速执行了三行汇编指令,未被打断,g_x的值变成1。然后紧接着线程2执行,在g_x=1的基础上累加,最终两个线程执行完后,g_x等于2。
2)场景2(最终结果g_x=1)
假设线程1先执行,当执行完前两条汇编指令后,线程1失去时间片(线程上下文信息保存到CONTEXT结构体中):
即线程1前两条汇编指令执行完,第3条汇编指令还没来得及执行,就失去CPU时间片了!
线程2执行,一次执行完三条指令,当前g_x=1。然后线程1获得CPU时间片,因为上次执行两条汇编指令后EAX寄存器中的值为1,因为线程1获取了时间片,保存线程上下文信息的CONTEXT恢复到线程1中,EAX=1,继续执行第3条指令,执行完后g_x还是1。
所以,这个多线程问题,需要从汇编代码的角度去理解,从C++源码的角度很难想明白。
从本例可以看出,即使是简单的变量自加操作,多线程操作时也要做同步,可以加锁,可以使用系统的原子锁Interlocked系列函数,比如原子自加函数InterlockedIncrement和原子自减函数InterlockedDecrement:
LONG InterlockedIncrement(
LPLONG volatile lpAddend // variable to increment
);
LONG InterlockedDecrement(
LPLONG volatile lpAddend // variable address
);
这些原子函数能保证会被原子地被执行,中间不会被打断。 修改后的代码为:
// define a global variable
long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam)
{
InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加
return 0;
}
DWORD WINAPI ThreadFunc2(PVOID pvParam)
{
InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加
return 0;
}
关于为什么要学习汇编以及学习汇编有哪些好处,可以查看我之前写的文章:
为什么要学习汇编?学习汇编有哪些好处?https://blog.csdn.net/chenlycly/article/details/130935428 关于排查C++软件异常所需要掌握的基础汇编知识,可以查看我之前写的文章:
分析C++软件异常需要掌握的汇编知识汇总https://blog.csdn.net/chenlycly/article/details/124758670
4.2、一个线程在遍历STL列表、另一个线程在删除STL列表元素或者向列表中添加元素
两个线程都访问了同一个STL列表,一个线程会遍历STL列表,另一线程会删除STL列表中的元素或者向列表中加入新的元素。但这两个线程操作同一个STL列表时不加锁,不一定会引发问题,甚至是很难出现读写的冲突,只是偶尔出现。甚至这样的代码在好几年中都没出现过问题!巧合的是,这样的问题场景前段时间我们就遇到过,代码是好几年前写的,之前几年从来没报过问题,结果在某天突然出现了。
只有当一个线程正在遍历STL列表,另一个线程正在向STL列表删除或添加元素,正好赶上时机了,两个线程在同时操作STL列表,然后就出问题了。这些对STL列表的操作可能掩藏的比较深,经历了多个函数的调用触发的,走读代码时可能很难快速发现问题。相关的场景演示代码如下所示:
// 1、ROOM信息结构体定义
typedef struct tagTRoomInfo
{
char szRoomName[128]; // Room名称
char szRoomId[128]; // RoomId
... // 其他字段省略
tagTRoomInfo() { memset(this, 0, sizeof(tagTRoomInfo)); }
}*PTRoomInfo, TRoomInfo;
// 2、定义看存放房间信息的STL列表
std::vector<TRoomInfo*> g_vtChatRooms;
// 3、通过roomid去遍历STL列表
TRoomInfo* FindRoom(char* lpszRoomId)
{
std::vector<TRoomInfo*>::iterator it = g_vtChatRooms.begin();
for ( ; it != g_vtChatRooms.end(); it++ )
{
if ( strcmp(lpszRoomId, it->szRoomId) == 0 )
{
return *it;
}
}
return NULL;
}
// 4、线程函数中调用了遍历STL列表的接口FindRoom(线程1)
unsigned int ThreadFunc(void* pParam)
{
... // 代码省略
string strRoomId;
// 中间已经对strRoomId赋值,相关代码省略
TRoomInfo* pDstRoom = FindRoom(strRoomId.c_str());
... // 代码省略
}
// 5、向STL列表中添加元素的接口(线程2调用了这个接口)
void AddRoom(TRoomInfo* lpRoomInfo)
{
g_vtChatRooms.push_back(lpRoomInfo);
}
线程函数ThreadFunc中调用FindRoom接口去遍历STL列表g_vtChatRooms(线程1),与此同时,另一个线程(线程2)在调用AddRoom在向STL列表中添加元素,这样就造成读写冲突了。应该在两个线程相关的地方都加锁。
5、多线程死锁问题
为了实现多线程之间能安全地访问一些共享资源(比如内存资源),我们会给共享资源加锁,以保证某个时刻不会出现一个线程在写资源、另一个线程在读资源的冲突情况。但加锁后,如果控制的不好,则可能会出现多线程之间的死锁问题。
死锁一般发生在多个线程之间,一般会涉及到两个或两个以上的锁。下面我们先大概地讲述一些发生死锁的场景及用于线程间同步的锁的类型。
5.1、发生死锁的场景说明
比如当前有两个线程,线程1和线程2;当前有两个锁,锁1和锁2。假设线程1占用了锁1,正在申请锁2,同时线程2占用了锁2,正在申请锁1,两线程都占用了各自的锁,都在申请对方占用的锁,各不相让,如下所示:
这样就导致了死锁,这是个典型的死锁场景。
还有一个比较典型的场景是,线程1和线程2之间发生了死锁,导致了线程3的死锁。假设线程2占用了线程3要申请的锁3,因为线程1与线程2之间产生了死锁,导致线程2一直在占用锁3,一直没有释放。而线程3的代码进入了要申请锁3的代码中,因为线程2一直在占用锁3不释放,这样也导致了线程3的死锁,如下所示:
5.2、锁的类型
此处我们以Windows平台的多线程锁为例来展开。在Windows平台中可以用多个对象来实现多线程间的锁,比如临界区对象、事件对象、互斥量对象、信号量对象等。
这些对象主要分用户态对象和内核态对象,其中临界区属于用户态对象,事件、互斥量和信号量则属于内核态对象。使用用户态对象的好处是,不用在用户态与内核态之间切换,在效率上相对高一些,所以在Windows平台上用户态的临界区用的比较多一些。使用内核态对象时,大部分程序代码都运行在用户态的,当操作到这些内核态对象时在底层就需要切换到内核态中,完成对应的操作后再返回到用户态代码中。如果代码在用户态和内核态之间频繁的切换,则执行效率上会有损伤。
用户态的临界区锁,只能用于一个进程中的多个线程间的同步。而事件、互斥量和信号量都属于内核态的对象,除了可以用于一个进程中的多个线程的同步,还可以跨进程使用。
使用Windbg去排查用户态的临界区死锁,则相对容易一些,Windbg默认是运行在用户态中的。如果要排查内核态锁引发的死锁,则要复杂一些,Windbg需要切入到内核态中去分析。
5.3、多线程死锁的排查实例
多线程死锁可以使用打印日志去排查,也可以通过调试器去分析。之前写过一篇使用Windbg分析多线程死锁的实例,可以查看对应的文章:
使用Windbg分析多线程临界区死锁问题分享https://blog.csdn.net/chenlycly/article/details/128532743
6、最后
本文系统地总结了多线程编程中的若干细节问题,希望能给C++初学者或刚入门的人提供一定的借鉴或参考。