大约3年前,我写下此篇文章的一小段草稿,给自己留下了几个问题,尘封至此。
1、怎么算是优雅的退出线程?
2、线程被阻塞的时候怎么退出线程?
3、线程可能依赖堆数据、设备等资源,保险起见,只要使用它们便要先判断其状态。但是在一些极端的情况下,如,就在你验证资源有效的那一刻,资源被干掉了,此时你的线程处理过程便极有可能产生异常。所以我总感觉,在大多时候,如果能先100%确认线程已停转,再去销毁其使用到的全部资源,似乎更靠谱!
其实只要优雅了,自然就更安全了。在线程退出时,可能常常关注如下几方面问题:
0、线程的停止过程应该是可控的,不可忽略掉的!
1、销毁线程依赖的(共享)资源(如堆栈)前,最好先确保线程已退出?
2、如果线程过程存在挂起的可能,则退出线程前要先唤醒线程?(耗时操作致使while-Flag延时生效)
3、线程退出的时刻,要保证入口函数是已经返回(执行完成)的,才算优雅吧?
4、若不等待线程 “真真正正” 完完全全的退出,会带来哪些灾难?
5、线程退出时,在哪些情况下,会造成内存泄漏等问题,如何避免?
6、进程退出后,未结束的线程能继续存活多久?会发生什么?
结合 《多线程/WinAPI线程退出方式详解》、《多线程/std::thread线程退出方式详解》、《多线程 /C++ 11 std::thread 类深入理解和应用实践》几篇文章中的相关表述,可知所谓的优雅大抵如是:无论如何,使得线程以 “入口函数完成返回” 的形式结束是最友好的线程结束方式,这也是确保所有线程资源被正确地清除的唯一办法。如果线程入口函数能够return返回,就可以确保下列事项的实现:
一个好的设计,应该始终使得线程以 “入口函数完成返回” 的形式结束。但函数的执行是需要时间或需要条件的,如果某一时刻线程恰好工作处于,睡眠、读写IO操作、bool运行标志、队列阻塞、条件变量阻塞等等的状态,线程将无法退出或无法立即退出。因此,如何保证线程入口函数总是能以函数返回的形式结束,才是保证友好退出线程的重中之重。
为了更好的解释这个问题,我用伪代码写了个入口函数如下,
void work_thread(int n)
{
//申请堆内存
char *bufferHeap = (char *)malloc(256);
//定义在线程栈上的对象/需要在析构中释放堆空间
ClassB aObjectInStack(n);
//线程循环
while (m_bRunFlag) {
do_work1(); //较耗时
do_work2(); //可能因等待某种条件而阻塞
do_work3();
}
//释放堆数据
free(bufferHeap);
//释放系统资源
release_os_rc1();
//日志记录
WriteLog(...);
}
通常,我们使用bool类型的运行标志,以控制结束线程入口函数内的while循环,使得线程入口函数返回的形式退出。
置零 runFlag 后,while 可能无法立即感知到,
while循环体每次执行都是需要时间的,我们不妨假设循序体内 do_work1函数 每次执行要耗时3s钟,那么当我们将runFlag置false后,最糟糕的情况下,是2.9999s后当while下次循环开始时它才能生效。也是在这个2.9999s的间隙中,陷阱就出来了:如果不等待入口函数返回,由于置位runFlag的ThreadStop函数是同步立即返回的,这对ThreadStop函数的调用者来说是一种欺骗。这种欺骗将带来诸多问题,如下列举两点:
进程抢先退出,
在上述2.9999s的间隙中,由于没有等待线程退出的机制,会有进程迫使线程退出的情况发生,该过程是粗暴的。具体的可以参见上文提到的另外几篇文章,此时线程的执行过程可能是 “戛然而止” 的,不受控制的。如,进程待退出的那一刻,线程可能执行到do_work1的某行代码、或do_work3的某行代码,由于进程迫使线程退出,后续代码将没有机会执行。这样,入口函数便不能正常返回,while 循环外的释放操作也不会被执行,相关类对象如aObjectInStack的析构函数也不会被触发。这就会造成,资源异常占用,内存泄漏等问题。
线程使用的资源已经被释放,
客户端在ThreadStop函数返回后, “大胆地” 将线程Process依赖的对象都释放掉。但线程Process此时并未真停止,当其运行到资源操作相关代码行的时候,就可能访问野指针、无效数据、无效资源等,造成异常;或者在你判空操作或其他异常检查代码的作用下,你的代码进入到异常处理,如果你没有严谨的异常处理过程,此时程序还是可能会出现不期望行为或者产生不被期望的脏数据。因此我的习惯通常是,如果可以,则尽量的在保证使用资源的线程真实退出了,再销毁相关资源。
这是我最早想到的方案,尽管没过多久我就否定了该方案,强迫症还是使我实现并测试了该方案的效果。而且还在这个方案上爬了半天的坑,加深了我对std::condition_variable类wait、notify函数的理解。这个方案是适用于任何形式的线程入口函数的,部分主要代码如下:
//定义私有类(用以等待线程退出)
class ITaskComm_Private
{
public:
typedef struct tagWaitThreadExit
{
std::mutex *p_mutex; //互斥锁
std::condition_variable *p_condition_variable; //条件变量
} TWaitThreadExit;
public:
ITaskComm_Private(int imaxCount)
{
m_iMaxOfCount = imaxCount;
m_arrayWaitCondition = (TWaitThreadExit *)malloc(sizeof(TWaitThreadExit) * imaxCount);
for (int index = 0; index < imaxCount; index++)
{
m_arrayWaitCondition[index].p_mutex = new std::mutex();
m_arrayWaitCondition[index].p_condition_variable = new std::condition_variable();
}
}
~ITaskComm_Private()
{
for (int index = 0; index < m_iMaxOfCount; index++)
{
delete m_arrayWaitCondition[index].p_mutex;
m_arrayWaitCondition[index].p_mutex = NULL;
delete m_arrayWaitCondition[index].p_condition_variable;
m_arrayWaitCondition[index].p_condition_variable = NULL;
}
free(m_arrayWaitCondition);
m_arrayWaitCondition = NULL;
}
public:
TWaitThreadExit *WaitCondition(int iDesID)
{
assert((0 != iDesID) && (iDesID <= m_iMaxOfCount));
return &m_arrayWaitCondition[iDesID - 1];
}
private:
TWaitThreadExit *m_arrayWaitCondition;
int m_iMaxOfCount;
};
//自定义线程管理基类
class BDSVR_API_EXPORT ITaskComm
{ ....
public:
//确认线程已退出
bool i_task_wait(int iLockID) //ID from 1
{
std::condition_variable *pCondition = m_TaskCommPrivate->WaitCondition(iLockID)->p_condition_variable;
//等待线程退出
std::unique_lock<std::mutex> lck_unique(*m_TaskCommPrivate->WaitCondition(iLockID)->p_mutex);
//@note
if (std::cv_status::timeout == pCondition->wait_for(lck_unique, std::chrono::milliseconds(8000)))
{
AflDebugError("Task:%s LockID:%d wait 8s Exit Failure", m_TaskCommPrivate->m_strTaskName.c_str(), iLockID);
return false;
}
return true;
}
//确认线程已退出
void i_task_notify(int iLockID) //ID from 1
{
//illu_1 填坑 //预留时间以确保条件变量先进入等待状态
std::this_thread::sleep_for(std::chrono::milliseconds(100));
//
std::condition_variable *pCondition = m_TaskCommPrivate->WaitCondition(iLockID)->p_condition_variable;
//
pCondition->notify_all();
}
如上基础代码,在从ITaskComm派生的线程类中:我们在TaskStop函数中,将线程循环的runningFlage置零后,调用i_task_wait等待线程退出;在线程入口函数的while循环之外调用i_task_notify函数,结束TaskStop函数的等待。当时踩了这样一个坑:
我有一个数据流接收处理线程,使用的是socket阻塞模型,当没有数据接收时,recv函数是挂起的。为了能在TaskStop函数中,置零操作后使得标志生效,我当时采用了关闭套接字的方法以触发recv函数打断阻塞返回ERROR。在实际调试中发现,线程已经明确的退出了(notify过程确认被执行了),但是TaskStop下的wait_for过程却没有收到通知,直至std::cv_status::timeout超时。
一开始还怀疑自己ITaskComm_Private的类构造有问题,前后鼓捣了两三个小时。中午在一篇资料中发现一句话"notify_all函数唤醒所有阻塞线程,若无线程等待则该函数不执行任何操作"的启发。TaskStop函数中,我在调用i_task_wait函数前,执行套接字关闭操作,可能使得线程在极短的时间内退出,也就是说,在关闭套接字的"瞬间" – notify_all就可能被执行了,而此时wait_for过程还没有被调用,尤其是在wait_for前还有调试打印信息的时候。
为了使得这种确认线程已退出的方案生效,增加了上文中"illu_1 填坑"处的补正代码。肯定有更好的修补方案,未再深入。
白惊喜一场的方案,
在查看condition_variable头文件帮助文档时,发现一个与thread相关的函数,以为发现了新大陆,结果虚惊一场。
void notify_all_at_thread_exit (condition_variable& cond, unique_lock<mutex> lck);
When the calling thread exits, all threads waiting on cond are notified to resume execution.
曾经还因为std::thread中没有wait类似的函数而抱怨,后来才知道是自己没理解透彻。当慢慢整理完《多线程 /C++ 11 std::thread 接口和属性详解 /std::thread 应用实践》中关于joinable属性的那一小节后,很容易的就联想出如下新方案,其主要理论支持:std::thread::detach函数 与 std::thread::join函数不是用来启动线程入口函数的,因为std线程对象,创建即启动;它们的本意更可能是为线程退出提供方式方法。
//在stop函数中利用join等待
void MyThreadStop() {
m_runningFlag = fale;
join();
}
平日里我们见到的示例程序大都简单到只是在main函数中创建子线程并直接退出,在main函数中 “同步地” 调用detach或join就完犊了。而实际使用中通常会更 “异步” 一些。如,在Qt环境的事件循环机制下,join的 “异步” 调用可能是:
#mian.cpp
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MianWnd w;
w.show();
return a.exec();
}
#mianWnd.h
class MianWnd : public QMainWindow
{
Q_OBJECT
public:
MianWnd(QWidget *parent = Q_NULLPTR);
~MianWnd();
private:
//函数入口
void pause_thread(int n);
//停止函数
void MyThreadStop();
private:
//线程
std::thread m_thread;
//运行标志
bool m_runningFlag = true;
//堆栈资源
struct TStruct
{ int a; int b; } *m_pResource;
private:
Ui::MianWndClass ui;
};
#mainWnd.cpp
//辅助函数 //时刻值ms
double DalOsTimeSysGetTime(void)
{
LARGE_INTEGER nFreq; LARGE_INTEGER nBeginTime;
QueryPerformanceFrequency(&nFreq);
QueryPerformanceCounter(&nBeginTime);
return (double)(nBeginTime.QuadPart * 1000 / (double)nFreq.QuadPart);
}
//辅助函数
void TraceForVStudio(char *fmt, ...)
{
char out[1024] = { 0 };
va_list body;
va_start(body, fmt);
vsprintf_s(out, 1024, fmt, body);
va_end(body);
OutputDebugStringA(out);
OutputDebugStringA("\r\n");
}
//入口函数
void MianWnd::pause_thread(int n)
{
while (m_runningFlag) //
{
if (NULL != m_pResource) //子线程内使用(共享)资源
TraceForVStudio("Using Resource# a:%d b:%d ", ++m_pResource->a, ++m_pResource->b);
std::this_thread::sleep_for(std::chrono::seconds(n));
}
//may do something other..
}
//停止函数
void MianWnd::MyThreadStop()
{
m_runningFlag = false;
m_thread.join(); //block
}
//构造函数
MianWnd::MianWnd(QWidget *parent)
: QMainWindow(parent)
{
ui.setupUi(this);
//资源
m_pResource = new TStruct();
//线程
m_thread = std::thread(&MianWnd::pause_thread, this, 5);
}
//析构函数
MianWnd::~MianWnd()
{
//停止线程
TraceForVStudio("Wait Begin At:%f", DalOsTimeSysGetTime()) ;
MyThreadStop();
TraceForVStudio("Wait Finish At:%f", DalOsTimeSysGetTime());
//销毁线程资源
if (NULL != m_pResource)
{ delete m_pResource; m_pResource = nullptr; }
//销毁UI及其子窗口对象..
}
//关闭窗口触发析构过程
//Using Resource# a:1 b:1
//Using Resource# a:2 b:2
//...
//Wait Begin At : 93813551.843300
//Exit Thread At : 93816981.917300 //about 3.5s
//Wait Finish At : 93816988.057200 //about 007ms
通过上述测试,可以确定join函数可以起到很好的等待线程退出的效果,比std::condition_variable 方便的多。上述,每5s完成单次循环,我随机关闭窗口触发析构过程,m_runningFlag 置零后大约过了3.5s后生效,然后入口函数退出,又过了7ms左右,join函数从阻塞过程中返回,析构过程继续执行堆栈资源的销毁过程。
DWORD WaitForSingleObject([in] HANDLE hHandle, [in] DWORD dwMilliseconds);
//[in] hHandle 对象的句柄。可以是:控制台输入、事件、内存资源通知、Mutex、进程、Semaphore、线程、可等待计时器。
//[in] dwMilliseconds 超时间隔(以毫秒为单位)。如果 dwMilliseconds 为 INFINITE,则仅当发出对象信号时,该函数才会返回。
在windows上,通常用 WaitForSingleObject 函数,等待一个线程的退出。除此之外,还可以使用WaitForMultipleObjects、WaitForThreadpoolWorkCallbacks、MsgWaitForMultipleObjects 等API函数,此处不赘述。
#include
#include
DWORD WINAPI ThreadProc(LPVOID lpParam)
{
for (int i = 0; i < 3; ++i) {
printf("at:%f, Thread is running %d \n", DalOsTimeSysGetTime(), i);
Sleep(1000);
}
return 0;
}
int main()
{
// 创建线程
HANDLE hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
if (hThread == NULL) {
std::cerr << "Failed to create thread." << std::endl;
return 1;
}
// 等待线程退出
printf("at:%f, wait thread exit begin \n", DalOsTimeSysGetTime());
DWORD dwResult = WaitForSingleObject(hThread, INFINITE);
printf("at:%f, wait thread exit finish \n", DalOsTimeSysGetTime());
if (dwResult == WAIT_OBJECT_0) {
std::cout << "Thread exited successfully." << std::endl;
}
else {
std::cerr << "Failed to wait for thread exit." << std::endl;
}
// 关闭线程句柄
CloseHandle(hThread);
return 0;
}
使用 std::thread 进行C++多线程编程时,使用detach分离线程, 此时可以借助 native_handle 接口获得特定实现下的本地线程句柄,然后使用Windows API 的接口,来实现对线程结束的等待。
//方式1
//t1.join();
//printf("at:%f, t1.join wait thread_func return \n", DalOsTimeSysGetTime());
//方式2
t1.detach();
void *H = t1.native_handle();
DWORD dr = WaitForSingleObject(H, INFINITE);
printf("at:%f, Windows wait thread_func return \n", DalOsTimeSysGetTime());
我们最好在完全确认线程已停止运行后,再去销毁线程过程使用的对象,这样不容易出问题,也可以避免在线程执行过程中频繁的去做NULL指针判断,从而在一定程度上提上线程执行效率。
其他注意:
执行delete资源对象操作后,被释放的指针一定要做赋值NULL操作,这并不是空穴来风。否则别处的线程/逻辑可能会再次判断它不为空,并对其再进行释放操作,这也可能导致程序崩溃。
其他注意:
以WinAP多I线程编程为例,如果线程被阻塞挂起了,此时使用ExitThread、等函数,是可以打断线程执行的,但这是粗鲁的方法。合理的方式时,给予合理的刺激,打断阻塞过程,使得入口函数自然返回。