MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ 网络编程结合多进程实践

2022-02-131.9k 阅读

C++ 网络编程基础

网络编程概述

网络编程旨在让不同设备(通常通过网络连接)上的程序能够相互通信。在 C++ 中,进行网络编程主要借助操作系统提供的网络编程接口,如 Berkeley 套接字(Berkeley Sockets),它是一种广泛使用的网络编程 API,几乎所有主流操作系统都对其提供支持。

套接字(Socket)可以看作是不同主机间进程通信的端点。它分为不同类型,主要有流式套接字(SOCK_STREAM)和数据报套接字(SOCK_DGRAM)。流式套接字基于 TCP 协议,提供可靠的、面向连接的字节流传输,适用于对数据准确性和顺序要求较高的场景,如文件传输、远程登录等;数据报套接字基于 UDP 协议,提供无连接的、不可靠的数据传输,适合对实时性要求高但能容忍一定数据丢失的场景,如视频流、音频流传输。

使用 TCP 套接字的基本流程

  1. 创建套接字:使用 socket 函数创建一个套接字描述符。在 Linux 系统下,代码如下:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        std::cerr << "Socket creation failed" << std::endl;
        return -1;
    }
    // 后续操作
    close(sockfd);
    return 0;
}

这里 socket 函数的第一个参数 AF_INET 表示使用 IPv4 地址族,第二个参数 SOCK_STREAM 表明是流式套接字,第三个参数通常设为 0,表示使用默认协议(对于 TCP 就是 TCP 协议)。

  1. 绑定地址:将套接字与特定的地址和端口绑定,以便其他进程能够找到该套接字。这一步使用 bind 函数,示例如下:
sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = INADDR_ANY;
servaddr.sin_port = htons(8080);

if (bind(sockfd, (const sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    std::cerr << "Bind failed" << std::endl;
    close(sockfd);
    return -1;
}

这里 sockaddr_in 结构体用于存储 IPv4 地址相关信息,INADDR_ANY 表示可以接受来自任何网络接口的连接,htons 函数将主机字节序的端口号转换为网络字节序。

  1. 监听连接:对于服务器端,需要监听来自客户端的连接请求。使用 listen 函数,例如:
if (listen(sockfd, 5) < 0) {
    std::cerr << "Listen failed" << std::endl;
    close(sockfd);
    return -1;
}

listen 函数的第二个参数表示等待连接队列的最大长度。

  1. 接受连接:服务器通过 accept 函数接受客户端的连接请求,返回一个新的套接字描述符用于与客户端进行通信:
int connfd = accept(sockfd, NULL, NULL);
if (connfd < 0) {
    std::cerr << "Accept failed" << std::endl;
    close(sockfd);
    return -1;
}
  1. 数据传输:连接建立后,就可以通过 sendrecv 函数(或 writeread 函数,在套接字描述符上等同于 sendrecv)进行数据传输。例如,服务器向客户端发送数据:
const char *msg = "Hello, client!";
if (send(connfd, msg, strlen(msg), 0) != strlen(msg)) {
    std::cerr << "Send failed" << std::endl;
}

客户端接收数据:

char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) {
    std::cerr << "Recv failed" << std::endl;
} else {
    buffer[n] = '\0';
    std::cout << "Received: " << buffer << std::endl;
}
  1. 关闭套接字:通信完成后,使用 close 函数关闭套接字,释放资源:
close(connfd);
close(sockfd);

使用 UDP 套接字的基本流程

  1. 创建套接字:与 TCP 类似,使用 socket 函数创建 UDP 套接字,但第二个参数为 SOCK_DGRAM
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    std::cerr << "Socket creation failed" << std::endl;
    return -1;
}
  1. 绑定地址:与 TCP 绑定地址方式基本相同:
sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = INADDR_ANY;
servaddr.sin_port = htons(8080);

if (bind(sockfd, (const sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    std::cerr << "Bind failed" << std::endl;
    close(sockfd);
    return -1;
}
  1. 数据传输:UDP 不需要建立连接,直接使用 sendtorecvfrom 函数进行数据的发送和接收。例如,服务器发送数据:
sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
const char *msg = "Hello, client!";
if (sendto(sockfd, msg, strlen(msg), MSG_CONFIRM, (const sockaddr *) &cliaddr, len) != strlen(msg)) {
    std::cerr << "Send failed" << std::endl;
}

客户端接收数据:

char buffer[1024];
sockaddr_in servaddr;
socklen_t len = sizeof(servaddr);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, MSG_WAITALL, (sockaddr *) &servaddr, &len);
if (n < 0) {
    std::cerr << "Recv failed" << std::endl;
} else {
    buffer[n] = '\0';
    std::cout << "Received: " << buffer << std::endl;
}
  1. 关闭套接字:同样使用 close 函数关闭套接字:
close(sockfd);

多进程编程基础

进程概念

进程是程序在操作系统中的一次执行实例,是系统进行资源分配和调度的基本单位。每个进程都有自己独立的地址空间、文件描述符表等资源。与线程不同,进程间的资源相互隔离,这保证了一个进程的崩溃不会影响其他进程。

创建进程

在 C++ 中,通常使用操作系统提供的接口来创建进程。在 Linux 系统下,主要使用 fork 函数。fork 函数会创建一个新的进程,称为子进程,它是调用 fork 的进程(父进程)的一个副本。子进程和父进程几乎完全相同,除了 fork 的返回值不同。在父进程中,fork 返回子进程的进程 ID(PID),在子进程中,fork 返回 0。示例代码如下:

#include <iostream>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        std::cerr << "Fork failed" << std::endl;
        return -1;
    } else if (pid == 0) {
        std::cout << "This is the child process. PID: " << getpid() << std::endl;
    } else {
        std::cout << "This is the parent process. Child PID: " << pid << std::endl;
    }
    return 0;
}

