A.12 管道与进程间通信
无论是现代软件构建系统在后台拉起子编译器、逐行捕获其输出诊断并依据退出状态码决断构建流程,还是系统服务在本地命名端点上静候工作进程递交业务请求,进程之间的数据流转与状态协同都无法单纯依托常规磁盘文件或外部网络协议栈来完成。这类承载于操作系统内核之上的跨进程协作通道,构成了进程间通信(Interprocess Communication,简称 IPC)的核心骨干。主线教程的第 7 章已系统推演过 POSIX 体系下的经典 pipe、FIFO 命名管道以及共享内存原语;本篇则将视线转向 Windows NT 体系中成熟且富有特色的 IPC 设施。
Windows 原生 IPC 体系虽然并未沿用 UNIX 的“一切皆文件描述符”范式,却通过面向对象的句柄模型与驱动级子系统(如命名管道文件系统 NPFS),为本地进程提供了功能完备且安全性极强的交互通道。理解 Windows 管道技术的切入点,在于掌握匿名管道在跨进程句柄继承与重定向时的生命周期约束、命名管道在 C/S 架构下的多实例连接与边界隔离机制、以原子消息(Message Mode)传输规避应用层粘包协议的设计优势,以及依托安全描述符(SDDL)和令牌模拟对客户端身份进行严密鉴权的防御纵深。
1. Windows 上的进程间通信
在 Windows 架构中,进程间通信由若干各司其职的独立子系统组成。下表梳理了常用的几种机制及其在 POSIX 规范中的平行对应物:
| 场景 | Windows 机制 | 在本教程的位置 | POSIX 对应 |
|---|---|---|---|
| 父子进程之间传一段字节流 | 匿名管道(anonymous pipe) | 本页第 2 节 | pipe |
| 本机任意进程之间的请求与应答 | 命名管道(named pipe) | 本页第 3、4 节 | FIFO、Unix 域套接字 |
| 大块数据共享,读写像访问内存 | 文件映射对象加同步对象 | A.6、A.10 | shm_open 加信号量 |
| 跨机器,或需要套接字接口 | Winsock,含 AF_UNIX | A.13 | 套接字 |
| 单向的小消息广播 | 邮槽(mailslot) | 本页不展开 | — |
邮槽(mailslot)本质上是基于单向广播的不可靠数据报机制,发送端无法收到接收确认,在当代系统编程实践中已极少登场。此外,诸如 COM、RPC、DDE 以及基于窗口句柄的 WM_COPYDATA 消息虽然同样能够跨进程搬运结构化数据,但它们深度耦合了特定的运行时组件或图形界面消息循环,并不属于底层无界面系统编程的通用基石。
在工程选型中,通信实体的拓扑关系与性能要求构成了最清晰的权衡标尺:对于具有血缘关系的父子进程间简单的数据重定向,匿名管道是最为轻巧直接的通道,既无需协商命名空间,也没有残留资源的清理隐患;而对于驻留在系统后台、为异构非特权进程提供常驻服务的本机守护程序,命名管道则是事实上的工业标准——它不仅原生支持细粒度访问控制列表(ACL),还能直接在内核层提取对端调用进程的物理 PID 与访问令牌进行身份校验;当系统需要保持与 Linux 生态的同构代码复用时,基于 loopback 的 TCP 或本地 AF_UNIX 套接字则提供了最平滑的跨平台接口;至于多媒体帧传输或超大规模状态同步,则应交给无额外拷贝开销的共享内存,配合事件或互斥量在用户态与内核态之间精密握手(如 A.6 与 A.10 所述)。
2. 匿名管道与子进程重定向
匿名管道是两个句柄之间的一条单向字节流:一个句柄只能读,另一个只能写。它没有名字,别的进程无法凭名字打开它,只能从创建它的进程那里继承句柄,或者用 DuplicateHandle 复制一份送过去(A.3)。这个限制正好适合父子进程:父进程建好管道,把一端塞进子进程的标准句柄,另一端自己留着。
2.1 CreatePipe 与句柄继承
创建匿名管道只需要一次调用:
SECURITY_ATTRIBUTES sa = {.nLength = sizeof sa, .bInheritHandle = TRUE};
HANDLE read_end = NULL;
HANDLE write_end = NULL;
if (!CreatePipe(&read_end, &write_end, &sa, 0)) {
/* 处理错误 */
}
SetHandleInformation(read_end, HANDLE_FLAG_INHERIT, 0);2
3
4
5
6
7
8
CreatePipe 的两个输出参数分别是读端和写端。第三个参数决定这两个句柄能否被子进程继承,传 NULL 时不可继承,要重定向就必须给一个 bInheritHandle 为 TRUE 的 SECURITY_ATTRIBUTES。最后一个参数是建议的缓冲区大小,传 0 用系统默认值。
SetHandleInformation(read_end, HANDLE_FLAG_INHERIT, 0) 把读端的继承标志去掉。父进程自己要用读端,子进程不需要它;留着继承标志,每启动一个子进程就会多出一份没人用的读端句柄。写端不能这样处理,它必须可继承,子进程要靠它拿到自己的标准输出。
漏掉可继承这一步,现象很容易看错。no_inherit.c 把 &sa 换成 NULL,两个句柄都不可继承:子进程的 hStdOutput 因此拿不到写端,它照常启动、照常返回 0,但一个字节也没写出来;父进程这边的 ReadFile 立刻返回 ERROR_BROKEN_PIPE,因为管道里根本没有别的写者。输出是 first ReadFile: ok=0, 0 bytes, error=109 和 child exit code 0,看起来像「子进程什么都没输出」,实际上问题出在父进程创建管道的那一行。
匿名管道的两端各只能单向使用,这一点和 POSIX 的 pipe 一样。不同的地方在继承的默认值:POSIX 的描述符默认跨 fork 继承,Windows 的句柄默认不继承,必须在创建时就声明可继承,再在 CreateProcessW 里传 bInheritHandles = TRUE,两个条件都满足才真的传过去。
2.2 捕获子进程的输出
下面这个程序把子进程的标准输出和标准错误都接到同一根管道的写端,自己从读端把字节收下来,最后报告字节数、行数和子进程的退出码。完整的源文件在 code/系统编程/拓展A_Win32系统编程/A_12_管道与进程间通信/capture.c,这里只列关键部分。
STARTUPINFOW si = {.cb = sizeof si};
PROCESS_INFORMATION pi;
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
si.hStdOutput = write_end;
si.hStdError = write_end;
if (!CreateProcessW(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) {
/* 处理错误 */
}
/* 子进程已经拿到写端的副本,父进程立刻关掉自己这一份。 */
CloseHandle(write_end);
write_end = NULL;
char buf[BUF_SIZE];
for (;;) {
DWORD got = 0;
if (!ReadFile(read_end, buf, sizeof buf, &got, NULL)) {
if (GetLastError() == ERROR_BROKEN_PIPE) {
break; /* 所有写端都已关闭,正常的结束 */
}
break; /* 其他错误,后面按失败处理 */
}
DWORD written = 0;
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), buf, got, &written, NULL);
/* 统计字节数与行数 */
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
STARTF_USESTDHANDLES 让 CreateProcessW 改用 STARTUPINFOW 里给出的三个句柄,而不是继承父进程的标准句柄。把 hStdError 也指向写端,子进程的错误输出就和正常输出混在一条流里,顺序与它实际写出的顺序一致。要让两者分开,就再建一根管道,hStdError 指向第二根的写端。
ReadFile 在管道上返回 FALSE 且 GetLastError() 为 ERROR_BROKEN_PIPE(109)时表示所有写端都已经关闭,这是读循环的正常结束条件,不是错误。每次读到的字节数在 1 到缓冲区长度之间,具体多少取决于当时缓冲区里有多少:管道是字节流,ReadFile 一次返回多少不等于子进程写一次的量,不能假设一次读就是一行或一条消息。
把管道里的字节原样转给标准输出
capture.c 用 WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), ...) 而不是 fwrite(..., stdout) 转发。CRT 的 stdout 默认是文本模式,写到文件或管道时会把每个 \n 变成 \r\n;管道里本来就有 \r\n 的地方会变成 \r\r\n,字节数也跟着涨。要保留原始字节,用 WriteFile,或者先 _setmode(_fileno(stdout), _O_BINARY)。
child.c 是配套的子进程(首次出现在 A.8),逐行打印参数,并把第一个参数当作退出码。两个程序都用 wmain,MinGW 编译时加 -municode:
PS> gcc -std=c17 -Wall -Wextra -municode -o capture.exe capture.c
PS> gcc -std=c17 -Wall -Wextra -municode -o child.exe child.c
PS> .\capture.exe .\child.exe 0 一 二 三
argv[1] = 0
argv[2] = 一
argv[3] = 二
argv[4] = 三
-- captured 58 bytes, 4 lines, exit code 0
PS> .\capture.exe .\child.exe 3 hello
argv[1] = 3
argv[2] = hello
-- captured 30 bytes, 2 lines, exit code 32
3
4
5
6
7
8
9
10
11
12
两次运行末尾那句 -- captured 里的字节数,数的是字节而不是字符:argv[1] = 0 是一行 11 个 ASCII 字符加 \r\n,共 13 字节;带汉字的行多出的汉字在 UTF-8 下占 3 字节,一行 15 字节。13 加三个 15 正好是 58。子进程的退出码由 GetExitCodeProcess 取出后打印,capture.exe 自己仍然返回 0。
输出多的程序也一样。用一个打印 5000 行、同时往标准错误写进度的程序试一下:
PS> .\capture.exe .\spew.exe | Select-Object -Last 2
line 5000
-- captured 53968 bytes, 5005 lines, exit code 02
3
行数比 5000 多 5,是因为 spew.exe 每 1000 行往标准错误写一行进度,两路输出都进了同一根管道。
2.3 关闭写端:读到结束的前提
ReadFile 返回 ERROR_BROKEN_PIPE 的条件是所有写端句柄都已关闭,包括父进程在创建子进程之前持有的那一份。子进程结束时,系统替它关掉它那一份写端,但父进程手里还有一份,管道因此仍然是「有人能写」的状态,读端就一直等下去。
管道读到结束的条件
管道的读端只在所有写端句柄都已关闭时才报告结束(ReadFile 返回 FALSE,错误为 ERROR_BROKEN_PIPE)。句柄是按进程计数的,父进程创建子进程时把自己的一份写端复制给了子进程,自己也还留着一份;两份都关闭,管道才算没有写者。
hang_demo.c 故意漏掉这一步,其余流程和 capture.c 一样:
PS> $p = Start-Process -FilePath '.\hang_demo.exe' -ArgumentList '.\child.exe','0','一','二','三' -PassThru -NoNewWindow -RedirectStandardOutput hang.out
PS> $p.WaitForExit(5000)
False
PS> Get-Content hang.out
argv[1] = 0
argv[2] = 一
argv[3] = 二
argv[4] = 三
PS> (Get-Process child -ErrorAction SilentlyContinue | Measure-Object).Count
0
PS> $p.Kill(); $p.WaitForExit()2
3
4
5
6
7
8
9
10
11
子进程已经把四行参数写完并退出(Get-Process child 一个也查不到),hang_demo.exe 却还卡在 ReadFile 里。WaitForExit(5000) 等满 5 秒返回 False,只能用 $p.Kill() 结束这一个进程。这类挂起如果发生在构建工具里,表现就是「编译已经完成,界面一直转圈」。
CreateProcessW(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
/* 写端还开着,读端永远等不到结束 */
while (ReadFile(read_end, buf, sizeof buf, &got, NULL) && got > 0) {
...
}2
3
4
5
6
CreateProcessW(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
CloseHandle(write_end); /* 子进程已经持有副本 */
write_end = NULL;
while (ReadFile(read_end, buf, sizeof buf, &got, NULL) && got > 0) {
...
}2
3
4
5
6
7
8
父进程关闭自身写端句柄的时机,必须严密卡在 CreateProcessW 调用成功之后、启动数据读取循环之前。若在子进程创建前过早关闭写端,子进程的句柄表初始化将无法复制该句柄,导致子进程的标准输出悬空失效;而若在读循环中迟迟不关闭,父进程自身持有的写端将维持内核管道对象的写者引用计数不归零,使读端在子进程退场后依然死等后续写入。因此,在子进程顺利确立句柄副本的瞬间立即关闭父进程手头的写端,是最为稳健的做法。
2.4 先读后等的管道死锁机理
除了句柄泄漏导致的挂起之外,并发管道通信中还存在另一类对称但更为隐蔽的死锁陷阱:父进程在启动子进程后,先去调用 WaitForSingleObject 等待子进程终结,试图待其彻底退场后再去读取管道里的输出。然而,操作系统内核为匿名管道分配的环形缓冲区容量极其有限(在 Windows 默认配置下通常仅有 4 KiB 左右)。一旦子进程的输出总量超过了这一缓冲区容量,子进程所调用的 WriteFile 就会被内核无情挂起,直到有读者将缓冲区腾出空闲配额;而此时父进程却在同步等待子进程结束退出。两端互为前置条件,瞬间构成了经典的死锁(deadlock)。
capacity.exe 通过精密计时将这一物理边界直接量化展现:它为子进程挂接一根默认尺寸的匿名管道,随后父进程主动休眠 2 秒拒绝读取,在此期间利用 PeekNamedPipe 探查管道内核缓冲区的状态以及子进程的停滞点。
PS> .\capacity.exe .\capacity_child.exe
after 2 s: 4097 bytes buffered, child blocked in WriteFile
drained 1048576 bytes2
3
子进程每写 1 字节就下一次 WriteFile,本想写满 1 MiB,两秒后只写进去 4097 字节就停住了,再读走,它才把余下的写完。CreatePipe 的最后参数只是建议值,0 表示用系统默认值;本机上这个默认值只能积压 4 KiB 出头。
waitfirst.c 把等待放在读之前:
PS> .\waitfirst.exe .\spew.exe
waiting for the child before reading...
WaitForSingleObject = 258 (WAIT_TIMEOUT)
read 4096 bytes2
3
4
状态码 258 对应 WAIT_TIMEOUT。由于子进程意图输出五万余字节的内容,远超内核管道的承载上限,它在缓冲区填满后被内核强制阻塞在 WriteFile 之中无法退出,最终导致父进程等待满 3 秒后超时返回。只有当父进程着手调用 ReadFile 抽取管道数据、为内核缓冲区腾挪出新的空闲配额后,子进程才能从阻塞中解脱并完成剩余输出。
由此可以总结出处理重定向流的严谨时序规则:创建子进程、立即关闭父进程本地持有的多余写端句柄、迅速启动持续推进的读取循环,直到读到 EOF 标志(即 ERROR_BROKEN_PIPE)后正常跳出读循环,最后再去检视子进程句柄并收割退出状态码。数据消费与终结等待的先后次序绝不可颠倒。如果在交互式工具中需要同时重定向标准输入与标准输出两根管道,单纯单线程串行操作依然极易因为读写背压交错而死锁,此时必须将标准输入写入与标准输出读取拆分至不同的专职工作线程中执行(参见 A.9),或者引入后续章节介绍的异步重叠 I/O 驱动。
并发启动多个子进程时,可继承句柄会漏进别的子进程
bInheritHandles = TRUE 让子进程继承父进程手里所有带继承标志的句柄。两个线程同时启动子进程 A 和 B,A 的写端可能在 B 创建的那一刻已经设成可继承,于是 B 也拿到了 A 的写端。A 那边看起来就是「子进程退出了,读端却读不到结束」,直到 B 也退出。Raymond Chen 在 2013 年的一篇文章里描述过这个问题。
解决办法是 STARTUPINFOEXW 加 PROC_THREAD_ATTRIBUTE_HANDLE_LIST:调用 InitializeProcThreadAttributeList 建一个属性列表,用 UpdateProcThreadAttribute 把这次要继承的句柄显式列出来,创建时传 EXTENDED_STARTUPINFO_PRESENT。这样只有列表里的句柄会过去。另一种做法是把创建子进程的代码串行化,用一把锁保证一次只有一个 CreateProcessW 在进行。
父进程创建子进程后没有关闭自己持有的管道写端,会发生什么?
管道的写端全部关闭之后,读端的 ReadFile 返回什么?
3. 命名管道
命名管道有名字,任何进程都能凭名字打开它。名字的写法固定为 \\.\pipe\ 加上自己起的名字,后半段不能含反斜杠。它和匿名管道共用一套读写接口:ReadFile、WriteFile、CloseHandle。
角色分服务器和客户端。服务器用 CreateNamedPipeW 创建实例,用 ConnectNamedPipe 等客户端连上来;客户端用 CreateFileW 打开这个名字,打开成功就算连上。一个名字下可以有多个实例,每个实例服务一个客户端,实例数量在创建时指定。
3.1 服务器
完整的服务器在 code/系统编程/拓展A_Win32系统编程/A_12_管道与进程间通信/pipe_echo_server.c。它的主循环每轮创建一个实例、等一个客户端、把一个线程交给这个客户端,然后继续下一轮:
HANDLE pipe = CreateNamedPipeW(
L"\\\\.\\pipe\\mdr_echo", PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
PIPE_UNLIMITED_INSTANCES, 4096, 4096, 0, NULL);
if (pipe == INVALID_HANDLE_VALUE) {
/* 处理错误 */
}
if (!ConnectNamedPipe(pipe, NULL) && GetLastError() != ERROR_PIPE_CONNECTED) {
/* 处理错误 */
}
HANDLE thread = CreateThread(NULL, 0, serve_client, pipe, 0, NULL);
CloseHandle(thread); /* 线程自己会结束,句柄不需要留 */2
3
4
5
6
7
8
9
10
11
12
13
14
参数逐个看。PIPE_ACCESS_DUPLEX 表示双向,服务器两端都能读写;单向的还有 PIPE_ACCESS_INBOUND 与 PIPE_ACCESS_OUTBOUND。PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE 让管道按消息处理,第 4 节专门讲。PIPE_WAIT 是阻塞模式;PIPE_NOWAIT 是给 LAN Manager 兼容留的,不要用它做异步。PIPE_REJECT_REMOTE_CLIENTS 拒绝来自其他机器的连接,本机服务应当带上。PIPE_UNLIMITED_INSTANCES 表示实例数量只受系统资源限制。
ConnectNamedPipe 正常返回非零;如果客户端在 CreateNamedPipeW 与 ConnectNamedPipe 之间就连上了,它返回 FALSE,GetLastError() 为 ERROR_PIPE_CONNECTED(535),这同样是连接成功。这个分支不是错误处理,是这套 API 的正常路径之一。
每个客户端一个线程是 Microsoft Learn 多线程管道服务器的做法,代码短、容易看懂。客户端多了以后线程数量会上去,A.14 会把这个服务器改成重叠 I/O 的单线程版本,A.15 再改成完成端口。
服务完一个客户端,线程依次调用 FlushFileBuffers、DisconnectNamedPipe、CloseHandle。FlushFileBuffers 在管道上等客户端把已经写入的数据读走,DisconnectNamedPipe 断开这个实例,实例随后可以复用(这里直接关掉,下一轮重新创建)。
3.2 客户端
客户端用 CreateFileW 打开管道,路径就是服务器创建时用的名字:
HANDLE pipe;
for (;;) {
pipe = CreateFileW(L"\\\\.\\pipe\\mdr_echo", GENERIC_READ | GENERIC_WRITE, 0, NULL,
OPEN_EXISTING, 0, NULL);
if (pipe != INVALID_HANDLE_VALUE) {
break;
}
if (GetLastError() != ERROR_PIPE_BUSY) {
/* 管道不存在或没有权限,可以放弃了 */
return 1;
}
if (!WaitNamedPipeW(L"\\\\.\\pipe\\mdr_echo", 5000)) {
/* 5 秒内没有实例空出来 */
return 1;
}
}
DWORD mode = PIPE_READMODE_MESSAGE;
SetNamedPipeHandleState(pipe, &mode, NULL, NULL);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CreateFileW 打开已存在的管道用 OPEN_EXISTING,访问权限按服务器的方向给:双向管道给 GENERIC_READ | GENERIC_WRITE。所有实例都忙时它返回 INVALID_HANDLE_VALUE,错误为 ERROR_PIPE_BUSY(231,系统消息是「所有的管道范例都在使用中。」),这时用 WaitNamedPipeW 等一个实例空出来再重试。
SetNamedPipeHandleState 这一句容易被忘掉。客户端打开管道时默认是字节读模式,即使服务器创建的是消息管道;不切换,服务器写出的每条消息在客户端看来只是一串连续的字节。第 4.2 节有对比。
3.3 一次完整的会话
服务器带一个参数表示要服务几个客户端,服务完就退出,这样演示脚本不用手动结束它。两个程序都用 gcc -std=c17 -Wall -Wextra 编译,客户端因为用 wmain 再加 -municode。先启动服务器,再依次运行两个客户端:
PS> $s = Start-Process -FilePath .\pipe_echo_server.exe -ArgumentList 2 -PassThru -NoNewWindow -RedirectStandardOutput server.out
PS> .\pipe_echo_client.exe alpha beta
client pid 3468 connected
sent "alpha", got "alpha"
sent "beta", got "beta"
PS> .\pipe_echo_client.exe gamma delta
client pid 25604 connected
sent "gamma", got "gamma"
sent "delta", got "delta"
PS> Get-Content server.out
[server] instance 1 waiting for a client
[server] instance 2 waiting for a client
[server] client connected, pid 3468
[server] got 5 bytes: "alpha"
[server] got 4 bytes: "beta"
[server] client disconnected
[server] client connected, pid 25604
[server] got 5 bytes: "gamma"
[server] got 5 bytes: "delta"
[server] client disconnected
[server] served 2 client(s), exiting2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
服务器在等第二个客户端时已经建好了第二个实例,所以第二个客户端一来就能连上,不需要等。两个客户端同时启动也可以:每个实例一个线程,输出会交错,顺序每次可能不同。
客户端里的 sent ..., got ... 是它自己打印的,说明回声真的绕了一圈回来。服务端的 [server] got 5 bytes 是 serve_client 里读到的字节数,一条消息一次读完,长度与客户端写的完全一致。
3.4 实例、连接与错误码
命名管道的几处返回值与直觉不同,pipe_errors.c 把四个最常见的错误码放在一次运行里:
PS> .\pipe_errors.exe
read after all writers closed:
ERROR_BROKEN_PIPE = 109: 管道已结束。
ConnectNamedPipe with a client already connected:
ERROR_PIPE_CONNECTED = 535: 管道的另一端有一进程。
CreateFileW while the only instance is busy:
ERROR_PIPE_BUSY = 231: 所有的管道范例都在使用中。
WaitNamedPipeW for a pipe that does not exist:
ERROR_FILE_NOT_FOUND = 2: 系统找不到指定的文件。2
3
4
5
6
7
8
9
ConnectNamedPipe 在客户端抢先连上时返回 FALSE,错误 535。把 535 当成失败处理,这个连接就被丢掉了,客户端会在下一次读时得到管道已断开。
实例数量在创建时确定。第一个实例的 nMaxInstances 就是上限,之后同名实例建到上限时,CreateNamedPipeW 失败、错误为 ERROR_PIPE_BUSY(231),和客户端遇到实例全忙时是同一个码。busy_client.exe 遇到 231 后用 WaitNamedPipeW 等 5 秒,另一个程序占用的实例一空出来就能连上:
PS> $s = Start-Process -FilePath .\busy_server.exe -PassThru -NoNewWindow -RedirectStandardOutput bsrv.out
PS> $c = Start-Process -FilePath .\busy_client.exe -ArgumentList 5000,3000 -PassThru -NoNewWindow -RedirectStandardOutput bc.out
PS> .\busy_client.exe 5000
CreateFileW failed: error 231
ERROR_PIPE_BUSY, waiting up to 5000 ms
connected after waiting
PS> Get-Content bsrv.out
server ready, one instance only
client 1 connected, holding the instance for 2 s
client 2 connected after the instance became free
server done2
3
4
5
6
7
8
9
10
11
第一个客户端在服务器就绪后立刻连上,服务器占住唯一的实例 2 秒;第二个客户端先撞上 ERROR_PIPE_BUSY,在 WaitNamedPipeW 里等到服务器调用 DisconnectNamedPipe,实例空出来后才连上。WaitNamedPipeW 等的是「有空闲实例」,不是「名字存在」:名字根本不存在时它立即返回 FALSE,错误是 ERROR_FILE_NOT_FOUND(2),而不是超时。
打开一个不存在的命名管道与打开一个不存在的文件,错误码相同,都是 2。服务器还没启动时,客户端看到的就是这个错误,可以直接退出或提示用户,不必重试。
管道的两种实现在 POSIX 里的对应物是 FIFO 和 Unix 域套接字。FIFO 有名字,用 mkfifo 创建,open 之后就是普通的读写,但它只有一根单向字节流,没有实例的概念,也没有消息模式;多个进程同时读写时,字节会混在一起。Unix 域套接字有连接的概念,一个监听套接字可以接受多个连接,这一点与命名管道最接近,但它的接口是套接字那一套(socket、bind、listen、accept)。
4. 消息模式
4.1 消息边界
在默认的字节流模式(Byte Mode)下,管道的行为类似于无结构的连续水流:写端写入的数据流在内核缓冲区中被抹平了调用边界,读端调用 ReadFile 时仅按当前请求的缓冲区尺寸与内核现存数据量进行切片抽取,导致写端的一次写入与读端的一次读取无法形成严格的一一对应关系。这迫使上层应用协议必须自行设计诸如「固定 4 字节长度前缀」或特殊分隔符等解包逻辑以防范粘包。而 Windows 命名管道原生支持的消息模式(Message Mode),则由命名管道文件系统驱动(NPFS)在内核记录每笔 WriteFile 提交的数据边界,使读端单次 ReadFile 能够天然精准对齐单条逻辑消息。
当应用程序提供的读取缓冲区足以容纳整条消息时,单次 ReadFile 即可将整条消息完整交付,返回 TRUE 并在参数中写回该消息的实际字节长度。而当读缓冲区尺寸小于当前消息时,ReadFile 会将当前能够承载的部分写入缓冲区,但向调用方返回 FALSE,并通过 GetLastError() 报告特定的中间状态码 ERROR_MORE_DATA(234)——明确提示该条消息尚未完结,剩余数据仍安全驻留在内核队列中等待后续读取。
msgmode.c 在同一个进程里建服务器线程和客户端,先写三条消息,再用两种大小的缓冲区读:
PS> .\msgmode.exe
server: wrote message 1, 5 bytes
server: wrote message 2, 5 bytes
server: wrote message 3, 36 bytes
read with 256-byte buffer: ok=1, 5 bytes, "alpha"
read with 256-byte buffer: ok=1, 5 bytes, "bravo"
read with 16-byte buffer: ok=0, 16 bytes, error=234
read with 16-byte buffer: ok=0, 16 bytes, error=234
read with 16-byte buffer: ok=1, 4 bytes, error=02
3
4
5
6
7
8
9
前两条各 5 字节,用 256 字节的缓冲区一次读完,返回值是 TRUE,两次读不会粘成一条。第三条 36 字节,用 16 字节的缓冲区分成三段:前两次各拿到 16 字节并附带 ERROR_MORE_DATA,最后一次拿到剩下的 4 字节,返回 TRUE。服务器线程和主线程并发运行,两种输出的先后顺序每次可能不同,内容不变。
注意最后一段的返回值是 TRUE、错误不再设置。循环的写法因此是「只要返回 FALSE 且错误是 234,就继续读」;把 234 当致命错误处理,长消息就被截断了。
没读完的消息不会跳到下一条。partial.c 用 16 字节的缓冲区读一条 36 字节的消息,然后换 256 字节的缓冲区接着读:
PS> .\partial.exe
read with a 16-byte buffer: ok=0, 16 bytes, error=234
next read with a 256-byte buffer: ok=1, 20 bytes, error=0, "ghijklmnopqrstuvwxyz"2
3
第二次读到的是这条消息剩下的 20 字节,不是紧跟在后面的那条 second-message。一条消息在读完之前一直占着读端当前的位置。
消息模式只保证 WriteFile 与 ReadFile 的对应关系,不保证送达。管道是流式的字节通道,服务器写的时候客户端没在读,数据留在缓冲区里,不会丢,但也没有确认机制;要确认,就在协议里自己加应答。
4.2 字节模式与消息模式的差别
同一个消息管道,客户端不调用 SetNamedPipeHandleState,读到的就是连成一片的字节:
PS> .\bytemode.exe
client reads in byte mode
read 1: ok=1, 3 bytes, "one"
read 2: ok=1, 8 bytes, "twothree"
read 3: ok=0, 0 bytes, ""
PS> .\bytemode.exe m
client reads in message mode
read 1: ok=1, 3 bytes, "one"
read 2: ok=1, 3 bytes, "two"
read 3: ok=1, 5 bytes, "three"2
3
4
5
6
7
8
9
10
字节模式下前两条消息被合成一次 8 字节的读,第三条读返回 FALSE、0 字节,因为此时服务器已经断开。消息模式下三次读正好对应三条消息。
服务器创建时的 PIPE_READMODE_MESSAGE 只决定服务器这一端,客户端的读模式要自己设。两端的读模式一致,消息分界才对客户端有意义。
4.3 一次往返:TransactNamedPipe 与 CallNamedPipeW
请求与应答这种一问一答的模式很常见,Win32 为此提供了两个封装。
TransactNamedPipe 在已经打开的管道句柄上写一条消息、再读一条消息,两个动作合成一次调用。它要求管道是消息类型且句柄处于消息读模式,否则直接失败。应答比缓冲区大时同样返回 FALSE、ERROR_MORE_DATA,余下的部分用 ReadFile 继续读。
CallNamedPipeW 更进一步,把连接、发送、接收、断开都包进去,客户端连管道名字都不用自己打开:
char reply[BUF_SIZE];
DWORD got = 0;
BOOL ok = CallNamedPipeW(L"\\\\.\\pipe\\mdr_transact", "ping", 4, reply, sizeof reply, &got, 2000);
if (ok) {
reply[got] = '\0';
wprintf(L"one call, %lu bytes back: \"%hs\"\n", got, reply);
}2
3
4
5
6
7
transact.c 跑出来的结果是:
PS> .\transact.exe
one call, 19 bytes back: "server echoes: ping"2
最后一个参数是超时毫秒数。CallNamedPipeW 适合一次性的短请求;如果客户端要连着发很多条,自己 CreateFileW 打开一次、反复 WriteFile 和 ReadFile 更省,因为每次调用都要重新连一次。
消息模式管道上 ReadFile 返回 FALSE、错误为 ERROR_MORE_DATA,表示什么?
5. 管道的安全与客户端身份
5.1 默认安全描述符
CreateNamedPipeW 的最后一个参数传 NULL 时,管道拿到一个默认的安全描述符。pipe_sddl.c 用 GetSecurityInfo 从管道句柄取回它,再用 ConvertSecurityDescriptorToStringSecurityDescriptorW 转成文字(已替换本机名称):
PS> .\pipe_sddl.exe
O:S-1-5-21-…-1001G:S-1-5-21-…-1001D:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;S-1-5-21-…-1001)(A;;FR;;;WD)(A;;FR;;;AN)2
这一段是 SDDL(安全描述符定义语言)记法,A.16 会逐段讲。现在只需要看 D: 后面括号里的五项,每一项是一条访问控制项(access control entry,ACE),格式是「允许、权限、主体」:
SY是 LocalSystem,BA是内置管理员组,第三项是创建者本人,三者都是FA(完全访问)。WD是 Everyone,AN是匿名账户,两者只有FR(读)。
也就是说,默认设置下任何本机用户都能连上这个管道,Everyone 至少能把数据读走。对回声服务这类不涉及秘密的程序问题不大,但一个处理用户数据的本机服务不能这样。给管道指定只允许当前用户访问的 DACL 在 A.16 里实现,那时会把这条默认设置换掉。
5.2 拒绝远程客户端
命名管道可以通过网络被别的机器连上,\\.\pipe\ 只是本机的写法,远程用 \\机器名\pipe\名字。只服务本机程序时,创建时加 PIPE_REJECT_REMOTE_CLIENTS,远程连接会被系统直接拒绝,不用等访问检查。
这是本页服务器采用的写法。它不影响本机客户端,也不改变本机客户端需要的权限。
本机服务只接受本机客户端,CreateNamedPipeW 应当加上哪个标志?
5.3 取得客户端身份
服务器经常需要知道连上来的是谁。GetNamedPipeClientProcessId 取出客户端进程的 ID,GetNamedPipeClientSessionId 取会话 ID,两者都只需要管道句柄:
ULONG pid = 0;
if (GetNamedPipeClientProcessId(pipe, &pid)) {
wprintf(L"[server] client connected, pid %lu\n", pid);
}2
3
4
前面 3.3 节服务器输出里的 pid 3468 就是它打印的。拿到 PID 之后可以 OpenProcess 查进程名、QueryFullProcessImageNameW 取完整路径(A.8)。本机客户端还好判断,远程连过来时 PID 属于对面机器,本机没有意义,这也是本机服务加上 PIPE_REJECT_REMOTE_CLIENTS 的又一个理由。
真正需要「以客户端的身份做事」时,服务器在读完请求之后调用 ImpersonateNamedPipeClient,线程的访问令牌临时换成客户端的,此后打开文件、注册表都按客户端的权限检查,做完再 RevertToSelf。这是 A.16 的话题,这里只点出它的入口。
FILE_FLAG_FIRST_PIPE_INSTANCE 解决的是另一个问题:抢先创建。管道名字是全局的,谁先创建谁就是服务器。一个低权限进程可以在真正的服务启动之前用同名管道占住位置,等客户端连上来骗走数据。加上这个标志,如果同名管道已经存在,CreateNamedPipeW 直接失败、错误为 ERROR_ACCESS_DENIED(5):
PS> .\first_instance.exe
first instance created
second CreateNamedPipeW with FIRST_PIPE_INSTANCE: error 5: 拒绝访问。
second instance without the flag: ok2
3
4
只有第一个实例加这个标志,后续实例不加,否则自己的第二个实例也创建不出来。
6. 习题
6.1 巩固
不加任何修改,编译运行本页的 broken_pipe.c,在运行之前写下你对两次 ReadFile 的预测(返回值、字节数、错误码),运行后对照。
然后删掉 CloseHandle(write_end) 这一行,重新运行。加上超时(Start-Process 后 WaitForExit(3000),返回 False 就 Kill)避免程序一直挂着,解释第二次 ReadFile 为什么不再返回。
改造 capture.c,把子进程的标准输出和标准错误分到两根管道,分别统计字节数,最后打印成 stdout: N bytes, stderr: M bytes。
用本页的 spew.exe 验证:标准输出 5000 行,标准错误 5 行,两边的字节数应当与 .\spew.exe > out.txt 2> err.txt 后用 (Get-Item out.txt).Length 量出的结果一致。
下面这段摘录(si.cb 等字段已在别处设好)有三处问题:一处让子进程拿不到管道的写端,一处让读循环永远不结束,一处转发的字节数比管道里的实际内容多。逐一指出、改正,编译运行验证。
CreatePipe(&read_end, &write_end, NULL, 0);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = write_end;
CreateProcessW(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
while (ReadFile(read_end, buf, sizeof buf, &got, NULL) && got > 0) {
fwrite(buf, 1, got, stdout);
}2
3
4
5
6
7
说明每一处对应本页哪一小节的现象。
6.2 编程
实现一个读消息的函数,处理 ERROR_MORE_DATA:
/* 从管道读一条完整消息,返回 malloc 的缓冲区和长度;失败返回 NULL。 */
char *read_message(HANDLE pipe, DWORD *len);2
要求:
- 缓冲区从小到大增长:第一次用 1024 字节,装不下时按 2 倍扩容,直到某次
ReadFile返回TRUE; - 每次
ReadFile之前要记住已经收了多少字节,下一次从缓冲区剩下的位置继续读,不要覆盖前面的内容; - 写一个测试程序,用消息模式管道发 10 条长度分别为 1、2、4、…、512、1024、4096 字节的消息,逐条用
read_message读回并比较内容与长度; - 再加一条 100000 字节的消息,说明为什么单条消息不能无限大(提示:
CreateNamedPipeW的缓冲区大小只是建议值,系统会按需扩展,但扩展时写操作会阻塞)。
写一个 runlog.exe command [args...]:运行给定的命令,把它的标准输出和标准错误分别写入 stdout.log 和 stderr.log,每一行前面加上从启动开始计算的毫秒数,最后打印退出码和两路各自的字节数。
要求:
- 用两根匿名管道,
hStdOutput、hStdError各接一根的写端; - 两路输出的读取不能用同一个线程串行做,否则一路写满时另一路会阻塞;用两个线程,或者用一个线程加
PeekNamedPipe轮询; - 时间戳用
GetTickCount64,格式为[1234] 原来的行,行被管道切断时(一次ReadFile只拿到半行)要缓存到下一行补齐; - 用
.\runlog.exe .\spew.exe验收:stdout.log恰好 5000 行(本机实测 53893 字节)、stderr.log恰好 5 行(75 字节),每行开头都有时间戳,退出码 0。
写一对程序 putfile 与 getfile,用命名管道 \\.\pipe\mdr_file 传文件。
要求:
- 服务器
getfile 保存路径创建消息模式管道,先收一条消息作为文件名,再循环收数据块(每块 4096 字节)写入文件,收到一条长度为 0 的消息表示结束;每收一块打印累计字节数; - 客户端
putfile 文件先发文件名,再分块发内容,最后发一条 0 字节的消息; - 服务器要能连续服务多个客户端,每来一个客户端起一个线程,用
GetNamedPipeClientProcessId打印客户端 PID; - 用
.\putfile传一个 1 MiB 的文件,用certutil -hashfile 文件 MD5比较源文件与收到的文件; - 传输结束后用
.\getfile的退出码表示成功与否:全部文件校验通过返回 0,否则返回 1。
6.3 综合与挑战
给本页的 pipe_echo_server.c 加三项保护,并用 PowerShell 验证每一项。
(1) 只允许当前用户连接:用 ConvertStringSecurityDescriptorToSecurityDescriptorW 构造 D:P(A;;FA;;;SY)(A;;FA;;;<当前用户 SID>),传给 CreateNamedPipeW 的 SECURITY_ATTRIBUTES;然后用另一个用户(或管理员身份的 PowerShell)尝试连接,确认被拒绝。构造 DACL 的方法见 A.16。
(2) 记录并打印每个客户端的 PID、会话 ID 和进程映像名,连接时打印一行。
(3) 拒绝来自其他会话的客户端:GetNamedPipeClientSessionId 与 ProcessIdToSessionId(GetCurrentProcessId(), ...) 比较,不一致就断开并打印原因。
实现 mypipe a | b 的效果:写一个程序 twostage.exe A B,启动两个子进程 A 和 B,用一根匿名管道把 A 的标准输出接到 B 的标准输入,自己只负责建立连接和等待。
要求:
- 建一根管道,写端给 A 的
hStdOutput,读端给 B 的hStdInput,两个子进程创建完成后父进程关闭所有管道句柄; - A 和 B 的
hStdError都接到父进程的标准错误,便于看到错误; - 用
.\twostage.exe cmd /c "echo hello" findstr hello验证输出hello; - 用
.\twostage.exe .\spew.exe findstr /c:"line 4999"验证大输出不会死锁,并说明为什么这里的死锁风险比capture.c小; - 把 A 换成
cmd /c "exit 3",检查twostage.exe报告的退出码是不是 3,A 的退出码与 B 的退出码分别怎么取。
7. 参考资料
- Microsoft Learn,Interprocess Communications:https://learn.microsoft.com/en-us/windows/win32/ipc/interprocess-communications
- Microsoft Learn,Pipes:https://learn.microsoft.com/en-us/windows/win32/ipc/pipes
- Microsoft Learn,Anonymous Pipe Operations:https://learn.microsoft.com/en-us/windows/win32/ipc/anonymous-pipe-operations
- Microsoft Learn,Creating a Child Process with Redirected Input and Output:https://learn.microsoft.com/en-us/windows/win32/procthread/creating-a-child-process-with-redirected-input-and-output
- Microsoft Learn,CreatePipe:https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-createpipe
- Microsoft Learn,CreateNamedPipeW:https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-createnamedpipew
- Microsoft Learn,CreateFileW:https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
- Microsoft Learn,WaitNamedPipeW:https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-waitnamedpipew
- Microsoft Learn,SetNamedPipeHandleState:https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-setnamedpipehandlestatew
- Microsoft Learn,Multithreaded Pipe Server:https://learn.microsoft.com/en-us/windows/win32/ipc/multithreaded-pipe-server
- Microsoft Learn,Named Pipe Client:https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-client
- Microsoft Learn,Named Pipe Security and Access Rights:https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights
- Microsoft Learn,TransactNamedPipe:https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-transactnamedpipe
- Microsoft Learn,CallNamedPipeW:https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-callnamedpipew
- Microsoft Learn,GetNamedPipeClientProcessId:https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
- Microsoft Learn,GetNamedPipeClientSessionId:https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getnamedpipeclientsessionid
- Microsoft Learn,ImpersonateNamedPipeClient:https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-impersonatenamedpipeclient
- Microsoft Learn,UpdateProcThreadAttribute(
PROC_THREAD_ATTRIBUTE_HANDLE_LIST):https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute - Raymond Chen,The Old New Thing,2013-10-18,关于重定向的标准句柄被子进程意外继承:https://devblogs.microsoft.com/oldnewthing/20131018-00/?p=2893
- Johnson M. Hart,Windows System Programming,第 4 版,第 11 章「Pipes」,Addison-Wesley,ISBN 978-0-321-65774-9
- Pavel Yosifovich,Windows 10 System Programming,Part 2「Pipes and Mailslots」:https://leanpub.com/windows10systemprogramming