本文章是由機器翻譯。

借助 C++ 進行 Windows 開發

Windows 中的執行緒和 I/O 的演變

Kenny Kerr

 

Kenny Kerr
當開始一個新專案時,您是否會自問一下,您的程式將是計算密集型的還是 I/O 密集型的?應該問一下。我發現,在大多數情況下,程式要麼是計算密集型的,要麼是 I/O 密集型的。您可能正在處理擁有大量資料的分析庫,並讓一組處理器保持忙碌狀態,同時將資料分解為一系列聚合。此外,您的代碼可能將其大多數時間花在等待發生事件、等待資料通過網路到達、等待使用者按一下某項內容或執行某種複雜的六指手勢等方面。在這種情況下,程式中的執行緒派不上大用場。當然,也存在程式同時屬於 I/O 密集型和計算密集型的情況。SQL Server 資料庫引擎此時可發揮作用,但它對於現今的電腦程式設計並不太常用。您的程式往往要執行協調他人工作的任務。這可能是 Web 服務器或用戶端與 SQL 資料庫通信、將一些計算推送到 GPU 或提供某些內容供使用者進行交互。考慮到所有這些不同情況,您如何決定您的程式需要什麼樣的執行緒功能,以及什麼樣的併發性構造塊是必需或有用的呢?當然,這通常是很難回答的問題,當您著手新專案時,您需要進行某些分析。但它有助於我們理解執行緒在 Windows 和 C++ 中的演變過程,以便您能夠基於所提供的實用選擇做出明智的決定。

當然,對於使用者而言,無論如何執行緒也不提供任何直接值。如果您使用的執行緒數量是另一個程式的兩倍,則您的程式將不再令人感到麻煩。原因就是這些執行緒發揮了作用。為了闡述這些想法以及執行緒隨時間推移演化的方式,我現在以從某個檔中讀取一些資料為例進行說明。我將跳過 C 和 C++ 庫,因為它們對於 I/O 的支援主要是為了適應同步 I/O 或阻塞 I/O;這通常沒什麼意義,除非您構建簡單的主控台程式。當然,這也沒什麼不妥的。我所喜歡的程式中就有一些是主控台程式,這些程式就執行一項任務,而且效果確實不錯。但這確實沒什麼讓人真正感興趣的,因此我就繼續介紹其他內容了。

一個執行緒

首先,我介紹 Windows API 以及老掉牙但好用且名稱非常貼切的 ReadFile 函數。在可以開始讀取檔的內容之前,我需要一個指向此檔的控制碼,此控制碼由功能極其強大的 CreateFile 函數提供:

