# 使用 C++ 進行 Windows 開發 - Visual C++ 2015 中的協同例程
作者?[Kenny Kerr](https://msdn.microsoft.com/zh-cn/magazine/mt149362?author=Kenny+Kerr)?| 2015 年 10 月
我于 2012 年初次了解 C++ 中的協同例程,并在 MSDN 雜志這里寫下了一系列文章來表達觀點。我研究了一種輕量形式的協作多任務,借助 switch 語句通過巧妙的方式模擬協同例程。然后,我探討了通過對 Promises 和 Futures 的建議擴展來提高異步系統的效率以及可組合性的一些措施。最后,我總結了一些即使在未來的 Futures 愿景中也依然存在的挑戰,以及對所謂可恢復函數的建議。對于 C++ 中優雅簡潔的并發,如果你對一些相關的挑戰和歷史感興趣,那么我建議你閱讀以下文章:
* “使用 C++ 實現輕型協作多任務”([msdn.microsoft.com/magazine/jj553509](https://msdn.microsoft.com/magazine/jj553509))
* “追求高效的可組合異步系統”([msdn.microsoft.com/magazine/jj618294](https://msdn.microsoft.com/magazine/jj618294))
* “回到可恢復函數構筑的未來”([msdn.microsoft.com/magazine/jj658968](https://msdn.microsoft.com/magazine/jj658968))
文章大部分都屬于理論上的內容,因為我當時沒有編譯器來實現其中任何一個想法,而不得不以各種方式模擬它們。而今年初推出了 Visual Studio 2015。此版本的 Visual C++ 包含了稱為 /await 的實驗編譯器選項,用于解鎖由編譯器直接支持的協同例程的實現。不再需要 Hack、宏或其他巧妙的技術。盡管是用于實驗,且尚未經過 C++ 委員會批準,但它實實在在可用。并且它不僅僅是編譯器前端的語法糖,例如你所了解的 C# yield 關鍵字和異步方法。C++ 實現在編譯器后端包含了深度工程投資,可提供難以置信地可擴展實現。事實上,當編譯器前端僅提供更方便的語法用于處理 Promises 和 Futures 或者甚至并發運行時任務類時,該實現所執行的操作遠比你所了解的還要多。那么讓我們重新訪問這一主題,然后看看今天它已發展到什么地步。由于自 2012 年以來發生了很多變化,因此我先進行一個簡單的回顧,說明我們當時所面臨的情況以及現在的情形,然后再查看一些更具體的示例和實際應用。
由于借助了可恢復函數的顯著示例總結出上述一系列內容,因此我從這里開始說起。想象一對要從某個文件讀取然后寫入網絡連接的資源:
~~~
struct File
{
? unsigned Read(void * buffer, unsigned size);
};
struct Network
{
? void Write(void const * buffer, unsigned size);
};
~~~
你可以隨意想象其他示例,但對于傳統的同步 I/O,該示例頗具代表性。文件的 Read 方法會嘗試將數據從當前文件位置讀取到緩沖區,直到緩沖區的最大大小,然后將返回實際復制的字節數。如果返回值小于請求大小,這通常意味著已經到達文件末端。Network 類模型是典型的面向連接的協議,例如 TCP 或 Windows 命名管道。Write 方法將特定字節數復制到網絡堆棧。典型的同步復制操作很容易想象,但我會借助圖 1?幫助你形成一個參考框架。
圖 1 同步復制操作
~~~
File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
while (unsigned const actual = file.Read(buffer, sizeof(buffer)))
{
? network.Write(buffer, actual);
}
~~~
只要 Read 方法返回一些大于零的值,便會使用 Write 方法將結果字節從中間緩沖區復制到網絡。任何一個合理的程序員(無論其背景如何)都可以理解這種類型的代碼。當然,Windows 提供的服務可以將此類型的操作完全卸載到內核以避免所有轉換,但這些服務僅限于特定方案,而這是應用通常與其捆綁的阻止操作類型的代表。
C++ 標準庫提供的 Futures 和 Promises 嘗試支持異步操作,但由于其不成熟的設計而備受詬病。我在 2012 年探討過這些問題。即使忽視這些問題,重寫圖 1?中的文件到網絡復制示例也非常不容易。同步的最直接(也是最簡單的)轉換在循環時需要經手動謹慎編寫的可以遍歷 Futures 鏈的迭代算法。
~~~
template <typename F>
future<void> do_while(F body)
{
? shared_ptr<promise<void>> done = make_shared<promise<void>>();
? iteration(body, done);
? return done->get_future();
}
~~~
該算法在迭代函數中才能起實際作用:
~~~
template <typename F>
void iteration(F body, shared_ptr<promise<void>> const & done)
{
? body().then([=](future<bool> const & previous)
? {
??? if (previous.get()) { iteration(body, done); }
??? else { done->set_value(); }
? });
}
~~~
lambda 必須按值捕獲共享 Promise,因此這實際是迭代而不是遞歸。但這樣會出現問題,因為這對于每個迭代而言意味著一對互鎖操作。此外,Futures 尚未具有鏈接延續的“then”方法,盡管你現在可以使用并發運行時任務類對其進行模擬。仍然假定這種未來算法和延續存在,我可以采用異步方式重寫圖 1?中的同步復制操作。我首先需要將異步重載添加到 File 和 Network 類。可能類似以下內容:
~~~
struct File
{
? unsigned Read(void * buffer, unsigned const size);
? future<unsigned> ReadAsync(void * buffer, unsigned const size);
};
struct Network
{
? void Write(void const * buffer, unsigned const size);
? future<unsigned> WriteAsync(void const * buffer, unsigned const size)
};
~~~
WriteAsync 方法的 Future 必須回響復制的字節數,因為這是任何延續可能所具有的全部數量以用于確定是否終止迭代。另一個選項可用于 File 類來提供 EndOfFile 方法。在任何考慮到這些新基元的情況下,如果你很清醒,你肯定可以采用可理解的方式來表達此復制操作。圖 2?說明了此方法。
圖 2 使用 Futures 的復制操作
~~~
File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
future<void> operation = do_while([&]
{
? return file.ReadAsync(buffer, sizeof(buffer))
? ??.then([&](task<unsigned> const & read)
??? {
????? return network.WriteAsync(buffer, read.get());
??? })
??? .then([&](task<unsigned> const & write)
??? {
????? return write.get() == sizeof(buffer);
??? });
});
operation.get();
~~~
只要循環“主體”返回 True,do_while 算法就有助于進行延續鏈接。因此調用了 ReadAsync,其結果由 WriteAsync 使用,后者的結果作為循環條件進行檢驗。這不是復雜的事情,但我并不想編寫這樣的代碼。它經過人為編寫,很快就會變得非常復雜而難以進行推論。輸入可恢復函數。
添加 /await 編譯器選項使編譯器能夠支持可恢復函數,一種 C++ 協同例程的實現。它們稱為可恢復函數而不是簡單的協同例程,因為這意味著它們要盡可能表現地像傳統 C++ 函數一樣。實際上,與我在 2012 年所探討的不同,某些函數的使用者根本不應該需要知道函數是否作為協同例程實現。
從本文撰寫開始,/await 編譯器選項還需要 /Zi 選項而非默認 /ZI 選項,以便禁用調試器的編輯和繼續功能。你還必須使用 /sdl- 選項禁用 SDL 檢查并避免使用 /RTC 選項,因為編譯器的運行時檢查與協同例程不兼容。所有這些限制都是暫時的并且由于實現的實驗本質,我希望在未來的編譯器更新中將其解除。但這些都是值得的,正如圖 3?所示。與使用 Futures 實現的復制操作的所需內容相比,毫無疑問,這樣編寫明顯更為簡單且易于理解。實際上,這與圖 1?中的原始同步示例非常相似。在本例中,WriteAsync Future 還無需返回特定值。
圖 3 可恢復函數內的復制操作
~~~
future<void> Copy()
{
? File file = // Open file
? Network network = // Open connection
? uint8_t buffer[4096];
? while (unsigned copied = await file.ReadAsync(buffer, sizeof(buffer)))
? {
??? await network.WriteAsync(buffer, copied);
? }
}
~~~
圖 3?中使用的 await 關鍵字以及 /await 編譯器選項提供的其他新關鍵字只能出現在可恢復函數內,因此便出現在返回 Future 的 Copy 函數內。我要使用上個 Futures 示例中相同的 ReadAsync 和 WriteAsync 方法,但重要的是要意識到編譯器完全不了解 Futures。實際上,它們根本不需要是 Futures。那么,具體的工作方式如何呢? 是這樣,只有編寫了為編譯器提供必要綁定的某些適配器函數后它才可正常工作。這類似于編譯器通過查找合適的 Begin 和 End 函數找出如何連接到基于范圍的 for 語句的方式。在 await 表達式的情況下,與查找 Begin 和 End 不同,編譯器要查找的合適函數稱為 await_ready、await_suspend 和 await_resume。與 Begin 和 End 相同,這些新函數可以是成員函數也可以是自由函數。編寫非成員函數的能力非常有用,因為你可以隨后為提供必要語義的現有類型編寫適配器,像我到目前為止已探索的未來 Futures 一樣。圖 4?提供的一組適配器可以滿足編譯器對圖 3?中可恢復函數的解釋。
圖 4 假設 Future 的 Await 適配器
~~~
namespace std
{
? template <typename T>
? bool await_ready(future<T> const & t)
? {
??? return t.is_done();
? }
? template <typename T, typename F>
? void await_suspend(future<T> const & t, F resume)
? {
??? t.then([=](future<T> const &)
??? {
????? resume();
??? });
? }
? template <typename T>
? T await_resume(future<T> const & t)
? {
??? return t.get();
? }
}
~~~
此外,請記住 C++ 標準庫的 Future 類模板尚未提供“then”方法來添加延續,但此模板足以使本示例適用于如今的編譯器。可恢復函數內的 await 關鍵字有效地設置了潛在的掛起點,當操作尚未完成時,執行可能在此處退出函數。如果 await_ready 返回 True,則不會掛起執行,并且立即調用 await_resume 來獲取結果。反之,如果 await_ready 返回 False,則調用 await_suspend,以便允許操作注冊由編譯器提供的要在最終完成時調用的恢復函數。調用恢復函數時,協同例程會立即在上一個掛起點恢復,并且執行繼續進行到下一個 await 表達式或函數的終止處。
請記住,恢復將在任何調用編譯器恢復函數的線程上進行。這意味著可恢復函數完全有可能在某一個線程上開始運行,然后在另一個線程上恢復并繼續執行。實際上從性能角度來看,這是一種理想狀況,因為替代意味著將恢復分派到其他線程,而這樣做通常開銷較大且并不必要。另一方面,如果存在任何后續代碼都具有線程關聯,這將是非常理想甚至是必需的情況,正如大多數圖形代碼的情況。遺憾的是,await 關鍵字尚未提供一種方式,可以讓 await 表達式的作者為編譯器提供這樣的提示。這并非沒有先例。并發運行時確實具有這樣的選項,但有趣的是,C++ 語音本身提供了一種你可能會遵循的模式:
~~~
int * p = new int(1);
// Or
int * p = new (nothrow) int(1);
~~~
同樣地,await 表達式也需要一種機制才能向 await_suspend 函數提供一個提示來影響發生恢復的線程上下文:
~~~
await network.WriteAsync(buffer, copied);
// Or
await (same_thread) network.WriteAsync(buffer, copied);
~~~
默認情況下,恢復通過盡可能最高效的操作方式進行。某些假設 std::same_thread_t 類型的 same_thread 常量可能會在 await_suspend 函數的重載之間產生歧義。圖 3?中的 await_suspend 將是最高效的默認選項,因為它很有可能在工作線程上恢復并在無需進一步切換上下文的情況下完成。當使用者需要線程關聯時,會請求圖 5?中所示的 same_thread 重載。
圖 5 假設 await_suspend 重載
~~~
template <typename T, typename F>
void await_suspend(future<T> const & t, F resume, same_thread_t const &)
{
? ComPtr<IContextCallback> context;
? check(CoGetObjectContext(__uuidof(context),
??? reinterpret_cast<void **>(set(context))));
? t.then([=](future<T> const &)
? {
??? ComCallData data = {};
??? data.pUserDefined = resume.to_address();
??? check(context->ContextCallback([](ComCallData * data)
??? {
????? F::from_address(data->pUserDefined)();
????? return S_OK;
??? },
??? &data,
??? IID_ICallbackWithNoReentrancyToApplicationSTA,
??? 5,
??? nullptr));
? });
}
~~~
此重載將檢索 IContextCallback 接口以查找調用線程(或單元)。然后,延續最終會從這一相同的上下文中調用編譯器的恢復函數。如果這恰好是應用的 STA,該應用便可以輕松地通過線程關聯繼續與其他服務交互。ComPtr 類模板和檢查幫助程序函數屬于 Modern 庫,你可以從[github.com/kennykerr/modern](http://github.com/kennykerr/modern)?下載它,但你也可以使用可供你支配的任何資源。
我介紹了大量內容,雖然其中的某些部分依然較為理論化,但 Visual C++ 編譯器已經提供所有繁重的工作而使之成為可能。這對于對并發感興趣的 C++ 開發者來說是一個令人興奮的時刻,我希望你們在下個月繼續同我一起研究,我將深入探討 Visual C++ 的可恢復函數。
* * *
Kenny Kerr?*是加拿大的計算機程序員,是 Pluralsight 的作者,也是一名 Microsoft MVP。他的博客網址是?[kennykerr.ca](http://kennykerr.ca/),你可以通過 Twitter?[@kennykerr](http://twitter.com/@kennykerr)?關注他。*
- 介紹
- Microsoft Azure - Microsoft Azure - 大圖
- 崛起 - 新手成功的秘訣
- ASP.NET - 借助 OmniSharp 和 Yeoman 隨時隨地使用 ASP.NET 5
- 使用 C++ 進行 Windows 開發 - Visual C++ 2015 中的協同例程
- Visual Studio - Bower: 用于 Web 開發的新型工具
- 測試運行 - 使用 C# 實現線性判別分析
- 代碼分析 - 借助與 NuGet 集成的 Roslyn 代碼分析來生成和部署庫
- 孜孜不倦的程序員 - 如何成為 MEAN: 快速安裝
- Microsoft Band - 借助 Microsoft Band SDK 開發 Windows 10 應用程序
- C# - C# 中的一種分裂與合并表達式分析器
- 別讓我打開話匣子 - 過時的東西
- 編者寄語 - 災難鏈