4. Windows进程间通信方式详解

1 前言

一个应用程序中,不同线程由于处于同一个地址空间中,因此只要拿到地址、就可以互相访问、传递数据。

但对于多个进程来说,由于各个进程都处于不同的空间内,就不能只靠简单的内存地址传递数据了。

进程间想要通信,就需要用到其它方法,例如共享内存、管道、邮槽、本地回环网络、剪切板、消息、信号量等等方式。

2 共享内存

首先是共享内存,顾名思义,它使得多个进程可以访问同一块内存空间,是最快的通信方式,是针对其他通信机制运行效率较低而设计的。

它往往与其它通信机制、如信号量等结合使用,来达到进程间的同步及互斥。

要理解共享内存,首先要理解应用程序的启动运行原理

  1. 点击exe应用程序
  2. 系统将该应用程序加载到内存中,开始运行
  3. 再一次点击同一个exe应用程序
  4. 系统发现该应用程序已经加载到内存中,不再重复加载,直接运行,这里注意,虽然启动了两个应用程序,但其实用的是同一块内存,只是拥有各自的内存地址
  5. 如果其中一个应用程序在运行过程中修改了应用程序中的某个内存,为了不影响另一个应用的正常运行,系统这时候就不能偷懒了,只能为该应用程序重新开辟一块新的内存,来保证应用程序之间的数据互不影响,这就是copy-on-write

而所谓的共享内存,就是告诉操作系统,这一块内存就是需要多进程共享,当更改数据时,不执行第5步。

在vs中设置共享内存很简单,只需要将共享的数据放在两个data_seg之间,并声明该数据段为可读可写和分享

#pragma data_seg("数据段名称")        // 声明共享数据段,并命名该数据段
   int SharedData = 0;       // 要共享的数据,注意必须在定义的同时进行初始化!
#pragma data_seg() //代表数据段的结束
#pragma comment(linker,"/section:数据段名称,特性")  // 特性可填rws,即可读(r:read)可写(w:write)可分享(s:share)

下面是一个例子:

#include<iostream>
using namespace std;

#pragma data_seg("Share") //声明一个叫Share的共享数据段
int g_num = 0;            //添加要共享的数据
#pragma data_seg()  //数据段的结束
#pragma comment(linker,"/section:Share,rws") //设置名字为Share的数据段为可读可写可分享

int main() {
	cout << "g_num:" << g_num << endl; //输出共享数据
	g_num += 10; //将共享数据+10
	getchar(); //让软件等待,便于观察结果
}

然后测试:

image.png

可以看到,应用程序每启动一次,数据就递增10,说明数据确实在不同进程之间实现了共享。

虽然使用起来很简单,但也有一些值得注意的地方:

  • #pragma data_seg()一般用于DLL中,用于多进程共享数据。
  • 数据段的名称为“Share”,那么在设置该段属性的时候,一定要保证段名称完全与“Share”相同,而且大小写敏感。一旦两者不同,连接器会警告错误。
  • rws之前不能有空格,否则编译器报错。
  • 共享段中的变量一定要初始化,否则连接器也会报错,也不能正常设置为共享段。
  • 所有的共享变量都要放置在共享数据段中。如果定义很大的数组,那么也会导致很大的DLL。
  • 不要在共享数据段中存放进程相关的信息。Win32中大多数的数据结构和值(比如HANDLE)只在特定的进程上下文中才是有效的。
  • 每个进程都有它自己的地址空间。因此不要在共享数据段中共享指针,指针指向的地址在不同的地址空间中是不一样的。
  • DLL在每个进程中是被映射在不同的虚拟地址空间中的,因此函数指针也是不安全的。

3 匿名管道

匿名管道如其名,就是没有名称的管道,一般用于父子进程、或兄弟进程之间通信,父进程创建一个匿名管道、并将匿名管道的句柄继承给子进程,那么子进程就可以与父进程之间进行通信了。

下面是一个例子,首先是父进程代码:

#include <windows.h>