auto fn = L"C:\\data\\greeting.txt";
auto f = CreateFile(fn, GENERIC_READ, 
  FILE_SHARE_READ, nullptr,
  OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
ASSERT(f);

為了讓示例保持簡潔,我只使用 ASSERT 和 VERIFY 宏作為預留位置,以指示您需要在何處添加一些錯誤處理,以管理由各種 API 函數報告的任何故障。 在此程式碼片段中,CreateFile 函數用於打開檔,而不是創建檔。 此同一個函數用於執行這兩個操作。 名稱中的 Create(創建)更多地強調創建內核檔物件這一事實,而不側重說明是否在檔案系統中創建了檔。 參數非常容易理解,與此處的談論沒多大關系,但倒數第二個參數例外,通過此參數可以指定一組標記和屬性,以指示內核中您需要的 I/O 行為類型。 在此情況下,我使用了 FILE_ATTRIBUTE_NORMAL 常量,該常量僅僅指示應打開此檔以實現正常的同步 I/O。 當您準備就緒後,記得調用 CloseHandle 函數,以釋放此檔上的內核鎖定。 控制碼包裝類(例如我在 2011 年 7 月的專欄「C++ 和 Windows API」仲介紹的一個控制碼包裝類,文章網址為 msdn.microsoft.com/magazine/hh288076)將獲得成功。

現在我們可以繼續,調用 ReadFile 函數以將檔的內容讀取到記憶體中:

char b[64];
DWORD c;
VERIFY(ReadFile(f, b, sizeof(b), &c, nullptr));
printf("> %.*s\n", c, b);

正如您所預期的那樣,第一個參數指定指向此檔的控制碼。 接下來的兩個參數描述應將檔的內容讀取到其中的記憶體。 如果可用位元組數少於所請求的位元組數,則 ReadFile 還將返回複製的實際位元組數。 最後一個參數僅用於非同步 I/O,稍後我將回過頭來介紹此參數。 在這個簡單的示例中,我僅僅輸出了從檔中實際讀取的字元。 當然,如果需要,您可能需要多次調用 ReadFile。

兩個執行緒

這種 I/O 模型很容易掌握,當然對於許多小型程式非常有用,尤其適用于基於主控台的程式。 但它無法很好地進行擴展。 如果您需要同時讀取兩個單獨的檔(可能是為了支援多個使用者),您將需要兩個執行緒。 沒問題,CreateThread 函數可派上用場了。 下面是一個簡單的示例:

auto t = CreateThread(nullptr, 0, 
  [] (void *) -> DWORD
{
  CreateFile/ReadFile/CloseHandle
  return 0;
},
nullptr, 0, nullptr);
ASSERT(t);
WaitForSingleObject(t, INFINITE);

在此,我使用一個無狀態的 lambda(而非回呼函數)來表示執行緒過程。 Visual C++ 2012 編譯器符合 C++11 語言規範,因為無狀態 lambda 必須可隱式轉換為函數指標。 這樣就很方便了,Visual C++ 編譯器通過在 x86 體系結構(此體系結構支援各種調用約定)上自動生成適當的調用約定,效果將更勝一籌。

CreateThread 函數返回一個表示執行緒的控制碼,然後我使用 WaitForSingleObject 函數進行等待。 當讀取檔時,執行緒自身就會受阻。 通過這種方法,我可以讓多個執行緒協同執行不同的 I/O 操作。 然後,我可以調用 WaitForMultipleObjects 進行等待,直到所有線程都已完成。 也請記得調用 CloseHandle,以釋放內核中與執行緒相關的資源。

但是,這種技術只有在使用者或檔較少的情況下才具有可擴充性,但無論如何,可擴充性向量對於您的程式而言都至關重要。 很明顯,這並不是多個未完成的讀取操作無法擴展。 恰恰相反。 而正是執行緒和同步開銷終止了程式的可擴充性。

回到一個執行緒

此問題的一種解決方案是通過非同步程序呼叫 (APC) 使用稱為可報警 I/O 的概念。 在此模型中,程式依賴于內核將其與每個執行緒關聯的 APC 的佇列。 APC 有兩種變體:核心模式和使用者模式。 也就是說,佇列中的過程或函數可能屬於使用者模式下的程式,或者屬於某個核心模式驅動程式。 後者對於內核而言是一種簡單的方法,使驅動程式能夠線上程的使用者模式位址空間的上下文中執行某些代碼,以便它能夠訪問其虛擬記憶體。 但這種方法也可供使用者模式程式設計人員使用。 因為從根本上講,I/O 在硬體上(因此在內核中)無論如何都是非同步,所以,應該開始讀取檔的內容,當 I/O 最終結束時,應讓內核將 APC 排入佇列中。

首先,傳遞到 CreateFile 函數的標記和屬性必須更新以提供重疊的 I/O,這樣,內核就不會對針對此檔的操作進行序列化。 術語「非同步」和「重疊」在 Windows API 中可互換使用,它們具有相同的含義。 不管怎樣,當創建檔案控制代碼時,必須使用 FILE_FLAG_OVERLAPPED 常量:

auto f = CreateFile(fn, GENERIC_READ, FILE_SHARE_READ, nullptr,
  OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr);

再次說明,此程式碼片段中的唯一差別是我將 FILE_ATTRIBUTE_NORMAL 常量替換為 FILE_FLAG_OVERLAPPED 常量,但運行時的差異非常大。 為了實際提供內核可在 I/O 完成時排隊的 APC,我需要使用可選的 ReadFileEx 函數。 儘管可以使用 ReadFile 發起非同步 I/O,但只有 ReadFileEx 能讓您在 I/O 結束時提供要調用的 APC。 然後,執行緒可以繼續執行其他有用的工作,可能是啟動其他非同步作業,而 I/O 在後臺完成。

再次說明,由於採用 C++11 和 Visual C++,因此可以使用 lambda 來表示 APC。 問題在於:APC 可能想要訪問新填充的緩衝區,但這不是 APC 的參數之一,並且由於只允許使用無狀態的 lambda,所以,無法使用 lambda 來擷取緩衝區變數。 解決方案是讓緩衝區在 OVERLAPPED 結構中掛起(可以這樣說)。 因為指向 OVERLAPPED 結構的指標可用於 APC,所以您只需將結果強制轉換為您選擇的結構。 圖 1 提供了一個簡單的示例。

圖 1 可警報 I/O 以及 APC

struct overlapped_buffer
{
  OVERLAPPED o;
  char b[64];
};
overlapped_buffer ob = {};
VERIFY(ReadFileEx(f, ob.b, sizeof(ob.b), 
  &ob.o, [] (DWORD e, DWORD c,
  OVERLAPPED * o)
{
  ASSERT(ERROR_SUCCESS == e);
  auto ob = reinterpret_cast<overlapped_buffer *>(o);
  printf("> %.*s\n", c, ob->b);
}));
SleepEx(INFINITE, true);

除了 OVERLAPPED 指標之外,APC 還提供錯誤代碼作為其第一個參數,並提供複製的位元組數作為其第二個參數。 在某一時間,I/O 結束,但為了使 APC 運行,必須將同一個執行緒放入可警報的狀態中。 為此,最簡單的方法是使用 SleepEx 函數,只要 APC 排入佇列,此函數就會喚醒執行緒,並在返回控制權之前執行任何 APC。 當然,如果佇列中已經有 APC,則執行緒可能根本不會掛起。 還可以檢查 SleepEx 的傳回值,以找出是什麼導致執行緒得到恢復。 您甚至可以使用零值(而非 INFINITE)來刷新 APC 佇列,然後繼續操作而不延遲。

但是,使用 SleepEx 並非在所有情況下都有用,並容易導致不擇手段的程式設計人員輪詢 APC,這從來就不是一個好主意。 很可能發生的情況是:如果您從單一執行緒使用非同步 I/O,則此執行緒也是您程式的消息迴圈。 再者,您還可以使用 MsgWaitForMultipleObjectsEx 函數來等待除 APC 之外的其他物件,並為您的程式構建更具有吸引力的單線程運行時。 APC 的潛在缺點是它們可能引入一些複雜的重入錯誤,因此請務必記住這一點。

每個處理器一個執行緒

當您查找程式要執行的更多工時,您可能會注意到:運行程式執行緒的處理器變得越來越忙,而電腦上的其他處理器卻無所事事正在等待任務。 儘管 APC 是最高效地執行非同步 I/O 的方法,但它們具有明顯的缺點:它們只在啟動操作的同一個執行緒上完成。 此時的問題是要制訂一個解決方案,將此工作擴展到所有可用的處理器上。 您可能會構思出您自己的獨特設計,或許是通過可警報的消息迴圈在若干執行緒間協調工作,但您所做的將無法實現 I/O 完成埠的全部性能和可擴充性,這在很大程度上是因為它與內核的不同部分深度集成。

儘管 APC 使非同步 I/O 操作能夠在單一線程上完成,但完成埠允許任何執行緒開始 I/O 操作並讓任意執行緒結果。 完成埠是您創建的一個內核對象,創建後,您可以將其與任何數量的檔物件、通訊端、管道等相關聯。 完成埠公開一個排隊介面,通過此介面,內核可以在 I/O 完成時向佇列中推送一個完成包,而程式可以在任意可用執行緒上取消該包排隊,並根據需要處理此包。 如果需要,您甚至可以將自己的完成包排入佇列。 主要難題在於容易產生混淆的 API。 圖 2 提供了完成埠的一個簡單包裝類,同時明確了如何使用函數以及函數如何相關。

圖 2 完成埠包裝

class completion_port
{
  HANDLE h;
  completion_port(completion_port const &);
  completion_port & operator=(completion_port const &);
public:
  explicit completion_port(DWORD tc = 0) :
    h(CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, tc))
  {
    ASSERT(h);
  }
  ~completion_port()
  {
    VERIFY(CloseHandle(h));
  }
  void add_file(HANDLE f, ULONG_PTR k = 0)
  {
    VERIFY(CreateIoCompletionPort(f, h, k, 0));
  }
  void queue(DWORD c, ULONG_PTR k, OVERLAPPED * o)
  {
    VERIFY(PostQueuedCompletionStatus(h, c, k, o));
  }
  void dequeue(DWORD & c, ULONG_PTR & k, OVERLAPPED *& o)
  {
    VERIFY(GetQueuedCompletionStatus(h, &c, &k, &o, INFINITE));
  }
};

