视频加载失败

从 gRPC 看 Windows IPC

4432 字
22 分钟
从 gRPC 看 Windows IPC

一、gRPC over Named Pipe 的实现#

1. 服务端:Kestrel 监听命名管道#

一个 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 进程才能连到管理员权限创建的管道。
  • CurrentUserOnly = false 不是可选项,是必须显式关闭的默认行为。 Kestrel 的 NamedPipeTransportOptions 有一个 CurrentUserOnly 属性,默认值是 true——只有创建管道的当前用户能连接,这比 Win32 原始 API(CreateNamedPipeNULL 安全描述符时默认对 Everyone 组开放读权限)严格得多。更关键的是,这两个参数是互斥的:一旦设置了自定义 PipeSecurityCurrentUserOnly 必须显式设为 false,否则服务端启动时会抛异常,且报错信息具有一定误导性,容易让人以为是别的原因导致的。
  • 上面的例子里,所有管道共用同一份 PipeSecurity。如果一个 Kestrel 应用要开多个管道端点、且每个端点需要不同的访问权限,从 .NET 9 起可以用 NamedPipeTransportOptions.CreateNamedPipeServerStream 这个选项,按管道名分别指定安全设置,不用再共用同一份规则。

服务启动还有几道安全闸:

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 端的客户端代码是这样的:

var socketsHttpHandler = new SocketsHttpHandler
{
UseProxy = false, // 关键:见下方说明
ConnectCallback = async (_, ct) =>
{
var stream = new NamedPipeClientStream(
serverName: ".",
pipeName: AppConfig.MutexAndPipeName,
direction: PipeDirection.InOut,
options: PipeOptions.WriteThrough | PipeOptions.Asynchronous,
impersonationLevel: TokenImpersonationLevel.Anonymous);
await stream.ConnectAsync(ct);
return stream;
}
};
using var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
{
HttpHandler = socketsHttpHandler
});
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(new HelloRequest { Name = "test" });

http://localhost 这个 URI 只是占位符,不代表真实网络地址。 设置了 ConnectCallback 后,SocketsHttpHandler 不会用它做 DNS 解析或 TCP 连接,实际传输由 ConnectCallback 返回的流决定。但”随便写”是有约束的:

约束项要求原因
Scheme必须是 http,不能是 https命名管道无 TLS,https 会触发 TLS 握手,和 ConnectCallback 返回的裸流对不上
Host/Port理论上可任意,但服务端做 :authority 校验时必须填服务端认可的值HTTP/2 请求的 :authority 伪头取自这个 URI;多数服务端不校验,但网关/路由类服务端可能会
UseProxy必须显式设为 false见下方说明,否则 host 不是 localhost 时会直接失败
URI 格式必须是合法 URI否则 GrpcChannel.ForAddress 在构造阶段就抛异常

为什么 UseProxy = false 是必须项,不是防御性写法#

SocketsHttpHandler 建立连接前,会先做一次独立的代理判断(系统代理配置 / IWebProxy / 环境变量),和 ConnectCallback 是并列的两条路径,不是包含关系:

不走代理

走代理

发起请求

代理判断

UseProxy / 系统代理配置

直连分支

调用 ConnectCallback

命名管道生效

代理连接分支

无 ConnectCallback 调用入口

命名管道永远不会被尝试

不走代理

走代理

发起请求

代理判断

UseProxy / 系统代理配置

直连分支

调用 ConnectCallback

命名管道生效

代理连接分支

无 ConnectCallback 调用入口

命名管道永远不会被尝试

ConnectCallback 只是直连分支里的扩展点,不是”接管整个连接过程”的钩子。实测结果:

场景URI 写 localhostURI 写其他未在绕过名单的 host
系统无代理正常正常
系统有代理,未设 UseProxy = false正常(默认在代理绕过名单里)失败
系统有代理,设了 UseProxy = false正常正常

也就是说,示例代码里习惯写 http://localhost,本质上是利用了代理绕过规则的巧合,而不是这个值本身有什么特殊含义——一旦代理策略变化或换个环境,这个巧合就可能失效。显式设置 UseProxy = false 才是让 URI 真正变成”纯占位符”、消除这个隐式依赖的正确做法。

关于 TLS:为什么必须用 http:// 而不是 https://#

HTTP/2 over Named Pipe 默认是没有 TLS 的。这在本地 IPC 场景下是合理的:通信双方本来就在同一台机器上,没有网络窃听的风险,安全性靠的是命名管道自身的 ACL(也就是前面服务端 PipeSecurity 那一套),不需要 TLS 再加一层。但 gRPC 客户端默认期待的是 HTTPS,如果这里配置不对,会在 TLS 握手这一步出错。