这里 getpid 函数用于获取当前进程的进程 ID。

进程间通信

由于进程间资源相互隔离,为了让不同进程能够交换数据,需要使用进程间通信(IPC)机制。常见的 IPC 机制包括管道(Pipe)、消息队列(Message Queue)、共享内存(Shared Memory)和信号量(Semaphore)等。

  1. 管道:管道是一种半双工的通信方式,数据只能单向流动,通常用于父子进程间的通信。有匿名管道和命名管道两种类型。匿名管道使用 pipe 函数创建,示例如下:
#include <iostream>
#include <unistd.h>
#include <string.h>

int main() {
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        std::cerr << "Pipe creation failed" << std::endl;
        return -1;
    }

    pid_t pid = fork();
    if (pid < 0) {
        std::cerr << "Fork failed" << std::endl;
        close(pipefd[0]);
        close(pipefd[1]);
        return -1;
    } else if (pid == 0) {
        close(pipefd[0]);
        const char *msg = "Hello from child";
        write(pipefd[1], msg, strlen(msg));
        close(pipefd[1]);
    } else {
        close(pipefd[1]);
        char buffer[1024];
        ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = '\0';
            std::cout << "Parent received: " << buffer << std::endl;
        }
        close(pipefd[0]);
    }
    return 0;
}
  1. 消息队列:消息队列是一种消息的链表,存放在内核中,并由消息队列标识符标识。进程可以向消息队列中发送消息,也可以从消息队列中读取消息。在 Linux 系统下,使用 msggetmsgsndmsgrcv 等函数来操作消息队列。
  2. 共享内存:共享内存允许不同进程访问同一块内存区域,是最快的 IPC 机制。它通过 shmat 函数将共享内存段连接到进程的地址空间,使用 shmdt 函数分离共享内存段。为了保证数据的一致性,通常需要结合信号量等同步机制。
  3. 信号量:信号量本质上是一个计数器,用于控制对共享资源的访问。它主要用于进程间或线程间的同步。在 Linux 系统下,使用 semgetsemopsemctl 等函数来操作信号量。

C++ 网络编程结合多进程实践

场景分析

假设我们要开发一个简单的网络服务器,它能够同时处理多个客户端的连接请求。如果使用单进程模型,在处理一个客户端连接时,其他客户端的请求可能会被阻塞。而采用多进程模型,每个客户端连接可以由一个独立的子进程处理,这样可以提高服务器的并发处理能力。

结合 TCP 套接字的多进程服务器实现

  1. 服务器端代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <cstring>
#include <sys/wait.h>

void handleClient(int connfd) {
    char buffer[1024];
    ssize_t n = recv(connfd, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
        buffer[n] = '\0';
        std::cout << "Child process received: " << buffer << std::endl;
        const char *reply = "Message received successfully";
        send(connfd, reply, strlen(reply), 0);
    } else if (n < 0) {
        std::cerr << "Recv failed in child" << std::endl;
    }
    close(connfd);
}

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        std::cerr << "Socket creation failed" << std::endl;
        return -1;
    }

    sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(8080);

    if (bind(sockfd, (const sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        std::cerr << "Bind failed" << std::endl;
        close(sockfd);
        return -1;
    }

    if (listen(sockfd, 5) < 0) {
        std::cerr << "Listen failed" << std::endl;
        close(sockfd);
        return -1;
    }

    while (true) {
        int connfd = accept(sockfd, NULL, NULL);
        if (connfd < 0) {
            std::cerr << "Accept failed" << std::endl;
            continue;
        }

        pid_t pid = fork();
        if (pid < 0) {
            std::cerr << "Fork failed" << std::endl;
            close(connfd);
        } else if (pid == 0) {
            close(sockfd);
            handleClient(connfd);
            _exit(0);
        } else {
            close(connfd);
            waitpid(pid, NULL, WNOHANG);
        }
    }

    close(sockfd);
    return 0;
}

在这个代码中,主进程负责监听客户端连接请求。每当有新的连接到来,主进程通过 fork 创建一个子进程,由子进程负责与客户端进行通信。子进程通过 handleClient 函数处理客户端数据,接收客户端发送的消息并回复确认信息。主进程继续监听新的连接请求,同时通过 waitpid 函数处理子进程的结束状态,避免产生僵尸进程。

  1. 客户端代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        std::cerr << "Socket creation failed" << std::endl;
        return -1;
    }

    sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    servaddr.sin_port = htons(8080);

    if (connect(sockfd, (const sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        std::cerr << "Connect failed" << std::endl;
        close(sockfd);
        return -1;
    }

    const char *msg = "Hello, server!";
    if (send(sockfd, msg, strlen(msg), 0) != strlen(msg)) {
        std::cerr << "Send failed" << std::endl;
    }

    char buffer[1024];
    ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
        buffer[n] = '\0';
        std::cout << "Received from server: " << buffer << std::endl;
    } else if (n < 0) {
        std::cerr << "Recv failed" << std::endl;
    }

    close(sockfd);
    return 0;
}

