Windows 與 C++

追求有效率且可撰寫的非同步系統

Kenny Kerr

 

Kenny Kerr
電腦硬體的執行情況嚴重影響 C 程式設計語言的設計,以跟隨到電腦程式設計的必要的方法。這種方法作為體現了程式狀態的語句序列的描述一個程式。這是一種故意選擇由 C 設計師鄧尼斯 · 裡奇。這讓他產生的組合語言可行的替代方案。裡奇還通過結構化和程式的設計,已證明能有效地提高品質和可維護性的程式,導致極大地更加成熟、 強大的系統軟體的創作。

特定的電腦程式集語言通常包含的處理器支援的指令集。程式師可以引用寄存器 — — 字面上的少量記憶體處理器本身上 — — 以及作為位址在主記憶體中。組合語言也將包含一些用於跳轉到不同的位置,在程式中,提供了一種簡單的方法來創建可重用的常式的說明。在 C 中實現的功能,以保留少量記憶體稱為"堆疊"。大多數情況下,此堆疊或呼叫堆疊,將存儲有關每個函數,以便程式可以自動存儲狀態調用的資訊 — — 本地和共用與它的調用方 — — 知道哪裡執行應恢復功能完成後。這是這種基本的一部分今日 (星期三) 計算大多數程式師不給它的第二次思想,但它是什麼使它可能編寫高效、 易於理解的程式的令人難以置信的重要組成部分。請考慮下列程式碼:

int sum(int a, int b) { return a + b; }
int main()
{
  int x = sum(3, 4);
  return sum(x, 5);
}

給定的循序執行假設,很明顯 — — 如果不是明確 — — 該程式的狀態將在任何給定的點。 第一個假定有沒有一些自動存儲的函數的參數和傳回值,以及一些知道在哪裡,當該函式呼叫返回時恢復執行該程式的方式,這些函數將毫無意義。 C 和 c + + 的程式師,它是在堆疊,使這成為可能,並允許我們寫簡單和有效的代碼。 不幸的是,它也是我們依賴導致 C 和 c + + 程式師的傷害世界到非同步時候在堆疊上程式設計。 傳統的系統程式設計語言 (如 C 和 c + + 必須調整以保持競爭力和生產力的世界充滿了越來越非同步作業。 雖然我懷疑 C 程式編制將繼續依靠傳統的技術來完成一段時間的併發性,但我希望 c + + 將會更快地發展和提供更豐富的語言,用來寫入高效、 可組合的非同步系統。

上個月探討了一種簡單的技術,您可以使用今天用任何 C 或 c + + 編譯器來實現羽量級合作多工處理通過類比與宏無窮無盡。 雖然足夠的 C 程式師,它挑戰一些 c + + 程式師,自然和正確地依賴于其他打破抽象的構造之間的本地變數。 在本專欄中,我要尋找一個可能的未來方向,c + +,直接支援非同步程式設計更自然和組合的方式。

任務和堆疊翻錄

在我最後一列述 (msdn.microsoft.com/magazine/jj553509),併發性並不意味著多執行緒的程式設計。 這是合併兩個單獨的問題,而是流行足以引起一些混亂。 因為 c + + 語言最初並沒有提供任何明確的支援併發性,程式師自然用不同的方法來實現相同。 隨著程式也變得更加複雜,它變成了必要 — — 並可能很明顯 — — 將程式分為邏輯的任務。 每個任務將是一種具有自己的堆疊的迷你程式。 通常情況下,作業系統實現此執行緒,並每個執行緒給自己的堆疊。 這將允許任務運行獨立和經常搶先調度策略和多個處理核心的可用性。 但是,每個任務或迷你的 c + + 程式,是簡單到編寫和可以按循序執行其堆疊隔離和體現了堆疊的狀態。 這一任務的執行緒每種方法有一些明顯的局限性,但是。 每個執行緒的開銷是禁止在許多情況下的。 即使是不那麼執行緒之間合作的缺乏導致多由於同步的必要性的複雜性對訪問的共用狀態,或執行緒之間進行通信。

很多流行的另一種方法是事件驅動程式設計。 也許是更明顯,併發性並不意味著多執行緒程式設計,當你考慮的許多例子的事件驅動的程式設計,UI 發展和依靠的回呼函數,實現任務合作管理的表單庫中。 但這種方法的局限性是至少有問題的一個執行緒每個任務的辦法。 立即清潔、 順序程式成為 web — — 或者,樂觀,意粉堆疊 — — 回呼函數而不是語句和函式呼叫的粘性序列。 這有時稱為堆疊翻錄,因為以前是一個函式呼叫的常式現在撕成兩個或更多的功能。 這反過來也經常導致整個程式的漣漪效應。 翻錄的堆疊是災難性的如果你在所有關心的複雜性。 而不是一個函數,您現在有至少兩個。 而不是依靠自動存儲在堆疊上的本地變數,您必須現在顯式管理這種狀態的存儲,就必須經得起之間一個堆疊位置及另一人。 簡單的語言構造這樣的迴圈必須重寫,以適應這種分離。 最後,調試堆疊翻錄程式是很難的因為該程式的狀態,不再體現在堆疊中,往往必須手動"組裝"程式師的頭。 請考慮我的最後一列,表示同步操作,以提供顯然循序執行簡單的快閃記憶體存儲驅動程式的一種嵌入式系統的示例:

void storage_read(void * buffer, uint32 size, uint32 offset);
void storage_write(void * buffer, uint32 size, uint32 offset);
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0);
  storage_write(buffer, sizeof(buffer), 1024);
}