主要混淆圍繞著 Create­IoCompletionPort 函數執行的雙重職責:首先是實際創建一個完成埠物件,然後將其與重疊的檔物件相關聯。 完成埠只創建一次,然後與任何數量的檔關聯。 從技術上來說,您可以在單一調用中同時執行這兩個步驟,但這僅當您將完成埠用於單一檔時才有用,那這樣又有什麼意義呢?

當創建完成埠時,唯一的注意事項是指示執行緒計數的最後一個參數。 這是允許用來併發對完成包清除佇列的最大執行緒數。 如果將此參數設置為零,則意味著內核將允許每個處理器一個執行緒。

添加檔在技術上稱作關聯;要注意的主要事項是一個參數,此參數指示要與檔關聯的鍵。 因為您無法在控制碼結束時掛起額外資訊(但使用 OVERLAPPED 結構時可以掛起),所以該鍵提供了一種方法,使您能夠將某些程式特定的資訊與檔相關聯。 只要內核將與此檔相關的完成包排入佇列,也就將包括此鍵。 這一點特別重要,因為檔案控制代碼甚至不包括在完成包中。

正如前面所述,您可以將自己的完成包排入佇列。 在這種情況下,您提供的值完全由您決定。 內核不關注這些值,也不試圖以任何方式解釋它們。 這樣,您可以提供一個偽 OVERLAPPED 指標,完全相同的位址將存儲在完成包中。

