如何构建高并发网络服务器?(总结)

如何构建高并发网络服务器?

        本文介绍了IO复用select,poll,epoll和异步IO- iocp的理论和使用示例。

        在 Socket 通信中,处理并发连接是构建高并发服务器(如 Web 服务器、聊天服务器)时的核心问题。主要有以下几种常见的并发处理方式。

1. 多进程/多线程模型

多进程模型

  • 每当服务器接收到一个新的客户端连接时,服务器创建一个新的进程来处理该连接。
  • 优点
    • 程序逻辑相对简单,每个进程独立运行,不会干扰其他进程。
    • 由于每个进程都有自己独立的内存空间,能够提供较好的隔离性和稳定性。
  • 缺点
    • 进程的创建和销毁代价较高,系统资源占用较大,尤其是处理大量并发时,效率较低。
    • 各个进程之间的通信复杂,通常需要使用 IPC(进程间通信)机制。

多线程模型

  • 服务器为每个客户端连接创建一个线程进行处理。所有线程共享相同的内存空间,因此比多进程更高效。
  • 优点
    • 相比多进程,线程的创建和销毁成本较低。
    • 能够更好地共享数据,减少进程间通信的开销。
  • 缺点
    • 线程共享内存空间,容易出现数据竞争问题,需要加锁保护共享资源,可能导致性能下降。
    • 如果一个线程崩溃,可能导致整个进程异常退出。

2. I/O 多路复用(如 select, poll, epoll)

  • 使用 I/O 多路复用模型,一个线程或进程同时监视多个连接,当某个连接有数据到达或可以发送数据时,通知应用程序处理。
  • 工作原理
    • 服务器使用 selectpollepoll 等机制,等待多个 socket 上的事件。
    • 一旦有事件发生(如可读、可写),程序处理该 socket 的数据,而其他空闲的 socket 不会被阻塞。
  • 优点
    • 能够高效处理大量并发连接,避免了为每个连接创建一个线程或进程的开销。
    • 适合大规模、高并发的网络服务器,尤其是 epoll 在处理大量连接时效率非常高。
  • 缺点
    • 实现相对复杂,尤其是当事件处理逻辑较复杂时,代码维护难度较高。
    • selectpoll 在处理大量文件描述符时性能较差,epoll 在大规模场景下表现更好。

3. 基于事件驱动的模型(如 Reactor 模式)

  • Reactor 模式 是一种典型的事件驱动设计模式,通常与 I/O 多路复用一起使用。
  • 工作原理
    • 一个主线程监听事件(如连接到达、可读、可写等),当事件发生时,分派给适当的处理器(可能是线程池中的一个工作线程)进行处理。
    • 工作线程处理完成后,主线程继续监听其他事件。
  • 优点
    • 主线程仅负责事件的分派和管理,不进行实际的 I/O 操作,提高了并发处理效率。
    • 适用于需要处理大量连接但每个连接的数据量较少的场景。
  • 缺点
    • 复杂的设计模式对开发人员的要求较高,需要仔细处理并发和事件分发逻辑。

4. 异步 I/O 模型(如windows下的IOCP)

  • 与 I/O 多路复用和阻塞 I/O 不同,异步 I/O 通过操作系统内核来完成 I/O 操作,并且在操作完成后通知应用程序,无需主动轮询或等待。
  • 工作原理
    • 服务器发起异步 I/O 请求(如读取数据),操作系统将请求放入 I/O 队列,立即返回。
    • 当数据就绪时,操作系统通知应用程序,应用程序在回调函数中处理数据。
  • 优点
    • 高效的 I/O 模型,完全依赖操作系统处理 I/O 操作,减少了程序处理 I/O 状态的开销。
    • 能够高效处理大量并发连接,特别是处理长连接或慢速网络时效果显著。
  • 缺点
    • 编程复杂度高,如IOCP 是基于事件驱动的异步 I/O 机制,线程从完成端口获取 I/O 完成结果。

5. 使用连接池和线程池

  • 连接池线程池 是常用的优化手段,避免频繁创建和销毁连接或线程。
  • 工作原理
    • 在服务器启动时,预先创建一定数量的线程或连接,客户端连接后,分配可用的线程或连接处理任务,处理完成后将其归还到池中。
    • 可以结合 I/O 多路复用或 Reactor 模式使用,以提高并发性能。
  • 优点
    • 避免了频繁的资源分配和释放,提高了服务器的整体效率。
    • 适合处理中等规模的并发请求,尤其是任务较为简单且执行时间较短的场景。

总结

  • 如果并发连接数量较小,多进程/多线程模型 是简单且有效的方案。
  • 对于大量并发连接,使用 I/O 多路复用事件驱动模型(Reactor 模式) 更为高效,尤其是在 Linux 下使用 epoll
  • 异步 I/O 是处理高并发、长连接和慢速网络的理想选择,但编程复杂度较高。
  • 使用连接池和线程池 是上述模型常用的优化策略,可以与其他并发处理方式结合使用,提高资源利用率。

2.补充

I/O多路复用的使用

        I/O 多路复用是一种网络编程技术,它允许一个线程同时监视多个文件描述符(例如套接字)以提高应用程序的并发处理能力。主要的实现机制包括 selectpollepoll(在 Linux 中)。

1. select

概述
  • select 是最早使用的 I/O 多路复用机制,可以在多个文件描述符上等待事件(如可读、可写或异常条件)。
工作原理
  1. 将需要监视的文件描述符集放入 fd_set 结构中。
  2. 调用 select 函数,并传递三个 fd_set 集合:可读集合、可写集合和异常集合,以及一个超时时间。
  3. select 会阻塞直到其中一个文件描述符状态变化(或者超时)。
  4. select 返回后,需要遍历 fd_set 以确定哪个文件描述符有事件发生。
缺点
  • 文件描述符限制:每个进程能够监视的文件描述符数量有限(通常是 1024)。
  • 性能问题:每次调用 select 都需要复制文件描述符集到内核,并在返回时更新集合,开销较大。
  • 线性扫描:事件发生后,需要逐个遍历文件描述符集合,效率低,尤其是当描述符数量较多时。

2. poll

概述
  • poll 克服了 select 一些限制,不再有文件描述符数量的限制。它使用 pollfd 结构数组来监视文件描述符。
工作原理
  1. 创建并初始化 pollfd 结构数组,其中每个结构描述一个需要监视的文件描述符及其感兴趣的事件(如可读、可写)。
  2. 调用 poll 函数,并传递 pollfd 数组和数组的大小。
  3. poll 会阻塞直到一个或多个文件描述符事件发生(或者超时)。
  4. poll 返回后,需要遍历 pollfd 数组以确定哪个文件描述符有事件发生。
缺点
  • 性能问题:尽管避免了文件描述符数量限制,但返回后仍需要线性扫描 pollfd 数组来确定事件结果,效率低下。

3. epoll

概述
  • epoll 是 Linux 特有的 I/O 多路复用机制,针对大量文件描述符的场景进行了优化,提高了系统效率。
工作原理
  1. 创建 epoll 事件实例:使用 epoll_create 函数创建一个 epoll 实例。
  2. 注册文件描述符:使用 epoll_ctl 函数将需要监视的文件描述符及其事件注册到 epoll 实例中。
  3. 等待事件:使用 epoll_wait 函数等待事件发生。当一个或多个文件描述符状态变化时,epoll_wait 返回发生事件的文件描述符列表。
优点
  • 事件驱动epoll 使用事件通知机制,一旦某个文件描述符的事件发生,内核会主动通知应程序,避免了频繁的轮询操作。
  • 无须扫描:通过回调机制,epoll 可以避免大量文件描述符的线性扫描,效率更高。
  • 扩展性好:能够监视大量文件描述符而不会显著增加开销,特别适用于高并发的网络服务器。

概述

特性

select

poll

epoll

文件描述符限制

有限制(典型值是 1024)

无明确限制

无明显限制

性能

线性扫描,性能较低

线性扫描,性能较低

事件驱动,性能高

使用复杂度

适中

适中

较高

适用场景

小规模文件描述符监视

中等规模文件描述符监视

大规模、高并发监视

平台依赖性

跨平台

一般在linux使用

Linux专用

具体代码例子

        下面是 selectpollepoll 的代码示例,用于监听并读取三个本地文件的文件描述符(fd)。假设已经打开了三个文件,分别为 fd1, fd2, 和 fd3

1. 使用 select 监听 3 个文件的文件描述符

select 可以监视一组文件描述符,等待其中某个文件描述符变为可读。

        下面的select函数还是poll函数返回的n都是就绪的文件描述符个数,之后可以用for循环去遍历,这里因为fd只有3就直接判断每一个,可以更加直观的看到使用这两个的缺陷,就是要判断每一个fd是否状态改变。

#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    int fd1 = open("file1.txt", O_RDONLY);
    int fd2 = open("file2.txt", O_RDONLY);
    int fd3 = open("file3.txt", O_RDONLY);

    if (fd1 == -1 || fd2 == -1 || fd3 == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    fd_set readfds;
    char buffer[1024];
    int max_fd = fd3;  // 最大的文件描述符

    while (1) {
        FD_ZERO(&readfds);
        FD_SET(fd1, &readfds);
        FD_SET(fd2, &readfds);
        FD_SET(fd3, &readfds);

        int result = select(max_fd + 1, &readfds, NULL, NULL, NULL);

        if (result == -1) {
            perror("select");
            exit(EXIT_FAILURE);
        }

        if (FD_ISSET(fd1, &readfds)) {
            read(fd1, buffer, sizeof(buffer));
            printf("fd1 is readable: %s\n", buffer);
        }
        if (FD_ISSET(fd2, &readfds)) {
            read(fd2, buffer, sizeof(buffer));
            printf("fd2 is readable: %s\n", buffer);
        }
        if (FD_ISSET(fd3, &readfds)) {
            read(fd3, buffer, sizeof(buffer));
            printf("fd3 is readable: %s\n", buffer);
        }
    }

    close(fd1);
    close(fd2);
    close(fd3);

    return 0;
}

2. 使用 poll 监听 3 个文件的文件描述符

poll 类似于 select,但更加灵活,可以监视更多的文件描述符,并且不需要设置最大文件描述符。

#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    int fd1 = open("file1.txt", O_RDONLY);
    int fd2 = open("file2.txt", O_RDONLY);
    int fd3 = open("file3.txt", O_RDONLY);

    if (fd1 == -1 || fd2 == -1 || fd3 == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    struct pollfd fds[3];
    fds[0].fd = fd1;
    fds[0].events = POLLIN;
    fds[1].fd = fd2;
    fds[1].events = POLLIN;
    fds[2].fd = fd3;
    fds[2].events = POLLIN;

    char buffer[1024];

    while (1) {
        int result = poll(fds, 3, -1);  // -1 表示无限等待

        if (result == -1) {
            perror("poll");
            exit(EXIT_FAILURE);
        }

        if (fds[0].revents & POLLIN) {
            read(fd1, buffer, sizeof(buffer));
            printf("fd1 is readable: %s\n", buffer);
        }
        if (fds[1].revents & POLLIN) {
            read(fd2, buffer, sizeof(buffer));
            printf("fd2 is readable: %s\n", buffer);
        }
        if (fds[2].revents & POLLIN) {
            read(fd3, buffer, sizeof(buffer));
            printf("fd3 is readable: %s\n", buffer);
        }
    }

    close(fd1);
    close(fd2);
    close(fd3);

    return 0;
}

3. 使用 epoll 监听 3 个文件的文件描述符

epoll 是 Linux 专用的、更加高效的 I/O 多路复用机制,适合处理大量文件描述符。

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/epoll.h>

#define MAX_EVENTS 3

int main() {
    int fd1 = open("file1.txt", O_RDONLY);
    int fd2 = open("file2.txt", O_RDONLY);
    int fd3 = open("file3.txt", O_RDONLY);

    if (fd1 == -1 || fd2 == -1 || fd3 == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    int epoll_fd = epoll_create1(0);  // 创建 epoll 实例
    if (epoll_fd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }

    struct epoll_event event, events[MAX_EVENTS];

    event.events = EPOLLIN;

    // 添加 fd1 到 epoll 实例
    event.data.fd = fd1;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd1, &event) == -1) {
        perror("epoll_ctl: fd1");
        exit(EXIT_FAILURE);
    }

    // 添加 fd2 到 epoll 实例
    event.data.fd = fd2;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd2, &event) == -1) {
        perror("epoll_ctl: fd2");
        exit(EXIT_FAILURE);
    }

    // 添加 fd3 到 epoll 实例
    event.data.fd = fd3;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd3, &event) == -1) {
        perror("epoll_ctl: fd3");
        exit(EXIT_FAILURE);
    }

    char buffer[1024];
    while (1) {
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);  // -1 表示无限等待
        if (n == -1) {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }

        for (int i = 0; i < n; i++) {
            if (events[i].events & EPOLLIN) {
                int fd = events[i].data.fd;
                read(fd, buffer, sizeof(buffer));
                printf("fd%d is readable: %s\n", fd, buffer);
            }
        }
    }

    close(fd1);
    close(fd2);
    close(fd3);
    close(epoll_fd);

    return 0;
}

使用差异

  • select 需要手动维护最大文件描述符,并且每次调用时都要重新设置 fd_set
  • poll 简化了监控多个文件描述符的代码,但对于大量文件描述符性能不佳。
  • epoll 是 Linux 系统中的高效解决方案,尤其适合大量文件描述符的监听,且不需要重复设置监控文件描述符。

异步I/O的使用iocp

使用 IOCP(I/O Completion Port)是实现高性能异步 I/O 服务器的一种重要技术,特别是在 Windows 平台上。下面给出一个简单的 IOCP 使用示例,并介绍常用的 IOCP 接口。

1. 常用的 IOCP 接口

在使用 IOCP 之前,需要了解一些常用的 API 和函数,这些函数提供了创建、管理和使用完成端口的基本功能:

  • CreateIoCompletionPort: 创建一个新的完成端口或者将一个已存在的 I/O 设备(如套接字或文件句柄)与现有的完成端口关联起来。
HANDLE CreateIoCompletionPort(
    HANDLE FileHandle,            // 设备或文件句柄(可选)
    HANDLE ExistingCompletionPort,// 已存在的完成端口(可选)
    ULONG_PTR CompletionKey,      // 用于标识完成端口上操作的键
    DWORD NumberOfConcurrentThreads // 并发线程数
);
  • GetQueuedCompletionStatus: 从完成端口中检索已完成的 I/O 操作的状态。如果没有可用的操作,它会阻塞直到有一个操作完成或超时。
BOOL GetQueuedCompletionStatus(
    HANDLE CompletionPort,        // 完成端口句柄
    LPDWORD lpNumberOfBytes,      // 指向接收传输字节数的变量
    PULONG_PTR lpCompletionKey,   // 指向接收与 I/O 关联的完成键的变量
    LPOVERLAPPED *lpOverlapped,   // 指向接收关联 OVERLAPPED 结构的指针
    DWORD dwMilliseconds          // 超时时间(毫秒),INFINITE 表示无限期等待
);
  • PostQueuedCompletionStatus: 手动向完成端口发送一个完成状态,常用于在线程间通信或向完成端口发送自定义的消息。
BOOL PostQueuedCompletionStatus(
    HANDLE CompletionPort,        // 完成端口句柄
    DWORD dwNumberOfBytesTransferred, // 要传输的字节数
    ULONG_PTR dwCompletionKey,    // 用于标识完成操作的键
    LPOVERLAPPED lpOverlapped     // 指向 OVERLAPPED 结构的指针
);

2. IOCP 使用示例

下面是一个简化的 IOCP 服务器的 C++ 示例,展示如何创建一个 IOCP 完成端口,并使用它处理客户端的异步 I/O 操作。这个示例省略了大量的错误处理和细节,主要目的是演示 IOCP 的核心工作原理。

#include <winsock2.h>
#include <windows.h>
#include <iostream>

#pragma comment(lib, "ws2_32.lib")

#define MAX_BUFFER 1024

struct PER_IO_DATA {
    OVERLAPPED overlapped;  // 用于异步 I/O 的 OVERLAPPED 结构
    SOCKET socket;          // 客户端套接字
    char buffer[MAX_BUFFER];// 数据缓冲区
    WSABUF wsabuf;          // 用于传递缓冲区和大小的信息
};

DWORD WINAPI WorkerThread(LPVOID lpParam) {
    HANDLE completionPort = (HANDLE)lpParam;
    DWORD bytesTransferred;
    ULONG_PTR completionKey;
    LPOVERLAPPED lpOverlapped;

    while (true) {
        // 获取完成的 I/O 操作状态
        BOOL result = GetQueuedCompletionStatus(
            completionPort,
            &bytesTransferred,
            &completionKey,
            &lpOverlapped,
            INFINITE
        );

        if (!result) {
            std::cerr << "GetQueuedCompletionStatus failed: " << GetLastError() << std::endl;
            continue;
        }

        PER_IO_DATA* ioData = (PER_IO_DATA*)lpOverlapped;
        if (bytesTransferred == 0) { // 客户端断开连接
            closesocket(ioData->socket);
            delete ioData;
            continue;
        }

        // 处理接收到的数据
        std::cout << "Received Data: " << ioData->buffer << std::endl;

        // 继续接收数据
        ZeroMemory(&ioData->overlapped, sizeof(OVERLAPPED));
        ioData->wsabuf.len = MAX_BUFFER;
        ioData->wsabuf.buf = ioData->buffer;
        DWORD flags = 0;
        WSARecv(ioData->socket, &ioData->wsabuf, 1, NULL, &flags, &ioData->overlapped, NULL);
    }
    return 0;
}

int main() {
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    // 创建完成端口
    HANDLE completionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

    // 创建工作线程
    for (int i = 0; i < 4; ++i) { // 创建4个工作线程
        CreateThread(NULL, 0, WorkerThread, completionPort, 0, NULL);
    }

    // 创建服务器套接字
    SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(12345);
    bind(listenSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
    listen(listenSocket, SOMAXCONN);

    while (true) {
        // 接受客户端连接
        SOCKET clientSocket = accept(listenSocket, NULL, NULL);
        if (clientSocket == INVALID_SOCKET) {
            std::cerr << "Accept failed: " << WSAGetLastError() << std::endl;
            continue;
        }

        // 创建与每个客户端相关联的 PER_IO_DATA
        PER_IO_DATA* ioData = new PER_IO_DATA;
        ZeroMemory(&ioData->overlapped, sizeof(OVERLAPPED));
        ioData->socket = clientSocket;
        ioData->wsabuf.len = MAX_BUFFER;
        ioData->wsabuf.buf = ioData->buffer;

        // 将客户端套接字与完成端口关联
        CreateIoCompletionPort((HANDLE)clientSocket, completionPort, (ULONG_PTR)clientSocket, 0);

        // 异步接收数据
        DWORD flags = 0;
        WSARecv(clientSocket, &ioData->wsabuf, 1, NULL, &flags, &ioData->overlapped, NULL);
    }

    WSACleanup();
    return 0;
}

3. 示例代码的关键步骤

  • 创建完成端口: 使用 CreateIoCompletionPort 创建完成端口。
  • 启动工作线程: 启动多个工作线程,每个线程调用 GetQueuedCompletionStatus 来等待异步 I/O 操作的完成通知。
  • 关联客户端套接字到完成端口: 使用 CreateIoCompletionPort 将接受的客户端套接字绑定到完成端口。
  • 发起异步 I/O 操作: 使用 WSARecvWSASend 发起异步的接收或发送操作。

4. 总结

IOCP 提供了一种高效的异步 I/O 处理机制,适用于需要处理大量并发 I/O 请求的应用程序,如网络服务器或数据库服务器。它通过将 I/O 操作与完成端口绑定,使用多个工作线程来处理已完成的 I/O 操作,从而实现了高效的资源利用和高并发处理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值