解决办法不是”两者选一个”,而是要看你用的是哪条 API 路径:

  • 本文这种 GrpcChannel.ForAddress + SocketsHttpHandlerConnectCallback)的写法:只要 URI 用 http://(而不是 https://),gRPC-dotnet 就会根据 scheme 判定这是明文连接,不会去走 TLS 握手,这样就够了,不需要再额外传 ChannelCredentials.Insecure
  • Grpc.Core 或其他显式传 ChannelCredentials 的 API 路径:这类路径没有 http:///https:// scheme 可以依赖,需要显式传 ChannelCredentials.Insecure 来达到同样”不做 TLS 校验”的效果。

两者是两条不同 API 路径下、达到同一目的的等效手段,不是同一条路径下需要叠加使用的两个步骤。本文用的是第一种路径,所以只需要把 URI 写成 http://localhost,无需再加 ChannelCredentials.Insecure

二、HTTP/2 不一定基于 TCP#

到这里很多人会本能反应:HTTP 不就是网络协议吗?它不就是基于 TCP 的吗?

这是一个非常常见的误区。下面我们慢慢拆。

1. 协议规范说了什么#

HTTP 是应用层协议,HTTP/2 标准(RFC 7540)本身没有规定必须使用 TCP,它只是定义了帧格式、流复用、头部压缩等应用层机制。

协议规定“说什么、怎么编码”,传输规定“字节怎么从 A 到 B”。两件事是正交的。

gRPC 的架构是:

gRPC 应用层

HTTP/2 帧

传输层(socket)

gRPC 应用层

HTTP/2 帧

传输层(socket)

当 gRPC 用 Named Pipe 时,替换的其实是操作系统提供的 socket 传输通道,也就是把“网络 socket”换成了“本地 IPC 通道”,但 HTTP/2 的帧格式、流复用、HPACK 头部压缩这些东西,跑在这条管道里的数据流本身,其实并不需要 TCP 的网络特性(IP 寻址、路由、拥塞控制)——它需要的只是一个可靠的、有序的字节流。

gRPC over Named Pipe 证明了 HTTP/2 不需要 TCP,它依赖的只是“可靠、有序的字节流传输”这个特性,只需要一个可靠有序的字节流传输,Named Pipe 满足这个条件,所以可以直接承载 HTTP/2 帧。HTTP/2 依赖的不是 TCP 这个具体协议,而是 TCP 所提供的那组特性:可靠、有序的字节流传输。TCP 只是提供这个特性最常见的一种方式,但不是唯一方式。任何能提供这组特性的通道都可以承载 HTTP/2 帧,包括:

  • TCP(最常见,网络场景)
  • Windows Named Pipe(本地 IPC,Windows 上的 gRPC 常用)
  • Unix Domain Socket(本地 IPC,很多微服务框架用)

2. .NET 的 HTTP 栈是可以换底层的#

HttpClient 的架构是这样的:

HttpClient

HttpMessageHandler(这一层可替换)

SocketsHttpHandler(默认实现)

ConnectCallback(这个回调决定「连接」是什么)

HttpClient

HttpMessageHandler(这一层可替换)

SocketsHttpHandler(默认实现)

ConnectCallback(这个回调决定「连接」是什么)

ConnectCallback 的签名是:

Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>>

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

如果你用 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。

类比一下:你可以把 HTTP 报文写在纸上用快递寄出去,快递不是 TCP,但内容还是 HTTP

权限检查:DACL vs 文件权限位#

Named Pipe 的权限系统本质上和文件系统权限是同一套东西——安全描述符(Security Descriptor)里挂着一张访问控制列表(ACL),真正做判断的是其中的 DACL(Discretionary ACL,自主访问控制列表)。CreateNamedPipelpSecurityAttributes 参数就是这唯一的权限开关,且访问检查会发生两次:服务端 CreateNamedPipe 建管道时查一次,客户端 CreateFile/CallNamedPipe 连接时再查一次。

如果做跨平台对比:Linux 上对应的 Unix Domain Socket 走的是完全不同的权限哲学——没有细粒度的规则列表,就是普通文件的 rwx 权限位,chmod 怎么用这里就怎么用,连接一个 socket 需要对该 socket 文件拥有写权限。一个是 Windows 式的”精确到账户的规则表”,一个是 Unix 式的”属主/属组/其他”三段式,没有孰优孰劣,只是两种不同的设计传统。

