I/O 多路复用解析

I/O 多路复用解析

基础概念

首先我们了解下2个基础概念,这2个概念在后续的文章中会反复用到。

Socket

套接字。百科:对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。

例子1:客户端将数据通过网线发送到服务端,客户端发送数据需要一个出口,服务端接收数据需要一个入口,这两个“口子”就是 Socket。

例子2:两个人通过电话进行通信,两个人都需要持有1个电话,socket 就类似于这个电话。

FD:file descriptor

文件描述符,非负整数。“一切皆文件”,linux 中的一切资源都可以通过文件的方式访问和管理。而 FD 就类似文件的索引(符号、指针),指向某个资源,内核(kernel)利用 FD 来访问和管理资源。

Socket 通信

image-20220920114704566

图中函数的含义如下:

socket:创建一个套接字

bind:将 socket 绑定到指定地址

listen:使套接字处于监听状态,等待客户端连接到来

accept:接受客户端连接

connect:客户端发起连接

read:从 fd 对应的 socket 中读取数据

write:将数据写入 fd 对应的 socket 中

close:关闭 socket 文件描述符

核心交互流程如下:

1)服务器端通过 socket、bind、listen 对 socket 进行初始化,最后阻塞在 accept 等待客户端请求到来。

2)客户端通过 socket 进行初始化,然后使用 connect 向服务端发起连接请求。此时客户端会和服务端进行 TCP 三次握手,三次握手完成后,客户端和服务端建立连接完毕,开始进入数据传输过程。

3)客户端发起 write 系统调用写入数据,数据从用户空间拷贝到内核空间 socket 缓冲区,最后内核将数据通过网络发送到服务器。

4)数据经过网络传输到达服务器网卡,接着内核将数据拷贝到对应的 socket 接收队列,最后将数据从内核空间拷贝到用户空间。

5)客户端和服务器完成交互后,调用 close 函数来断开连接。

同步阻塞IO

核心流程

当应用程序发起 read 系统调用时,在内核数据没有准备好之前,应用程序会一直处于阻塞等待状态,直到内核把数据准备好了返回给应用程序

交互流程

我们通过两段代码的一个动图来模拟同步阻塞IO下服务端和客户端的执行流程:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EBZk1LUx-1663756213838)(https://gitee.com/jtyyds/drawing-bed/raw/master/phones/pic/c1ba2e9821c34088841bb69efba6a2c4.gif)]

大致流程如下:

1)服务端进行初始化:新建 socket、绑定地址、转为服务端 socket

2)服务端调用 accept,进入阻塞状态,等待客户端连接

3)客户端新建 socket,向服务端发起连接

4)服务端和客户端通过 TCP 三次握手建立连接

5)服务端继续执行 read 函数,进入阻塞状态,等待客户端发送数据

6)客户端向服务端发送数据

7)服务端读取数据,执行逻辑处理9/article/details/124699602

同步阻塞IO模型

我们通过 read 函数来看下服务器内部用户空间和内核空间的调用流程,如下图所示:

image-20220920170417173

大致流程如下:

1)应用进程发起 read 系统调用

2)应用进程阻塞等待数据就绪

3)数据通过网络传输到达网卡,然后再到内核socket缓冲区,当数据被拷贝到内核 socket 缓冲区时,此时处于就绪状态

4)将数据从内核拷贝到应用程序缓冲区,返回成功

多线程版本:文中使用的例子是单线程,如果是多线程则在每个 socket 建立连接后新建线程去负责处理该 socket 后续的流程,这样就不会由于单个 socket 阻塞住而影响到其他 socket。

总结

单线程:某个 socket 阻塞,会影响到其他 socket 处理。

多线程:当客户端较多时,会造成资源浪费,全部 socket 中可能每个时刻只有几个就绪。同时,线程的调度、上下文切换乃至它们占用的内存,可能都会成为瓶颈。

同步非阻塞IO

核心流程

当应用程序发起 read 系统调用时,在内核数据没有准备好之前,内核会直接返回错误,应用程序不断轮询内核,直到内核把数据准备好了返回给应用程序。

交互流程

我们通过两段代码的一个动图来模拟同步阻塞IO下服务端和客户端的执行流程:

img

大致流程如下:

