[结构化学习]网络IO模型思想及实现

1. 网络IO流程简介

对于一个 CS 架构的服务,其中少不了网络通信。而涉及到网络通信,自然就涉及到 Socket 编程,而网络IO模型 简单来说就是对Socket处理流程进行抽象。

注:本文中的IO仅代指网络IO

一个简化的Socket处理流程如下:

  1. 服务端创建Socket监听端口
  2. 服务端等待Socket连接建立
  3. 客户端建立Socket连接
  4. 服务端感知到Socket连接建立,继续执行代码,对Socket发起读请求
  5. 客户端写入数据
  6. 服务端感知到Socket数据准备就绪
  7. 服务端进行数据读取
  8. 服务端根据数据进行业务处理

2. 基础IO模型

在不断提升的网络并发要求下,服务端的IO模型也在不断演进,服务端的优化策略主要有这些方面:

  • 多线程对IO请求进行处理,平衡CPU和IO的速度差异
  • 通过避免内核空间频繁切换,降低线程数量等方式,降低服务端的性能损耗
  • 批量处理多个IO请求

为了支持不同的IO模型,一般来说操作系统提供了多种IO相关的系统调用(也可以说是计算机硬件、操作系统、应用三层在实际的业务需求中相互影响而来)。

2.1. 阻塞IO

我们一般说阻塞IO即最基础的IO模型(BIO),所有步骤全部为单线程处理,例如第4步如果客户端没有发送数据,线程会阻塞住无法继续执行。

具体理解示例可以参考五种IO模型(详解+形象例子说明),写的很好不再赘述。

代码实现:

注:本文代码只在linux下验证,使用c/c++演示

下文代码实现了一个BIO服务端和一个客户端,服务端监听12345端口,客户端发送helloworld消息,服务端接收并将消息写回Socket。

服务端代码:

c 复制代码
// bio_server.c
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024

int main() {
    int server_sock, client_sock;
    char message[BUF_SIZE];
    int str_len, i;

    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_size;

    server_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (server_sock == -1) {
        perror("socket() error");
        exit(1);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(12345);

    if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind() error");
        exit(1);
    }
	// 步骤1
    if (listen(server_sock, 5) == -1) {
        perror("listen() error");
        exit(1);
    }

    client_addr_size = sizeof(client_addr);
	// 步骤2
    client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
    if (client_sock == -1) {
        perror("accept() error");
        exit(1);
    }
	// 步骤4+ 6
    while ((str_len = read(client_sock, message, BUF_SIZE)) != 0) {
        message[str_len] = 0;
        printf("Received message: %s\n", message);
		write(client_sock, message, str_len);

    close(client_sock);
    close(server_sock);

    return 0;
}

客户端代码:

c 复制代码
// bio_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024

int main() {
    int sock;
    char message[BUF_SIZE];
    int str_len;

    struct sockaddr_in server_addr;

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket() error");
        exit(1);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    server_addr.sin_port = htons(12345);
	//步骤3
    if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("connect() error");
        exit(1);
    }

    strcpy(message, "helloworld");
	//步骤5
    write(sock, message, strlen(message));
    str_len = read(sock, message, BUF_SIZE - 1);
    message[str_len] = 0;
    printf("Received message: %s\n", message);

    close(sock);

    return 0;
}

上述代码一个服务端每次只能处理一个线程,比较离谱,所以生产环境中一般不这么干,会单独起一个线程来处理客户端连接,就像下面这样:

c 复制代码
// bio_pthread_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>

#define BUF_SIZE 1024

void *handle_socket(void *args);

int main()
{
    int server_sock, client_sock;
    int str_len, i;

    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_size;

    server_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (server_sock == -1)
    {
        perror("socket() error");
        exit(1);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(12345);

    if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("bind() error");
        exit(1);
    }

    if (listen(server_sock, 5) == -1)
    {
        perror("listen() error");
        exit(1);
    }
	//增加循环
    while (1)
    {
        printf("waiting for client ...\n");
        client_addr_size = sizeof(client_addr);
		//步骤2
        client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
        printf("new client connected ! \n");
        if (client_sock == -1)
        {
            perror("accept() error");
            exit(1);
        }
		
        pthread_t client_thread;
        if (pthread_create(&client_thread, NULL, handle_socket, (void *)&client_sock) != 0)
        {
            perror("pthread create error");
            close(client_sock);
            continue;
        }
        //开辟新线程处理连接,主线程继续跑下一次循环
        pthread_detach(client_thread);
        printf("detach a thread to handle new client \n");
    }
    close(server_sock);

    return 0;
}

