c++异步回调函数引用传递空指针异常

c++异步回调函数引用传递空指针异常

问题描述

最近使用 c++ / qt 开发的一个桌面应用,运行到一处异步执行python脚本任务的方法处报错:

进程已结束,退出代码-1073741819 (0xC0000005)

此处是单独开一个线程异步执行一个python脚本后,回调 UI 线程传来的回调函数将结果返回给 UI 线程,大致代码如下:

void TestCaseProject::initProTestCasesEnvAsync(const std::function<void(std::vector<std::pair<std::string, Json::Value>>)>& _callback) {
    std::thread t{[&] () {
        doWithSetRunning([&]() {
            auto result = this->initProTestCasesEnv();
            _callback(result);
        });
    }};
    t.detach();
}

void callBackFunc(const std::vector<std::pair<std::string, Json::Value>>& rps) {
    // do some things
}

// UI线程某函数调用initProTestCasesEnvAsync
void uiFunc() {
    // create project and other code
    project->initProTestCasesEnvAsync(callBackFunc);
    // do other things
}

解决方案

问题解读

搜索进程已结束,退出代码-1073741819 (0xC0000005),网上对其的准确描述很少,于是进行总结:

  • 退出代码就是执行的程序退出时的返回值,如main函数直接返回、调用程序退出的函数void exit(int _Code)、有未解决的异常从程序抛出到系统后返回系统定义的错误退出码等,通常是一个十六进制 int 值。

  • 退出代码中括号内的才是实际的十六进制退出代码(一般使用这个),前面是其十进制表示(因为起始有一个十六进制数 c 所以变成负数,类似一个标识,用来区分各系列错误代码)。

  • 错误代码无法确定错误的详细信息,只能大致进行判断,具体情况需要进一步分析代码上下文,或者捕捉异常、进行调试来确定。

  • 错误退出代码一般由未处理的异常触发,而不是直接退出程序并返回该代码。

  • 在Windows系统下,错误代码定义在头文件<winerror.h>

    image-20220405233306811

    Windows错误代码详情可见官方文档说明。同时,微软官方提供了一个错误代码的含义查找工具,下载链接

  • 举一反三,在其他操作系统上也有定义错误代码的位置,但定义位置可能不同,大家可以自行查找。不过,错误代码及其含义在各系统平台定义基本是一致的,不会有太大出入。

问题分析

1. 错误代码分析

使用微软错误代码查找工具查找错误代码0xC0000005,结果如下:

PS D:\tools> .\Err_6.4.5.exe C0000005
# for hex 0xc0000005 / decimal -1073741819

  ISCSI_ERR_SETUP_NETWORK_NODE                                   iscsilog.h
# Failed to setup initiator portal. Error status is given in
# the dump data.

  STATUS_ACCESS_VIOLATION                                        ntstatus.h
# The instruction at 0x%p referenced memory at 0x%p. The
# memory could not be %s.

  USBD_STATUS_DEV_NOT_RESPONDING                                 usb.h
# as an HRESULT: Severity: FAILURE (1), FACILITY_NONE (0x0), Code 0x5
# for hex 0x5 / decimal 5

  WINBIO_FP_TOO_FAST                                             winbio_err.h
# Move your finger more slowly on the fingerprint reader.
# as an HRESULT: Severity: FAILURE (1), FACILITY_NULL (0x0), Code 0x5

  ERROR_ACCESS_DENIED                                            winerror.h
# Access is denied.

# 5 matches found for "C0000005"

经过分析,其中,第5条查找结果(第20行)就是问题的主要原因(主要看定义在<winerror.h>中的代码)。

ERROR_ACCESS_DENIED:Access is denied.表示访问被拒绝,这是访问了无权访问的内存地址空间,常见的场景有:

  • 空指针
  • 数组越界
  • 释放内存后产生的野指针

以上场景都会造成未定义行为,并可能抛出异常触发ERROR_ACCESS_DENIED错误并退出。

2. 代码调试

在调试模式运行,查看抛出的异常信息如下:

terminate called after throwing an instance of 'std::bad_function_call'
  what():  bad_function_call

异常std::bad_function_call在调用空的函数对象(std::function)时抛出。空的函数对象一般情况是未给函数对象赋值或赋值null。

我们回到问题描述的代码部分,回调函数的函数对象是 UI 主线程中某个函数将全局函数的指针传入构造的,initProTestCasesEnvAsync方法的参数是常量引用,被线程执行的 lambda 函数捕捉其引用,又被线程执行函数内的doWithSetRunning的 lambda 函数参数捕捉其引用,并在其内调用该函数对象。

经过单行调试,发现异常就是在异步线程执行该回调函数对象是触发的。

机智的小伙伴可能已经发现,根据上面描述的变量传递关系,最终执行的回调函数对象就是 UI 线程调用initProTestCasesEnvAsync时传入callBackFunc函数指针并构建的局部函数对象的引用。正常一个串行执行的程序,这样自然没有问题,在initProTestCasesEnvAsync返回时已完成callBackFunc的调用。但若创建回调函数对象与执行该回调函数对象处在不同线程,就会发生局部的回调函数对象因为其上下文的函数异步执行结束而释放内存,导致执行线程保存的回调函数的引用内部空指针,调用时触发std::bad_function_call异常。

问题解决

知道了问题所在,解决起来就很简单了。一个异步执行的线程,除了全局、动态分配的内存等非局部的对象可共享内存数据进行读写,局部数据都要进行数据拷贝以实现隔离。基于上面的理论,这里应该是 UI 线程调用时传来的局部变量执行拷贝,动态申请的对象直接引用。由于实际执行的代码体中只使用了this指针和传来的函数参数,所以都执行拷贝即可。

在任务线程的 lambda 执行函数中,将捕捉引用改为捕捉值,内部的doWithSetRunning的 lambda 执行函数捕捉该异步线程捕捉值得到的拷贝的引用,即可实现非局部变量(this指向的内存对象)的共享与局部变量(_callback 回调函数对象)的隔离。

修改代码如下:

void TestCaseProject::initProTestCasesEnvAsync(const std::function<void(std::vector<std::pair<std::string, Json::Value>>)>& _callback) {
    std::thread t{[=] () {
        doWithSetRunning([&]() {
            auto result = this->initProTestCasesEnv();
            _callback(result);
        });
    }};
    t.detach();
}

最后,提醒大家一定要注意 lambda 的引用传递的正确性,因为小编已经遇到多次这里的问题,而在异步场景下就更要注意对象传递过程中各对象的传递关系与生命周期了。

posted @ 2022-04-06 01:21  _哲思  阅读(1211)  评论(0编辑  收藏  举报