但是,在大多數情況下,一旦非同步 I/O 操作完成,您將等待內核將完成包排入佇列。 通常,程式會對每個處理器創建一個或多個執行緒,並在無限迴圈中調用 GetQueuedCompletionStatus 或我的取消佇列包裝函數。 當程式需要結束並且您希望這些執行緒終止時,您可能要將一個特殊的控制完成包排入佇列(每個執行緒一個包)。 對於 APC,您可以在 OVERLAPPED 結構中掛起更多資訊,以便將額外資訊與每個 I/O 操作關聯:

completion_port p;
p.add_file(f);
overlapped_buffer ob = {};
ReadFile(f, ob.b, sizeof(ob.b), nullptr, &ob.o);

此處,我再次使用最初的 ReadFile 函數,但在此情況下,我提供一個指向 OVERLAPPED 結構的指標作為其最後一個參數。 一個等待中的執行緒可能會取消完成包的排隊,如下所示:

DWORD c;
ULONG_PTR k;
OVERLAPPED * o;
p.dequeue(c, k, o);
auto ob = reinterpret_cast<overlapped_buffer *>(o);

池執行緒

如果您關注我的專欄已經有一段時間了,您會記得我去年花了五個月時間詳細介紹了 Windows 執行緒池。 此同一個執行緒池 API 是使用 I/O 完成埠實現的,同時提供了這一相同的工作佇列模型但無需您自行管理執行緒,這一點對您來說也不會覺得奇怪。 它還提供了一系列功能和便利性,這使其成為一種具有吸引力的替代方法,使您不必直接使用完成埠物件。 如果您尚未採用上述方法,我建議您閱讀這些專欄,以儘快採用 Windows 執行緒池 API。 可通過 bit.ly/StHJtH 獲得我的線上專欄清單。

至少,您可以使用 TrySubmitThreadpoolCallback 函數來獲取執行緒池,以便在內部創建其工作物件之一,並將其回檔立即提交以供執行。 這一過程非常簡單,如下所示:

TrySubmitThreadpoolCallback([](PTP_CALLBACK_INSTANCE, void *)
{
  // Work goes here!
},
nullptr, nullptr);

如果您需要多一些控制權,您當然可以直接創建一個工作物件,並將其與執行緒池環境和清理組相關聯。 這種方法還可以向您提供最佳性能。

當然,此處討論的內容是關於重疊 I/O 的,執行緒池只是為這種情況提供 I/O 物件。 我不想在這方面花費很多時間,因為我已經在我的 2011 年 12 月的專欄「執行緒池計時器和 I/O」(msdn.microsoft.com/magazine/hh580731) 中探討了這一內容,但圖 3 提供了一個新示例。

圖 3 執行緒池 I/O