1)服务端调用 accept,数据未就绪,内核返回-1

2)服务端调用 accept,数据未就绪,内核返回-1

3)服务端调用 accept,数据未就绪,内核返回-1

4)客户端新建 socket,向服务端发起连接

4)服务端调用 accept,服务端和客户端通过 TCP 三次握手建立连接

5)服务端执行后续逻辑处理

我们通过 read 函数来看下服务器内部用户空间和内核空间的调用流程,如下图所示:

img

大致流程如下:

1)服务端调用 read,数据未就绪,内核返回-1

2)服务端调用 read,数据未就绪,内核返回-1

3)服务端调用 read,数据就绪

4)将数据从内核拷贝到应用程序缓冲区,返回成功

同步非阻塞IO模型

image-20220920172311819

总结

提供了非阻塞调用的方式,从操作系统层面解决了阻塞问题。

优点

单个 socket 阻塞,不会影响到其他 socket

缺点

需要不断的遍历进行系统调用,有一定开销

SELECT

核心流程

1)应用程序首先发起 select 系统调用,传入要监听的文件描述符集合

2)内核遍历应用程序传入的 fd 集合,如果遍历完一遍后发现没有就绪的 fd 则用户进程会进入阻塞状态,如果有就绪的 fd 则会对就绪的 fd 打标,然后返回

3)应用程序遍历 fd 集合,找到就绪的 fd,进行相应的事件处理

select 接口

/**
 * 获取就绪事件
 *
 * @param nfds      3个监听集合的文件描述符最大值+1
 * @param readfds   要监听的可读文件描述符集合
 * @param writefds  要监听的可写文件描述符集合
 * @param exceptfds 要监听的异常文件描述符集合
 * @param timeval   本次调用的超时时间
 * @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
 */
int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

交互流程

我们通过一个动图来模拟服务器内部用户空间和内核空间的调用流程,如下图所示:

img

大致流程如下:

1)用户空间发起 select 系统调用,将监听的 fd 集合从用户空间拷贝到内核空间

2)内核遍历 fd 集合,检查数据是否就绪

3)如果遍历一遍后发现没有 fd 就绪,则会将当前用户进程阻塞,让出 CPU 给其他进程

4)当客户端将数据发送到服务端,进入内核后,会通过数据库包找到对应的socket

PS:客户端发送数据到数据进入服务端内核的流程类似下面 epoll 的流程

5)socket 检查是否有阻塞等待的进程,如果有则唤醒该进程

6)用户进程恢复运行后,会再遍历 fd 集合进行检查,此时它会检查到某些 fd 已经就绪了,它会给这些 fd 打上标记,然后结束阻塞,返回到用户空间

7)用户空间知道有事件就绪,遍历 fd 集合,找到就绪的 fd,进行相应的事件处理,例如将数据从内核缓冲区拷贝到应用程序缓冲区

8)最后执行逻辑处理。

IO多路复用模型

img

fd_set

fd_set 在 select 的整个调用过程中表达了两种不同的意思。

在入参时,fd_set 表示应用程序要监听哪些 fd;在回参时,fd_set表示哪些 fd 已经就绪了。

应用程序传入的 fd_set 其实是个位图,例如我们要监听 fd = 1、fd = 4,则传入 0000 0101,也就是 5。

这边使用的 long 类型数组来实现位图:1个 long 可以表示64位,则16个long可以表示1024位。

当内核处理完毕,将就绪的 fd 返回时,会将就绪的 fd 对应的位标记为1,然后覆盖掉入参的 fd_set,所以我们最终返回时的 fd_set 表示的是哪些 fd 是就绪的。

总结

  • 将 socket 是否就绪检查逻辑下沉到操作系统层面,避免大量系统调用。
  • 告诉你有事件就绪,但是没告诉你具体是哪个 FD。

优点

  • 不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核态切换问题

缺点

  • 单进程监听的 FD 存在限制,默认1024
  • 每次调用需要将 FD 从用户态拷贝到内核态
  • 不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符
  • 入参的3个 fd_set 集合每次调用都需要重置

POLL

核心流程

基本同 select。

poll 接口

/**
 * 获取就绪事件
 *
 * @param pollfd  要监听的文件描述符集合
 * @param nfds    文件描述符数量
 * @param timeout 本次调用的超时时间
 * @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
 */
