C++11相比之前的版本具有很多优秀的特性,比如lambda表达式,初始化列表,右值引用,自动类型推导。同时,C++11标准库现在也支持正则表达式、智能指针、多线程库。
但现代C++在并行和异步计算方面依然较为薄弱,特别是与C#等语言相比。
为什么需要支持异步呢?多核处理器几乎无处不在、并在云中分布的核,使得计算机体系结构变得越来越并行化和分布式化。软件程序往往越来越多的由使用了位于单个机器或跨网络的多个核的各组件组成。现代编程语言需要提供对这种并行的支持。
同时,响应性(这是响应式编程的原则之一)已成为越来越不可或缺的软件质量。
响应性的意思是在进行IO操作时不是阻塞住等待它的完成。在服务器端不阻塞一个worker线程、而让它继续做其他的事情,待操作完成后等待下一个任务。在客户端不阻塞主线程或GUI线程,否则将使程序变得反应迟钝。因此能写异步代码对于管理IO操作的延迟越来越重要。例如,在WinRT中有一个规则,所有耗时超过50ms的IO密集型API只提供异步接口,甚至没有传统的阻塞式接口可调用。
接下来我们看下C++目前提供的支持并行编程的方法,这些方法又有什么新特点。我们可以看到标准方法,以及微软提供的windows特定的PPL框架。
为了便于理解在C++中如何写异步代码,我们先写一个简单的示例:读一个文件,将其内容写到另一个文件中。
#include
#include
#include
#include
using namespace std;
vector<char> readFile(const string& inPath)
{
ifstream file(inPath, ios::binary | ios::ate);
size_t length = (size_t)file.tellg();
vector<char> buffer(length);
file.seekg(0, std::ios::beg);
file.read(&buffer[0], length);
return buffer;
}
size_t writeFile(const vector<char>& buffer, const string& outPath)
{
ofstream file(outPath, ios::binary);
file.write(&buffer[0], buffer.size());
return (size_t)file.tellp();
}
由上述函数可以写一个简单的函数实现复制一个文件的内容到另一个文件中,并返回字符数目:
size_t sync_copyFile(const string& inFile, const string& outFile)
{
return writeFile(readFile(inFile), outFile);
}
显然,我们希望依次执行readFile和writelFile函数。但是有必要阻塞的等待它们完成吗?当然,这是一个人为的例子,如果文件不是很大这一点可能无关紧要,如果文件很大,我们可以使用缓存及块拷贝的方法代替将文件内容填充到很大的vector返回。但是readFile和writeFile都是IO密集函数,在这里只表示一种更复杂IO操作的模式。在真实程序中从网络中读取数据、经过转换后返回或写入是很普遍的。
让我们看看在标准C++中如何实现异步执行copyFile操作。
C++标准库提供了一些支持并发的机制。首先是std::thread,以及相关的同步用对象(std::mutex, std::lock_guards, std::condition_variables等),最终提供了可写“传统的”基于多线程并发代码的便捷方法。
我们需要修改copyFile:创建一个新线程执行拷贝操作,并在线程执行完成后使用condition_variable进行通知。但是使用线程和锁进行操作比较复杂麻烦。现代框架(例如.net中的TPL)以基于任务(task)的并发方式提供了更高级的抽象。一个task代表了一个可以与其他操作并行运行的异步操作,系统隐藏了其实现的具体细节。
C++11标准库头文件也以promise和future提供对基于task并行的(有限地)支持。类std::promise和std::future类比.net中的Task、或者Java8中的Future。这两个类总是成对出现,将调用函数与获取执行结果分离开来。
当我们调用异步函数时调用方并不是得到类型T的结果,而是一个std::future对象,这是一个将在未来某个时间点返回结果的占位符。
一旦获得future,我们可以继续做其他事情,同时异步任务也在一个独立的线程中执行。
std::promise对象代表异步调用被调用方的执行结果,这是一个将异步结果传给调用方的通道。当任务完成后,被调用方通过调用promise::set_value将结果赋给一个promise对象。
当调用方最后需要结果时可调用阻塞函数future::get()即可获得。如果任务已经完成,结果可被立即使用,否则,调用线程将被挂起直到结果可用。
上述copyFile使用future和promise修改后的版本:
#include
size_t future_copyFile(const string& inFile, const string& outFile)
{
std::promise<vector<char>> prom1;
std::future<vector<char>> fut1 = prom1.get_future();
std::thread th1([&prom1, inFile](){
prom1.set_value(readFile(inFile));
});
std::promise<int> prom2;
std::future<int> fut2 = prom2.get_future();
std::thread th2([&fut1, &prom2, outFile](){
prom2.set_value(writeFile(fut1.get(), outFile));
});
size_t result = fut2.get();
th1.join();
th2.join();
return result;
}
注意到我们将readFile和writeFile的执行移动到了单独的task中,但是我们仍然需要设置、启动线程运行它们。并且,在lambda表达式中捕获了需要的promise和future对象,以便可以在lambda表达式中直接使用。第一个线程执行读操作,当读完成时将结果填入vector类型的promise中。第二个线程阻塞地等待,当第一个线程读操作完成时,结果会传入此线程的写函数中。最后,写操作完成时,写的字符数填入第二个future中。
主线程可以有效利用这种并行,在调用future::get()获得最终结果之前可以做其他的事情。当然,当调用future::get()时如果读写操作还未完成,主线程还是会阻塞。
可以用packaged_task稍微简化上述代码。类std::packaged_task 是一个task及其promise的容器。它的模板类型T是一个任务函数类型(例如,对于上面的readFile函数,T就是vector(const string&) )。它是一个可调用类型(定义了operator()),并为我们自动创建和管理了一个std::promise对象。
size_t packagedtask_copyFile(const string& inFile, const string& outFile)
{
using Task_Type_Read = vector<char>(const string&);
packaged_task pt1(readFile);
future<vector<char>> fut1{ pt1.get_future() };
thread th1{ move(pt1), inFile };
using Task_Type_Write = size_t(const string&);
packaged_task pt2([&fut1](const string& path){
return writeFile(fut1.get(), path);
});
future fut2{ pt2.get_future() };
thread th2{ move(pt2), outFile };
size_t result = fut2.get();
th1.join();
th2.join();
return result;
}
(译者注:promise封装的是数据类型,packaged_task封装的是可调用对象,两者都有get_future()接口获得future对象)
注意到需要使用move()函数传递packaged_task给线程,因为packaged_task类型无法被拷贝。
使用packaged_task后代码没有改变多少,稍微好读了一些,但是我们仍然需要手动创建线程、决定task运行在哪个线程中。
如果我们使用标准库中的std::async函数,一切会变得非常简单。传入一个lambda或functor,std::async会返回包含执行结果值的future。copyFile函数用std::async()修改后的版本如下:
size_t async_copyFile(const string& inFile, const string& outFile)
{
auto fut1 = async(readFile, inFile);
auto fut2 = async([&fut1](const string& path){
return writeFile(fut1.get(), path);
},
outFile);
return fut2.get();
}
从某种程度上来说std::async()相当于TPL任务调度。它会决定在哪个线程中执行任务,或在新创建的新线程中,或在重复使用的老线程中。
也可以通过第二个参数决定启动策略,“async”表示立即异步地执行任务,可能是在不同的线程中,“deferred”表示只有在get()被调用的时候才开始执行任务。
std::async的优点是隐藏了所有的实现、平台相关的细节。查看vs2013中头文件,可以看到其在windows平台下的实现,内部使用了相当于.NET TPL的PPL(Parallel Patterns Library)。
上面看到的都是C++11标准化的内容。客观上来说future和promise的使用依然是有限的,特别是跟C#和.net相比。
主要的限制是在C++11中future不是可组合的(composable)。如果启动多个task并行计算,我们无法实现阻塞所有的future、等待其中任意一个完成,但在某一刻只有一个future返回。同样,也没有好的方法将一组task组合成一个序列,每个task的结果作为下一个task的输入。可组合task可以使整个体系结构非阻塞且事件驱动。真的希望C++中也有类似任务延续(task continuations)或async/await的模式。
有了PPL(又名Concurrency Runtime)微软有可能突破标准的束缚,尝试task库的更复杂的实现。
在PPL中,类Concurrency::task(定义在< ppltasks.h>头文件中)代表了一个task。一个task等价于一个future。同时也提供了相同的阻塞方法以获取结果——get()。模板参数T表示返回类型,task进行初始化时将工作函数传入其中(lambda表达式、函数指针或函数对象)。
下面暂时不考虑可移植性,重新实现copyFile函数:
size_t ppl_copyFile(const string& inFile, const string& outFile)
{
Concurrency::task<vector<char>> tsk1 = Concurrency::create_task([inFile]() {
return readFile(inFile);
});
Concurrency::task tsk2 = Concurrency::create_task([&tsk1, outFile]() {
return writeFile(tsk1.get(), outFile);
});
return tsk2.get();
}
此处创建了2个task对象,由两个lambda表达式进行初始化,分别表示读和写操作。
现在我们真正不用考虑线程问题了。由PPL调度来决定在哪运行task、如何管理一个线程池。要注意我仍然要手动地协调两个task之间的相互作用关系:task2拥有task1的引用,明确地等待task1结束后再使用其结果。这在像这种简单例子中是可以接受的,但如果是更多task、更复杂的代码,这将会变得非常繁琐。
与future不同,PPL的task支持通过延续来进行组合。task::next方法可以将一个个task延续起来。当前序task完成时将返回其结果并调用后续task。
这次使用任务延续重写copyFile:
size_t ppl_then_copyFile(const string& inFile, const string& outFile)
{
Concurrency::task result =
Concurrency::create_task([inFile]() {
return readFile(inFile);
}).then([outFile](const vector<char>& buffer) {
return writeFile(buffer, outFile);
});
return result.get();
}
这段代码非常简洁。我们把copy函数的逻辑拆分成两个独立的、可以运行在任意线程的部分(task),并被一个task调度程序运行。
上述copyFile的实现仍然会在最后处阻塞以获得最终结果,但在一个真实的程序中可以仅返回task对象,插入到程序逻辑中,添加到一个任务延续中异步操作其结果。例如:
Concurrency::task ppl_create_copyFile_task(const string& inFile, const string& outFile)
{
return Concurrency::create_task([inFile]() {
return readFile(inFile);
}).then([outFile](const vector& buffer) {
return writeFile(buffer, outFile);
});
}
...
auto tCopy = ppl_create_copyFile_task(inFile, outFile).then([](size_t written) {
cout << written << endl;
});
...
tCopy.wait();
PPL同时还提供了其他方法来组合任务,task::wait_all和task::wait_any,对管理并行运行的一组任务很有帮助。
对于一组task,when_all会在所有task完成后创建另一个task(内部实现了join)。相反,when_any会在任意一个task完成时创建一个另一个task,这在有些地方是很有用的,比如要限制task的并行数量、只有在一个task完成后才开始一个新task。
以上只是PPL的冰山一角。PPL提供了非常丰富的函数集,几乎与其托管版本相当。还提供了一些调度类,用于实现管理工作线程池、分配task到线程中等重要任务。详细请见此处。
很快我们有希望在C++标准中见到上面介绍的一些PPL中的实现。这里已经有一份Niklas Gustaffson等人写的文件N3857,它提出了一些标准的变化。特别地,提供了 future::then, future::when_any and future::when_all实现future的可组合,这同PPL有着相同的语义。对于之前的例子,用新的future可以很方便的写成如下代码:
future future_then_copyFile(const string& inFile, const string& outFile)
{
return async([inFile]() {
return readFile(inFile);
}).then([outFile](const vector<char>& buffer) {
return writeFile(buffer, outFile);
});
}
还有函数 future::is_ready用于在调用阻塞函数get()之前先测试future是否已完成,future::unwrap用于管理嵌套future(future返回的还是future)。当然,此方案的所有细节都可以在上述提供的文件中找到。
这就是完美解决方案了?不完全是。在.net中的经验告诉我们基于task的异步代码仍然很难编写、调试、维护。有时候非常困难。这就是为什么为了通过async/await模式更方便的管理异步任务,添加了一些新关键词到C# 5.0中。在C++世界是否有类似的方法呢?
【译自:https://paoloseverini.wordpress.com/2014/04/07/concurrency-in-c11/,翻译不好,望包涵】