//步骤4和步骤6放到这里
void *handle_socket(void *args)
{
    int client_sock = *((int *)args);
    char message[BUF_SIZE];
    int  str_len,recv_len;
    while ((str_len = read(client_sock, message, BUF_SIZE)) != 0)
    {
        message[str_len] = 0;
        printf("Received message: %s\n", message);
        write(client_sock, message, str_len);
    }
    close(client_sock);
}

但是这样仍然有一些问题:

  1. 每次处理一个连接都起一个服务端线程,资源损耗较大
  2. 服务端每个线程只能监听一个socket,利用率较低
  3. 如果没有新的客户端连接,主线程会阻塞在accept函数上无法做其他事情

在其他模型中有一些解决方案。

2.2. 非阻塞IO

非阻塞IO即在阻塞IO的基础上,在第2步不会阻塞线程,而是有一个线程一直循环尝试读取数据,如果没有数据则继续循环,当数据准备好后,再读取数据,此方法对并发处理较好,但是对cpu占有率较高

代码实现:

注:后面代码就只贴关键部分,全部代码会放到github上

c 复制代码
// bio_noblock_server.c
// ...省略服务端监听初始化代码...
//设置server socket为非阻塞模式
fcntl(server_sock, F_SETFL, O_NONBLOCK);
client_addr_size = sizeof(client_addr);
//建立连接时,由于accept函数不再阻塞,增加一个循环处理,没有获取到连接就继续循环
while(1){
	printf("begin accept ...\n");
	client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
	if (client_sock == -1) {
		printf("no client connect! continue... \n");
		sleep(2);
	}else{
		printf("accepted client connect\n");
		break;
	}
}

2.3. IO多路转接(IO多路复用)

IO多路转接的思路是基于阻塞IO的基础上,在步骤6和步骤7只需要用一个线程(即"复用")监听多个Socket的IO事件,可以大幅节省线程资源,只要有事件触发,它会唤起相应的处理线程来处理IO事件。

通常在linux环境下提供了select/poll/epoll系统调用来实现IO多路复用。

下面是基于select函数实现的IO多路复用:

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/time.h>
#include <errno.h>

#define BUF_SIZE 1024

int main()
{
    int server_sock = init_server_sock();
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(server_sock, &read_fds);

    while (1)
    {
        select_loop(server_sock, read_fds);
    }

    close(server_sock);
    return 0;
}

int select_loop(int server_sock, fd_set read_fds)
{
    int max_fd = server_sock;
    char buffer[BUF_SIZE];

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

    socklen_t client_addr_size;
    client_addr_size = sizeof(client_addr);
    // 每次select事件触发后,结构会清空,需要调用select重新传入fds
    int ret = select(server_sock + 1, &read_fds, NULL, NULL, &timeout);
    if (ret == -1)
    {
        perror("select");
        exit(1);
    }
    else if (ret == 0)
    {
        printf("Timeout");
        return -1;
    }
    // server_sock如果触发了IO可读事件,说明有新连接建立
    if (FD_ISSET(server_sock, &read_fds))
    {
        int client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
        if (client_sock == -1)
        {
            perror("accept");
            return -1;
        }
        FD_SET(client_sock, &read_fds);
        max_fd = client_sock > server_sock ? client_sock : server_sock;
    }
    int fd;
    for (fd = server_sock+1; fd < max_fd + 1; fd++)
    {
        // client_sock如果成功建立,读数据
        if (FD_ISSET(fd, &read_fds))
        {
            printf("read:%d\n", fd);
            int recv_len = read(fd, buffer, BUF_SIZE - 1);
            if (recv_len == -1)
            {
                perror("read");
                close(fd);
                FD_CLR(fd, &read_fds);
                continue;
            }
            else if (recv_len == 0)
            {
                printf("Client disconnected");
                close(fd);
                FD_CLR(fd, &read_fds);
                continue;
            }
            
            buffer[recv_len] = '\0';
            printf("Received: %s\n", buffer);
            send(fd, buffer, recv_len, 0);
        }
    }
}

int init_server_sock()
{
    int server_sock, max_fd, min_fd;
    struct sockaddr_in server_addr;
    server_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (server_sock == -1)
    {
        perror("socket");
        exit(1);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(12345);

    if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("bind");
        exit(1);
    }

    if (listen(server_sock, 5) == -1)
    {
        perror("listen");
        exit(1);
    }
    return server_sock;
}

其他的两种函数可以参考bio_poll_server.cbio_epoll_server.c

那么三个函数有什么区别呢,参数啥意思?

简单来说,select函数有几个参数,代表着读、写、异常事件类型的socket文件描述符集合及超时设置(在Linux中,每个Socket对应一个文件描述符):

