WinRT: 可能是 Windows 上最好用的 Native ABI 和遠程調用方案
前言
Windows 自從很久以來就有一個叫做 COM 的 Native ABI。這是一套面向對象的 ABI,在此之上 Windows 基于 COM ABI 暴露了各種各樣的 API,例如 Management API、Shell API 和 DirectX API 就是典型。COM 自然不僅局限于進程內調用,跨進程的 RPC 調用也是不在話下。但無論如何,COM 用起來都很不順手。
不過自從 Windows 8 以來,Windows 引入了全新的 WinRT,一下子讓 Windows 的 Native ABI 變得方便快捷。
WinRT 的前身:COM
不少人可能對 COM 并沒有什么概念,那首先我們來簡單說一下 COM 是什么。
例如當你需要提供一些算術的 API 時,你可以定義下面這樣一個 interface:
[[uuid(dc58f02e-ceaa-46dc-8fb8-2a1348412421)]]
interface ICalc
{
HResult Add(int x, int y, int* result);
}
這個 interface 提供了加法的 API,加法 API 傳入兩個參數,通過指針輸出一個參數作為返回值,而 API 自身返回是否執行成功。為了讓這個 interface 能夠被唯一識別,我們用 GUID(uuid) 給他提供一個 ID。
所有的 COM interface 都自動派生自 IUnknown,其中包含兩個用于管理生命周期的引用計數函數 AddRef 和 Release,以及一個用于查詢接口的函數 QueryInterface。
當我們拿到一個 object 的時候,如果我們想要調用 ICalc 上的方法,很自然我們會想到要把這個 object 先轉換到 ICalc 再調用:
object x = ...;
int result;
((ICalc)x).Add(1, 2, &result);
但這樣并不安全:你怎么知道這個 object 實現了 ICalc? 于是這里 QueryInterface 就派上用場了,調用它用 GUID 查詢接口,成功后會返回給你查詢的接口在這個 object 上對應方法表的入口點(等價于強制轉換為 ICalc 之后的 object),然后你就能在這個 object 上調用它實現的 ICalc 里的方法了:
object x = ...;
ICalc calcEntry;
if (x.QueryInterface(guid, &calcEntry) == S_OK) {
int result;
calcEntry.Add(1, 2, &result);
}
這個時候假設我們 A 包含這個接口的實現,而 B 需要在不知道 A 的實現的情況下調用加法,只需要 A 實現這個 ICalc 的接口并且注冊好,那么 B 只需要調用 QueryInterface 就能把 A 傳過來的 object 變成 ICalc 然后調用了。
更進一步,我們的 A 有個類型實現了 IClassFactory,里面有個函數 CreateInstance 用來根據 GUID 來創建 object,這樣我們在 B 里可以首先從 A 拿到 IClassFactory 的 object,然后直接用 ICalc 的 GUID 來調用它,就能創建一個對應 object 返回給我們來用了。
Windows 貼心的提供了一個系統 API CoGetClassObject,就是用來完成從獲取 IClassFactory 到創建對象整個流程的,我們在 B 里簡單調用 CoGetClassObject 這個函數,就能做到憑空產生我們想要的 object。
而 A 只需要把自己的 IClassFactory 注冊到進程內(如果 A 和 B 在同一個進程里的話)或者系統里(如果 A 是一個獨立進程的話)就可以了,這樣系統就能知道從哪里去獲取這個 IClassFactory。只不過進程內的情況是通過調用 A 暴露的 DllGetClassObject 函數,而不是 CoGetClassObject。
如此一來,無論 A 在哪里(進程內或者進程外),只要注冊了,B 就能直接用 GUID 創建一個 ICalc 拿來用,進程內調用和進程間調用的差異被 COM 徹底抹除了。
為了溝通 A 和 B,我們用一個叫做 idl 的語言描述接口,編譯后會生成一個 tlb 文件來描述類型信息,這個類型信息就能讓系統知道你都定義了哪些東西。
COM 得益于其結構化和兼容性的設計,既不像字符串作為交互格式時導致處理容易出現問題,又能在保留兼容性的同時隨意擴展已有類型,在 Windows 甚至是 macOS 上都被大量的使用(是的你沒有看錯,macOS 里也有大量的 COM 接口),但是 COM 也有自身的局限性和難用之處:
- 類型定義全靠 GUID,一個 GUID 就是一個類型,沒有文檔的時候你根本不知道這個 GUID 到底是什么類型
- 注冊獨立進程的 COM 組件要往系統注冊表里面寫配置,需要管理員身份
- 不支持異步,非常不現代
其中第 2 點伴隨著 Windows 8 引入 appx 包的概念,已經可以做到在程序包內部注冊 COM 組件而不需要寫到注冊表里,到今天已經不是什么問題。但是 1 和 3 還是沒有解決。
進程內/進程間通信原理
你可能好奇 COM 是如何來用作進程內/進程間通信的。
類型分為兩部分:數據和方法。
class Foo
{
public int A;
public string B;
public void C() { ... }
public void D() { ... }
}
例如上面的 A 和 B 就是數據,而 C 和 D 則是方法。
COM 用作 IPC/RPC 的原理則是,Client 持有對 Server 的對象的一份引用,這份引用中包含對 Server 對象的方法表的引用,而數據部分則:
- 簡單的數據(例如 int、string),則 marshal 后在 Server 和 Client 之間傳送
- 復雜數據(例如基礎類型之外的 object),則直接傳送引用
因此當 Client 進程持有 Server 進程的一個 object 時,調用上面的方法會直接在實際 object 所在的進程上執行,也就是這里的 Server。
也由于這種特性,你甚至能在 Server 和 Client 之間直接傳遞委托(函數)、屬性和事件!因此例如 Client 訂閱 Server 的事件,讓 Server 的那個事件觸發后在 Client 上執行某個特定的函數是完全沒有問題的!
如果 Client 和 Server 不是兩個單獨的進程,而是 Server 作為一個 dll 被 Client 中導入和使用的話,遵循這套方法的進程內恰好消除了一切開銷,Client 調用 Server 的 API 本質上和直接調用 dll 里面的函數沒有任何的區別!
這也是為什么 COM 用來做進程內/進程間通信的效率遠遠甩開其他任何 IPC/RPC 方案。
更有趣的是,假如有一個 Server A,和兩個 Client B、C,此時如果 B 把自己的 object 傳到 A,A 又把它傳到 C,如果此時 C 調用了拿到的 object 上的方法,實際調用會在 B 上完成。
WinRT 時代的到來
伴隨著 Windows 8 的誕生,Windows 引入了全新的 WinRT ABI,WinRT 扎根于 COM 之上,但是相比 COM 使用的 IUnknown 而言,WinRT 使用 IInspectable。
IInspectable 多了三個方法:GetIids、GetRuntimeClassName 和 GetTrustLevel。第一個方法用來獲得當前類型都實現了哪些 interface,第二個方法用來獲取當前對象的類型名字,而第三個方法用來獲取當前對象的信賴等級。
這相當于給 COM 對象添加了反射功能,因此開發人員不再需要面對一大堆的 GUID,只需要知道類型的名字就行了。
WinRT 同時還引入了新的 midl 3.0 和 Windows Metadata。相對于 COM 的 idl 和 tlb 那種需要實現 stub 和 proxy 的方法而言,WinRT 通過 winmd (metadata)文件實現了接口的自描述,同時 winmd 采用了 ECMA-335 格式,格式完全公開,因此任何程序都可以方便地讀取 winmd 來生成對應的投影和互操作代碼。
除此之外,WinRT 還引入了 IAsyncAction、IAsyncOperation<TResult>、IAsyncActionWithProgress<TProgress>和IAsyncOperation<TResult, TProgress> 用來原生支持異步操作。
WinRT 既然基于 COM,那自然底層也是走 COM 那一套。不過相比 COM 而言,CoGetClassObject 變成了 RoGetActivationFactory,而 CoRegisterClassObejct 變成了 RoRegisterActivationFactories。
同時,WinRT 將激活范圍限定在 app package 內,當然也可以通過 dynamic dependency 來激活其他 package 的 WinRT 類型,因此不需要像 COM 那樣使用管理員身份注冊 COM 組件到注冊表,只需要在 package manifest 中添加一行注冊信息就夠了,用戶安裝你的 app package 的時候會自動注冊,整個流程不需要任何的管理員身份。
于是 WinRT 成功地在解決了 COM 在使用上所有的痛點的同時,繼承了 COM 的所有優點并繼續發揚光大。
走 WinRT ABI 的進程內/進程間調用
WinRT Server 和 COM Server 同樣,分為進程內 server (作為 dll 形式存在)和進程外 server(作為單獨 exe 形式存在)。
不過無論是進程內 server 還是進程外 server,實現方法都沒啥太大區別。
一般來說,我們采用 C++/WinRT、C#/WinRT 以及 Rust/WinRT 來實現我們的 WinRT server 和 client。
下面我用 C# 舉個例子:
首先我們引用 CsWinRT 這個包,然后我們就可以編寫我們自己的 WinRT interface 和 class 了,注意 WinRT class 必須是 sealed 的。
using Windows.Foundation;
namespace WinRTServer;
public interface IFoo
{
int Add(int a, int b);
}
public sealed class MyClass : IFoo
{
public int Add(int a, int b) => a + b;
}
如果我們需要編寫異步 API 的話,可以直接使用 IAsyncAction、IAsyncOperation<TResult>、IAsyncActionWithProgress<TProgress>和IAsyncOperation<TResult, TProgress>,例如:
public sealed class MyClass2
{
public IAsyncOperation<int> AddAsync(int a, int b)
{
Task<int> DoAsync()
{
await Task.Delay(1000);
return a + b;
}
return DoAsync().AsAsyncOperation();
}
}
我們在項目屬性中設置:
<PropertyGroup>
<CsWinRTComponent>true</CsWinRTComponent>
<CsWinRTWindowsMetadata>10.0.22621.0</CsWinRTWindowsMetadata>
<AssetTargetFallback>native;net481;$(AssetTargetFallback)</AssetTargetFallback>
</PropertyGroup>
表示這是一個 WinRT component,這樣 CsWinRT 就會自動為我們生成相關代碼。
其中,CsWinRT 會為我們生成一個叫做 Module 的 class, 這個 class 里面包含了 GetActivationFactory 方法,用來根據類型名返回不同的類型。
還記得我們之前說過,WinRT server 需要用 RoRegisterActivationFactories 來注冊類型,從而允許 client 根據類型名字創建類型實例,因此在 server 上每一個 class 都有一個各自的 activation factory。
而 CsWinRT 為我們生成的 GetActivationFactory 邏輯其實包含了我們寫的所有 class 的 factory,因此我們可以直接重用這個 factory。
RoRegisterActivationFactories的函數簽名是 RoRegisterActivationFactories(string[] classNames, void*[] activationFactories, int* cookie),調用后會從最后一個 cookie 傳出一個類似 handle 的玩意,方便我們后續刪除注冊過的 factory,而前兩個參數則是我們需要傳入的東西:類型名和 activation factory。
因此就很好辦了,我們實現一個 GetActivationFactory 來包裝一下 CsWinRT 為我們生成的 factory:
using WinRT;
namespace WinRTServer;
unsafe class InternalModule
{
public static int GetActivationFactory(void* activatableClassId, void** factory)
{
const int E_INVALIDARG = unchecked((int)0x80070057);
const int CLASS_E_CLASSNOTAVAILABLE = unchecked((int)0x80040111);
const int S_OK = 0;
if (activatableClassId is null || factory is null)
{
return E_INVALIDARG;
}
try
{
IntPtr obj = Module.GetActivationFactory(MarshalString.FromAbi((IntPtr)activatableClassId));
if ((void*)obj is null)
{
return CLASS_E_CLASSNOTAVAILABLE;
}
*factory = (void*)obj;
return S_OK;
}
catch (Exception e)
{
ExceptionHelpers.SetErrorInfo(e);
return ExceptionHelpers.GetHRForException(e);
}
}
}
然后我們就可以注冊我們的 factory 了:
unsafe
{
PInvoke.RoInitialize(PInvoke.RO_INIT_TYPE.RO_INIT_MULTITHREADED);
if (PInvoke.WindowsCreateString("WinRTServer.MyClass", (uint)"WinRTServer.MyClass".Length, out var classId) != 0)
{
Console.WriteLine("Failed to create string.");
}
if (PInvoke.RoRegisterActivationFactories([classId], [InternalModule.GetActivationFactory], out var cookie) != 0)
{
Console.WriteLine("Failed to register activation factories.");
}
Console.WriteLine("Server is ready. Press enter to exit the server.");
Console.ReadLine();
}
如果有多個 class 的話那也只是把多個名稱和各自的 factory 傳進去罷了。
這里我們所有的 class 都是用一個 factory InternalModule.GetActivationFactory,因此假如有三個 class,那只需要:
PInvoke.RoRegisterActivationFactories([classId1, classId2, classId3], [InternalModule.GetActivationFactory, InternalModule.GetActivationFactory, InternalModule.GetActivationFactory], out var cookie)
順帶一提上面這個是實現進程外 WinRT server 的流程。
如果你要實現進程內 WinRT server 作為 dll 來用的話,只需要把上面這個 InternalModule.GetActivationFactory 作為 DllGetActivationFactory 函數導出就行了。
不過導出 dll 函數這件事情對于 C++ 和 Rust 容易,對于 C# 在 CsWinRT 尚不支持 NativeAOT 的目前情況而言不太容易,因此 CsWinRT 為我們準備了個 WinRT.Host.dll 作為 server 的 dll,這個 dll 會載入我們的程序集幫我們注冊 factory。要注意如果用 C# 來實現進程內 server 的話,server 項目不能發布為自包含;反過來如果要實現進程外 server 的話,server 項目需要發布為自包含。
接下來我們繼續說我們的進程外 WinRT server。
有了上面的實現之后,編譯項目便會在輸出目錄里得到一個 winmd 文件,此時可以用 ILSpy 之類的軟件打開看看,就會發現這個 winmd 就是只包含了類型和方法簽名,但是沒有實現的 .NET 程序集,這就是 Windows Metadata。
有了這個 Windows Metadata 后,我們就可以方便的實現我們的 client 了。
創建一個 client 項目,引入 CsWinRT 項目,然后設置以下內容引用我們剛剛得到的 winmd 文件:
<PropertyGroup>
<CsWinRTIncludes>WinRTServer</CsWinRTIncludes>
<CsWinRTWindowsMetadata>10.0.22621.0</CsWinRTWindowsMetadata>
<AssetTargetFallback>native;net481;$(AssetTargetFallback)</AssetTargetFallback>
</PropertyGroup>
<ItemGroup>
<CsWinRTInputs Include="path/to/winmd">
<Name>%(Filename).winmd</Name>
<IsWinMDFile>true</IsWinMDFile>
</CsWinRTInputs>
</ItemGroup>
這樣一來 CsWinRT 就會自動幫我們生成投影的代碼了,自動生成的代碼中同樣也包含了調用 RoGetActivationFactory 獲取 factory 的代碼,這部分邏輯被自動放到了對應投影的類型的構造函數里。
最后我們需要在 package manifest 中添加 server 和 class 的配置。找到 Package.appxmanifest,添加如下內容即可:
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
...
<Extensions>
<Extension Category="windows.activatableClass.outOfProcessServer">
<OutOfProcessServer ServerName="WinRTServer" uap5:IdentityType="activateAsPackage" uap5:RunFullTrust="true">
<Path>path/to/server/exe</Path>
<Instancing>singleInstance</Instancing>
<ActivatableClass ActivatableClassId="WinRTServer.MyClass" />
</OutOfProcessServer>
</Extension>
</Extensions>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
如果有多個 class 只需要添加多行 ActivatableClass 即可。
然后要記得把 winmd 文件放到 client 所在的文件夾中,不然的話運行時會因為找不到 metadata 而無法成功找到類型。
最后我們就可以方便的用 client 這邊的投影的類型調用 server 上的函數了,就如同我們在直接調用 server 的代碼一樣:
using WinRTServer;
var a = new MyClass();
var result = a.Add(1, 2);
Console.WriteLine(result);
非常方便!
WinRT 還會自動幫我們確保 server 的進程只激活一次。
最后強調一下,WinRT 是語言無關的原生 ABI,因此不僅 C# 能用,C++、Rust 以及其他任何語言都可以使用!C++ 和 Rust 有對應的 cppwinrt 和 windows-rs 庫可以用,他們就是 C++、Rust 上的 CsWinRT。其他語言無論是自己編寫代碼自己調用那些系統 API、還是有現成工具自動生成代碼調用那些系統 API,都可以做到同樣的事情。
并且對于 WinRT 而言,只要你實現了進程內的 WinRT Server,跨進程 WinRT Server 也自動實現!
我將實現的例子放在 GitHub 上,有需要的人可以自行前往查看:
https://github.com/hez2010/WinRTServer
性能測試
這里我們來測測 WinRT server 分別作為進程內和進程外 server 的性能。
測試很簡單,就是在 server 上寫個加法 API,然后從 client 處調用看看每秒能 round-trip 多少次。
先測試進程外 server 的情況,即跨進程 RPC。
在 client 這邊單線程循環跑一百萬次的結果:
Completed in 9395ms, speed: 106439.595529537 times/s
借助 Parallel.For 多線程循環跑一百萬次的結果:
Completed in 1894ms, speed: 527983.1045406547 times/s
可以看到,跨進程在 server 和 client 之間通信,單線程成功做到每秒 10 萬次以上,多線程更是跑到了每秒 52 萬次調用以上。
這個速度直接薄紗其他任何的 RPC 框架,甚至薄紗了 Unix Domain Socket。
接下來測試一下進程內 server 的情況。
進程內調用由于速度實在是太快了,一百萬次調用連 1ms 都不用,因此我們將循環次數放大到 1 億次。
單線程:
Completed in 823ms, speed: 121506682.86755772 times/s
多線程:
Completed in 82ms, speed: 1219512195.121951 times/s
可以看到,進程內在 server 和 client 之間通信,單線程成功做到每秒 1.2 億次以上,多線程更是跑到了每秒 12 億次調用以上。
這個速度跟直接從原生 dll 中導入查找函數入口點然后直接調用沒有任何的區別!
不過對于 server 和 client 都是 C# 的項目而言,我們倒不如直接用 client 項目引用 server 項目調用更直接。
進程內 WinRT server 的一般用途是當我們想要將項目 A 作為 dll 給項目 B 用時,又不想用傳統的 C ABI 來暴露 API(因為 C ABI 涉及到復雜類型會非常的麻煩,而且也不支持異步),這個時候就可以用 WinRT ABI,于是就能避開 C ABI 的一切不便之處。
總結
有了 WinRT,我們不僅擁有了跨語言的現代 ABI,同時還擁有了能統一進程內和進程間調用的超高性能 IPC/RPC 設施。易用性和性能兼顧。

浙公網安備 33010602011771號