从此
文章
📄文章 #️⃣专题 🌐上网 📺 🛒 📱

WinRT - Windows远程调用COM取代者

🕗2024-02-23

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,其中包含两个用于管理生命周期的引用计数函数 AddRefRelease,以及一个用于查询接口的函数 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 也有自身的局限性和难用之处:

  1. 类型定义全靠 GUID,一个 GUID 就是一个类型,没有文档的时候你根本不知道这个 GUID 到底是什么类型
  2. 注册独立进程的 COM 组件要往系统注册表里面写配置,需要管理员身份
  3. 不支持异步,非常不现代

其中第 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 多了三个方法:GetIidsGetRuntimeClassNameGetTrustLevel。第一个方法用来获得当前类型都实现了哪些 interface,第二个方法用来获取当前对象的类型名字,而第三个方法用来获取当前对象的信赖等级。

这相当于给 COM 对象添加了反射功能,因此开发人员不再需要面对一大堆的 GUID,只需要知道类型的名字就行了。

WinRT 同时还引入了新的 midl 3.0 和 Windows Metadata。相对于 COM 的 idl 和 tlb 那种需要实现 stub 和 proxy 的方法而言,WinRT 通过 winmd (metadata)文件实现了接口的自描述,同时 winmd 采用了 ECMA-335 格式,格式完全公开,因此任何程序都可以方便地读取 winmd 来生成对应的投影和互操作代码。

除此之外,WinRT 还引入了 IAsyncActionIAsyncOperation<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 的话,可以直接使用 IAsyncActionIAsyncOperation<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 设施。易用性和性能兼顾。