从 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(CreateNamedPipe传NULL安全描述符时默认对Everyone组开放读权限)严格得多。更关键的是,这两个参数是互斥的:一旦设置了自定义PipeSecurity,CurrentUserOnly必须显式设为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 是并列的两条路径,不是包含关系:
ConnectCallback 只是直连分支里的扩展点,不是”接管整个连接过程”的钩子。实测结果:
| 场景 | URI 写 localhost | URI 写其他未在绕过名单的 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+SocketsHttpHandler(ConnectCallback)的写法:只要 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 用 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 的架构是这样的:
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.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。
类比一下:你可以把 HTTP 报文写在纸上用快递寄出去,快递不是 TCP,但内容还是 HTTP。
权限检查:DACL vs 文件权限位
Named Pipe 的权限系统本质上和文件系统权限是同一套东西——安全描述符(Security Descriptor)里挂着一张访问控制列表(ACL),真正做判断的是其中的 DACL(Discretionary ACL,自主访问控制列表)。CreateNamedPipe 的 lpSecurityAttributes 参数就是这唯一的权限开关,且访问检查会发生两次:服务端 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 方案全景
那有人会问:那是不是说 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。对于 Electron + C++ 这类跨语言场景,gRPC 的 protobuf 契约反而比裸 pipe + JSON 更清晰。
4. 性能极致敏感
Memory-Mapped File 做数据传输 + Named Pipe 做信令。
五、总结
把这次思考整理成几条要带走的结论:
- 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。选 gRPC 是因为它有 streaming 需求和较多的接口数量,不是因为”最简单”。
Stream是个伟大的抽象。无论是 .NET 的System.IO.Stream、Java 的InputStream、还是 Go 的io.Reader,本质都是”字节流”这个抽象。一旦有了它,上层协议就能不在乎下层是什么。