c 复制代码
//select函数定义
//在centos7,位于/usr/include/sys/select.h头文件中
extern int select (int __nfds, fd_set *__restrict __readfds,
		   fd_set *__restrict __writefds,
		   fd_set *__restrict __exceptfds,
		   struct timeval *__restrict __timeout);

select使用的参数类型fd_set支持的文件描述符数量有限(1024),而epoll函数针对数量限制做了提升,支持超过1024个描述符数量。

c 复制代码
//poll函数定义
//pollfd可以传入更多的描述符,其他的功能和select并无太大区别
extern int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);

从示例代码可以看出在基于select和poll函数中,需要循环调用select和poll函数,不断进行文件描述符的传入,传入后在内核中还需要遍历所有的文件描述符,这样会进行较多的内核和用户空间的上下文切换,造成较大的资源损耗。所以Linux推出了epoll函数.

epoll的思路是首先在事件注册时,只拷贝一次文件描述符进内核,而且为每个文件描述符注册一个回调函数,当IO事件发生时,内核会回调函数吧文件描述符放入一个就绪列表,这样就不用每次都遍历所有的文件描述符检查是否就绪,只检查就绪列表中的即可。

epoll代码核心实现参考bio_epoll_server.c,此处不做更深入的源码分析

2.4. 信号驱动IO

信号驱动IO的思路是,用户进程向内核注册SIGIO信号触发的回调函数。IO事件发生后(比如内核缓冲区数据就位)后,通知用户程序,用户进程在signal回调函数中进行业务处理。

但是信号驱动IO在TCP中,导致SIGIO通知的条件过多,而且没有告诉用户进程发生了什么IO事件,在TCP套接字的开发过程中很少使用。

但是在UDP套接字上,通过SIGIO信号进行下面两个事件的类型判断即可:

  1. 数据报到达套接字
  2. 套接字上发生错误

因此,在SIGIO出现的时候,用户进程很容易进行判断:如果不是发生错误,就是有数据报到达了,信号驱动IO在TCP编程中使用较少,本文暂未实现代码。

2.5. 异步IO

异步IO相当于注册一个IO请求到内核,当Socket的IO读事件被触发时,内核会直接执行这个IO请求,也就是IO流程由内核接管了,内核将相应的数据读入指定的地址,然后回调用户线程通知数据准备完成。

异步IO的缺点是要求内核有相应实现,而且占用内核的资源较多。 代码实现见aio_server.c

3. Reactor IO模型

Reactor IO模型严格来说也是一种IO模型,但是它在底层的IO上并没有什么变化,而是基于多路复用IO模型的API和一些职责分离的编程模式进行组合而成的一种较高层次的IO模型,我一般视为是一个应用级别的IO模型(或者叫设计模式),常见的应用包括nginx、redis、netty底层都应用了这种IO模型。

Reactor从字面翻译为响应式,也就是有事件发生再进行响应的处理,他的思路是把事件的监听者和事件的处理者职责分离,线程分离,这样会提高一定的性能,也有更高的扩展性。

所以说具体实现中,基本的Reactor模型主要有事件监听器(Reactor)和事件处理器(Handler)两个角色。而部分生产实现中,会将Reactor分为主从两个角色,把建立客户端新连接的职责分离出来。

也即是一共三种角色:

  1. MainReactor(主反应器):负责建立客户端新连接,并分发相应的连接建立事件
  2. SubReactor(反应器):负责监听和分配事件,将IO事件分配到对应的handler
  3. Handler(处理器):处理Reactor分派的相应的事件 三种角色间一般都单独开启独立的线程,充分利用CPU,大大提升服务端处理大量链接的能力。

一个基础的单线程reactor编程模型实现:

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/time.h>
#include <errno.h>

#define BUF_SIZE 1024
void handle_client_event(char buffer[BUF_SIZE], int recv_len, int fd);

int main()
{
    int server_sock = init_server_sock();
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(server_sock, &read_fds);

    while (1)
    {
        select_loop(server_sock, read_fds);
    }

    close(server_sock);
    return 0;
}

int select_loop(int server_sock, fd_set read_fds)
{
    int max_fd = server_sock;
    struct timeval timeout;
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;

    // 每次select事件触发后,结构会清空,需要调用select重新传入fds
    int ret = select(server_sock + 1, &read_fds, NULL, NULL, &timeout);
    if (ret == -1)
    {
        perror("select");
        exit(1);
    }
    else if (ret == 0)
    {
        printf("Timeout");
        return -1;
    }
 
    max_fd = react(server_sock, max_fd, read_fds);
}

