用C++写代码的时候,有很多的场景需要关注一种情况,就是当需要调用一个可能引起长时间阻塞的函数(例如阻塞式的io操作)。大家遇到这种情况,代码的写法一般是这样的:
1.调用需要阻塞以等待操作Func完成的接口
-> 成功 调用 FcncDone
这样的调用方式会导致线程阻塞,尤其是在主线程中,应该避免这样的调用方式,以免造成用户体验上的卡顿,转化为如下写法:
2.使用异步的方式(创建线程或者使用线程池任务的方式)调用需要阻塞以等待操作Func完成的接口
-> 等待一小段时间
-> Done ? 调用 FuncDone
-> Pend ? 调用 FuncPend
...
-> (另一部分代码)异步通知或者轮询或者回调的方式,得知Func接口调用完成
-> 调用 FuncDone
第二种方法是程序里面经常使用的,但是写起来并不容易,特别是在c11之前。
好在现在有c11,c14的新特性,bind,auto,decltype等等,可以很方便的写出类似下面结构的代码:
3.使用异步的方式(创建线程或者使用线程池任务的方式)调用需要阻塞以等待操作Func完成的接口
-> 等待一小段时间(或者不等待)
-> Done(立刻或者未来某个时间) ? 调用 FuncDone
-> Pend ? 调用 FuncPend
从以上的三种代码的结构来看,3的代码更清晰,并且如果能够达到的预期的调用效果,那就很完美了。
下面介绍一个简单的实现这种调用的一个封装,代码如下:
template<typename FuncCallAsync, typename FuncCallDone, typename FuncCallPend>
bool asynccall(FuncCallAsync&& funcasync, int waitmillisec, FuncCallDone&& funcdone, FuncCallPend&& funcpend)
{
std::shared_ptr<std::atomic_bool> done(new std::atomic_bool(false));
std::shared_ptr<std::promise<void>> wait(new std::promise<void>());
std::thread t([](FuncCallAsync&& funcasync, FuncCallDone&& funcdone, std::shared_ptr<std::atomic_bool> done, std::shared_ptr<std::promise<void>> wait)
{funcasync(); funcdone(); (*done) = true; wait->set_value(); }
, std::forward<FuncCallAsync>(funcasync), std::forward<FuncCallDone>(funcdone), done, wait);
t.detach();
if (!(*done))
{
if (waitmillisec > 0)
{
wait->get_future().wait_for(std::chrono::milliseconds(waitmillisec));
}
else
{
std::this_thread::yield();
}
}
if (!(*done))
{
std::forward<FuncCallPend>(funcpend)();
}
return (*done);
}
使用方式如下:
void testasyncassync()
{
for (int i = 0; i < 100; ++i)
{
{
VAR* var = new VAR(new int(102));
bool res2 = asynccall(
std::bind([](VAR* var)
{
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
*(var->m_ptr) += 32;
}, var)
, 0
, std::([&](const VAR* var)
{
std::cout << *var->m_ptr << std::endl;
delete var;
std::cout << "done" << std::endl;
}, var)
, []()
{
std::cout << "pend" << std::endl;
});
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
_getch();
}
接下来分析代码,以及介绍其中存在的坑。
1.可以看到asynccall是一个模板函数,但是其实这个函数的其中三个函数类型的参数都是无参数函数,不过这里依然使用模板是因为在使用中很可能用到了std::bind,所得到的结果不是一个无参函数,而是一个函数对象,当然也可能是lambda表达式。
2.为什么三个函数都要是无参数的?其实在c++新特性中有个变长参数的模版可以使用,如下(类似std::invoke):
template<typename Func, typename... Args>
constexpr auto funccall(Func&& func, Args&&... args)
// noexcept(noexcept(std::forward<Func>(func)(std::forward<Args>(args)...)))
-> typename std::result_of<Func(Args...)>::type
{
return std::forward<Func>(func)(std::forward<Args>(args)...);
}
在只涉及到一个参数是函数类型参数的时候是可以做到的,但是asynccall有三个函数类参数,如果都可以是任意长度参数的函数,则没有办法区别每个函数的参数具体有多长。而之所以都定义成无参数的函数形式,是因为可以通过std::bind将不同个数参数的函数都转化成无参函数。
3.坑1)使用lambda表达式作为参数调用asynccall时,如果在lambda表达式中需要使用外部变量,请最好使用参数而不要使用捕获,尤其是[=]或者[&]这种所有变量都捕获的使用方式,,如果使用参数,需要使用std::bind绑定参数,使之最终变成无参形式。
4.坑2)出现在funcasync和funcdone中的捕获变量或者参数,请务必保证他们的生命期限,因为这两个函数是异步方式在其他线程中执行的,有可能调用asynccall的语句块已经离开了作用域但是新线程还没有运行完,那么在新线程中的lambda表达式所使用到的变量将失效,引起内存错误。这也就是为什么在3中提到的尽量不要使用[=]和[&]的捕获方式的原因,即你需要知道自己在干嘛,不要被语言提供的便利给摆一道。
5.坑3)其实还是第四点,关于lambda表达式中使用的变量的生命周期问题,一般在调用asynccall之前new,然后在funcdone结尾的地方delete。
6.大家看到这个asynccall很容易想到std::async,其实两个是不同的概念,或者有人想asynccall中的线程创建是不是可以使用std::async代替,其实可以,当然这里也有个坑,在于判断是否执行完成也应该用std::atomic_bool,如果使用std::future,则因为std::future在析构时会等待到ready状态,所以看似异步其实还是同步的。
7.这个函数是最简单的实现,没有考虑线程并发数量的问题,其实自己看代码或者实验可以知道在std::async中是有线程管理策略的(虽然有些让人难以理解),所以如果想要实现更高级的线程管理策略,可以改写asynccall中的第5~7行,使用自己的线程创建管理策略(如线程池)来处理可能存在的线程数量并发问题。