不難弄清楚怎麼在這裡。 堆疊的後盾的 1 KB 緩衝區傳遞給 storage_read 函數,暫停該程式,直到資料已被讀取到緩衝區。 然後將此相同的緩衝區傳遞給 storage_write 函數,暫停該程式,直到在傳輸完成。 在這一點上,自動程式返回安全地回收的堆疊空間時所使用的複製操作。 該程式不做有用的工作,同時暫停,等待 I/O 完成的明顯的缺點。

我的最後一列顯示出一種簡單的上一篇技術­多效率合作任務 c + + 中的一種方式,您可以返回到順序的程式設計風格。 但是,如果不能使用本地變數,它有點有限。 雖然堆疊管理仍然是自動在函式呼叫和返回去,損失的自動堆疊變數是一個相當嚴重的限制。 儘管如此,它勝過全面爆發堆疊翻錄。 考慮使用傳統的事件驅動的方法前面的代碼可能類似,您可以明顯地看到翻錄行動中的堆疊。 首先,存儲函數將需要能重新聲明,以容納某種形式的事件通知,通常以一個回呼函數:

typedef void (* storage_done)(void * context);
void storage_read(void * b, uint32 s, uint32 o, storage_done, void * context);
void storage_write(void * b, uint32 s, uint32 o, storage_done, void * context);

下一步,程式本身就需要重寫實現適當的事件處理常式:

void write_done(void *)
{
  ...
signal completion ...
}
void read_done(void * b)
{
  storage_write(b, 1024, 1024, write_done, nullptr);
}
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0, read_done, buffer);
  ...
wait for completion signal ...
}

這是比早些時候的同步方法,顯然更複雜,但它是很多今天在 C 和 c + + 程式規範。 請注意的複製操作,最初隻限於的主要功能怎麼現在傳播以上三項職能。 不但如此,但你幾乎需要到程式中反向,原因為 write_done 回檔需要在 read_done 之前宣佈,它需要在主函數之前宣佈。 不過,此程式是有點過分簡單化,和你應該明白如何這只會更麻煩,因為在現實世界中的任何應用程式中完全實現了"事件鏈"。

C + + 11 已取得一些顯著的步驟,走向一個優雅的解決方案,但我們還沒。 儘管 C + + 11 現在有很大的說併發在標準庫中,它仍然是語言本身上基本上保持緘默。 庫本身也別去遠不足以使程式師能夠方便地編寫更複雜的組合和非同步程式。 不過,已經完成偉大的工作,和 C + + 11 為進一步完善提供了良好的基礎。 首先,我要告訴你什麼 C + + 11 優惠,什麼是缺少然後,最後,一個可能的解決方案。

