从 Starward 看 Windows IPC:HTTP/2 不一定要跑在 TCP 上

3469 字
17 分钟
从 Starward 看 Windows IPC:HTTP/2 不一定要跑在 TCP 上

最近在翻 Starward 的源码(一个开源的第三方 HoYoverse 游戏启动器),顺手把它的进程间通信架构看了一遍,挖出来一个挺有意思的认知盲点:HTTP/2 并不一定要跑在 TCP 上,gRPC 也不一定是“远程”调用

Scighost
/
Starward
Waiting for api.github.com...
00K
0K
0K
Waiting...

本文把这条线索从头到尾理一遍,包含:

  • 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 把它们拆到了一个独立的后台进程里:

graph LR subgraph UI["Starward.exe(普通用户)"] A["WinUI 3 主进程<br/>RpcService"] end subgraph RPC["Starward.RPC.exe<br/>管理员权限,后台常驻可选"] B["ASP.NET Core<br/>Kestrel"] C["EnvController<br/>生命周期管理"] D["GameInstallController<br/>下载 / 安装 / 卸载"] B --> C B --> D end A -- "gRPC / HTTP/2<br/>over Named Pipe(双向)" --> B

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 的架构是这样的:

flowchart TD A["HttpClient"] B["HttpMessageHandler(这一层可替换)"] C["SocketsHttpHandler(默认实现)"] D["ConnectCallback(这个回调决定「连接」是什么)"] A --> B --> C --> D

ConnectCallback 的签名是:

Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>>

它返回一个 Stream。.NET 的 HTTP/2 实现拿到这个 Stream 之后,就在上面跑 HTTP/2 的帧协议。它不知道也不关心这个 Stream 背后是什么——TCP socket、Named Pipe、Unix Domain Socket、内存流、串口都行,只要是双向字节流。

所以 Starward 这条调用链实际是:

flowchart TD A["gRPC 序列化<br/>Protobuf"] B["HTTP/2 帧"] C["NamedPipeClientStream<br/>❌ 没有 TCP / IP / 网卡"] D["Windows 内核<br/>Named Pipe"] E["NamedPipeServerStream(Starward.RPC.exe)"] A --> B --> C --> D --> E

如果你用 Wireshark 抓包,会发现两个进程通信时完全没有 127.0.0.1 的 TCP 流量,只有 \Device\NamedPipe\... 的 IPC 操作。

3. 这种解耦其实到处都是#

一旦接受了这个分层观点,你会发现”协议和传输解耦”是计算机系统设计里非常常见的套路:

协议通常跑在什么上也可以跑在什么上
HTTP/1.1TCPTLS、Unix Socket、Named Pipe
HTTP/2TCPNamed Pipe、Unix Socket、内存流
HTTP/3QUIC over UDP——
gRPCHTTP/2 over TCPHTTP/2 over Pipe / UDS、in-process channel
SSHTCP串口、Unix Socket
X11TCPUnix Socket(本机默认就是这个)
PostgreSQLTCPUnix Socket
RedisTCPUnix Socket
TLSTCPUDP(DTLS)、任何可靠流

工程上这套解耦的红利包括:

  • 测试:ASP.NET Core 的 WebApplicationFactory 在跑集成测试时,“HTTP 请求”根本不出进程,直接走内存。客户端代码用的还是 HttpClient
  • Docker:Docker CLI 和 Daemon 之间用的是 HTTP REST API。Linux 上走 /var/run/docker.sock,Windows 上走 \\.\pipe\docker_engine。同一套 API、同一个客户端代码。
  • Kuberneteskubelet 和容器运行时之间的 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 COMOffice、Shell、老 Windows 生态★★★★★历史遗留,新项目不推荐
共享内存 + 自定义同步极致性能★★★★★自己造轮子的领域
AppService / Background TaskUWP / 打包应用★★★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 上玩过:

Terminal window
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,靠首参数区分。避免了发布两个独立可执行文件。

七、总结#

把这次思考整理成几条要带走的结论:

  1. gRPC ≠ 远程调用。gRPC 只是”序列化 + 协议层”,传输层可以是 TCP,也可以是 Named Pipe、Unix Socket、内存流。
  2. HTTP 不一定基于 TCP。HTTP 规范要求”可靠有序的字节流”,TCP 只是最常见的实现。HTTP/3 直接就不基于 TCP。
  3. 协议和传输是正交的,这种分层是计算机系统设计的通用套路。理解这一点,再看 Docker、Kubernetes、Service Mesh、X11、PostgreSQL 这些项目的本地连接方式,会清晰很多。
  4. Windows 上的 IPC 方案选型要看需求。简单场景裸 Named Pipe 或 StreamJsonRpc 就够,复杂场景才上 gRPC over Named Pipe。Starward 选 gRPC 是因为它有 streaming 需求和较多的接口数量,不是因为”最简单”。
  5. Stream 是个伟大的抽象。无论是 .NET 的 System.IO.Stream、Java 的 InputStream、还是 Go 的 io.Reader,本质都是”字节流”这个抽象。一旦有了它,上层协议就能不在乎下层是什么。

参考资料#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从 Starward 看 Windows IPC:HTTP/2 不一定要跑在 TCP 上
https://cialo.site/posts/grpc-starward/
作者
洛璃
发布于
2026-05-23
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
洛璃
初春的离去,晚樱的谢幕
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
26
分类
10
标签
106
总字数
91,067
运行时长
0
最后活动
0 天前

文章目录