OVERLAPPED o = {};
char b[64];
auto io = CreateThreadpoolIo(f, [] (PTP_CALLBACK_INSTANCE, 
  void * b,   void *, ULONG e, ULONG_PTR c, PTP_IO)
{
  ASSERT(ERROR_SUCCESS == e);
  printf("> %.*s\n", c, static_cast<char *>(b));
},
b, nullptr);
ASSERT(io);
StartThreadpoolIo(io);
auto r = ReadFile(f, b, sizeof(b), nullptr, &o);
if (!r && ERROR_IO_PENDING != GetLastError())
{
  CancelThreadpoolIo(io);
}
WaitForThreadpoolIoCallbacks(io, false);
CloseThreadpoolIo(io);

考慮到 CreateThreadpoolIo 讓我向排隊的回檔傳遞一個附加的上下文參數,因此我不需要將緩衝區從 OVERLAPPED 結構中掛起,儘管需要時我完全可以這麼做。 要記住的主要事項是必須在開始非同步 I/O 操作之前調用 StartThreadpoolIo,並且如果 I/O 操作失敗或內聯完成,則必須調用 CancelThreadpoolIo。

快速、流暢的執行緒

在將執行緒池的概念提到新的高度後,適用于 Windows 應用商店應用程式的新 Windows API 還提供了執行緒池抽象,但這種抽象要簡單得多並且其功能也少得多。 幸運的是,沒有什麼可以阻止您使用適合您的編譯器和庫的備選執行緒池。 無論您過去是怎樣獲得這一線程池的,友好的 Windows 應用商店管理者都是另一回事。 然而,Windows 應用商店應用程式的執行緒池還是值得一提,它集成了由適用于 Windows 應用商店應用程式的 Windows API 所體現的非同步模式。

通過使用靈活的 C++/CX 擴展,可以提供相對簡單的 API 來以非同步方式運行一些代碼:

ThreadPool::RunAsync(ref new WorkItemHandler([] (IAsyncAction ^)
{
  // Work goes here!
}));

從語法上講,這是十分簡單的。 我們甚至希望,如果編譯器可以自動從 lambda 生成 C++/CX 委託(至少在概念上是這樣),這與它目前針對函數指標生成此類委託一樣,則上述代碼在將來的 Visual C++ 版本中將變得更簡單。