int poll(struct pollfd *fds,
         unsigned int nfds,
         int timeout);
 
struct pollfd {
    int fd;         // 监听的文件描述符
    short events;   // 监听的事件
    short revents;  // 就绪的事件
}

poll 函数基本同 select,只是对 select 进行了一些小优化,一个是优化了1024个文件描述符上限,另一个是新定义了 pollfd 数据结构,使用两个不同的变量来表示监听的事件和就绪的事件,这样就不需要像 select 那样每次重置 fd_set 了。

总结

跟 select 基本类似,主要优化了监听1024的限制。

优点

  • 不需要每个 FD 都进行一次系统调用,导致频繁的用户态内核态切换

缺点

  • 每次需要将 FD 从用户态拷贝到内核态
  • 不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符

EPOLL

核心流程

1)应用程序调用 epoll_create,内核会分配一块内存空间,创建一个 epoll,最后将 epoll 的 fd 返回,我们后续可以通过这个 fd 来操作 epoll 对象

2)应用程序不断调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,内核通过红黑树的结构来高效的维护我们传入的 fd 集合

3)应用程序调用 epoll_wait 来获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞,否则直接返回就绪的事件。

4)应用程序根据内核返回的就绪事件,进行相应的事件处理

epoll 接口

/**
 * 创建一个epoll
 *
 * @param size epoll要监听的文件描述符数量
 * @return epoll的文件描述符
 */
int epoll_create(int size);
 
/**
 * 事件注册
 *
 * @param epfd        epoll的文件描述符,epoll_create创建时返回
 * @param op          操作类型:新增(1)、删除(2)、更新(3)
 * @param fd          本次要操作的文件描述符
 * @param epoll_event 需要监听的事件:读事件、写事件等
 * @return 如果调用成功返回0, 不成功返回-1
 */
int epoll_ctl(int epfd,
              int op,
              int fd,
              struct epoll_event *event);
 
/**
 * 获取就绪事件
 *
 * @param epfd      epoll的文件描述符,epoll_create创建时返回
 * @param events    用于回传就绪的事件
 * @param maxevents 每次能处理的最大事件数
 * @param timeout   等待I/O事件发生的超时时间,-1相当于阻塞,0相当于非阻塞
 * @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
 */
int epoll_wait(int epfd,
               struct epoll_event *events,
               int maxevents,
               int timeout);

交互流程

我们通过一个动图来模拟服务器内部用户空间和内核空间的调用流程,如下图所示:

img

大致流程如下:

1)用户空间调用 epoll_create ,内核新建 epoll 对象,返回 epoll 的 fd,用于后续操作

2)用户空间反复调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,底层通过红黑树来高效的维护 fd 集合

3)用户空间调用 epoll_wait 获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞

4)客户端向服务端发送数据,数据通过网络传输到服务端的网卡

5)网卡通过 DMA 的方式将数据包写入到指定内存中(ring_buffer),处理完成后通过中断信号告诉 CPU 有新的数据包到达

6)CPU 收到中断信号后,进行响应中断,首先保存当前执行程序的上下文环境,然后调用中断处理程序(网卡驱动程序)进行处理:

根据数据包的ip和port找到对应的socket,将数据放到socket的接收队列;

执行 socket 对应的回调函数:将当前 socket 添加到 eventpoll 的就绪列表、唤醒 eventpool 等待队列里的用户进程(设置为RUNNING状态)

7)用户进程恢复运行后,检查 eventpoll 里的就绪列表不为空,则将就绪事件填充到入参中的 events 里,然后返回

8)用户进程收到返回的事件后,执行 events 里的事件处理,例如读事件则将数据从内核缓冲区拷贝到应用程序缓冲区

9)最后执行逻辑处理。

IO多路复用模型

img

总结

epoll 直接将 fd 集合维护在内核中,通过红黑树来高效管理 fd 集合,同时维护一个就绪列表,当 fd 就绪后会添加到就绪列表中,当应用空间调用 epoll_wait 获取就绪事件时,内核直接判断就绪列表即可知道是否有事件就绪。

优点

解决了 select 和 poll 的缺点,高效处理高并发下的大量连接,同时有非常优异的性能。

