尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

匿名管道IPC实战:从CreatePipe到句柄继承的Windows进程通信

匿名管道IPC实战:从CreatePipe到句柄继承的Windows进程通信 简介面向VC开发者的进程间通信技术讲解文档聚焦Windows平台下利用管道与多线程实现不同进程间的数据交换与协作。文档以父进程Parent与子进程Child的通信实例为主线详细说明CreatePipe创建管道、WriteFile写入信息、ReadFile读取信息以及CreateProcess创建子进程、CreateThread创建工作线程、WaitForSingleObject等待线程结束等关键API的用法通过父进程菜单控制子进程绘制不同图形直观展示管道的单向数据传递与多线程协作机制。资源包内含1个doc格式文档压缩包整体仅61KB便于查阅已有571人浏览学习。整体上对学习Windows进程间通信、理解管道模型及多线程编程的开发者具有较强的参考价值。1. 匿名管道与工作线程一个延续到现在的通信范式很多新人第一次接触进程间通信IPC是从命名管道或 Socket 开始的容易忽略匿名管道Anonymous Pipe这个最朴素的解法。这个例子里父进程 Parent 在鼠标左键按下时创建一条匿名管道再用 CreateProcess 启动子进程 Child同时把写句柄留着读句柄通过标准输入传给子进程子进程起来后不等 UI 消息立刻派一个 CWinThread 工作线程去 ReadFile把读到的参数转成绘图动作。整个过程没有自定义协议也没有 UI 阻塞。这条路线在 VC4.1 时代就是经典放到今天依然是理解 Windows 进程隔离、句柄继承和多线程配合的最佳入门样本。2. 为什么是匿名管道进程隔离下的共享内存与句柄继承2.1 剪贴板、DDE 与管道先做对选型Windows 95 时代可选通信手段就有剪贴板、DDE、OLE还有后来进 Win32 的管道。剪贴板适合用户主动复制粘贴的场景进程双方几乎不需要控制关系DDE 需要维护一套会话协议消息格式和超时处理都很繁琐OLE 更是重量级为文档对象服务。管道与它们不同本质上是一段由系统管理的共享内存两个进程通过读/写句柄往里放数据没有固定的协议约束所以实现起来比 DDE 轻得多。对于父子进程这种 1:1 通信匿名管道是最直接的选择。通信方式是否需要协议典型使用场景实现成本剪贴板无但依赖用户操作复制粘贴式数据交换低DDE会话协议老式 Excel 联动高OLE复合文档对象嵌入高匿名管道无父子进程单向/双向流低这里最容易被忽略的一点管道没有“消息边界”写入的数据只是按字节流顺序排进缓冲区读取端必须自己约定一帧数据的长度。后面发送的 FIGURE 结构体是固定大小所以读端每次读 sizeof(FIGURE) 字节即可不会发生分帧问题。如果换成变长字符串就要额外定义包头这是匿名管道使用中最容易犯的错误。2.2 CreatePipe 的共享缓冲区与安全属性CreatePipe 签名BOOL CreatePipe( PHANDLE hReadPipe, // 返回读句柄 PHANDLE hWritePipe, // 返回写句柄 LPSECURITY_ATTRIBUTES lpPipeAttributes, DWORD nSize // 0 表示系统默认缓冲区 );lpPipeAttributes 常被忽略。它决定返回的句柄能否被子进程继承。如果直接传 NULL读和写两个句柄都不会被继承子进程拿不到管道端通信直接失败。正确做法是填一个 SECURITY_ATTRIBUTES 并把 bInheritHandle 设为 TRUE同时 nLength 设为结构体大小、lpSecurityDescriptor 置 NULL 使用默认安全描述符。SECURITY_ATTRIBUTES sa; sa.nLength sizeof(SECURITY_ATTRIBUTES); sa.lpSecurityDescriptor NULL; sa.bInheritHandle TRUE; CreatePipe(hPipeRead, hPipeWrite, sa, 0);nSize 为 0 时使用系统默认缓冲区这个值随 Windows 版本不同而不同不要依赖具体数字。CreatePipe 创建的管道是半双工的读端和写端是两条独立通道。如果父子进程需要双向通信就得创建两条方向相反的管道而不是共用一对句柄。原例只要求父写子读所以一对句柄足够。这样创建的读句柄和写句柄都是可继承的。但父子进程通信中通常只希望子进程继承读端父进程保留写端。如果写端也被子进程继承那么子进程自己持有写端句柄读端 ReadFile 永远不会遇到“写端已关闭”的 EOF 条件程序只能永远阻塞。所以下一步要借助 DuplicateHandle 把父进程的写句柄复制一份不可继承的副本关闭原来的可继承写句柄。这一步是管道初始化里最容易踩坑的地方后面排错章节还会再提。2.3 句柄继承规则子进程为什么能拿到读端“继承句柄”不是把句柄值直接传给子进程而是子进程创建时如果 bInheritHandles 为 TRUE系统会扫描父进程所有可继承句柄并复制一份映射到子进程的句柄表中。子进程通过 GetStdHandle(STD_INPUT_HANDLE) 拿到管道读端靠的是 STARTUPINFO 中把 hStdInput 赋成 hPipeRead并设置 STARTF_USESTDHANDLES。这样子进程的标准输入被重定向到管道而不是键盘。需要说明的是CreateProcess 的 bInheritHandles 参数必须为 TRUE否则即便句柄表中标了可继承子进程也拿不到。原例中这一参数传的就是 TRUE这是整个通信链路成立的前提。如果换了框架比如用 _beginthread 或 std::thread进程创建的参数更要注意。另外继承下来的句柄在原父进程里仍然有效不会因为子进程关闭而失效。Windows 维护每个句柄的引用计数DuplicateHandle 复制出新句柄不改变原句柄计数CloseHandle 才会递减。因此父进程关闭原始写句柄后如果子进程还持有继承来的一份写句柄管道的写端实际上并未真正关闭。这就是为什么必须在启动子进程前先把父进程的写句柄换成不可继承的版本从源头保证写端只有父进程一个持有者。3. 父进程实战CreatePipe / CreateProcess / WriteFile 三段式3.1 从 AppWizard 搭建 Parent 框架开始先创建一个 MFC AppWizard(EXE) 工程生成 ParentView 类。在 global.h 里放两个进程共用的结构和常量// Global.h 共享头文件 typedef struct Figure { int iShape; // 图形控制参数 } FIGURE, *PFIGURE; #define ID_RECT 32771 // 画矩形命令 #define ID_ELLIPSE 32772 // 画椭圆命令 #define ID_TERMINATE 32773 // 终止通信命令ID 值用 32771、32772、32773 这种自定义常量避开系统标准消息号。两个进程各自 include 这个文件就能对“一帧数据”达成统一格式相当于轻量协议。注意这里用的是固定长度结构体WriteFile 和 ReadFile 读写长度相同避免粘包和半包。3.2 OnLButtonDown一条龙初始化逻辑在 CParentView 里增加 WM_LBUTTONDOWN 的响应函数。鼠标按下一次就完成“创建管道 复制写句柄 启动子进程 发送初始命令”四件事。整理后的代码如下void CParentView::OnLButtonDown(UINT nFlags, CPoint point) { SECURITY_ATTRIBUTES sa; STARTUPINFO sui; PROCESS_INFORMATION pi; BOOL bTest; HANDLE hPipeRead; // 管道读端交给子进程 HANDLE hPipeWriteNew; // 不可继承的写句柄 sa.nLength sizeof(SECURITY_ATTRIBUTES); sa.lpSecurityDescriptor NULL; sa.bInheritHandle TRUE; bTest CreatePipe(hPipeRead, hPipeWrite, sa, 0); if (!bTest) { MessageBox(CreatePipe failed!, NULL, MB_OK); return; } // 把可继承的写句柄复制成不可继承的新句柄 bTest DuplicateHandle( GetCurrentProcess(), hPipeWrite, GetCurrentProcess(), hPipeWriteNew, 0, FALSE, DUPLICATE_SAME_ACCESS); if (!bTest) { MessageBox(Dup Handle failed!, NULL, MB_OK); CloseHandle(hPipeRead); CloseHandle(hPipeWrite); return; } CloseHandle(hPipeWrite); // 关闭原始可继承写句柄 hPipeWrite hPipeWriteNew; // 保存不可继承写句柄 memset(sui, 0, sizeof(STARTUPINFO)); sui.cb sizeof(STARTUPINFO); sui.dwFlags STARTF_USESTDHANDLES; sui.hStdInput hPipeRead; sui.hStdOutput GetStdHandle(STD_OUTPUT_HANDLE); sui.hStdError GetStdHandle(STD_ERROR_HANDLE); bTest CreateProcess(NULL, child.exe, NULL, NULL, TRUE, 0, NULL, NULL, sui, pi); if (!bTest) { MessageBox(CreateProcess failed!, NULL, MB_OK); CloseHandle(hPipeWrite); } else { hProcess pi.hProcess; CloseHandle(pi.hThread); figure.iShape ID_RECT; SendCommand(); } CloseHandle(hPipeRead); CView::OnLButtonDown(nFlags, point); }这段代码有几个关键点。CreateProcess 的第六个参数 dwCreationFlags 传 0表示子进程直接继承父进程的优先级如果传 CREATE_NEW_CONSOLE会弹出一个新控制台窗口对窗口程序不必要。sui.hStdInput 指向管道读端因此子进程的顺序是管道读端 - 标准输入 - GetStdHandle(STD_INPUT_HANDLE)。CreateProcess 成功后保留 pi.hProcess关闭 pi.hThread避免线程句柄泄漏。hProcess 在发送 ID_TERMINATE 后需要 CloseHandle。原例中 DuplicateHandle 的第 4 个参数传 NULL只能让原句柄变成不可继承但没有得到新句柄。上面代码改为输出到 hPipeWriteNew再替换成员变量这样写句柄的生命周期才清晰。3.3 菜单响应与 SendCommand 的数据写入菜单 Rect、Ellipse、Terminate 分别对应 OnRect、OnEllipse、OnTerminate。它们只改 figure.iShape 然后调用 SendCommand()void CParentView::OnRect() { figure.iShape ID_RECT; SendCommand(); } void CParentView::OnEllipse() { figure.iShape ID_ELLIPSE; SendCommand(); } void CParentView::OnTerminate() { figure.iShape ID_TERMINATE; SendCommand(); } BOOL CParentView::SendCommand() { BOOL bTest; DWORD dwWritten; bTest WriteFile(hPipeWrite, figure, sizeof(FIGURE), dwWritten, NULL); if (!bTest) { MessageBox(WriteFile failed!, NULL, MB_OK); } if ((!bTest) || (figure.iShape ID_TERMINATE)) { CloseHandle(hProcess); hProcess NULL; CloseHandle(hPipeWrite); } return bTest; }WriteFile 句柄是匿名管道写端缓冲区是 figure 结构体地址nNumberOfBytesToWrite 传入 sizeof(FIGURE)dwWritten 接收实际写入字节数。最后一个参数为 NULL 表示同步写如果管道缓冲区已满WriteFile 会阻塞直到有空间可用。对于几字节的结构体几乎不存在阻塞。发送 ID_TERMINATE 后立即关闭写句柄目的是让子进程的 ReadFile 读到 0 字节并返回 FALSE从而结束线程。这个关闭动作是终止通信的关键不能省略。还有一个容易被忽略的细节ID_TERMINATE 发送后立刻 CloseHandle(hProcess) 和 CloseHandle(hPipeWrite)但读端的子进程未必已经读到终止命令。不过管道缓冲区很小WriteFile 是同步调用它返回时数据已经进入内核缓冲子进程的 ReadFile 会拿到这帧数据所以不用担心消息丢。如果担心还没读完就关闭可以把关闭放到确认子进程退出之后用 WaitForSingleObject(hProcess, INFINITE) 等它结束。原例没有这一步直接关句柄大多数情况下能工作但严格说不够稳。4. 子进程与工作线程CWinThread 里的 ReadFile 循环4.1 创建 CWinThread 派生线程类 CThr在子进程 Child 这边用 ClassWizard 新建一个类 CThr基类选 CWinThread。CWinThread 是 MFC 对线程的封装派生类可以实现 InitInstance 和 ExitInstance在线程启动时执行初始化。头文件里需要声明读管道、线程句柄、以及从管道读到的形状参数// Thr.h 线程类头文件 class CThr : public CWinThread { public: LONG PipeThread(); // 管道读取循环 void DoRead(void); // 读取一帧 HANDLE hPipeRead; // 管道读句柄 HANDLE hThread; // 线程句柄 DWORD dwThreadID; // 线程 ID int iShape; // 最近一次读到的图形命令 BOOL bTerminate; // 终止标志 };构造函数里通过 GetStdHandle(STD_INPUT_HANDLE) 取得读句柄而不是再去打开管道。这就是 STARTF_USESTDHANDLES 的约定父进程已经把读端安到标准输入上。如果句柄无效弹出提示。注意 GetActiveWindow 在构造函数里可能拿不到正确的窗口但这里只用于错误提示不深究。4.2 线程入口与读管道循环CThr 的 InitInstance 在子进程主线程创建后、消息循环启动前被调用。可以在里面设置优先级然后直接进入 PipeThread 循环BOOL CThr::InitInstance() { bTerminate FALSE; SetThreadPriority(THREAD_PRIORITY_BELOW_NORMAL); // 后台读取 ResumeThread(); PipeThread(); return TRUE; } LONG CThr::PipeThread() { while (!bTerminate) { DoRead(); } return 0L; } void CThr::DoRead(void) { FIGURE Figure; DWORD dwRead; BOOL bTest; bTest ReadFile(hPipeRead, Figure, sizeof(Figure), dwRead, NULL); if (bTest) { if (Figure.iShape ID_TERMINATE) { bTerminate TRUE; } else { iShape Figure.iShape; HWND hwndMain GetActiveWindow(); InvalidateRect(hwndMain, NULL, TRUE); UpdateWindow(hwndMain); } } else { bTerminate TRUE; // 读失败或写端已关闭结束循环 } return; }ReadFile 是同步阻塞调用。收到父进程发来的 FIGURE 后直接改 iShape 并刷新窗口。由于读取线程一直在后台UI 线程不会被管道读操作卡住。设置 THREAD_PRIORITY_BELOW_NORMAL 让读取线程低于普通优先级避免和界面重绘抢时间片。注意 GetActiveWindow 返回的是调用线程所在进程的活跃窗口如果工作线程调用可能不是子进程主窗口原代码这里并不严谨。更可靠的是保存主窗口 HWND 到线程对象InvalidateRect 时直接引用它。这也是老代码常见的问题。提示在线程里调用 GetActiveWindow 返回的是调用线程的窗口而不是主窗口。更稳妥的做法是在 CreateThread 之前把主窗口的 HWND 保存到 CThr 成员变量里。4.3 视图类如何消费线程读到的数据CChildView 的构造函数里 new 一个 CThr并在 PreCreateWindow 时调用 CreateThread 启动它#include global.h #include thr.h CThr *m_pThr; CChildView::CChildView() { m_pThr new CThr; // 创建线程对象 } CChildView::~CChildView() { delete m_pThr; // 析构时清理线程对象 } BOOL CChildView::PreCreateWindow(CREATESTRUCT cs) { m_pThr-CreateThread(); // 启动工作线程 return CView::PreCreateWindow(cs); } void CChildView::OnDraw(CDC *pDC) { CBrush brush(RGB(0, 0, 0)); pDC-SelectObject(brush); if (m_pThr-iShape ID_RECT) pDC-Rectangle(12, 45, 200, 178); if (m_pThr-iShape ID_ELLIPSE) pDC-Ellipse(12, 45, 200, 178); }这里有一个线程同步点工作线程写 m_pThr-iShapeUI 线程在 OnDraw 里读同一个变量。由于 iShape 是 int 类型在 32 位系统上的读写是原子的所以没有用临界区也不会立即崩溃。但规范做法是加 CriticalSection 或 CEvent 保护。传递 iShape 之后还可以触发自定义消息让视图主动重绘比在线程里直接 InvalidateRect 更干净。原例这样写在教学环境能跑在真实项目里要补同步。5. 从匿名管道到命名管道排错清单与演进方向5.1 常见失败点句柄继承与堵塞性读现象可能原因处理方式Child 启动后 ReadFile 一直阻塞写句柄被子进程继承管道写端未关闭DuplicateHandle 后关闭可继承写句柄CreateProcess 返回成功但收不到数据CreateProcess 的 bInheritHandles 设为 FALSE改为 TRUEWriteFile 报错 6句柄非法写句柄被当成不可继承句柄关闭了检查句柄生命周期不要提前 CloseHandleReads 返回 0 字节父进程写端已关闭收到 0 字节应退出线程子进程启动后一闪而过child.exe 路径不对或依赖 DLL 缺失使用绝对路径并调试子进程工作目录调试时推荐在 DoRead 的 bTest 分支里加 OutputDebugString 输出 dwRead 和 Figure.iShape用 DebugView 观察比 MessageBox 更不干扰运行。5.2 快速验证父子通信是否正常一个简单验证流程在 Parent 的 OnLButtonDown 里断点确认 CreatePipe 和 CreateProcess 返回值然后到子进程的 DoRead 断点看 ReadFile 是否返回 TRUE。若 ReadFile 被阻塞按暂停键看线程调用栈通常卡在句柄等待上。再用 Process Explorer 查看 child 进程句柄列表确认它继承了 \Device\NamedPipe 相关句柄。另一个验证方法是在 Parent 的 WriteFile 前后分别记录 GetTickCount然后让子进程每收到一次消息就发送 WM_APP1形成一个对称的时间戳日志观察平均延迟。匿名管道的开销非常低几千字节以内的数据延迟通常不到一毫秒如果大于这个量级就要看看是不是线程优先级过低导致响应慢。5.3 往后走命名管道带来了什么匿名管道只能用于父子进程且只能通过标准输入输出传递。如果两个毫无关系的进程需要通信或者需要从局域网另一端读写数据就得换成命名管道 NamedPipe。命名管道用 CreateNamedPipe 创建客户端用 CreateFile 按路径连接支持异步 I/O 和消息模式。把上面例子改成命名管道的成本不高父进程创建后等待连接子进程打开管道名后读取几乎可以复用同一套 FIGURE 结构和 ReadFile 循环。协议部分不变变的只是句柄来源和连接建立。另一种替代是直接走 TCP 127.0.0.1但管道在内网穿透、权限继承上更贴近 Windows 原生安全模型适合进程存在于同一台机器且不想开额外端口的场景。本文还有配套的精品资源点击获取
返回列表