//事件响应器
int react(int server_sock, int max_fd, fd_set read_fds)
{

    struct sockaddr_in client_addr;
    socklen_t client_addr_size;
    client_addr_size = sizeof(client_addr);
    char buffer[BUF_SIZE];
    // server_sock如果触发了IO可读事件,说明有新连接建立
    if (FD_ISSET(server_sock, &read_fds))
    {
        int client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
        if (client_sock == -1)
        {
            perror("accept");
            return max_fd;
        }
        FD_SET(client_sock, &read_fds);
        max_fd = client_sock > server_sock ? client_sock : server_sock;
    }
    int fd;
    for (fd = server_sock + 1; fd < max_fd + 1; fd++)
    {
        // client_sock如果成功建立,读数据
        if (FD_ISSET(fd, &read_fds))
        {
            printf("read:%d\n", fd);
            int recv_len = read(fd, buffer, BUF_SIZE - 1);
            if (recv_len == -1)
            {
                perror("read");
                close(fd);
                FD_CLR(fd, &read_fds);
                continue;
            }
            else if (recv_len == 0)
            {
                printf("Client disconnected");
                close(fd);
                FD_CLR(fd, &read_fds);
                continue;
            }

            handle_client_event(buffer, recv_len, fd);
        }
    }
    return max_fd;
}

//业务处理器
void handle_client_event(char buffer[BUF_SIZE], int recv_len, int fd)
{
    buffer[recv_len] = '\0';
    printf("Received: %s\n", buffer);
    send(fd, buffer, recv_len, 0);
}

int init_server_sock()
{
    int server_sock, max_fd, min_fd;
    struct sockaddr_in server_addr;
    server_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (server_sock == -1)
    {
        perror("socket");
        exit(1);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(12345);

    if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("bind");
        exit(1);
    }

    if (listen(server_sock, 5) == -1)
    {
        perror("listen");
        exit(1);
    }
    return server_sock;
}

4. 总结

4.1. 优缺点对比

下面是各种IO模型的特点总结,没有一种模型在所有场景都是最好的(否则只会存在这一种模型了),而且各种io模型可能会有结合使用的情况,例如IO多路转接可以是阻塞的也可以是非阻塞的,使用中可以根据具体场景从去具体分析,找到最适合当前场景的IO模型实现

  1. 阻塞IO:
  • 优点:实现简单易于理解,各操作系统兼容性较好。
  • 缺点:在等待IO操作完成的过程中,进程无法做其他的事情,导致CPU资源的浪费。
  1. 非阻塞IO:
  • 优点:发起调用后,若当前不具备IO条件,则立即返回,可以做其他的事情,提高了当前进程的效率。
  • 缺点:不断从应用向内核发起轮询请求,CPU占用率较高。
  1. IO多路转接:
  • 优点:可以实现多个IO操作的并发执行,提高了效率。
  • 缺点:需要维护多个IO连接的状态和切换逻辑,实现较为复杂。
  1. 信号驱动IO:
  • 优点:IO就绪时,通过信号通知进程进行IO操作,实现了异步处理,提高了CPU的利用率。
  • 缺点:需要处理信号和IO操作的映射关系,而且信号的种类是有限的,实现较为复杂且扩展性较差。
  1. 异步IO:
  • 优点:通过异步IO告诉操作系统哪些数据需要拷贝到何处,等待与拷贝的过程都由操作系统完成,进一步提高了CPU的利用率。
  • 缺点:流程最为复杂,需要依赖操作系统的支持,内核占用率较高。
  1. Reactor IO模型:
  • 优点:采用事件驱动的方式,避免了阻塞和轮询的开销,提高了资源利用率和效率,高效。
  • 缺点:需要维护事件循环和回调函数的逻辑,实现相对复杂。

5. 参考文献

相关推荐
阿尔帕兹5 小时前
构建 HTTP 服务端与 Docker 镜像:从开发到测试
网络协议·http·docker
FeelTouch Labs6 小时前
Netty实现WebSocket Server是否开启压缩深度分析
网络·websocket·网络协议
千天夜7 小时前
使用UDP协议传输视频流!(分片、缓存)
python·网络协议·udp·视频流
follycat8 小时前
[极客大挑战 2019]HTTP 1
网络·网络协议·http·网络安全
earthzhang20219 小时前
《深入浅出HTTPS》读书笔记(5):随机数
网络协议·http·https
xiaoxiongip6669 小时前
HTTP 和 HTTPS
网络·爬虫·网络协议·tcp/ip·http·https·ip
JaneJiazhao9 小时前
HTTPSOK:SSL/TLS证书自动续期工具
服务器·网络协议·ssl
JaneJiazhao9 小时前
HTTPSOK:智能SSL证书管理的新选择
网络·网络协议·ssl
城南vision11 小时前
计算机网络——HTTP篇
网络协议·计算机网络·http
点点滴滴的记录1 天前
RPC核心实现原理
网络·网络协议·rpc