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

高并发网络编程实战经验

2021-12-282.3k 阅读

一、高并发网络编程概述

在当今数字化时代,互联网应用的用户数量呈爆发式增长,对后端系统的性能和并发处理能力提出了极高的要求。高并发网络编程旨在使服务器能够同时处理大量客户端的请求,确保系统在高负载情况下依然稳定、高效地运行。

高并发网络编程面临着诸多挑战。首先是资源管理问题,包括内存、文件描述符等。当大量并发连接涌入时,如果不能合理分配和回收这些资源,很容易导致系统资源耗尽,进而使服务崩溃。其次是性能瓶颈,如网络 I/O 操作本身是相对耗时的,如何优化 I/O 操作,减少等待时间,提升系统整体吞吐量是关键。另外,数据一致性也是一个重要方面,在多线程或多进程处理并发请求时,对共享数据的访问和修改必须保证一致性,否则可能出现数据错误。

二、网络编程基础回顾

  1. Socket 编程 Socket 是网络编程的基础,它为应用程序提供了一种与网络进行交互的接口。在 UNIX 系统中,Socket 被视为一种特殊的文件描述符,可像操作文件一样对其进行读写操作。

以下是一个简单的基于 TCP 的 Socket 服务器端代码示例(以 C 语言为例):

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

#define PORT 8080
#define BACKLOG 10

int main(int argc, char const *argv[]) {
    int sockfd, new_sock;
    struct sockaddr_in servaddr, cliaddr;

    // 创建 socket 文件描述符
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

    // 填充服务器地址结构
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);

    // 绑定 socket 到指定地址和端口
    if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(sockfd, BACKLOG) < 0) {
        perror("listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    socklen_t len = sizeof(cliaddr);
    // 接受客户端连接
    new_sock = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
    if (new_sock < 0) {
        perror("accept failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    char buffer[1024] = {0};
    // 从客户端读取数据
    read(new_sock, buffer, sizeof(buffer));
    printf("Message from client: %s\n", buffer);

    char response[] = "Message received successfully";
    // 向客户端发送响应
    write(new_sock, response, strlen(response));

    close(new_sock);
    close(sockfd);
    return 0;
}

这段代码创建了一个简单的 TCP 服务器,它监听指定端口,接受客户端连接,读取客户端发送的消息并回显一个确认消息。

  1. 网络协议 常用的网络协议包括 TCP(传输控制协议)和 UDP(用户数据报协议)。TCP 是面向连接的、可靠的协议,它通过三次握手建立连接,保证数据的有序传输和完整性。UDP 则是无连接的、不可靠的协议,它的优点是传输速度快,适合对实时性要求高但对数据完整性要求相对较低的场景,如视频流、音频流传输等。

三、高并发处理模型

  1. 多进程模型 在多进程模型中,每当有新的客户端连接到来时,服务器会创建一个新的进程来处理该连接。每个进程都有自己独立的地址空间,相互之间不受影响。这种模型的优点是稳定性高,一个进程出现问题不会影响其他进程。然而,创建和销毁进程的开销较大,会占用较多系统资源。

以下是一个简单的基于多进程的 TCP 服务器代码示例(以 C 语言为例):

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

#define PORT 8080
#define BACKLOG 10

void handle_client(int client_sock) {
    char buffer[1024] = {0};
    read(client_sock, buffer, sizeof(buffer));
    printf("Message from client: %s\n", buffer);

    char response[] = "Message received successfully";
    write(client_sock, response, strlen(response));

    close(client_sock);
}

int main(int argc, char const *argv[]) {
    int sockfd, new_sock;
    struct sockaddr_in servaddr, cliaddr;

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

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

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

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

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

    while (1) {
        socklen_t len = sizeof(cliaddr);
        new_sock = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
        if (new_sock < 0) {
            perror("accept failed");
            continue;
        }

        pid_t pid = fork();
        if (pid == 0) {
            // 子进程处理客户端连接
            close(sockfd);
            handle_client(new_sock);
            exit(EXIT_SUCCESS);
        } else if (pid < 0) {
            perror("fork failed");
            close(new_sock);
        } else {
            // 父进程继续监听
            close(new_sock);
        }
    }

    while ((wait(NULL)) > 0);
    close(sockfd);
    return 0;
}

在这个示例中,每当有新的客户端连接时,服务器通过 fork 创建一个子进程来处理该连接,父进程继续监听新的连接。

  1. 多线程模型 多线程模型与多进程模型类似,但线程是共享进程的地址空间的。这意味着线程之间的通信和数据共享更加方便,但也带来了数据一致性的问题,需要使用同步机制(如互斥锁、条件变量等)来保证数据的正确访问。多线程模型的创建和销毁开销相对较小,但由于共享资源,一个线程的错误可能影响整个进程。

以下是一个基于多线程的 TCP 服务器代码示例(以 C 语言为例,使用 POSIX 线程库):

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

#define PORT 8080
#define BACKLOG 10

void *handle_client(void *arg) {
    int client_sock = *((int *)arg);
    char buffer[1024] = {0};
    read(client_sock, buffer, sizeof(buffer));
    printf("Message from client: %s\n", buffer);

    char response[] = "Message received successfully";
    write(client_sock, response, strlen(response));

    close(client_sock);
    pthread_exit(NULL);
}

int main(int argc, char const *argv[]) {
    int sockfd, new_sock;
    struct sockaddr_in servaddr, cliaddr;
    pthread_t tid;

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

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

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

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

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

    while (1) {
        socklen_t len = sizeof(cliaddr);
        new_sock = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
        if (new_sock < 0) {
            perror("accept failed");
            continue;
        }

        if (pthread_create(&tid, NULL, handle_client, (void *)&new_sock) != 0) {
            perror("pthread_create failed");
            close(new_sock);
        }
    }

    close(sockfd);
    return 0;
}

在这个代码中,每当有新的客户端连接,服务器创建一个新线程来处理该连接。

  1. I/O 多路复用模型 I/O 多路复用模型允许一个进程监视多个文件描述符的状态变化,当有任何一个文件描述符就绪(可读、可写或有异常)时,系统通知进程进行相应处理。常见的 I/O 多路复用技术有 select、poll 和 epoll。

select: select 函数通过设置三个文件描述符集合(读集合、写集合和异常集合),并指定一个超时时间,来监视这些文件描述符。当 select 函数返回时,会修改这些集合,告知哪些文件描述符已经就绪。然而,select 有一些局限性,比如它能监视的文件描述符数量有限(通常是 1024),并且每次调用都需要将文件描述符集合从用户空间拷贝到内核空间,性能较低。

以下是一个使用 select 的简单 TCP 服务器示例(以 C 语言为例):

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

#define PORT 8080
#define BACKLOG 10
#define MAX_CLIENTS 1024

int main(int argc, char const *argv[]) {
    int sockfd, new_sock;
    struct sockaddr_in servaddr, cliaddr;
    fd_set read_fds, tmp_fds;
    int activity, i, valread;
    int max_sd;

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

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

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

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

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

    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);

    FD_SET(sockfd, &read_fds);
    max_sd = sockfd;

    while (1) {
        tmp_fds = read_fds;
        activity = select(max_sd + 1, &tmp_fds, NULL, NULL, NULL);

        if ((activity < 0) && (errno!= EINTR)) {
            printf("select error");
        } else if (activity > 0) {
            if (FD_ISSET(sockfd, &tmp_fds)) {
                socklen_t len = sizeof(cliaddr);
                new_sock = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
                if (new_sock < 0) {
                    perror("accept failed");
                    continue;
                }

                FD_SET(new_sock, &read_fds);
                if (new_sock > max_sd) {
                    max_sd = new_sock;
                }
            }

            for (i = 0; i <= max_sd; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    char buffer[1024] = {0};
                    valread = read(i, buffer, sizeof(buffer));
                    if (valread == 0) {
                        close(i);
                        FD_CLR(i, &read_fds);
                    } else {
                        printf("Message from client: %s\n", buffer);
                        char response[] = "Message received successfully";
                        write(i, response, strlen(response));
                    }
                }
            }
        }
    }

    close(sockfd);
    return 0;
}

poll: poll 与 select 类似,但它使用一个 pollfd 结构体数组来表示要监视的文件描述符,并且没有文件描述符数量的限制。然而,它仍然需要将整个结构体数组从用户空间拷贝到内核空间,并且每次返回时都需要遍历整个数组来检查哪些文件描述符就绪,性能提升有限。

epoll: epoll 是 Linux 特有的 I/O 多路复用技术,它通过一个 epoll 实例来管理大量的文件描述符。epoll 使用红黑树来存储文件描述符,通过回调机制来通知就绪的文件描述符,避免了每次都需要遍历所有文件描述符的开销,大大提高了性能。

以下是一个使用 epoll 的 TCP 服务器示例(以 C 语言为例):

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

#define PORT 8080
#define BACKLOG 10
#define MAX_EVENTS 10

int main(int argc, char const *argv[]) {
    int sockfd, new_sock;
    struct sockaddr_in servaddr, cliaddr;
    struct epoll_event ev, events[MAX_EVENTS];
    int epollfd, i, valread;
    char buffer[1024] = {0};

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

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

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

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

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

    epollfd = epoll_create1(0);
    if (epollfd == -1) {
        perror("epoll_create1");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    ev.events = EPOLLIN;
    ev.data.fd = sockfd;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev) == -1) {
        perror("epoll_ctl: sockfd");
        close(sockfd);
        close(epollfd);
        exit(EXIT_FAILURE);
    }

    while (1) {
        int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            break;
        }

        for (i = 0; i < nfds; i++) {
            if (events[i].data.fd == sockfd) {
                socklen_t len = sizeof(cliaddr);
                new_sock = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
                if (new_sock == -1) {
                    perror("accept");
                    continue;
                }

                ev.events = EPOLLIN;
                ev.data.fd = new_sock;
                if (epoll_ctl(epollfd, EPOLL_CTL_ADD, new_sock, &ev) == -1) {
                    perror("epoll_ctl: new_sock");
                    close(new_sock);
                }
            } else {
                new_sock = events[i].data.fd;
                valread = read(new_sock, buffer, sizeof(buffer));
                if (valread == 0) {
                    if (epoll_ctl(epollfd, EPOLL_CTL_DEL, new_sock, NULL) == -1) {
                        perror("epoll_ctl: del");
                    }
                    close(new_sock);
                } else {
                    printf("Message from client: %s\n", buffer);
                    char response[] = "Message received successfully";
                    write(new_sock, response, strlen(response));
                }
            }
        }
    }

    close(sockfd);
    close(epollfd);
    return 0;
}

四、优化高并发网络编程的技巧

  1. 内存管理优化 在高并发场景下,频繁的内存分配和释放可能导致内存碎片,降低系统性能。可以采用内存池技术,预先分配一块较大的内存,然后在需要时从内存池中分配小块内存,使用完毕后再归还到内存池。这样可以减少内存分配和释放的次数,提高内存使用效率。

  2. 网络 I/O 优化 使用非阻塞 I/O 可以避免在 I/O 操作时阻塞线程或进程,提高系统的并发处理能力。结合 I/O 多路复用技术,如 epoll,可以进一步优化网络 I/O 的性能。另外,合理设置 TCP 缓冲区大小也能对性能产生影响。增大发送和接收缓冲区可以减少网络拥塞的可能性,但也会占用更多内存。

  3. 数据结构优化 选择合适的数据结构对于高并发网络编程至关重要。例如,在存储大量客户端连接信息时,使用哈希表可以快速定位和查找连接,而不是使用线性表。同时,对于共享数据结构,要考虑使用线程安全的数据结构,如 C++ 中的 std::unordered_map 在多线程环境下需要加锁保护,而一些专门的线程安全哈希表则可以直接在多线程中使用。

  4. 负载均衡 当系统面临高并发请求时,单一服务器可能无法承受,这时需要引入负载均衡机制。负载均衡器可以将客户端请求均匀地分配到多个服务器上,从而提高系统的整体处理能力和可用性。常见的负载均衡算法有轮询、加权轮询、最少连接数等。

五、高并发网络编程中的常见问题及解决方法

  1. 连接超时问题 在高并发环境下,由于网络延迟或服务器繁忙,可能会出现连接超时的情况。解决方法可以是适当增加连接超时时间,同时在应用层实现重试机制。当连接超时发生时,客户端可以尝试重新连接一定次数,以提高连接成功率。

  2. 数据丢失问题 数据丢失可能发生在网络传输过程中,尤其是在 UDP 协议下。对于 UDP 应用,可以通过在应用层实现确认和重传机制来保证数据的可靠性。在 TCP 协议下,虽然 TCP 本身保证了数据的可靠传输,但在极端情况下(如网络拥塞、服务器崩溃等)也可能出现数据丢失。这时可以通过记录传输日志,以便在出现问题时进行排查和恢复。

  3. 死锁问题 在多线程或多进程编程中,死锁是一个常见问题。当多个线程或进程相互等待对方释放资源时,就会发生死锁。为了避免死锁,要遵循资源分配的有序性原则,例如对所有资源进行编号,所有线程或进程按照相同的顺序获取资源。同时,要避免嵌套锁的使用,并且合理设置锁的超时时间。

六、案例分析

以一个在线游戏服务器为例,该服务器需要处理大量玩家的实时连接,进行游戏数据的传输和处理。在设计之初,采用了多线程模型,但随着玩家数量的增加,出现了性能瓶颈和死锁问题。经过分析,发现是由于线程之间对共享资源的访问没有进行合理的同步控制。

后来,将模型改为基于 epoll 的 I/O 多路复用模型,并对共享数据结构采用线程安全的数据结构和锁机制进行保护。同时,引入了负载均衡器,将玩家连接分配到多个服务器节点上。经过这些优化,服务器的并发处理能力得到了显著提升,能够稳定地支持大量玩家同时在线游戏。

七、总结与展望

高并发网络编程是后端开发中的核心技术之一,它对于构建高性能、高可用的互联网应用至关重要。通过深入理解网络编程基础、掌握高并发处理模型以及运用优化技巧和解决常见问题的方法,开发者可以打造出高效稳定的后端系统。随着技术的不断发展,新的网络编程框架和技术(如 Rust 的异步编程、Go 语言的 goroutine 等)不断涌现,为高并发网络编程带来了更多的选择和可能性,开发者需要不断学习和探索,以适应不断变化的技术需求。