Named Pipe(DACL)Unix Domain Socket(文件权限位)
权限载体DACL——按账户/组分别指定允许或拒绝的规则表rwx 三组权限位,没有”规则”概念
修改方式代码里显式构造 PipeSecurity/PipeAccessRule事后 chmod,与创建 socket 的代码无关
检查粒度精确到具体账户或安全组,可分别允许/拒绝多个身份只有属主 / 属组 / 其他三档,无法单独指定某个账户
检查时机服务端建管道、客户端连接各查一次,两次都可能拒绝客户端连接时检查一次,要求对 socket 文件有写权限
默认行为分层:Win32 层默认较开放,Kestrel 层默认仅当前用户可连由进程 umask 决定,无框架层的额外收紧

权限被拒之后,看到的不是 PermissionDenied#

直觉上,DACL 拒绝了连接,catch 到的 RpcException 状态码应该是 StatusCode.PermissionDenied——但实际不是。DACL 检查发生在传输连接建立阶段,这时候 gRPC 层面还没有走到”识别调用方身份、判断这个身份有没有权限”这一步,连接本身就先失败了。grpc-dotnet 对这一类传输层建连失败,不管根因是什么,统一包装成 RpcException,状态码是 StatusCode.Unavailable,真正的原始异常(UnauthorizedAccessException)被塞进 DebugException 字段里,不会以”权限”的名义直接暴露出来。

官方 gRPC 状态码规范里写得很明确:如果调用方身份没能被识别,就不该用 PermissionDenied,这种情况该用 Unauthenticated。DACL 拒绝这个场景比这还要早一步——身份识别这一步压根没发生,连接都没建立起来,所以是更底层的 Unavailable

三、为什么不裸用 Named Pipe#

微软官方文档对这个选择本身给出过明确建议:gRPC 客户端和服务端之间的调用通常走 TCP socket,但如果客户端和服务端在同一台机器上,建议改用 Unix domain socket 或 named pipe 这类本机 IPC 传输。也就是说,“用 gRPC 承载本机通信”这件事本身,不是本文的独家发明,是官方推荐的路径。

那接下来的问题是:既然底层就是命名管道,为什么不裸用 Pipe,要套一层 gRPC?

裸 Named Pipe 当然能做 IPC,但你要自己解决一系列问题:

  • 消息边界:Pipe 是字节流,要自己定帧(长度前缀、分隔符等)
  • 序列化:发什么格式?JSON?自定义二进制?
  • 接口契约:调用方和服务方靠什么对齐?口头约定?
  • 多路复用:同时发多个请求怎么对应响应?
  • 双向流:服务端持续推送怎么做?
  • 超时/取消:每个调用的 deadline 怎么传?
  • 错误码:失败了怎么区分网络错误和业务错误?

gRPC over Named Pipe 把这些全部解决了:

  • .proto 文件就是接口文档,编译期生成强类型代码,加新接口改 proto 就行
  • stream 关键字一个搞定服务端推送
  • 超时直接传 deadline: DateTime.UtcNow.AddSeconds(5),框架处理
  • 错误码用 RpcException + StatusCode

举个例子。GameInstall.proto 里有这么一条:

rpc GetTaskProgress (EmptyMessage) returns (stream GameInstallContextDTO);

UI 调用一次,后台不断推送下载速度、读写速度、百分比、剩余时间。裸 Pipe 要自己实现这套推送协议,gRPC 一个关键字搞定。

代价是引入了 Grpc.AspNetCore 这个不轻的依赖,以及 Kestrel 的启动开销。接口会持续增加,用 proto 管理省很多维护成本。

四、Windows 上 IPC 方案全景#

ipc_overview
ipc_overview

那有人会问:那是不是说 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。对于 Electron + C++ 这类跨语言场景,gRPC 的 protobuf 契约反而比裸 pipe + JSON 更清晰。

4. 性能极致敏感#

Memory-Mapped File 做数据传输 + Named Pipe 做信令。

五、总结#

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

  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。选 gRPC 是因为它有 streaming 需求和较多的接口数量,不是因为”最简单”。
  5. Stream 是个伟大的抽象。无论是 .NET 的 System.IO.Stream、Java 的 InputStream、还是 Go 的 io.Reader,本质都是”字节流”这个抽象。一旦有了它,上层协议就能不在乎下层是什么。

参考资料#

从 gRPC 看 Windows IPC
https://cialo.site/posts/windows/grpc-ipc/
作者
洛璃
发布于
2026-05-23
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
洛璃
初春的离去,晚樱的谢幕
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
最新动态
站点统计
文章
38
分类
12
标签
166
总字数
160,464
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.6
文章许可
CC BY-NC-SA 4.0
文章目录