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

Linux C语言网络编程的性能优化

2022-06-211.4k 阅读

网络编程基础回顾

在深入探讨性能优化之前,我们先来回顾一下 Linux C 语言网络编程的基础概念。网络编程主要涉及到套接字(Socket)编程,通过套接字,不同主机上的进程可以进行通信。

套接字类型

  1. 流式套接字(SOCK_STREAM):提供面向连接、可靠的字节流服务。典型的应用是 TCP(传输控制协议),它保证数据按序到达且无差错。例如,HTTP 协议通常基于 TCP 套接字实现。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main(int argc, char const *argv[]) {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    memset(&servaddr.sin_zero, 0, sizeof(servaddr.sin_zero));

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

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

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

    int connfd = accept(sockfd, NULL, NULL);
    if (connfd < 0) {
        perror("Accept failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    char buffer[BUFFER_SIZE] = {0};
    read(connfd, buffer, BUFFER_SIZE);
    printf("Received: %s\n", buffer);

    char *hello = "Hello from server";
    write(connfd, hello, strlen(hello));

    close(connfd);
    close(sockfd);
    return 0;
}
  1. 数据报套接字(SOCK_DGRAM):提供无连接、不可靠的数据传输服务。UDP(用户数据报协议)是基于数据报套接字的典型协议,它适用于对实时性要求高但对数据准确性要求相对较低的场景,如视频流、音频流传输。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main(int argc, char const *argv[]) {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    memset(&servaddr.sin_zero, 0, sizeof(servaddr.sin_zero));

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

    bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr));

    char buffer[BUFFER_SIZE] = {0};
    struct sockaddr_in cliaddr;
    socklen_t len = sizeof(cliaddr);
    recvfrom(sockfd, (char *)buffer, BUFFER_SIZE, MSG_WAITALL, (struct sockaddr *)&cliaddr, &len);
    printf("Received: %s\n", buffer);

    char *hello = "Hello from server";
    sendto(sockfd, (const char *)hello, strlen(hello), MSG_CONFIRM, (const struct sockaddr *)&cliaddr, len);

    close(sockfd);
    return 0;
}

网络字节序

由于不同的计算机系统可能采用不同的字节序(大端序或小端序),为了保证网络通信的正确性,需要进行字节序转换。在 Linux C 语言网络编程中,提供了以下函数:

  • htonl():将主机字节序的长整型数转换为网络字节序。
  • htons():将主机字节序的短整型数转换为网络字节序。
  • ntohl():将网络字节序的长整型数转换为主机字节序。
  • ntohs():将网络字节序的短整型数转换为主机字节序。

性能优化原则与策略

减少系统调用

系统调用是用户空间与内核空间交互的接口,然而,系统调用的开销相对较大。每次进行系统调用时,都需要进行上下文切换,这会消耗 CPU 时间。

  1. 批量操作:尽量减少对 send()recv() 等系统调用的次数。例如,可以将多个小的数据块合并成一个大的数据块再进行发送。
// 优化前
for (int i = 0; i < num_packets; i++) {
    send(sockfd, &data[i], packet_size, 0);
}

// 优化后
char combined_data[num_packets * packet_size];
for (int i = 0; i < num_packets; i++) {
    memcpy(combined_data + i * packet_size, &data[i], packet_size);
}
send(sockfd, combined_data, num_packets * packet_size, 0);
  1. 使用缓冲区:在用户空间设置缓冲区,只有当缓冲区满或者达到一定条件时,才调用系统调用将数据发送出去。

优化内存使用

  1. 减少内存分配与释放:频繁的内存分配(如 malloc())和释放(如 free())会导致内存碎片,降低内存分配效率。可以使用内存池技术,预先分配一块较大的内存,然后从这块内存中分配小块内存供程序使用,使用完毕后再归还到内存池。
// 简单的内存池示例
#define POOL_SIZE 1024
#define CHUNK_SIZE 64

void *memory_pool[POOL_SIZE / CHUNK_SIZE];
int pool_index = 0;

void *pool_alloc() {
    if (pool_index < POOL_SIZE / CHUNK_SIZE) {
        memory_pool[pool_index] = malloc(CHUNK_SIZE);
        return memory_pool[pool_index++];
    }
    return NULL;
}

void pool_free(void *ptr) {
    for (int i = 0; i < pool_index; i++) {
        if (memory_pool[i] == ptr) {
            free(ptr);
            for (; i < pool_index - 1; i++) {
                memory_pool[i] = memory_pool[i + 1];
            }
            pool_index--;
            break;
        }
    }
}
  1. 合理使用栈内存:对于一些小的临时变量,尽量使用栈内存而不是堆内存。栈内存的分配和释放速度比堆内存快。

优化算法与数据结构

  1. 选择合适的算法:在网络编程中,例如在处理数据包排序、查找等操作时,选择合适的算法至关重要。例如,对于小规模数据的排序,插入排序可能比快速排序更高效;而对于大规模数据,快速排序通常表现更好。
  2. 选择合适的数据结构:根据应用场景选择合适的数据结构。例如,在需要快速查找的场景下,可以使用哈希表;在需要保持数据有序的场景下,可以使用红黑树。

网络 I/O 优化

阻塞与非阻塞 I/O

  1. 阻塞 I/O:默认情况下,套接字的 I/O 操作是阻塞的。例如,当调用 recv() 时,如果没有数据到达,线程会被阻塞,直到有数据可读。这种方式简单直接,但在高并发场景下,会导致线程大量等待,浪费 CPU 资源。
  2. 非阻塞 I/O:通过 fcntl() 函数可以将套接字设置为非阻塞模式。在非阻塞模式下,当调用 recv() 时,如果没有数据可读,函数会立即返回 -1,并设置 errnoEAGAINEWOULDBLOCK。这样可以让线程在等待数据的同时去处理其他任务。
#include <fcntl.h>

// 将套接字设置为非阻塞
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

I/O 多路复用

  1. select()select() 函数可以监听多个文件描述符(包括套接字)的状态变化。它允许程序在多个 I/O 操作上等待,而不会阻塞在单个 I/O 操作上。
#include <sys/select.h>
#include <sys/types.h>
#include <unistd.h>

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(sockfd, &read_fds);

struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;

int activity = select(sockfd + 1, &read_fds, NULL, NULL, &timeout);
if (activity > 0) {
    if (FD_ISSET(sockfd, &read_fds)) {
        // 有数据可读
    }
} else if (activity == 0) {
    // 超时
} else {
    // 错误
}
  1. poll()poll() 函数与 select() 类似,但它在处理大量文件描述符时表现更好。poll() 使用 struct pollfd 结构体数组来表示需要监听的文件描述符及其事件。
#include <poll.h>

struct pollfd fds[1];
fds[0].fd = sockfd;
fds[0].events = POLLIN;

int ret = poll(fds, 1, 5000);
if (ret > 0) {
    if (fds[0].revents & POLLIN) {
        // 有数据可读
    }
} else if (ret == 0) {
    // 超时
} else {
    // 错误
}
  1. epoll()epoll() 是 Linux 特有的 I/O 多路复用机制,它在处理大量并发连接时性能更优。epoll() 采用事件驱动的方式,只有在文件描述符状态发生变化时才会通知应用程序。
#include <sys/epoll.h>

int epollfd = epoll_create1(0);
struct epoll_event event;
event.data.fd = sockfd;
event.events = EPOLLIN;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &event);

struct epoll_event events[10];
int num_events = epoll_wait(epollfd, events, 10, -1);
for (int i = 0; i < num_events; i++) {
    if (events[i].events & EPOLLIN) {
        int client_fd = events[i].data.fd;
        // 处理数据
    }
}

零拷贝技术

  1. 原理:零拷贝技术旨在减少数据在用户空间和内核空间之间的拷贝次数。传统的网络数据传输需要经过多次拷贝,如从磁盘到内核缓冲区,再从内核缓冲区到用户空间,最后从用户空间到网络接口。零拷贝技术通过直接在内核空间完成数据的传输,避免了不必要的拷贝。
  2. 应用:在 Linux 中,sendfile() 函数是零拷贝技术的典型应用。它可以直接将文件数据从内核缓冲区发送到网络接口,减少了一次用户空间的拷贝。
#include <sys/sendfile.h>
#include <fcntl.h>
#include <unistd.h>

int in_fd = open("file.txt", O_RDONLY);
struct stat stat_buf;
fstat(in_fd, &stat_buf);

int out_fd = socket(AF_INET, SOCK_STREAM, 0);
// 连接到目标服务器

sendfile(out_fd, in_fd, NULL, stat_buf.st_size);

close(in_fd);
close(out_fd);

多线程与多进程优化

多线程编程

  1. 线程模型:在网络编程中,常见的线程模型有线程池模型。线程池预先创建一定数量的线程,当有任务到来时,从线程池中取出一个线程来处理任务,任务完成后线程返回线程池等待下一个任务。这样可以避免频繁创建和销毁线程的开销。
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define THREAD_POOL_SIZE 5

typedef struct {
    void (*func)(void *);
    void *arg;
} Task;

typedef struct {
    Task tasks[100];
    int head;
    int tail;
    int count;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    int stop;
} ThreadPool;

ThreadPool pool;

void *worker(void *arg) {
    while (1) {
        pthread_mutex_lock(&pool.mutex);
        while (pool.count == 0 &&!pool.stop) {
            pthread_cond_wait(&pool.cond, &pool.mutex);
        }
        if (pool.stop && pool.count == 0) {
            pthread_mutex_unlock(&pool.mutex);
            pthread_exit(NULL);
        }
        Task task = pool.tasks[pool.head];
        pool.head = (pool.head + 1) % 100;
        pool.count--;
        pthread_mutex_unlock(&pool.mutex);

        task.func(task.arg);
    }
}

void init_thread_pool() {
    pool.head = 0;
    pool.tail = 0;
    pool.count = 0;
    pool.stop = 0;
    pthread_mutex_init(&pool.mutex, NULL);
    pthread_cond_init(&pool.cond, NULL);

    pthread_t threads[THREAD_POOL_SIZE];
    for (int i = 0; i < THREAD_POOL_SIZE; i++) {
        pthread_create(&threads[i], NULL, worker, NULL);
    }
}

void add_task(void (*func)(void *), void *arg) {
    pthread_mutex_lock(&pool.mutex);
    while (pool.count == 100) {
        pthread_cond_wait(&pool.cond, &pool.mutex);
    }
    pool.tasks[pool.tail] = (Task){func, arg};
    pool.tail = (pool.tail + 1) % 100;
    pool.count++;
    pthread_cond_signal(&pool.cond);
    pthread_mutex_unlock(&pool.mutex);
}

void destroy_thread_pool() {
    pthread_mutex_lock(&pool.mutex);
    pool.stop = 1;
    pthread_cond_broadcast(&pool.cond);
    pthread_mutex_unlock(&pool.mutex);

    pthread_t threads[THREAD_POOL_SIZE];
    for (int i = 0; i < THREAD_POOL_SIZE; i++) {
        pthread_join(threads[i], NULL);
    }

    pthread_mutex_destroy(&pool.mutex);
    pthread_cond_destroy(&pool.cond);
}
  1. 线程同步:在多线程编程中,线程同步至关重要。常用的同步机制有互斥锁(pthread_mutex_t)、条件变量(pthread_cond_t)等。互斥锁用于保护共享资源,防止多个线程同时访问;条件变量用于线程间的通信,当某个条件满足时,通知等待的线程。

多进程编程

  1. 进程模型:在网络编程中,fork - exec 模型是常见的多进程编程模型。通过 fork() 函数创建子进程,子进程可以通过 exec() 系列函数执行不同的程序。例如,在服务器端,可以为每个客户端连接创建一个子进程来处理。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    // 绑定、监听套接字

    while (1) {
        int connfd = accept(sockfd, NULL, NULL);
        if (connfd < 0) {
            perror("Accept failed");
            continue;
        }

        pid_t pid = fork();
        if (pid < 0) {
            perror("Fork failed");
            close(connfd);
            continue;
        } else if (pid == 0) {
            close(sockfd);
            // 子进程处理客户端连接
            char buffer[1024] = {0};
            read(connfd, buffer, 1024);
            printf("Child received: %s\n", buffer);
            char *response = "Response from child";
            write(connfd, response, strlen(response));
            close(connfd);
            exit(EXIT_SUCCESS);
        } else {
            close(connfd);
            wait(NULL);
        }
    }

    close(sockfd);
    return 0;
}
  1. 进程间通信:多进程编程中,进程间通信(IPC)是必要的。常见的 IPC 方式有管道(pipe())、消息队列(msgget()msgsnd()msgrcv())、共享内存(shmat()shmdt()shmctl())等。管道适用于父子进程间的简单通信;消息队列适用于不同进程间按消息类型进行通信;共享内存则适用于需要快速数据共享的场景。

协议优化

TCP 协议优化

  1. TCP 连接优化
    • TCP 慢启动:在 TCP 连接建立初期,发送方会以一个较小的拥塞窗口(通常为 1 个 MSS,最大段大小)开始发送数据。随着数据的确认(ACK)返回,拥塞窗口逐渐增大。可以通过调整 tcp_slow_start_after_idle 参数来优化慢启动过程。例如,将其设置为 0,可以避免在连接空闲后重新进入慢启动。
    • TCP 拥塞控制:TCP 拥塞控制算法(如 Reno、CUBIC 等)会根据网络拥塞情况调整发送速率。在某些场景下,可以根据网络特性选择合适的拥塞控制算法。例如,在高速网络中,CUBIC 算法可能表现更好。
  2. TCP 选项优化
    • TCP_NODELAY:默认情况下,TCP 会启用 Nagle 算法,它会将小的数据包合并发送,以提高网络利用率。但在一些实时性要求高的应用中,如游戏、视频会议,这可能会导致延迟增加。通过设置 TCP_NODELAY 选项,可以禁用 Nagle 算法,立即发送数据。
int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (const char *)&flag, sizeof(flag));
- **TCP_KEEPALIVE**:该选项用于检测连接是否存活。在长时间没有数据传输的情况下,启用 `TCP_KEEPALIVE` 后,TCP 会定期发送探测包,如果对方没有响应,会认为连接已断开。可以通过设置 `tcp_keepalive_time`、`tcp_keepalive_intvl`、`tcp_keepalive_probes` 等参数来调整探测的时间间隔和次数。

UDP 协议优化

  1. UDP 可靠性增强:由于 UDP 本身是不可靠的协议,在一些需要保证数据准确性的应用中,需要自行实现可靠性机制。例如,可以通过在应用层添加序列号、确认机制和重传机制来保证数据的可靠传输。
// 简单的 UDP 可靠性示例
#define SEQ_NUM_SIZE sizeof(uint32_t)
#define ACK_SIZE sizeof(uint32_t)

// 发送端
uint32_t seq_num = 0;
while (1) {
    char packet[BUFFER_SIZE + SEQ_NUM_SIZE];
    memcpy(packet, &seq_num, SEQ_NUM_SIZE);
    memcpy(packet + SEQ_NUM_SIZE, data_to_send, BUFFER_SIZE);

    int sent = sendto(sockfd, packet, BUFFER_SIZE + SEQ_NUM_SIZE, MSG_CONFIRM, (const struct sockaddr *)&cliaddr, len);
    if (sent < 0) {
        perror("Send failed");
        continue;
    }

    // 等待 ACK
    char ack_buffer[ACK_SIZE];
    struct sockaddr_in from;
    socklen_t from_len = sizeof(from);
    int received = recvfrom(sockfd, ack_buffer, ACK_SIZE, MSG_WAITALL, (struct sockaddr *)&from, &from_len);
    if (received < 0) {
        perror("ACK receive failed");
        // 重传
        continue;
    }

    uint32_t received_seq_num;
    memcpy(&received_seq_num, ack_buffer, ACK_SIZE);
    if (received_seq_num == seq_num) {
        seq_num++;
    } else {
        // 重传
    }
}

// 接收端
while (1) {
    char packet[BUFFER_SIZE + SEQ_NUM_SIZE];
    struct sockaddr_in from;
    socklen_t from_len = sizeof(from);
    int received = recvfrom(sockfd, packet, BUFFER_SIZE + SEQ_NUM_SIZE, MSG_WAITALL, (struct sockaddr *)&from, &from_len);
    if (received < 0) {
        perror("Packet receive failed");
        continue;
    }

    uint32_t seq_num;
    memcpy(&seq_num, packet, SEQ_NUM_SIZE);
    char data[BUFFER_SIZE];
    memcpy(data, packet + SEQ_NUM_SIZE, BUFFER_SIZE);

    // 处理数据

    // 发送 ACK
    sendto(sockfd, &seq_num, ACK_SIZE, MSG_CONFIRM, (const struct sockaddr *)&from, from_len);
}
  1. UDP 性能优化
    • 调整 UDP 缓冲区大小:通过 setsockopt() 函数可以调整 UDP 接收和发送缓冲区的大小。适当增大缓冲区可以减少丢包的可能性,提高数据传输效率。
int recv_buf_size = 65536;
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));

int send_buf_size = 65536;
setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &send_buf_size, sizeof(send_buf_size));
- **合理设置 UDP 校验和**:UDP 校验和是可选的,计算校验和会消耗一定的 CPU 资源。在一些对数据准确性要求不高但对性能要求极高的场景下,可以考虑禁用 UDP 校验和,通过 `setsockopt()` 设置 `IP_HDRINCL` 选项,自己构建 IP 头并设置 UDP 校验和为 0。

网络拓扑与硬件优化

网络拓扑优化

  1. 合理选择网络拓扑结构:不同的网络拓扑结构(如星型、总线型、环型等)对网络性能有不同的影响。在服务器端网络环境中,星型拓扑结构通常更为常见,它具有易于管理、故障隔离性好等优点。但在一些特定场景下,如需要长距离传输且节点较少的情况下,总线型拓扑结构可能更合适。
  2. 减少网络跳数:每经过一个网络设备(如路由器),都会引入一定的延迟和处理开销。在设计网络拓扑时,应尽量减少数据传输过程中的网络跳数,以降低延迟。

硬件优化

  1. 选择高性能网络接口卡(NIC):高性能的 NIC 通常具有更高的数据传输速率、更低的 CPU 占用率等优点。例如,10Gbps 甚至 100Gbps 的 NIC 适用于大数据量、高并发的网络应用场景。同时,一些 NIC 支持硬件加速功能,如 TCP 卸载引擎(TOE),可以将 TCP 协议处理的部分工作卸载到硬件上,减轻 CPU 负担。
  2. 优化服务器硬件配置:增加服务器的内存可以提高数据缓存能力,减少磁盘 I/O 操作,从而提升网络性能。对于多核 CPU,合理分配网络处理任务到不同的核心上,可以充分利用 CPU 资源,提高处理效率。同时,使用高速存储设备(如 SSD)可以加快数据的读写速度,对于需要频繁读写数据的网络应用(如文件服务器)尤为重要。

通过对以上各个方面进行深入优化,可以显著提升 Linux C 语言网络编程的性能,满足各种复杂的网络应用场景的需求。在实际应用中,需要根据具体的业务需求和系统环境,综合运用这些优化技术,以达到最佳的性能效果。