關閉和 Lambda 運算式

一般來說,閉包是一個函數,加上一些識別功能需要執行的任何非局部資訊的狀態。 考慮我去年覆蓋我的執行緒池系列中的 TrySubmitThreadpoolCallback 函數 (msdn.microsoft.com/magazine/hh335066):

void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * state) { ...
}
int main()
{
  void * state = ...
TrySubmitThreadpoolCallback(callback, state, nullptr);
  ...
}

請注意該 Windows 函數如何接受一個函數以及一些國家。 這其實是一個封閉的偽裝 ; 它肯定不像你典型的封閉,但功能是相同的。 可以說,函數物件實現同樣的目的。 停止辦公,一個一流的概念成名于功能的程式設計世界,但 C + + 11 方面取得進展,以支援這一概念,lambda 運算式的形式:

void submit(function<void()> f) { f(); }
int main()
{
  int state = 123;
  submit([state]() { printf("%d\n", state); });
}

在此示例中有一個簡單提交函數,我們可以假裝將導致提供的函數物件在一些其他上下文中執行。 函數物件創建從 lambda 運算式中的主要功能。 這個簡單的 lambda 運算式包含必要的屬性來限定為一個封閉和簡約美以令人信服。 [狀態] 部分表示什麼狀態是要進行"捕獲",而其餘部分實際上是對這種狀態具有存取權限的匿名函數。 顯然,你可以看到,編譯器將創建一個函數物件要拔掉這高度的道德。 提交功能了一個範本,編譯器可能甚至有優化掉函數物件本身,導致性能增益之外的句法的收益。 更重要的問題,但是,是這是否是真正的一個有效的封閉。 Lambda 運算式不會真正關閉所綁定的非局域變數的運算式嗎? 本示例應澄清至少一部分的難題:

int main()
{
  int state = 123;
  auto f = [state]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

此程式將列印"123"並不是"0"因為狀態變數被捕獲的價值,而不是通過引用。 我可以,當然,告訴它來捕獲通過引用的變數:

int main()
{
  int state = 123;
  auto f = [&]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

在這裡我感到指定預設的捕獲模式來捕獲的引用,並讓編譯器計算出,我所指的狀態變數的變數。 預計,該程式現在盡職盡責地列印"0"而不是"123"。這一問題,當然,是該變數的存儲仍然綁定到在其中聲明它的堆疊幀。 如果提交功能順延強制堆疊回退,然後,國家將會丟失,並不正確,您的程式。

動態語言 (如 javascript) 來解決此問題,通過合併功能的樣式,依賴于 C 必須世界遠低於到堆疊上,與本質上是無序的聯想容器的每個物件。 C + + 11 提供的這樣和 make_shared 的範本,提供高效的替代品,即使他們不很簡明。 因此,lambda 運算式和智慧指標允許關閉,在上下文中定義,並允許從沒有太多句法開銷堆疊中釋放出來的國家解決問題的一部分。 不是很理想,但這是一個開始。

承諾和期貨

乍看起來,另一個 C + + 11 項稱為期貨的功能可能會出現提供答案。 你能想到的期貨為賦的顯式非同步函式呼叫。 當然,面臨的挑戰是在界定什麼完全意味著和其獲取如何執行。 很容易解釋期貨為例。 未來啟用版本的原始的同步 storage_read 函數可能像下面這樣:

// void storage_read(void * b, uint32 s, uint32 o);
future<void> storage_read(void * b, uint32 s, uint32 o);

請注意,唯一的區別是返回類型裹在一個未來的範本。 想法是新的 storage_read 函數將開始或佇列傳輸之前返回一個未來的物件。 這一未來可以再用作同步物件等待操作完成:

int main()
{
  uint8 buffer[1024];
  auto f = storage_read(buffer, sizeof(buffer), 0);
  ...
f.wait();
  ...
}

這可能會被稱為非同步方程的消費者結束。 Storage_read 函數文摘走了提供程式的結尾,並同樣簡單。 Storage_read 函數將需要創建一個承諾和佇列中的請求參數和返回的相關聯的未來。 同樣,這是容易理解的代碼中:

future<void> storage_read(void * b, uint32 s, uint32 o)
{
  promise<void> p;
  auto f = p.get_future();
  begin_transfer(move(p), b, s, o);
  return f;
}

一旦操作完成後,存儲驅動程式可以發送信號到未來它是準備好了:

p.set_value();

這是什麼價值? 好吧,沒有價值,因為我們要使用的前途和未來的專門化 void,但是你可以想像之上這可能包括一個 file_read 函數的存儲驅動程式的檔案系統抽象。 此函數可能需要被稱為無需知道某個特定檔的大小。 然後,它可以返回的實際傳輸的位元組數:

future<int> file_read(void * b, uint32 s, uint32 o);

在這種情況下,也會用一個具有 int 類型的承諾,從而提供管道,進行通信的位元組數實際傳輸:

promise<int> p;
auto f = p.get_future();
...
p.set_value(123);
...
f.wait();
printf("bytes %d\n", f.get());

未來提供 get 方法通過其可能獲得的結果。 很好,我們有一種等待未來,和我們所有的問題都解決了! 嗯,不是那麼快。 這不會真正解決我們的問題呢? 我們同時可以啟動多個操作嗎? 有。 我們可以輕鬆地撰寫的聚合運算或甚至只是等待任何或所有未完成的操作呢? 號 在原始的同步示例中,讀取的操作一定完成寫操作之前就開始了。 所以期貨做不其實離我們這麼遠。 問題是等待一個未來的行為仍然是同步操作,有沒有標準的方式來撰寫一連串的事件。 也是沒有辦法來創建期貨的聚合。 您可能想要等待不一,但任何數量的期貨。 您可能需要等待所有期貨或只是第一就是準備好了。

在將來的期貨

期貨與承諾的問題是他們還遠遠不夠,可以說完全的缺陷。 如方法等待獲取,兩者的阻塞,直到結果就是準備好了,對聯併發和非同步程式設計。 我們需要將嘗試檢索的結果,如果的 try_get 之類的練習,這是可用的但返回立即,無論:

int bytes;
if (f.try_get(bytes))
{
  printf("bytes %d\n", bytes);
}

進一步說,期貨應提供一個延續機制以便我們可以簡單地 lambda 運算式與相關聯的非同步作業完成。 這是當我們開始看到的期貨可組合性:

int main()
{
  uint8 buffer[1024];
  auto fr = storage_read(buffer, sizeof(buffer), 0);
  auto fw = fr.then([&]()
  {
    return storage_write(buffer, sizeof(buffer), 1024);
  });
  ...
}

Storage_read 函數返回讀取未來 (fr),lambda 運算式用來建造這未來使用其當時的方法,從而導致寫未來 (fw) 的延續。 因為總是返回期貨,您可能更願意更隱式但等效的樣式:

auto f = storage_read(buffer, sizeof(buffer), 0).then([&]()
{
  return storage_write(buffer, sizeof(buffer), 1024);
});

在這種情況下還有只有單個明確未來代表的所有操作的高潮。 這可能會調用順序組成,但並行 AND 和 OR 是組成也會最平凡的系統 (認為 WaitForMultipleObjects) 的必要條件。 在這種情況下,我們將需要一對 wait_any 和 wait_all variadic 函數。 同樣的這些將返回期貨,使我們能夠提供 lambda 運算式作為繼續像以前那樣使用然後方法的總和。 它也可能非常有用,可以將已完成的未來傳遞給在那裡完成的特定未來不明顯的情況下繼續進行。

更詳盡看未來的期貨,其中包括基本主題的取消,請看看阿圖爾 · Laksberg 和尼古拉斯爾斯 · 古斯塔夫松紙張,"A 標準程式設計介面為非同步作業,"在 bit.ly/MEgzhn

敬請關注的下一期,我哪裡的期貨未來更深入地挖掘,向您展示更多流體化的寫作高效、 可組合的非同步系統。

肯尼 Kerr 是充滿熱情的本機 Windows 開發的軟體工匠。 他在到達 kennykerr.ca

由於下面的技術專家,檢討這篇文章:阿圖爾 · Laksberg