客户端创建套接字并连接到服务器,发送一条消息给服务器,然后接收服务器的回复并输出。

结合 UDP 套接字的多进程服务器实现

  1. 服务器端代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <cstring>
#include <sys/wait.h>

void handleClient(int sockfd, sockaddr_in cliaddr, socklen_t len) {
    char buffer[1024];
    ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, MSG_WAITALL, (sockaddr *) &cliaddr, &len);
    if (n > 0) {
        buffer[n] = '\0';
        std::cout << "Child process received: " << buffer << std::endl;
        const char *reply = "Message received successfully";
        sendto(sockfd, reply, strlen(reply), MSG_CONFIRM, (const sockaddr *) &cliaddr, len);
    } else if (n < 0) {
        std::cerr << "Recv failed in child" << std::endl;
    }
}

int main() {
    int sockfd = socket(AF_INET, SOCK_DUDP, 0);
    if (sockfd < 0) {
        std::cerr << "Socket creation failed" << std::endl;
        return -1;
    }

    sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(8080);

    if (bind(sockfd, (const sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        std::cerr << "Bind failed" << std::endl;
        close(sockfd);
        return -1;
    }

    while (true) {
        sockaddr_in cliaddr;
        socklen_t len = sizeof(cliaddr);
        int newSockfd = socket(AF_INET, SOCK_DUDP, 0);
        if (newSockfd < 0) {
            std::cerr << "Socket creation for child failed" << std::endl;
            continue;
        }

        pid_t pid = fork();
        if (pid < 0) {
            std::cerr << "Fork failed" << std::endl;
            close(newSockfd);
        } else if (pid == 0) {
            close(sockfd);
            handleClient(newSockfd, cliaddr, len);
            _exit(0);
        } else {
            close(newSockfd);
            waitpid(pid, NULL, WNOHANG);
        }
    }

    close(sockfd);
    return 0;
}

这里服务器主进程监听 UDP 套接字,当有数据到达时,创建子进程处理。子进程从特定的 UDP 套接字接收客户端数据并回复。主进程继续监听新的数据。

  1. 客户端代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

int main() {
    int sockfd = socket(AF_INET, SOCK_DUDP, 0);
    if (sockfd < 0) {
        std::cerr << "Socket creation failed" << std::endl;
        return -1;
    }

    sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    servaddr.sin_port = htons(8080);

    const char *msg = "Hello, server!";
    socklen_t len = sizeof(servaddr);
    if (sendto(sockfd, msg, strlen(msg), MSG_CONFIRM, (const sockaddr *) &servaddr, len) != strlen(msg)) {
        std::cerr << "Send failed" << std::endl;
    }

    char buffer[1024];
    ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, MSG_WAITALL, (sockaddr *) &servaddr, &len);
    if (n > 0) {
        buffer[n] = '\0';
        std::cout << "Received from server: " << buffer << std::endl;
    } else if (n < 0) {
        std::cerr << "Recv failed" << std::endl;
    }

    close(sockfd);
    return 0;
}

客户端向服务器发送 UDP 数据报并接收服务器的回复。

注意事项

  1. 资源管理:在多进程编程中,每个进程都有自己独立的资源,如文件描述符。要注意在合适的地方关闭不再使用的文件描述符,避免资源泄漏。例如,在子进程中关闭监听套接字,在父进程中关闭与客户端通信的套接字。
  2. 进程同步:虽然多进程可以提高并发处理能力,但进程间的同步也很重要。例如,在处理共享资源(如日志文件)时,需要使用合适的同步机制(如信号量)来避免数据竞争。
  3. 错误处理:网络编程和多进程编程都可能出现各种错误,如套接字创建失败、绑定失败、fork 失败等。要对这些错误进行妥善处理,以提高程序的稳定性和健壮性。

通过结合 C++ 网络编程和多进程技术,我们可以开发出高性能、高并发的网络应用程序,满足不同场景下的需求。无论是开发网络服务器、分布式系统还是其他网络相关的应用,这些技术都具有重要的应用价值。