然而,這種比較簡單的語法掩蓋了大量的複雜性。 從較高層次來說,ThreadPool 是一個靜態類(這是從 C# 語言中借來的術語),因此無法創建。 它提供了靜態 RunAsync 方法的一些開銷,僅此而已。 每個執行緒池都至少將一個委託作為其第一個參數。 此處我使用 lambda 構造此委託。 RunAsync 方法還返回一個 IAsyncAction 介面,同時提供對非同步作業的訪問。

為方便起見,假設這種方法效果非常好,並與適用于 Windows 應用商店應用程式的 Windows API 中普遍採用的非同步程式設計模型完美集成。 例如,您可以在一個並行模式庫 (PPL) 任務中包裝由 RunAsync 方法返回的 IAsyncAction 介面並實現可組合性級別,這一可組合性級別與我在以下文章仲介紹的級別相似:我的九月份和十月份專欄「追求高效的可組合非同步系統」(msdn.microsoft.com/magazine/jj618294) 和「回到可恢復函數構築的未來」(msdn.microsoft.com/magazine/jj658968)。

但是,認識到這些看上去乏味的代碼真正表示什麼,這是非常有用的並且在一定程度上會使您冷靜下來。 C++/CX 擴展的核心是基於 COM 的運行時及其 IUnknown 介面。 此類基於介面的物件模型不可能提供靜態方法。 此時必須有一個可成為介面的物件,並且必須有某種類工廠來創建該物件,而實際上確實就有。

Windows 運行時定義了稱為運行時類的物件,它與傳統的 COM 類非常相似。 如果您是守舊派,您甚至可以在 IDL 檔中定義該類,並通過專門針對此任務的新版 MIDL 編譯器來運行該類,此時它將生成 .winmd 元資料檔案和適當的標頭。

運行時類可以同時具有實例方法和靜態方法。 它們是使用單獨的介面定義的。 在生成的中繼資料中,包含實例方法的介面成為類的預設介面,而包含靜態方法的介面歸屬於運行時類。 在這種情況下,ThreadPool 運行時類缺少可啟動的屬性且沒有預設介面,但一旦創建,就可以對靜態介面進行查詢,然後可以調用那些不是那麼靜態的方法。 圖 4 提供了其中可能包含的內容的示例。 請記住,其中大部分內容是編譯器生成的,但它會讓您很好地瞭解如下這一點:進行這種簡單的靜態方法調用來以非同步方式運行委託,真正的價值體現在哪些方面。

圖 4 WinRT 執行緒池

class WorkItemHandler :
  public RuntimeClass<RuntimeClassFlags<ClassicCom>,
  IWorkItemHandler>
{
  virtual HRESULT __stdcall Invoke(IAsyncAction *)
  {
    // Work goes here!
return S_OK;
  }
};
auto handler = Make<WorkItemHandler>();
HSTRING_HEADER header;
HSTRING clsid;
auto hr = WindowsCreateStringReference(
  RuntimeClass_Windows_System_Threading_ThreadPool, 
  _countof(RuntimeClass_Windows_System_Threading_ThreadPool)
  - 1, &header, &clsid);
ASSERT(S_OK == hr);
ComPtr<IThreadPoolStatics> tp;
hr = RoGetActivationFactory(
  clsid, __uuidof(IThreadPoolStatics),
  reinterpret_cast<void **>(tp.GetAddressOf()));
ASSERT(S_OK == hr);
ComPtr<IAsyncAction> a;
hr = tp->RunAsync(handler.Get(), a.GetAddressOf());
ASSERT(S_OK == hr);

毫無疑問,要實現調用 TrySubmitThreadpoolCallback 函數的相對簡單性和高效率,還有很長一段路要走。 瞭解您所使用的抽象的代價很有説明,即使您已經通過某種效率度量手段證明了這種代價是物有所值的也不例外。 我簡短地展開介紹一下。

WorkItemHandler 委託實際上是一個基於 IUnknown 並帶有一個 Invoke 方法的 IWorkItemHandler 介面。 此介面的實現不是由 API 而是由編譯器提供的。 這樣就合情合理了,因為它為 lambda 捕獲的任何變數提供了一個便利的容器,並且 lambda 的主體自然位於編譯器生成的 Invoke 方法中。 在本例中,我僅僅依賴 Windows 運行時庫 (WRL) RuntimeClass 範本類來實現 IUnknown。 然後,我可以使用便利的 Make 範本函數來創建我的 WorkItemHandler 的實例。 對於無狀態的 lambda 和函數指標,我進一步預期編譯器將生成靜態實現以及 IUnknown 的 no-op 實現,以避免動態分配開銷。

為了創建運行時類的實例,我需要調用 RoGet­ActivationFactory 函數。 然而,它需要類 ID。 注意,這不是傳統 COM 的 CLSID,而是類型(在這種情況下為 Windows.System.Threading.ThreadPool)的完全限定名稱。 此處我使用 MIDL 編譯器生成的常量陣列,以避免不得不在運行時對字串計數。 似乎這還不夠,我還需要創建此類 ID 的 HSTRING 版本。 此處,我使用 WindowsCreateStringReference 函數,此函數與常規 WindowsCreateString 函數不同,它不創建源字串的副本。 為方便起見,WRL 還提供了包裝此功能的 HStringReference 類。 現在我可以調用 RoGetActivationFactory 函數,同時直接請求 IThreadPoolStatics 介面並將生成的指標存儲在 WRL 提供的智慧指標中。

現在,我最終可以對此介面調用 RunAsync 方法,向其提供我的 IWorkItemHandler 實現以及表示所生成的操作物件的 IAsyncAction 智慧指標的位址。

此執行緒池 API 提供的功能和靈活性與核心 Windows 執行緒池 API 或併發運行時提供的功能和靈活性無法相提並論,這一點可能也並不奇怪。 然而,C++/CX 和運行時類的優勢是沿著程式與運行時自身之間的邊界實現的。 作為 C++ 程式設計人員,您可能對 Windows 8 不是全新平臺以及傳統的 Windows API 仍可在您需要時供您隨意使用而感到欣慰。

Kenny Kerr 是一位熱衷於本機 Windows 開發的軟體專家。您可以通過 kennykerr.ca 與他聯繫。

衷心感謝以下技術專家對本文的審閱:詹姆斯 P. McNellis