int main() {
    HANDLE hRead, hWrite;
    SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES), NULL, TRUE }; // 允许继承句柄

    // 创建匿名管道
    if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
        std::cerr << "CreatePipe failed.\n";
        return 1;
    }

    // 启动子进程并传递读端句柄
    STARTUPINFO si = { sizeof(si) };
    PROCESS_INFORMATION pi;
    char cmdLine[] = "child.exe";

    // 传递给子进程的环境变量或句柄需要继承设置
    si.dwFlags = STARTF_USESTDHANDLES;
    si.hStdInput = hRead;
    si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
    si.hStdError = GetStdHandle(STD_ERROR_HANDLE);

    if (!CreateProcess(
        NULL, cmdLine, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) {
        std::cerr << "CreateProcess failed.\n";
        return 1;
    }

    // 父进程写数据
    const char* msg = "Hello from parent!";
    DWORD written;
    WriteFile(hWrite, msg, strlen(msg), &written, NULL);
    CloseHandle(hWrite); // 关闭写端(通知子进程读完)

    // 等待子进程结束
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    CloseHandle(hRead);

    return 0;
}

然后是子进程代码:

#include <windows.h>
#include <iostream>

int main() {
    char buffer[128];
    DWORD bytesRead;

    while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) {
        buffer[bytesRead] = '\0';
        std::cout << "Child received: " << buffer << std::endl;
    }

    return 0;
}

可以看到,匿名管道的创建方式就是使用CreatePipe函数,然后得到管道的两端:写入、读取句柄,将这两个句柄放在不同的进程中,就能互相通信了。

比如这里是通过继承的方式让子进程拿到该句柄,由于这里将子进程的标准输入句柄设置为了管道的写入句柄,因此子进程中就直接用的GetStdHandle函数拿到标准输入句柄、然后使用ReadFile读取其内的数据。

重命名一下子进程的可执行文件名称,然后双击父进程:

image.png

可以看到,子进程已经成功接收到父进程的数据、并将其打印出来了。

由于上面的代码中,子进程继承了很多父进程的属性,比如标准输入输出也被继承了,所以子进程的输出也会打印在父进程的控制台上。

注意匿名管道是单向通信,如果你想要双向通信,一般就需要创建两个匿名管道。

4 命名管道

命名管道相比于上面的匿名管道就更强一些了,它允许任意进程之间进行通信,而不仅仅局限于父子、兄弟进程,并且也不再是单向通信,而是双向通信。

一个简单的示例如下,首先是服务端代码:

#include <windows.h>
int main() {
    const char* pipeName = R"(\\.\pipe\MyPipe)";

    // 创建命名管道(双向通信)
    HANDLE hPipe = CreateNamedPipeA(
        pipeName,
        PIPE_ACCESS_DUPLEX, // 双向
        PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
        1, 4096, 4096, 0, NULL);

    if (hPipe == INVALID_HANDLE_VALUE) {
        std::cerr << "CreateNamedPipe failed.\n";
        return 1;
    }

    std::cout << "Waiting for client to connect...\n";
    if (!ConnectNamedPipe(hPipe, NULL)) {
        std::cerr << "ConnectNamedPipe failed.\n";
        CloseHandle(hPipe);
        return 1;
    }

    std::cout << "Client connected.\n";

    // 接收数据
    char buffer[128];
    DWORD bytesRead;
    ReadFile(hPipe, buffer, sizeof(buffer) - 1, &bytesRead, NULL);
    buffer[bytesRead] = '\0';
    std::cout << "Received from client: " << buffer << std::endl;

    // 发送回复
    const char* reply = "Hello from server!";
    DWORD bytesWritten;
    WriteFile(hPipe, reply, strlen(reply), &bytesWritten, NULL);

    CloseHandle(hPipe);
    Sleep(1000 * 60);
    return 0;
}

然后是客户端代码:


#include <windows.h>
int main() {
    const char* pipeName = R"(\\.\pipe\MyPipe)";

    // 连接到命名管道
    HANDLE hPipe;
    while (true) {
        hPipe = CreateFileA(
            pipeName,
            GENERIC_READ | GENERIC_WRITE,
            0, NULL, OPEN_EXISTING, 0, NULL);

        if (hPipe != INVALID_HANDLE_VALUE)
            break;

        // 等待管道可用
        if (GetLastError() != ERROR_PIPE_BUSY) {
            std::cerr << "CreateFile failed.\n";
            return 1;
        }
        WaitNamedPipeA(pipeName, 2000);
    }

    // 发送数据
    const char* msg = "Hello from client!";
    DWORD bytesWritten;
    WriteFile(hPipe, msg, strlen(msg), &bytesWritten, NULL);

    // 接收回复
    char buffer[128];
    DWORD bytesRead;
    ReadFile(hPipe, buffer, sizeof(buffer) - 1, &bytesRead, NULL);
    buffer[bytesRead] = '\0';
    std::cout << "Received from server: " << buffer << std::endl;

    CloseHandle(hPipe);
    Sleep(1000 * 60);
    return 0;
}

