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

Linux C语言高性能网络编程基础

2024-11-276.5k 阅读

一、Linux 网络编程基础概念

在 Linux 环境下进行 C 语言网络编程,首先要理解一些基本概念。

(一)网络协议栈

网络协议栈是网络通信的核心架构。在 Linux 系统中,常见的网络协议栈基于 TCP/IP 模型,它分为四层:应用层、传输层、网络层和链路层。

  • 应用层:负责处理应用程序之间的通信,如 HTTP、FTP、SMTP 等协议都在这一层。在 C 语言网络编程中,我们编写的应用程序就处于这一层,通过调用系统提供的网络编程接口来实现与其他应用程序的通信。
  • 传输层:主要提供端到端的可靠或不可靠的数据传输服务。TCP(传输控制协议)和 UDP(用户数据报协议)是这一层的两个重要协议。TCP 提供可靠的、面向连接的数据传输,适合对数据准确性要求高的场景,如文件传输、网页浏览等;UDP 则提供不可靠的、无连接的数据传输,适合对实时性要求高但对数据准确性要求相对较低的场景,如视频流、音频流传输。
  • 网络层:负责将数据包从源节点传输到目的节点,主要协议是 IP(网际协议)。它处理路由选择、拥塞控制等功能。
  • 链路层:负责将网络层传来的数据包转换为物理信号在物理介质上传输,常见的以太网协议就处于这一层。

(二)套接字(Socket)

套接字是 Linux 网络编程的核心概念。它是一种抽象的接口,用于不同主机之间的进程通信。可以把套接字看作是两个网络应用程序之间的通信端点。在 C 语言中,通过调用系统提供的 socket 函数来创建套接字。

套接字有多种类型,常见的有:

  • 流式套接字(SOCK_STREAM):基于 TCP 协议,提供可靠的、面向连接的数据传输。数据以字节流的形式进行传输,适用于对数据准确性和顺序要求严格的应用,如文件传输、远程登录等。
  • 数据报套接字(SOCK_DGRAM):基于 UDP 协议,提供不可靠的、无连接的数据传输。数据以数据报的形式发送,每个数据报都是独立的,不保证顺序和可靠性,适用于对实时性要求高但对数据准确性要求相对较低的应用,如视频会议、实时游戏等。

二、TCP 编程基础

(一)TCP 服务器端编程流程

  1. 创建套接字 通过 socket 函数创建一个套接字描述符。函数原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
  • domain 参数指定协议族,常用的是 AF_INET(IPv4 协议族)。
  • type 参数指定套接字类型,对于 TCP 编程,通常使用 SOCK_STREAM
  • protocol 参数一般设为 0,由系统根据 domaintype 自动选择合适的协议。

示例代码:

int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 绑定地址和端口 将创建的套接字与本地的地址和端口绑定,使用 bind 函数。函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd 是创建的套接字描述符。
  • addr 是一个指向 struct sockaddr 结构体的指针,对于 IPv4 通常使用 struct sockaddr_in 结构体,需要进行类型转换。
  • addrlenaddr 结构体的长度。

struct sockaddr_in 结构体定义如下:

struct sockaddr_in {
    sa_family_t sin_family; /* 地址族,AF_INET */
    in_port_t sin_port;     /* 端口号 */
    struct in_addr sin_addr; /* 32 位 IP 地址 */
};
struct in_addr {
    in_addr_t s_addr; /* 32 位 IP 地址 */
};

示例代码:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(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)) < 0) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

这里 SERVER_PORT 是自定义的服务器端口号,htons 函数用于将主机字节序转换为网络字节序。

  1. 监听连接 使用 listen 函数使套接字进入监听状态,等待客户端的连接请求。函数原型:
#include <sys/socket.h>
int listen(int sockfd, int backlog);
  • sockfd 是已绑定的套接字描述符。
  • backlog 参数指定等待连接队列的最大长度。

示例代码:

if (listen(sockfd, BACKLOG) < 0) {
    perror("listen failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

这里 BACKLOG 是自定义的等待连接队列长度。

  1. 接受连接 使用 accept 函数接受客户端的连接请求,返回一个新的套接字描述符用于与客户端通信。函数原型:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd 是监听套接字描述符。
  • addr 用于存储客户端的地址信息,如果不需要可以设为 NULL
  • addrlenaddr 结构体的长度,传入时是输入参数,返回时是实际地址长度。

示例代码:

struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
    perror("accept failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 数据传输 通过新的套接字描述符 connfd 进行数据的读写操作。常用的函数有 readwritesendrecv。以 readwrite 为例:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
  • fd 是套接字描述符。
  • buf 是数据缓冲区。
  • count 是要读取或写入的数据字节数。

示例代码:

char buff[BUFFER_SIZE];
ssize_t n = read(connfd, buff, sizeof(buff));
if (n < 0) {
    perror("read failed");
    close(connfd);
    close(sockfd);
    exit(EXIT_FAILURE);
}
buff[n] = '\0';
printf("Received: %s\n", buff);

const char *response = "Message received successfully";
n = write(connfd, response, strlen(response));
if (n < 0) {
    perror("write failed");
    close(connfd);
    close(sockfd);
    exit(EXIT_FAILURE);
}

这里 BUFFER_SIZE 是自定义的数据缓冲区大小。

  1. 关闭套接字 通信完成后,使用 close 函数关闭套接字。
#include <unistd.h>
int close(int fd);

示例代码:

close(connfd);
close(sockfd);

(二)TCP 客户端编程流程

  1. 创建套接字 与服务器端相同,使用 socket 函数创建套接字。
int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 连接服务器 使用 connect 函数连接到服务器。函数原型:
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd 是创建的套接字描述符。
  • addr 是服务器的地址信息,同样对于 IPv4 使用 struct sockaddr_in 结构体。
  • addrlenaddr 结构体的长度。

示例代码:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &servaddr.sin_addr);

if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    perror("connect failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

这里 SERVER_PORT 是服务器端口号,SERVER_IP 是服务器的 IP 地址,inet_pton 函数用于将点分十进制的 IP 地址转换为网络字节序的二进制形式。

  1. 数据传输 与服务器端类似,通过套接字描述符 sockfd 进行数据的读写操作。
const char *message = "Hello, Server!";
ssize_t n = write(sockfd, message, strlen(message));
if (n < 0) {
    perror("write failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

char buff[BUFFER_SIZE];
n = read(sockfd, buff, sizeof(buff));
if (n < 0) {
    perror("read failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
buff[n] = '\0';
printf("Received: %s\n", buff);
  1. 关闭套接字 使用 close 函数关闭套接字。
close(sockfd);

三、UDP 编程基础

(一)UDP 服务器端编程流程

  1. 创建套接字 使用 socket 函数创建 UDP 套接字,与 TCP 不同的是 type 参数设为 SOCK_DGRAM
int sockfd;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 绑定地址和端口 与 TCP 服务器端类似,使用 bind 函数将套接字与本地地址和端口绑定。
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(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)) < 0) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 接收数据 使用 recvfrom 函数接收客户端发送的数据。函数原型:
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd 是套接字描述符。
  • buf 是接收数据的缓冲区。
  • len 是缓冲区的长度。
  • flags 一般设为 0。
  • src_addr 用于存储发送方的地址信息,如果不需要可以设为 NULL
  • addrlensrc_addr 结构体的长度,传入时是输入参数,返回时是实际地址长度。

示例代码:

struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
char buff[BUFFER_SIZE];
ssize_t n = recvfrom(sockfd, (char *)buff, BUFFER_SIZE,
                     MSG_WAITALL, (struct sockaddr *) &cliaddr, &clilen);
buff[n] = '\0';
printf("Received: %s\n", buff);
  1. 发送数据 使用 sendto 函数向客户端发送数据。函数原型:
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
  • 参数与 recvfrom 类似,dest_addr 是目标地址(客户端地址)。

示例代码:

const char *response = "Message received successfully";
n = sendto(sockfd, (const char *)response, strlen(response),
           MSG_CONFIRM, (const struct sockaddr *) &cliaddr, clilen);
if (n < 0) {
    perror("sendto failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 关闭套接字 使用 close 函数关闭套接字。
close(sockfd);

(二)UDP 客户端编程流程

  1. 创建套接字 使用 socket 函数创建 UDP 套接字。
int sockfd;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 发送数据 使用 sendto 函数向服务器发送数据。
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &servaddr.sin_addr);

const char *message = "Hello, Server!";
ssize_t n = sendto(sockfd, (const char *)message, strlen(message),
                   MSG_CONFIRM, (const struct sockaddr *) &servaddr, sizeof(servaddr));
if (n < 0) {
    perror("sendto failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 接收数据 使用 recvfrom 函数接收服务器返回的数据。
char buff[BUFFER_SIZE];
socklen_t len = sizeof(servaddr);
n = recvfrom(sockfd, (char *)buff, BUFFER_SIZE,
             MSG_WAITALL, (struct sockaddr *) &servaddr, &len);
buff[n] = '\0';
printf("Received: %s\n", buff);
  1. 关闭套接字 使用 close 函数关闭套接字。
close(sockfd);

四、高性能网络编程优化

(一)I/O 复用技术

I/O 复用技术可以让程序同时监听多个文件描述符(包括套接字)的状态变化,从而提高程序的并发处理能力。常见的 I/O 复用函数有 selectpollepoll

  1. select select 函数原型:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • nfds 是需要监听的最大文件描述符值加 1。
  • readfdswritefdsexceptfds 分别是读、写和异常事件的文件描述符集合。
  • timeout 是等待的超时时间,可以设为 NULL 表示无限等待。

示例代码:

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(sockfd, &read_fds);
int activity = select(sockfd + 1, &read_fds, NULL, NULL, NULL);
if (activity < 0) {
    perror("select error");
    close(sockfd);
    exit(EXIT_FAILURE);
} else if (activity > 0) {
    if (FD_ISSET(sockfd, &read_fds)) {
        // 处理数据接收
    }
}
  1. poll poll 函数原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds 是一个 struct pollfd 结构体数组,每个结构体包含文件描述符、事件和返回事件。
  • nfds 是数组中元素的个数。
  • timeout 是等待的超时时间,单位是毫秒,-1 表示无限等待。

struct pollfd 结构体定义:

struct pollfd {
    int fd;         /* 文件描述符 */
    short events;   /* 等待的事件 */
    short revents;  /* 实际发生的事件 */
};

示例代码:

struct pollfd fds[1];
fds[0].fd = sockfd;
fds[0].events = POLLIN;
int activity = poll(fds, 1, -1);
if (activity < 0) {
    perror("poll error");
    close(sockfd);
    exit(EXIT_FAILURE);
} else if (activity > 0) {
    if (fds[0].revents & POLLIN) {
        // 处理数据接收
    }
}
  1. epoll epoll 是 Linux 特有的 I/O 复用机制,相比 selectpoll,它具有更高的效率,特别是在处理大量文件描述符时。epoll 有三个主要函数:epoll_createepoll_ctlepoll_wait
  • epoll_create 函数用于创建一个 epoll 实例,返回一个 epoll 文件描述符。函数原型:
#include <sys/epoll.h>
int epoll_create(int size);
  • epoll_ctl 函数用于控制 epoll 实例,添加、修改或删除要监听的文件描述符。函数原型:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op 参数可以是 EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)或 EPOLL_CTL_DEL(删除)。event 是一个指向 struct epoll_event 结构体的指针,用于指定事件类型。

struct epoll_event 结构体定义:

struct epoll_event {
    uint32_t events;      /* 事件类型 */
    epoll_data_t data;    /* 用户数据 */
};
typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;
  • epoll_wait 函数用于等待事件发生。函数原型:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
  • epfdepoll 文件描述符。
  • events 是一个 struct epoll_event 结构体数组,用于存储发生的事件。
  • maxeventsevents 数组的大小。
  • timeout 是等待的超时时间,单位是毫秒,-1 表示无限等待。

示例代码:

int epfd = epoll_create(1024);
struct epoll_event event;
event.data.fd = sockfd;
event.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);

struct epoll_event events[10];
int nfds = epoll_wait(epfd, events, 10, -1);
for (int i = 0; i < nfds; i++) {
    if (events[i].data.fd == sockfd) {
        // 处理数据接收
    }
}

(二)多线程与多进程

  1. 多线程 在网络编程中,使用多线程可以提高程序的并发处理能力。每个线程可以独立处理一个客户端连接,从而实现并发服务。在 C 语言中,可以使用 POSIX 线程库(pthread)来创建和管理线程。

示例代码:

#include <pthread.h>
void *handle_client(void *arg) {
    int connfd = *((int *)arg);
    // 处理客户端连接
    close(connfd);
    pthread_exit(NULL);
}

int main() {
    // 创建监听套接字并绑定、监听
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    // 绑定和监听代码省略

    while (1) {
        struct sockaddr_in cliaddr;
        socklen_t clilen = sizeof(cliaddr);
        int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);

        pthread_t tid;
        pthread_create(&tid, NULL, handle_client, (void *)&connfd);
        pthread_detach(tid);
    }

    close(sockfd);
    return 0;
}
  1. 多进程 多进程方式同样可以实现并发处理客户端连接。每个进程独立运行,互不干扰。在 C 语言中,可以使用 fork 函数来创建子进程。

示例代码:

int main() {
    // 创建监听套接字并绑定、监听
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    // 绑定和监听代码省略

    while (1) {
        struct sockaddr_in cliaddr;
        socklen_t clilen = sizeof(cliaddr);
        int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);

        pid_t pid = fork();
        if (pid == 0) {
            close(sockfd);
            // 子进程处理客户端连接
            close(connfd);
            exit(0);
        } else if (pid > 0) {
            close(connfd);
        } else {
            perror("fork failed");
            close(sockfd);
            exit(EXIT_FAILURE);
        }
    }

    close(sockfd);
    return 0;
}

(三)缓冲区优化

  1. 接收缓冲区优化 在接收数据时,可以适当增大接收缓冲区的大小,以减少数据丢失的可能性。可以通过 setsockopt 函数来设置套接字选项。例如,设置接收缓冲区大小:
int recvbuf = 32 * 1024; // 32KB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recvbuf, sizeof(recvbuf));
  1. 发送缓冲区优化 同样,对于发送缓冲区,也可以根据实际情况进行调整。设置发送缓冲区大小:
int sndbuf = 32 * 1024; // 32KB
setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));

五、错误处理与调试

(一)错误处理

在网络编程中,错误处理至关重要。常见的网络编程错误包括套接字创建失败、绑定失败、连接失败等。通过 perror 函数可以打印出错误信息,帮助定位问题。例如:

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

(二)调试技巧

  1. 打印调试信息 在关键代码位置添加打印语句,输出变量的值、函数的执行状态等信息,帮助理解程序的执行流程。例如:
printf("Socket created with fd: %d\n", sockfd);
  1. 使用调试工具
  • gdb:GNU 调试器是常用的调试工具。可以通过在编译时加上 -g 选项,然后使用 gdb 进行调试。例如:
gcc -g -o server server.c
gdb server

gdb 中,可以设置断点、单步执行、查看变量值等。

  • stracestrace 工具可以跟踪系统调用,帮助分析程序在系统层面的执行情况。例如:
strace -f./server

它会输出程序执行过程中调用的所有系统调用及其参数和返回值,有助于发现网络编程中的系统调用错误。

通过以上对 Linux C 语言高性能网络编程基础的介绍,包括网络编程基础概念、TCP 和 UDP 编程流程、高性能优化以及错误处理与调试等方面,希望能为读者在 Linux 环境下进行高效的网络编程开发提供有力的支持。在实际应用中,还需要根据具体的需求和场景,灵活运用这些知识和技巧,以实现高性能、稳定的网络应用程序。