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

C 语言网络编程结合多进程实践

2024-02-117.9k 阅读

C 语言网络编程基础

网络编程概述

在计算机领域,网络编程是指编写程序使计算机能够通过网络进行数据交换和通信。C 语言作为一种高效且底层控制能力强的编程语言,在网络编程方面有着广泛的应用。网络编程涉及到网络协议、套接字(Socket)等关键概念。

网络协议是计算机网络中进行数据交换而建立的规则、标准或约定的集合。常见的网络协议如 TCP(传输控制协议)和 UDP(用户数据报协议),它们在不同场景下有着各自的优势。TCP 提供可靠的、面向连接的数据传输,适用于对数据准确性要求高的场景,如文件传输、网页浏览等;而 UDP 则提供无连接、不可靠但速度快的数据传输,常用于实时性要求高但对数据准确性容忍度稍高的场景,如视频流、音频流传输。

套接字(Socket)

套接字是网络编程的核心概念,它是一种特殊的文件描述符,为应用程序提供了一种访问网络服务的接口。在 C 语言中,套接字相关的函数主要定义在 <sys/socket.h> 头文件中。

  1. 创建套接字 通过 socket() 函数创建套接字,其函数原型为:
int socket(int domain, int type, int protocol);

其中,domain 表示协议族,常见的有 AF_INET(IPv4 协议)、AF_INET6(IPv6 协议)等;type 表示套接字类型,常见的有 SOCK_STREAM(面向连接的流套接字,通常用于 TCP 协议)、SOCK_DGRAM(无连接的数据报套接字,用于 UDP 协议);protocol 一般设为 0,表示使用默认协议。例如,创建一个基于 IPv4 的 TCP 套接字:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 绑定套接字 对于服务器端,需要将套接字绑定到一个特定的地址和端口,以便客户端能够连接。使用 bind() 函数实现,其原型为:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

struct sockaddr 是一个通用的地址结构,在实际使用中,通常会使用 struct sockaddr_in(用于 IPv4)或 struct sockaddr_in6(用于 IPv6)。以 IPv4 为例,绑定的代码示例如下:

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

if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

这里 htons() 函数用于将主机字节序转换为网络字节序,INADDR_ANY 表示服务器可以接受来自任何网络接口的连接。

  1. 监听套接字(仅服务器端) 对于 TCP 服务器,在绑定后需要监听客户端的连接请求。使用 listen() 函数,其原型为:
int listen(int sockfd, int backlog);

backlog 表示等待连接队列的最大长度。例如:

if (listen(sockfd, BACKLOG) == -1) {
    perror("listen failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 接受连接(仅服务器端) 服务器通过 accept() 函数接受客户端的连接请求,其原型为:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

addr 用于存储客户端的地址信息,addrlen 为该地址结构的长度。成功时返回一个新的套接字描述符,用于与客户端进行通信。示例代码如下:

struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd == -1) {
    perror("accept failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 发送和接收数据 对于 TCP 套接字,可以使用 send()recv() 函数进行数据的发送和接收。其原型分别为:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

buf 是数据缓冲区,len 是要发送或接收的数据长度,flags 一般设为 0。例如,发送数据:

const char *message = "Hello, client!";
ssize_t bytes_sent = send(connfd, message, strlen(message), 0);
if (bytes_sent == -1) {
    perror("send failed");
    close(connfd);
    close(sockfd);
    exit(EXIT_FAILURE);
}

接收数据:

char buffer[BUFFER_SIZE];
ssize_t bytes_received = recv(connfd, buffer, sizeof(buffer) - 1, 0);
if (bytes_received == -1) {
    perror("recv failed");
    close(connfd);
    close(sockfd);
    exit(EXIT_FAILURE);
}
buffer[bytes_received] = '\0';

对于 UDP 套接字,使用 sendto()recvfrom() 函数进行数据传输,因为 UDP 无连接,所以需要在每次发送和接收时指定目标地址和端口。其原型分别为:

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
  1. 关闭套接字 使用完套接字后,需要通过 close() 函数关闭,以释放资源。例如:
close(sockfd);

多进程编程基础

进程的概念

进程是操作系统进行资源分配和调度的基本单位,它是程序在一个数据集合上运行的过程。每个进程都有自己独立的地址空间、文件描述符表等资源。在 C 语言中,可以使用 fork() 函数来创建新的进程。

fork() 函数

fork() 函数的原型为:

pid_t fork(void);

pid_t 是一种数据类型,用于表示进程 ID。fork() 函数调用一次,返回两次。在父进程中返回子进程的进程 ID,在子进程中返回 0。如果 fork() 失败,返回 -1。示例代码如下:

pid_t pid = fork();
if (pid == -1) {
    perror("fork failed");
    exit(EXIT_FAILURE);
} else if (pid == 0) {
    // 子进程代码
    printf("I am the child process, my PID is %d\n", getpid());
} else {
    // 父进程代码
    printf("I am the parent process, my child's PID is %d\n", pid);
}

在上述代码中,getpid() 函数用于获取当前进程的进程 ID。

进程间通信(IPC)

在多进程编程中,进程间通常需要进行通信。常见的进程间通信方式有管道(Pipe)、消息队列(Message Queue)、共享内存(Shared Memory)等。

  1. 管道 管道是一种半双工的通信方式,数据只能单向流动。分为无名管道和有名管道。
    • 无名管道:使用 pipe() 函数创建,其原型为:
int pipe(int pipefd[2]);

pipefd 是一个数组,pipefd[0] 用于读,pipefd[1] 用于写。例如,父子进程通过管道通信:

int pipefd[2];
if (pipe(pipefd) == -1) {
    perror("pipe creation failed");
    exit(EXIT_FAILURE);
}

pid_t pid = fork();
if (pid == -1) {
    perror("fork failed");
    close(pipefd[0]);
    close(pipefd[1]);
    exit(EXIT_FAILURE);
} else if (pid == 0) {
    // 子进程向管道写数据
    close(pipefd[0]);
    const char *message = "Hello from child";
    write(pipefd[1], message, strlen(message));
    close(pipefd[1]);
} else {
    // 父进程从管道读数据
    close(pipefd[1]);
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = read(pipefd[0], buffer, sizeof(buffer) - 1);
    if (bytes_read != -1) {
        buffer[bytes_read] = '\0';
        printf("Parent received: %s\n", buffer);
    }
    close(pipefd[0]);
}
- **有名管道(FIFO)**:有名管道可以在不相关的进程间通信。通过 `mkfifo()` 函数创建,其原型为:
int mkfifo(const char *pathname, mode_t mode);

pathname 是管道的路径名,mode 是管道的权限。例如,创建一个有名管道并进行读写操作:

if (mkfifo("myfifo", 0666) == -1) {
    perror("mkfifo failed");
    exit(EXIT_FAILURE);
}

pid_t pid = fork();
if (pid == -1) {
    perror("fork failed");
    unlink("myfifo");
    exit(EXIT_FAILURE);
} else if (pid == 0) {
    // 子进程写有名管道
    int fd = open("myfifo", O_WRONLY);
    if (fd == -1) {
        perror("open for writing failed");
        unlink("myfifo");
        exit(EXIT_FAILURE);
    }
    const char *message = "Hello from child via FIFO";
    write(fd, message, strlen(message));
    close(fd);
} else {
    // 父进程读有名管道
    int fd = open("myfifo", O_RDONLY);
    if (fd == -1) {
        perror("open for reading failed");
        unlink("myfifo");
        exit(EXIT_FAILURE);
    }
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
    if (bytes_read != -1) {
        buffer[bytes_read] = '\0';
        printf("Parent received via FIFO: %s\n", buffer);
    }
    close(fd);
    unlink("myfifo");
}
  1. 消息队列 消息队列是一个消息的链表,存放在内核中,由消息队列标识符标识。通过 msgget() 函数获取或创建消息队列,msgsnd() 函数发送消息,msgrcv() 函数接收消息。相关函数原型如下:
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

这里 key 是消息队列的键值,msgp 是指向消息结构的指针,msgsz 是消息的大小,msgtyp 是消息类型。

  1. 共享内存 共享内存是最快的 IPC 方式,它使得多个进程可以直接访问同一块内存区域。通过 shmget() 函数获取或创建共享内存段,shmat() 函数将共享内存段连接到进程的地址空间,shmdt() 函数断开连接,shmctl() 函数对共享内存进行控制。相关函数原型如下:
int shmget(key_t key, size_t size, int shmflg);
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

C 语言网络编程结合多进程实践

基于多进程的 TCP 服务器

在实际应用中,为了提高服务器的并发处理能力,常常采用多进程的方式。每个客户端连接到来时,服务器创建一个新的子进程来处理该客户端的请求,这样主进程可以继续监听新的连接。以下是一个简单的基于多进程的 TCP 服务器示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>

#define SERVER_PORT 8080
#define BACKLOG 10
#define BUFFER_SIZE 1024

void handle_client(int connfd) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_received = recv(connfd, buffer, sizeof(buffer) - 1, 0);
    if (bytes_received == -1) {
        perror("recv failed");
        close(connfd);
        exit(EXIT_FAILURE);
    }
    buffer[bytes_received] = '\0';
    printf("Received from client: %s\n", buffer);

    const char *response = "Message received successfully";
    ssize_t bytes_sent = send(connfd, response, strlen(response), 0);
    if (bytes_sent == -1) {
        perror("send failed");
        close(connfd);
        exit(EXIT_FAILURE);
    }
    close(connfd);
}

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

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

    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    if (listen(sockfd, BACKLOG) == -1) {
        perror("listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    while (1) {
        struct sockaddr_in cliaddr;
        socklen_t clilen = sizeof(cliaddr);
        int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);
        if (connfd == -1) {
            perror("accept failed");
            continue;
        }

        pid_t pid = fork();
        if (pid == -1) {
            perror("fork failed");
            close(connfd);
        } else if (pid == 0) {
            // 子进程处理客户端请求
            close(sockfd);
            handle_client(connfd);
            exit(EXIT_SUCCESS);
        } else {
            // 父进程继续监听
            close(connfd);
        }
    }

    close(sockfd);
    return 0;
}

在上述代码中,main 函数创建套接字、绑定、监听后进入一个无限循环,每次接受一个客户端连接,创建一个子进程来处理该连接。子进程关闭监听套接字 sockfd,父进程关闭连接套接字 connfd,从而实现并发处理多个客户端请求。

基于多进程的 UDP 服务器

UDP 服务器同样可以结合多进程来提高处理能力。以下是一个简单的基于多进程的 UDP 服务器示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>

#define SERVER_PORT 8080
#define BUFFER_SIZE 1024

void handle_client(int sockfd, struct sockaddr_in cliaddr, socklen_t clilen) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_received = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&cliaddr, &clilen);
    if (bytes_received == -1) {
        perror("recvfrom failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    buffer[bytes_received] = '\0';
    printf("Received from client: %s\n", buffer);

    const char *response = "Message received successfully";
    ssize_t bytes_sent = sendto(sockfd, response, strlen(response), 0, (struct sockaddr *)&cliaddr, clilen);
    if (bytes_sent == -1) {
        perror("sendto failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
}

int main() {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == -1) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

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

    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    while (1) {
        struct sockaddr_in cliaddr;
        socklen_t clilen = sizeof(cliaddr);
        pid_t pid = fork();
        if (pid == -1) {
            perror("fork failed");
            continue;
        } else if (pid == 0) {
            // 子进程处理客户端请求
            handle_client(sockfd, cliaddr, clilen);
            close(sockfd);
            exit(EXIT_SUCCESS);
        }
    }

    close(sockfd);
    return 0;
}

在这个 UDP 服务器示例中,主进程创建套接字并绑定后,进入循环,每次收到客户端数据时,创建一个子进程来处理。子进程从套接字接收数据、处理并回复客户端,最后关闭套接字。

多进程网络编程中的注意事项

  1. 资源管理:在多进程网络编程中,要注意文件描述符的正确关闭。每个进程都有自己独立的文件描述符表,父进程和子进程需要关闭不需要的文件描述符,以避免资源泄漏。例如,在基于多进程的 TCP 服务器中,父进程关闭连接套接字 connfd,子进程关闭监听套接字 sockfd
  2. 进程同步:虽然每个子进程独立处理客户端请求,但在某些情况下,如共享资源访问时,需要进行进程同步。可以使用信号量(Semaphore)等机制来实现进程间的同步。信号量是一个整型变量,通过对其值的操作来控制对共享资源的访问。例如,创建一个信号量:
sem_t *sem = sem_open("/mysem", O_CREAT, 0666, 1);
if (sem == SEM_FAILED) {
    perror("sem_open failed");
    exit(EXIT_FAILURE);
}

在访问共享资源前,通过 sem_wait() 函数获取信号量:

if (sem_wait(sem) == -1) {
    perror("sem_wait failed");
    exit(EXIT_FAILURE);
}

访问完共享资源后,通过 sem_post() 函数释放信号量:

if (sem_post(sem) == -1) {
    perror("sem_post failed");
    exit(EXIT_FAILURE);
}
  1. 错误处理:在网络编程和多进程编程中,错误处理至关重要。例如,socket()bind()listen()accept()fork() 等函数都可能失败,需要及时检查返回值并进行相应的错误处理,如打印错误信息并退出程序或进行适当的重试操作。

通过结合网络编程和多进程编程,C 语言可以开发出高效、并发处理能力强的网络应用程序,满足各种实际场景的需求。无论是开发小型的本地网络服务,还是大型的分布式网络系统,这种结合方式都有着广泛的应用前景。在实际开发中,需要根据具体需求选择合适的网络协议、进程间通信方式,并合理管理资源和处理错误,以确保程序的稳定性和可靠性。