效果如下:

image.png

命名管道的关键就是管道的名称,用CreateNamedPipeA函数创建,名称必须以\\.\pipe\开头,后面紧跟我们管道的名称,需要保证独一无二。

然后其中一个应用程序就可以创建命名管道、像服务器一样等待客户端连接上来,而另一个进程则通过CreateFile函数打开这个命名管道,就像是打开一个文件一样,之后两者就可以互相发送数据了。

5 邮槽

邮槽相比于上面的管道又有些特别:拥有了名称、但也只支持单向通信。

就像邮件一样邮槽的名称就是邮件的地址,可以单向向指定地址发送一条又一条邮件。

一个简单的示例如下,首先是邮槽的接收端:

#include <windows.h>
#include <iostream>

int main() {
    const char* slotName = R"(\\.\mailslot\MyMailslot)";

    HANDLE hSlot = CreateMailslotA(
        slotName,       // 邮槽路径
        0,              // 最大消息大小(0 = 任意)
        MAILSLOT_WAIT_FOREVER, // 永久等待
        NULL);          // 默认安全属性

    if (hSlot == INVALID_HANDLE_VALUE) {
        std::cerr << "CreateMailslot failed: " << GetLastError() << "\n";
        return 1;
    }

    std::cout << "Mailslot server running...\n";

    char buffer[512];
    DWORD bytesRead;

    while (true) {
        BOOL result = ReadFile(
            hSlot,
            buffer,
            sizeof(buffer) - 1,
            &bytesRead,
            NULL);

        if (result && bytesRead > 0) {
            buffer[bytesRead] = '\0';
            std::cout << "Received: " << buffer << std::endl;
        } else {
            Sleep(100); // 无消息时稍作等待
        }
    }

    CloseHandle(hSlot);
    return 0;
}

然后是邮槽的发送端:

#include <windows.h>
#include <iostream>

int main() {
    const char* slotName = R"(\\.\mailslot\MyMailslot)";

    HANDLE hSlot = CreateFileA(
        slotName,              // 邮槽路径
        GENERIC_WRITE,         // 只写
        FILE_SHARE_READ,       // 允许读取共享
        NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL,
        NULL);

    if (hSlot == INVALID_HANDLE_VALUE) {
        std::cerr << "CreateFile failed: " << GetLastError() << "\n";
        return 1;
    }

    const char* msg = "Hello from client!";
    DWORD bytesWritten;

    BOOL success = WriteFile(
        hSlot,
        msg,
        strlen(msg),
        &bytesWritten,
        NULL);

    if (!success) {
        std::cerr << "WriteFile failed: " << GetLastError() << "\n";
    } else {
        std::cout << "Message sent.\n";
    }

    CloseHandle(hSlot);
    return 0;
}

效果如下:

image.png

先运行接收端,然后多次启动发送端,就会多次向接收端发送数据了。

整个代码逻辑和上面的命名管道很像,只是换了个函数CreateMailslotA。并且名称也发生了变化,这里需要用\\.\mailslot\作为前缀。

6 本地回环网络

所谓本地回环网络,指的就是127.0.0.1这个网段,使用网络套接字进行通信。

这个网段只在本机上有效,所以可以用来通信,因此这种方式本质上就是本地不同进程之间、通过网络编程的方式在本地通信。

这方面的内容可以参考其它文章:网络编程详解

这里就不再赘述了。

7 剪切板

我们可以很容易的发现,在任意一个应用程序中使用剪切板复制内容,也可以在任意程序中进行粘贴,这便说明剪切板是全局所有应用程序共享的。

因此理论上我们可以通过剪切板实现进程间的通信,虽然大多数时候我们并不会这么干。

下面是一个简单的示例代码,首先是粘贴:

#include <windows.h>
#include <iostream>

