从 Starward 看 Windows IPC:HTTP/2 不一定要跑在 TCP 上
最近在翻 Starward 的源码(一个开源的第三方 HoYoverse 游戏启动器),顺手把它的进程间通信架构看了一遍,挖出来一个挺有意思的认知盲点:HTTP/2 并不一定要跑在 TCP 上,gRPC 也不一定是“远程”调用。
本文把这条线索从头到尾理一遍,包含:
- Starward 的多进程架构与 IPC 选型
- gRPC over Named Pipe 的实现原理
- 为什么 HTTP 这种”网络协议”能跑在命名管道上
- Windows 上 .NET 项目可选的 IPC 方案全景与选型建议
一、Starward 的多进程架构
先说项目本身。Starward 是一个用 WinUI 3 + .NET 10 写的桌面应用,目标是替代米哈游官方的 HoYoPlay 启动器。它的解决方案目录长这样:
src/├─ Starward/ # WinUI 3 主进程(UI)├─ Starward.Core/ # 核心业务模型,被多端引用├─ Starward.Language/ # 本地化资源├─ Starward.Launcher/ # 启动器子进程├─ Starward.RPC/ # ASP.NET Core 后台服务├─ Starward.Setup/ # 安装组件└─ Starward.Setup.Core/注意 Starward.RPC 这个项目。WinUI 3 只是 UI 框架,启动器本身却需要做一堆”重活”:
- 游戏下载/安装/校验/卸载(要写系统目录、长时间运行)
- 设置全局限速
- 管理生命周期、清理临时文件
这些活有的需要管理员权限,有的不希望被 UI 进程的崩溃带走。所以 Starward 把它们拆到了一个独立的后台进程里:
UI 进程不持有管理员权限,所有需要提权或大流量长任务的工作都丢给 RPC 后台进程。两个进程之间通过 gRPC over Named Pipe 通信。
有意思的来了。
二、gRPC over Named Pipe 的实现
1. 服务端:Kestrel 监听命名管道
Starward.RPC 是一个 ASP.NET Core 应用。它的 Program.cs 关键代码:
builder.WebHost.ConfigureKestrel(serverOptions =>{ serverOptions.ListenNamedPipe(AppConfig.MutexAndPipeName, listenOptions => { listenOptions.Protocols = HttpProtocols.Http2; });}).UseNamedPipes(options =>{ var defaultSecurity = new PipeSecurity(); var usersGroup = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); defaultSecurity.AddAccessRule(new PipeAccessRule( usersGroup, PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, AccessControlType.Allow)); options.PipeSecurity = defaultSecurity; options.CurrentUserOnly = false;});
builder.Services.AddGrpc(...);// ...app.MapGrpcService<EnviromentController>();app.MapGrpcService<GameInstallController>();关键点:
ListenNamedPipe是 .NET 8 才加入的 API,让 Kestrel 直接监听 Windows 命名管道而不是 TCP 端口。PipeSecurity给 BUILTIN\Users 读写权限,这样普通权限的 UI 进程才能连到管理员权限创建的管道。- 管道名形如
Starward.RPC/{SessionId}/{AppVersion},自带会话隔离和版本隔离。
服务启动还有几道安全闸:
if (args.Length < 2 || args[1] is not AppConfig.StartupMagic) return; // 魔术字校验using Mutex mutex = new(true, AppConfig.MutexAndPipeName, out bool createdNew);if (!createdNew) return; // 单实例if (!AppConfig.IsAdmin) return; // 必须管理员StartupMagic 是个硬编码的随机串,防止其他程序伪造命令行启动它来骗取管理员权限。
2. 客户端:替换 HttpClient 的连接回调
UI 端的客户端工厂是这样的:
private static GrpcChannel CreateChannel(){ var connectionFactory = new NamedPipesConnectionFactory(AppConfig.MutexAndPipeName); var socketsHttpHandler = new SocketsHttpHandler { ConnectCallback = connectionFactory.ConnectAsync, UseProxy = false, // 防止系统代理污染本地连接 }; return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions { HttpHandler = socketsHttpHandler });}NamedPipesConnectionFactory.ConnectAsync 返回的是一个 NamedPipeClientStream:
public async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext _, CancellationToken ct = default){ var clientStream = new NamedPipeClientStream( serverName: ".", pipeName: this.pipeName, direction: PipeDirection.InOut, options: PipeOptions.WriteThrough | PipeOptions.Asynchronous, impersonationLevel: TokenImpersonationLevel.Anonymous); await clientStream.ConnectAsync(ct); return clientStream;}http://localhost 这个地址只是 gRPC channel 要求的 URI 占位符,实际上从来没有发起过任何 TCP 连接。
三、HTTP/2 不一定基于 TCP
到这里很多人会本能反应:HTTP 不就是网络协议吗?它不就是基于 TCP 的吗?
这是一个非常常见的误区。下面我们慢慢拆。
1. 协议规范说了什么
HTTP 是应用层协议,规范定义的是”请求/响应的格式和语义”。HTTP/1.1 和 HTTP/2 的规范说的是**“需要一个可靠的有序字节流传输层”**,而不是”必须用 TCP”。
事实是:
- HTTP/1.1 通常跑在 TCP 上,但跑在 TLS、Unix Socket、Named Pipe 上都是合法的。
- HTTP/2 同理。
- HTTP/3 干脆就不是基于 TCP,它跑在 QUIC over UDP 上。
协议规定”说什么、怎么编码”,传输规定”字节怎么从 A 到 B”。两件事是正交的。
2. .NET 的 HTTP 栈是可以换底层的
HttpClient 的架构是这样的:
ConnectCallback 的签名是:
Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>>它返回一个 Stream。.NET 的 HTTP/2 实现拿到这个 Stream 之后,就在上面跑 HTTP/2 的帧协议。它不知道也不关心这个 Stream 背后是什么——TCP socket、Named Pipe、Unix Domain Socket、内存流、串口都行,只要是双向字节流。
所以 Starward 这条调用链实际是:
如果你用 Wireshark 抓包,会发现两个进程通信时完全没有 127.0.0.1 的 TCP 流量,只有 \Device\NamedPipe\... 的 IPC 操作。
3. 这种解耦其实到处都是
一旦接受了这个分层观点,你会发现”协议和传输解耦”是计算机系统设计里非常常见的套路:
| 协议 | 通常跑在什么上 | 也可以跑在什么上 |
|---|---|---|
| HTTP/1.1 | TCP | TLS、Unix Socket、Named Pipe |
| HTTP/2 | TCP | Named Pipe、Unix Socket、内存流 |
| HTTP/3 | QUIC over UDP | —— |
| gRPC | HTTP/2 over TCP | HTTP/2 over Pipe / UDS、in-process channel |
| SSH | TCP | 串口、Unix Socket |
| X11 | TCP | Unix Socket(本机默认就是这个) |
| PostgreSQL | TCP | Unix Socket |
| Redis | TCP | Unix Socket |
| TLS | TCP | UDP(DTLS)、任何可靠流 |
工程上这套解耦的红利包括:
- 测试:ASP.NET Core 的
WebApplicationFactory在跑集成测试时,“HTTP 请求”根本不出进程,直接走内存。客户端代码用的还是HttpClient。 - Docker:Docker CLI 和 Daemon 之间用的是 HTTP REST API。Linux 上走
/var/run/docker.sock,Windows 上走\\.\pipe\docker_engine。同一套 API、同一个客户端代码。 - Kubernetes:
kubelet和容器运行时之间的 CRI 接口走 gRPC over Unix Socket,思路和 Starward 一模一样。
类比一下:你可以把 HTTP 报文写在纸上用快递寄出去,快递不是 TCP,但内容还是 HTTP。
四、为什么不裸用 Named Pipe
回到 Starward 的设计。你可能会问:既然底层就是命名管道,为什么不裸用 Pipe,要套一层 gRPC?
裸 Named Pipe 当然能做 IPC,但你要自己解决一系列问题:
- 消息边界:Pipe 是字节流,要自己定帧(长度前缀、分隔符等)
- 序列化:发什么格式?JSON?自定义二进制?
- 接口契约:调用方和服务方靠什么对齐?口头约定?
- 多路复用:同时发多个请求怎么对应响应?
- 双向流:服务端持续推送怎么做?
- 超时/取消:每个调用的 deadline 怎么传?
- 错误码:失败了怎么区分网络错误和业务错误?
gRPC over Named Pipe 把这些全部解决了:
.proto文件就是接口文档,编译期生成强类型代码,加新接口改 proto 就行stream关键字一个搞定服务端推送(Starward 用它来推送下载进度)- 超时直接传
deadline: DateTime.UtcNow.AddSeconds(5),框架处理 - 错误码用
RpcException+StatusCode
举个 Starward 的例子。GameInstall.proto 里有这么一条:
rpc GetTaskProgress (EmptyMessage) returns (stream GameInstallContextDTO);UI 调用一次,后台不断推送下载速度、读写速度、百分比、剩余时间。裸 Pipe 要自己实现这套推送协议,gRPC 一个关键字搞定。
代价是引入了 Grpc.AspNetCore 这个不轻的依赖,以及 Kestrel 的启动开销。对 Starward 这个规模的项目,这个 tradeoff 是合理的——接口会持续增加,用 proto 管理省很多维护成本。
五、Windows 上 IPC 方案全景
聊到这里有人会问:那是不是说 gRPC over Named Pipe 是 Windows 上”最简单最成熟”的 IPC 方案?
不是。“最简单”和”最成熟”是两件事,而且取决于需求复杂度。下面把 Windows 上的 IPC 方案按”复杂度从低到高 / 能力从弱到强”排一下:
| 方案 | 适用场景 | 上手难度 | 备注 |
|---|---|---|---|
| 环境变量 / 命令行参数 | 启动时一次性传参 | ★ | 别忘了它也是 IPC |
| 文件 / 临时文件 | 偶发数据交换、跨语言 | ★ | 配合 FileSystemWatcher 也行 |
| Anonymous Pipe | 有亲缘关系的进程间单向流 | ★★ | 需句柄继承传递;无 ACL |
| Unix Domain Socket | 跨平台代码复用;本机进程通信 | ★★ | Win10 1903+ 默认可用;跨平台项目统一代码路径的首选 |
| Memory-Mapped File | 大块数据共享、零拷贝 | ★★★ | 需要自己做同步 |
| 裸 Named Pipe | 任意进程双向流 | ★★ | 自己定帧、自己序列化 |
| WM_COPYDATA / 窗口消息 | 有窗口的进程间小数据 | ★★ | Win32 老办法,UI 应用常用 |
| WCF NetNamedPipeBinding | .NET Framework 老项目 | ★★★ | .NET Core+ 不可用(非”不推荐”) |
| Core WCF(社区) | 想要 WCF 风格 + 现代 .NET | ★★★ | 还能用,但生态在缩 |
| gRPC over Named Pipe | 多接口、流式、强类型、跨语言 | ★★★★ | 重,但功能全 |
| StreamJsonRpc | 想要 RPC 但不想搞 protobuf | ★★ | 微软出品,VS Code/LSP 系都用 |
| Windows RPC (MSRPC) | 系统级 / 跨语言 / 老传统 | ★★★★★ | 写过的人都不想再写 |
| COM / Out-of-Proc COM | Office、Shell、老 Windows 生态 | ★★★★★ | 历史遗留,新项目不推荐 |
| 共享内存 + 自定义同步 | 极致性能 | ★★★★★ | 自己造轮子的领域 |
| AppService / Background Task | UWP / 打包应用 | ★★★ | Win32 普通应用用不上 |
选型建议
90% 的小工具场景(纯 .NET 内部通信),下面两个之一就够了:
1. 裸 Named Pipe + System.Text.Json + 长度前缀分帧
不到 100 行代码就能搭一个能用的 IPC。请求一个 JSON、回一个 JSON,完事。
var server = new NamedPipeServerStream( "MyApp", PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Message); // ← Message mode 帮你自动分帧await server.WaitForConnectionAsync();注意 PipeTransmissionMode.Message 是 Windows 特性,服务端和客户端都需要设置,否则客户端仍以字节流方式读取,分帧失效——这是常见踩坑点。Linux 没有此模式,跨平台项目需注意。
2. StreamJsonRpc(微软出品,Visual Studio / LSP 都在用)
// 服务端var rpc = JsonRpc.Attach(pipeServer, new MyService());
// 客户端var client = JsonRpc.Attach<IMyService>(pipeClient);await client.DoSomethingAsync(args);接口就是普通 C# 接口,序列化是 JSON 或 MessagePack,支持双向调用、通知、流。这个组合在 .NET 圈子里其实比 gRPC over Pipe 更普及——Roslyn、Visual Studio、各种 LSP 实现都在用。我个人觉得它对大多数人是比 gRPC 更合适的”默认选择”。
3. 跨语言 / 复杂场景才上 gRPC
接口契约重要、需要跨语言、有 streaming 需求、长期维护——这种情况上 gRPC over Named Pipe + Kestrel(就是 Starward 这套)。对于 Electron + C++ 这类跨语言场景,gRPC 的 protobuf 契约反而比裸 pipe + JSON 更清晰。
4. 性能极致敏感
Memory-Mapped File 做数据传输 + Named Pipe 做信令。
一个容易踩的坑
如果以后你自己写类似的代码,注意 HTTP/2 over Named Pipe 默认是没有 TLS 的。这在本地 IPC 场景是合理的(你已经在同一台机器上,管道本身有 ACL 保护),但 gRPC 客户端默认期待 HTTPS。
要么用 http:// 明确指定明文(Starward 就是这么做的),要么传 GrpcChannelOptions.Credentials = ChannelCredentials.Insecure。
类似的,如果你之前在 Linux 上玩过:
curl --unix-socket /var/run/docker.sock http://localhost/version你已经在做同一件事了——http://localhost 是协议要求的占位符,真正的传输是 socket 文件。
六、Starward 设计上的几个细节
最后回到 Starward,把这套架构里几个值得借鉴的细节列一下:
| 设计点 | 解决的问题 |
|---|---|
| Named Pipe 而非 TCP | 不开端口、不被防火墙/杀软拦、不被本机代理污染、自带 ACL |
Mutex 名 = 管道名 = Starward.RPC/{Session}/{Version} | 单实例 + 多用户会话隔离 + 版本隔离 |
StartupMagic 校验 | 防止其他程序伪造命令行启动它来骗取管理员权限 |
单 EXE 双角色(rpc 子命令切换) | 部署简单,版本一致 |
| 父 PID 监听 + KeepRunning 开关 | UI 关闭时后台资源能正确清理或选择保留 |
| gRPC server streaming | 下载进度高频推送,不用 polling |
Grpc.AspNetCore + DI | 后台是个完整的 ASP.NET Core 应用,日志、HttpClient、Polly、配置都白嫖现成 |
启动流程也值得看一眼:UI 想用 RPC 时调 EnsureRpcServerRunningAsync:
if (!CheckRpcServerRunning()) { // Mutex 探测 Process.Start(new ProcessStartInfo { FileName = AppConfig.StarwardExecutePath, Verb = "runas", // ← UAC 提权 UseShellExecute = true, Arguments = $"rpc {StartupMagic} {Environment.ProcessId}", });}var client = RpcClientFactory.CreateRpcClient<Env.EnvClient>();await client.GetRpcServerInfoAsync(new EmptyMessage(), deadline: DateTime.UtcNow.AddSeconds(5)); // 5 秒握手超时await SetEnviromentAsync(); // 告诉 RPC: 我的 PID、是否后台保留、限速注意:
- 启动参数三件套:
rpc子命令 + 魔术字 + 父进程 PID。RPC 拿到 PID 后监听Exited事件,UI 退出时跟着退(除非用户开了”后台保留下载”)。 - 用户在 UAC 弹窗点取消会抛
Win32Exception ERROR_CANCELLED (0x4C7),被捕获后返回false,UI 优雅降级。 - 同一个 EXE 既是 UI 也是 RPC server,靠首参数区分。避免了发布两个独立可执行文件。
七、总结
把这次思考整理成几条要带走的结论:
- gRPC ≠ 远程调用。gRPC 只是”序列化 + 协议层”,传输层可以是 TCP,也可以是 Named Pipe、Unix Socket、内存流。
- HTTP 不一定基于 TCP。HTTP 规范要求”可靠有序的字节流”,TCP 只是最常见的实现。HTTP/3 直接就不基于 TCP。
- 协议和传输是正交的,这种分层是计算机系统设计的通用套路。理解这一点,再看 Docker、Kubernetes、Service Mesh、X11、PostgreSQL 这些项目的本地连接方式,会清晰很多。
- Windows 上的 IPC 方案选型要看需求。简单场景裸 Named Pipe 或 StreamJsonRpc 就够,复杂场景才上 gRPC over Named Pipe。Starward 选 gRPC 是因为它有 streaming 需求和较多的接口数量,不是因为”最简单”。
Stream是个伟大的抽象。无论是 .NET 的System.IO.Stream、Java 的InputStream、还是 Go 的io.Reader,本质都是”字节流”这个抽象。一旦有了它,上层协议就能不在乎下层是什么。
参考资料
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!