缺点

  • 跨平台性不够好,只支持 linux,macOS 等操作系统不支持
  • 相较于 epoll,select 更轻量可移植性更强
  • 在监听连接数和事件较少的场景下,select 可能更优

LT 和 ET

LT:Level-triggered,水平(条件)触发,默认。epoll_wait 检测到事件后,如果该事件没被处理完毕,后续每次 epoll_wait 调用都会返回该事件。

ET:Edge-triggered,边缘触发。epoll_wait 检测到事件后,只会在当次返回该事件,不管该事件是否被处理完毕。

小结

epoll 和 select、poll 默认都是 LT 模式,LT 模式会更安全一点,而 ET 则是 epoll 为了性能开发的一种新模式,LT 模式下内核在返回就绪事件之前都会进行一次额外的判断,如果 fd 量较大,会有一定的性能损耗。

异步IO

  • 用户线程进行aio_read,进行系统调用切换到内核
  • 内核立即返回,并不会阻塞用户线程
  • 内核准备好数据后会将数据从内核空间拷贝到用户空间并通知用户线程操作已完成
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,针对你的问题,我可以为你提供一些思路和参考代码。 首先,为了使用I/O多路复用,我们可以使用select函数。select函数可以同时监听多个文件描述符的读写事件,从而实现多路复用。 下面是一个简单的使用select函数的示例代码: ```c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <arpa/inet.h> #include <sys/select.h> #define MAX_CLIENTS 10 #define BUF_SIZE 1024 int main(int argc, char **argv) { int server_sock, client_sock[MAX_CLIENTS]; struct sockaddr_in server_addr, client_addr; fd_set read_fds, write_fds; char buf[BUF_SIZE]; int i, max_fd, fd_num, client_count = 0; // 创建服务器套接字 server_sock = socket(AF_INET, SOCK_STREAM, 0); // 初始化服务器地址 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = INADDR_ANY; server_addr.sin_port = htons(8080); // 绑定服务器套接字到指定地址和端口 bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)); // 监听连接请求 listen(server_sock, 5); // 初始化文件描述符集合 FD_ZERO(&read_fds); FD_SET(server_sock, &read_fds); max_fd = server_sock; while (1) { // 复制文件描述符集合 write_fds = read_fds; // 等待文件描述符就绪 fd_num = select(max_fd + 1, &write_fds, NULL, NULL, NULL); // 处理就绪的文件描述符 for (i = 0; i <= max_fd && fd_num > 0; i++) { if (FD_ISSET(i, &write_fds)) { fd_num--; // 处理服务器套接字的连接请求 if (i == server_sock) { // 接受连接请求 int client_fd = accept(server_sock, (struct sockaddr *)&client_addr, sizeof(client_addr)); // 将新的客户端套接字加入文件描述符集合 FD_SET(client_fd, &read_fds); if (client_fd > max_fd) { max_fd = client_fd; } // 记录客户端套接字 client_sock[client_count++] = client_fd; printf("New client connected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); } // 处理客户端套接字的读写事件 else { // 接收客户端消息 int recv_len = recv(i, buf, BUF_SIZE, 0); // 处理客户端断开连接 if (recv_len == 0) { close(i); FD_CLR(i, &read_fds); printf("Client disconnected: %d\n", i); // 从客户端套接字列表中删除 for (int j = 0; j < client_count; j++) { if (client_sock[j] == i) { for (int k = j; k < client_count - 1; k++) { client_sock[k] = client_sock[k + 1]; } client_count--; break; } } } // 处理客户端消息 else { // TODO: 处理HTTP消息头字段,支持响应状态码200、400和404 printf("Received message from client: %s\n", buf); send(i, buf, recv_len, 0); } } } } } // 关闭服务器套接字 close(server_sock); return 0; } ``` 在上面的示例代码中,我们使用了一个循环来等待文件描述符就绪,并通过select函数实现了I/O多路复用。此外,我们还处理了服务器套接字的连接请求和客户端套接字的读写事件。 对于HTTP消息头字段和响应状态码的处理,我们需要在接收到客户端的请求后进行解析和处理,并发送相应的响应消息。这部分内容比较复杂,需要根据HTTP协议的规范来实现。在此就不再提供具体的代码了。 希望以上内容能对你有所帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值