int main() {
    const char* msg = "Hello from writer process";

    // 打开剪贴板
    if (!OpenClipboard(NULL)) {
        std::cerr << "Cannot open clipboard.\n";
        return 1;
    }

    EmptyClipboard(); // 清空之前的内容

    // 分配全局内存并复制字符串
    size_t len = strlen(msg) + 1;
    HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, len);
    if (!hMem) {
        CloseClipboard();
        std::cerr << "GlobalAlloc failed.\n";
        return 1;
    }

    memcpy(GlobalLock(hMem), msg, len);
    GlobalUnlock(hMem);

    // 设置剪贴板内容
    SetClipboardData(CF_TEXT, hMem);

    CloseClipboard();
    std::cout << "Message written to clipboard.\n";
    return 0;
}

然后是复制:

#include <windows.h>
#include <iostream>

int main() {
    if (!OpenClipboard(NULL)) {
        std::cerr << "Cannot open clipboard.\n";
        return 1;
    }

    HANDLE hData = GetClipboardData(CF_TEXT);
    if (hData == NULL) {
        std::cerr << "No text data in clipboard.\n";
        CloseClipboard();
        return 1;
    }

    char* data = static_cast<char*>(GlobalLock(hData));
    if (data) {
        std::cout << "Clipboard contains: " << data << std::endl;
        GlobalUnlock(hData);
    }

    CloseClipboard();
    return 0;
}

效果如下:

image.png

逻辑很简单,就是一个进程往剪切板写入数据,另一个进程从剪切板读取数据即可。

唯一有点奇怪的是GlobalLock这个函数,它的作用就是全局锁住剪切板,保证在本应用写入剪切板数据的时候、不会有其它进程来占用这个剪切板,因此当不再使用的时候需要调用GlobalUnlock解锁。

8 消息通信

消息是windows窗口应用程序的机制,其中有一个消息名称为WM_COPYDATA,这个消息就可以用来作为进程间通信。

使用方法和正常的消息发送、接收是一样的,如果不理解的可以参考文章:C/C++创建窗口程序详解

该消息的使用说明可以参考官方文档:WM_COPYDATA消息 (Winuser.h) - Win32 apps | Microsoft Learn

这里不再赘述。

9 信号量

信号量一般是用来作为进程之间同步的,比如一个进程可以通过信号量来判断另一个进程是不是已经读取了共享内存中的数据。

一个简单的示例如下,首先是创建信号量、并用sleep假设这里正常执行一个耗时的任务:

#include <windows.h>
#include <iostream>
#include <fstream>

int main() {
    // 创建命名信号量,初始为 0
    HANDLE hSem = CreateSemaphoreA(NULL, 0, 1, "MySemaphore");
    if (!hSem) {
        std::cerr << "CreateSemaphore failed: " << GetLastError() << "\n";
        return 1;
    }
	Sleep(1000*15);
    // 写入共享文件
    std::ofstream out("shared.txt");
    out << "Hello from writer process!";
    out.close();
    std::cout << "Data written to file.\n";

    // 通知子进程
    ReleaseSemaphore(hSem, 1, NULL);
    std::cout << "Semaphore released.\n";

    CloseHandle(hSem);
    return 0;
}

然后再创建一个进程,等待另一个进程耗时任务完成通知自己:

#include <windows.h>
#include <iostream>
#include <fstream>
#include<string>

int main() {
    // 打开命名信号量
    HANDLE hSem = OpenSemaphoreA(SYNCHRONIZE, FALSE, "MySemaphore");
    if (!hSem) {
        std::cerr << "OpenSemaphore failed: " << GetLastError() << "\n";
        return 1;
    }

    std::cout << "Waiting for semaphore...\n";
    WaitForSingleObject(hSem, INFINITE);

    std::ifstream in("shared.txt");
    std::string line;
    std::getline(in, line);
    in.close();

    std::cout << "Child read: " << line << std::endl;
    CloseHandle(hSem);
    return 0;
}

效果如下:

image.png

可以看到,p2进程成功等待了p1进程完成对文件的写入,并读取出了其写入的内容。

信号量的创建使用的是CreateSemaphoreA函数,它的第二个参数是初始化计数,第三个参数是最大计数,所以这里的意思就是初始化为0,最大为1,当使用ReleaseSemaphore函数之后,就会让它的值从0变成了1。

而子进程中WaitForSingleObject函数发现信号量不为0了,就会结束等待,将信号量的值-1,返回,继续向下执行代码。

作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux