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

IO多路复用与多线程模型在服务器开发中的对比

2021-12-094.6k 阅读

一、服务器开发中的基本概念

在深入探讨 IO 多路复用与多线程模型之前,我们先来明确一些服务器开发中的基本概念。

(一)I/O 操作

在服务器开发中,I/O 操作是至关重要的环节。I/O 操作涵盖了从网络套接字接收和发送数据、读取和写入文件等。对于网络服务器而言,与客户端的通信本质上就是一系列的 I/O 操作。比如,服务器需要从客户端套接字读取请求数据,经过处理后再将响应数据写回到客户端套接字。

从操作系统角度看,I/O 操作涉及到用户空间与内核空间的交互。当应用程序发起一个 I/O 请求时,内核负责实际的硬件操作,如从网络接口卡接收数据或者将数据写入磁盘。这中间存在着上下文切换的开销,因为应用程序需要从用户态切换到内核态来执行 I/O 操作,完成后再切换回用户态。

(二)进程与线程

  1. 进程 进程是操作系统进行资源分配和调度的基本单位。每个进程都有独立的地址空间,包括代码段、数据段、堆和栈等。不同进程之间相互隔离,这意味着一个进程无法直接访问另一个进程的内存空间,它们之间的通信需要通过特定的进程间通信(IPC)机制,如管道、消息队列、共享内存等。在服务器开发中,如果采用多进程模型,每个进程可以独立处理客户端请求,这样可以利用多核 CPU 的优势,但进程间通信相对复杂,并且进程的创建和销毁开销较大。
  2. 线程 线程是进程中的一个执行单元,是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的地址空间,包括代码段、数据段和堆,但是每个线程有自己独立的栈空间。线程间的通信相对简单,因为它们共享内存,可以直接访问进程内的变量。然而,共享内存也带来了同步问题,需要使用诸如互斥锁、条件变量等同步机制来避免数据竞争。在服务器开发中,多线程模型可以有效利用进程内的资源,并且线程的创建和销毁开销相对进程较小。

二、IO 多路复用模型

(一)IO 多路复用原理

IO 多路复用允许应用程序在一个线程内同时监控多个文件描述符(如套接字)的 I/O 事件。它通过一个系统调用,如 select、poll 或 epoll(在 Linux 系统中),让内核去检查这些文件描述符上是否有事件发生(如可读、可写等)。当有事件发生时,系统调用返回,应用程序可以对发生事件的文件描述符进行相应的 I/O 操作。

以 select 为例,它的函数原型如下:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

其中,nfds 是需要检查的文件描述符集合中的最大文件描述符值加 1;readfds、writefds 和 exceptfds 分别是需要检查的可读、可写和异常文件描述符集合;timeout 用于设置等待的超时时间。select 函数会阻塞等待,直到有文件描述符上发生事件或者超时。

(二)IO 多路复用优势

  1. 高效利用资源 IO 多路复用模型在单线程内可以处理多个连接,避免了为每个连接创建单独的线程或进程所带来的资源开销。这对于高并发场景下的服务器非常重要,因为系统资源是有限的,过多的线程或进程可能会导致系统资源耗尽。例如,在一个聊天服务器中,可能会同时有数千个客户端连接,如果采用多线程或多进程模型,系统可能无法承受如此多的并发实体。而 IO 多路复用模型可以在一个线程内高效地处理这些连接,大大提高了资源利用率。
  2. 降低上下文切换开销 由于只使用一个线程,避免了多线程或多进程模型中频繁的上下文切换。上下文切换会带来一定的性能开销,包括保存和恢复寄存器值、切换内存映射等操作。在高并发场景下,频繁的上下文切换会严重影响系统性能。IO 多路复用模型通过单线程处理多个连接,减少了上下文切换的次数,从而提高了系统的整体性能。

(三)IO 多路复用劣势

  1. 编程复杂度较高 相比多线程模型,IO 多路复用模型的编程实现相对复杂。开发人员需要手动管理文件描述符集合,处理事件的分发逻辑等。例如,在使用 select 时,每次调用后都需要重新设置文件描述符集合,因为 select 会修改传入的文件描述符集合。这增加了编程的难度和出错的可能性,对开发人员的技术要求较高。
  2. 不适用于 CPU 密集型任务 IO 多路复用模型主要适用于 I/O 密集型任务,对于 CPU 密集型任务,由于单线程执行,无法充分利用多核 CPU 的优势。例如,在进行复杂的数据分析或加密计算时,单线程的处理速度会成为性能瓶颈。

(四)IO 多路复用代码示例(以 C 语言使用 select 为例)

#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 MAX_CLIENTS 100

int main(int argc, char const *argv[]) {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

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

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听套接字
    if (listen(server_fd, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    fd_set read_fds;
    fd_set tmp_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_fd, &read_fds);

    int activity, new_socket_fd;
    while (1) {
        tmp_fds = read_fds;
        activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);

        if ((activity < 0) && (errno!= EINTR)) {
            printf("select error");
        } else if (activity) {
            if (FD_ISSET(server_fd, &tmp_fds)) {
                if ((new_socket_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    continue;
                }
                FD_SET(new_socket_fd, &read_fds);
                printf("New connection, socket fd is %d, ip is : %s, port : %d \n", new_socket_fd, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
            }

            for (int i = 0; i <= server_fd; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    if (i!= server_fd) {
                        valread = read(i, buffer, 1024);
                        if (valread == 0) {
                            getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                            printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
                            close(i);
                            FD_CLR(i, &read_fds);
                        } else {
                            buffer[valread] = '\0';
                            send(i, buffer, strlen(buffer), 0);
                        }
                    }
                }
            }
        }
    }
    return 0;
}

在上述代码中,通过 select 函数实现了一个简单的服务器,能够同时处理多个客户端连接。首先创建服务器套接字并进行绑定和监听,然后使用 select 监控服务器套接字和已连接客户端套接字的可读事件。当有新连接到来时,接受连接并将新套接字加入到监控集合中;当有客户端发送数据时,读取数据并回显给客户端。

三、多线程模型

(一)多线程原理

在多线程模型中,服务器为每个客户端连接创建一个独立的线程来处理请求。每个线程在进程的地址空间内独立执行,拥有自己的栈空间,但共享进程的代码段、数据段和堆。当一个客户端连接到服务器时,服务器主线程会创建一个新的线程,该线程负责与客户端进行通信,包括读取客户端请求、处理请求并返回响应。

以 C 语言的 POSIX 线程库为例,创建线程的函数如下:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

其中,thread 是指向新线程标识符的指针;attr 用于设置线程属性,通常可以设为 NULL 使用默认属性;start_routine 是新线程开始执行的函数指针;arg 是传递给 start_routine 函数的参数。

(二)多线程优势

  1. 简单直观的编程模型 多线程模型的编程逻辑相对简单。开发人员可以将每个客户端连接的处理逻辑封装在一个独立的函数中,由新创建的线程来执行。这种方式使得代码结构清晰,易于理解和维护。例如,在一个简单的文件服务器中,每个线程可以独立处理一个客户端的文件读取或写入请求,代码逻辑类似于顺序执行的程序,不需要像 IO 多路复用模型那样复杂地管理文件描述符集合和事件分发。
  2. 充分利用多核 CPU 对于 CPU 密集型任务,多线程模型可以充分利用多核 CPU 的优势。因为每个线程可以在不同的 CPU 核心上并行执行,从而提高整体的处理能力。例如,在进行大数据分析的服务器中,不同的线程可以并行处理不同的数据块,大大加快了数据分析的速度。

(三)多线程劣势

  1. 资源开销较大 每个线程都需要占用一定的系统资源,包括栈空间、线程控制块等。在高并发场景下,创建大量线程会消耗大量的系统内存。例如,假设每个线程的栈空间为 1MB,如果同时有 1000 个线程,仅栈空间就需要 1GB 的内存。此外,线程的创建和销毁也会带来一定的性能开销,包括分配和释放栈空间、初始化和清理线程控制块等操作。
  2. 同步问题复杂 由于多个线程共享进程的地址空间,可能会出现数据竞争问题。例如,多个线程同时访问和修改同一个全局变量时,如果没有适当的同步机制,可能会导致数据不一致。为了解决这个问题,需要使用互斥锁、条件变量等同步工具。然而,同步机制的使用会增加编程的复杂度,并且可能会导致死锁等问题。例如,两个线程相互等待对方释放锁,就会陷入死锁状态,使得程序无法继续执行。

(四)多线程代码示例(以 C 语言使用 POSIX 线程库为例)

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

#define PORT 8080

void *handle_client(void *arg) {
    int client_fd = *((int *)arg);
    char buffer[1024] = {0};
    int valread = read(client_fd, buffer, 1024);
    if (valread < 0) {
        perror("read failed");
        pthread_exit(NULL);
    }
    buffer[valread] = '\0';
    printf("Received from client: %s\n", buffer);
    send(client_fd, buffer, strlen(buffer), 0);
    close(client_fd);
    pthread_exit(NULL);
}

int main(int argc, char const *argv[]) {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

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

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听套接字
    if (listen(server_fd, 10) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    pthread_t threads[10];
    int i = 0;
    while (1) {
        if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
            perror("accept");
            continue;
        }
        printf("New connection accepted\n");
        int *client_fd = (int *)malloc(sizeof(int));
        *client_fd = new_socket;
        if (pthread_create(&threads[i], NULL, handle_client, (void *)client_fd)!= 0) {
            perror("pthread_create");
            free(client_fd);
            close(new_socket);
            continue;
        }
        i = (i + 1) % 10;
    }
    for (i = 0; i < 10; i++) {
        pthread_join(threads[i], NULL);
    }
    close(server_fd);
    return 0;
}

在上述代码中,通过 POSIX 线程库实现了一个简单的多线程服务器。主线程负责监听客户端连接,每当有新连接到来时,创建一个新的线程来处理该客户端的通信。新线程从客户端读取数据,回显数据后关闭连接。这里使用了 pthread_create 函数创建线程,pthread_join 函数等待线程结束。

四、IO 多路复用与多线程模型对比

(一)性能对比

  1. I/O 密集型场景 在 I/O 密集型场景下,IO 多路复用模型通常具有更好的性能。由于它可以在单线程内处理多个连接,避免了多线程模型中频繁的上下文切换开销,并且能够高效利用系统资源。例如,在一个 Web 服务器中,大部分时间都花在等待网络 I/O 操作完成上,IO 多路复用模型可以在一个线程内同时处理大量的客户端连接,提高服务器的并发处理能力。而多线程模型在这种场景下,由于线程创建和上下文切换的开销,随着并发连接数的增加,性能会逐渐下降。
  2. CPU 密集型场景 对于 CPU 密集型场景,多线程模型更具优势。因为多线程模型可以充分利用多核 CPU 的并行处理能力,将计算任务分配到不同的线程上,在不同的 CPU 核心上并行执行。例如,在进行大规模数据加密的服务器中,多线程模型可以显著提高加密的速度。而 IO 多路复用模型由于是单线程执行,无法充分利用多核 CPU 的优势,在处理 CPU 密集型任务时性能会受到限制。

(二)资源占用对比

  1. 内存占用 多线程模型的内存占用相对较大。每个线程都需要占用一定的栈空间,在高并发场景下,创建大量线程会消耗大量的内存。相比之下,IO 多路复用模型只需要一个线程,内存占用主要集中在文件描述符集合等数据结构上,内存占用相对较小。例如,在一个需要同时处理数千个连接的服务器中,多线程模型可能会因为内存不足而无法创建足够的线程,而 IO 多路复用模型可以在有限的内存下正常运行。
  2. 系统资源开销 多线程模型在系统资源开销方面除了内存占用外,还包括线程的创建、销毁以及上下文切换等开销。这些开销在高并发场景下会对系统性能产生较大影响。而 IO 多路复用模型通过单线程处理多个连接,避免了这些开销,对系统资源的消耗相对较小。例如,在频繁有客户端连接和断开的场景中,多线程模型需要不断创建和销毁线程,而 IO 多路复用模型只需要在连接建立和断开时进行简单的文件描述符操作,系统资源开销明显更小。

(三)编程复杂度对比

  1. 代码结构 多线程模型的代码结构相对简单直观。每个客户端连接的处理逻辑可以封装在一个独立的函数中,由线程执行,类似于顺序执行的程序。开发人员可以更容易地理解和维护代码。例如,在一个简单的聊天服务器中,每个线程负责处理一个客户端的消息收发,代码逻辑清晰明了。而 IO 多路复用模型需要开发人员手动管理文件描述符集合,处理事件的分发逻辑,代码结构相对复杂。例如,在使用 select 时,需要不断重新设置文件描述符集合,处理不同类型的事件,增加了代码的复杂度。
  2. 同步机制 多线程模型由于多个线程共享进程的地址空间,需要使用同步机制来避免数据竞争等问题,这增加了编程的复杂度。同步机制的使用不当还可能导致死锁等问题。例如,在多个线程同时访问和修改共享数据时,需要使用互斥锁来保证数据的一致性。而 IO 多路复用模型由于是单线程执行,不存在数据竞争问题,不需要使用同步机制,编程相对简单。

(四)适用场景对比

  1. 高并发 I/O 密集型应用 对于高并发的 I/O 密集型应用,如 Web 服务器、邮件服务器等,IO 多路复用模型是更好的选择。这些应用大部分时间都在等待网络 I/O 操作完成,IO 多路复用模型可以高效地处理大量的并发连接,提高系统的性能和并发处理能力。例如,在一个面向大量用户的 Web 服务器中,使用 IO 多路复用模型可以在有限的资源下支持更多的并发访问。
  2. CPU 密集型或 I/O 与 CPU 均衡型应用 对于 CPU 密集型应用,如大数据分析、科学计算等,或者 I/O 与 CPU 负载相对均衡的应用,多线程模型更为合适。多线程模型可以充分利用多核 CPU 的优势,提高计算效率。例如,在一个进行实时数据处理的服务器中,既需要进行数据的网络传输(I/O 操作),也需要对数据进行复杂的计算(CPU 操作),多线程模型可以更好地平衡 I/O 和 CPU 的负载,提高系统的整体性能。