文章目录
常见的网络I/O 模型分为四种: 同步阻塞IO,同步非阻塞IO,I/O多路复用,异步非阻塞IO。本文侧重讲述 同步阻塞IO和 I/O多路复用
1. IO读写的基本原理
1.1 系统调用
利用 socket 进行网络编程时,底层还是依赖操作系统提供的 read 和 write 系统调用。
当客户端和服务器建立连接之后,双方均是通过 文件描述符 进行数据的交互,具体系统调用可以在 Linux命令行 通过
man 2 read/write
查看详细的函数文档
其中:
- read: 将数据从内核缓冲区复制到进程缓冲区
- write: 把数据从进程缓冲区复制到内核缓冲区
1.2 内存缓冲区
当我们运行一个应用程序时,进程处于用户态;一旦需要访问系统资源(内存,CPU,信号处理,网络通信等) 便需要进入内核态,在网络通信中,我们最为关注的便是内存缓冲区:
- 用户态内存缓冲区:进程读取和写入的数据均会经过用户态缓冲区
- 内核缓冲区:内核将网卡传输的数据先存放到内核缓冲区
那么缓存区存在的意义是什么呢?
为了减少频繁的系统IO调用,减少系统上下文切换所带来的CPU资源开销
试想一下: 如果不存在缓冲区,从网卡读取一部分数据后就要进行上下文切换将数据拷贝给用户进程空间,为了减少这种损耗的时间,还有损耗性能的时间, 所以出现了缓冲区
有了缓冲区,操作系统使用read函数从内核缓冲区复制到进程缓冲区,write函数从进程缓冲区复制到内核缓冲区,只有缓冲区中的数据达到一定的量再IO的系统,提升性能.而用户程序的IO操作,大部分情况下没有进行实际的IO操作,而是进程缓冲区和内核缓冲区之间直接进行数据交换(这块的优化手段可以采用零拷贝技术,本文不展开描述)
1.3 网络编程
下图总结了网络编程的基本流程
- 服务端和客户端初始化 socket,得到文件描述符;
- 服务端调用 bind,将绑定在 IP 地址和端口;
- 服务端调用 listen,进行监听;
- 服务端调用 accept,等待客户端连接;
- 客户端调用 connect,向服务器端的地址和端口发起连接请求;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;服务端调用 read 读取数据;
- 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭
2. 四种网络IO模型
2.1 基本概念
2.1.1 阻塞与非阻塞IO
阻塞IO:需要内核IO操作彻底完成后,才返回用户空间执行用户的操作
非阻塞IO:用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间执行用户的操作,与此同时内核会立即返回给用户一个状态值
2.1.2 同步与非同步
同步:进程在执行某个请求,若该请求需容要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行下去;
异步:进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程进行处理
2.2. 同步阻塞IO
这种技术也是Linux系统默认网络通信方式,Linux提供了同步阻塞IO 网络编程的一种范式(省略了函数参数):
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定端口
listen(listenfd); // 监听端口
while(1) {
connfd = accept(listenfd); // 阻塞等待建立连接
int n = read(connfd, buf); // 阻塞等待读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
从图中中可以看出,阻塞点有两个:
- accept() 函数 等待 TCP连接 (进行三次握手)
- read() 函数 等待读取网络传输的数据
- 服务端: 用户态调用read读取数据时会进入阻塞状态
- 数据从网卡读取到内核缓冲区,再从内核缓冲区拷贝到用户态缓冲区才结束read 系统调用
❓ 弊端: 服务端会在read()系统调用出阻塞,无法处理其它客户端的连接,那么如何进行改造?
用户端改造方式: 利用子进程/线程完成数据读取和写出的任务
- 将read函数从主线程中抽离出来,交由子线程执行,具体方式如下所示:
while(1) { connfd = accept(listenfd); // 阻塞建立连接 pthread_create(doWork); // 创建一个新的线程 } void doWork() { int n = read(connfd, buf); // 阻塞读数据,若数据过多可利用while循环读取,此处不做展示 doSomeThing(buf); // 业务逻辑处理数据 close(connfd); // 关闭当前连接 }
- 通过创建子进程完成数据读取
int rv_fork; while (1) { fd = accept(tcp_socket, NULL, NULL); rv_fork = fork(); // 创建子进程,rv_fork为子进程id if (0 == rv_fork) { // 为0表示为子进程 doWork(buf); // 阻塞读数据,若数据过多可利用while循环读取,此处不做展示 } } void doWork(int fd) { int n = read(connfd, buf); // 阻塞读数据,若数据过多可利用while循环读取,此处不做展示 doSomeThing(buf); // 业务逻辑处理数据 close(connfd); // 关闭当前连接 }
2.3 非阻塞IO
利用操作系统提供的
fcntl
系统调用可以设置文件描述符为非阻塞状态fcntl(connfd, F_SETFL, O_NONBLOCK); // 设置文件描述符的标志位为 O_NONBLOCK int n = read(connfd, buffer) != SUCCESS); // 开始读取数据
通过这种设置在网络数据未到达内核缓冲区之前都是非阻塞的,但是一旦数据从内核态拷贝到用户态依然是阻塞的,这种模式很少使用
2.4. I/O多路复用
可以解决 C10K 问题 (每个文件描述符对应一个线程/进程,I/O多路复用技术)
在阻塞IO中利用线程或者进行改造read函数的方法中还存在弊端
- 线程/进程的创建会消耗系统资源
- 创建的线程/进程数是有限的
- 可扩展性极差
多路复用技术可以解决资源限制的问题:
2.4.1 select
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
// 1.NULL,永远等下去
// 2.设置timeval,等待固定时间
// 3.设置timeval里时间均为0,检查描述字后立即返回,轮询
此时需要两个线程:
- 线程一负责建立网络连接并将对应文件描述符设置为 非阻塞状态
- 线程二负责将文件描述符交给操作系统内核去遍历,以寻找到有数据传输的描述符
从上图可以看出网络事件发生时,select的弊端:
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
2.4.2 poll
解决了select 限制文件描述符最大数量(1024)的弊端
2.4.3 epoll
它是redis,nginx 网络通信的基石,解决了select和poll存在的弊端(资源消耗)
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合
epoll 对应的系统调用如下图所示:
int epoll_create(int size); // 创建epoll句柄
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 向内核添加,删除或者修改监控的文件描述符
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout); // 发起调用
epoll 在select的基础上优化细节如下图所示:
epoll 对应的使用流程如下所示(具体细节可以在参考文献的文章中读取)
- epoll_create 实现
SYSCALL_DEFINE1(epoll_create1, int, flags) {
struct eventpoll *ep = NULL;
//创建一个 eventpoll 对象
error = ep_alloc(&ep);
}
struct eventpoll {
//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
struct list_head rdllist;
//每个epoll对象中都有一颗红黑树
struct rb_root rbr;
......
}
- wq: 等待队列链表
- rbr: 红黑树,支持对大量连接的高效查找、插入和删除,eventpoll 内部使用红黑树维护了所有文件描述符的信息(红黑树在Linux内核中经常出现)
- rdllist: 就绪的描述符的链表
- epoll_ctl 添加 socket
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
struct eventpoll *ep;
struct file *file, *tfile;
//根据 epfd 找到 eventpoll 内核对象
file = fget(epfd);
ep = file->private_data;
//根据 socket 句柄号, 找到其 file 内核对象
tfile = fget(fd);
switch (op) {
case EPOLL_CTL_ADD:
if (!epi) {
epds.events |= POLLERR | POLLHUP;
error = ep_insert(ep, &epds, tfile, fd);
} else
error = -EEXIST;
clear_tfile_check_list();
break;
}
struct epitem {
//红黑树节点
struct rb_node rbn;
//socket文件描述符信息
struct epoll_filefd ffd;
//所归属的 eventpoll 对象
struct eventpoll *ep;
//等待队列
struct list_head pwqlist;
}
- 分配一个红黑树节点对象 epitem,
- 添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback
- 将 epitem 插入到 epoll 对象的红黑树里
- epoll_wait 等待接收
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,int, maxevents, int, timeout){
error = ep_poll(ep, events, maxevents, timeout);
}
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,int maxevents, long timeout) {
wait_queue_t wait;
......
fetch_events:
//1 判断就绪队列上有没有事件就绪
if (!ep_events_available(ep)) {
//2 定义等待事件并关联当前进程
init_waitqueue_entry(&wait, current);
//3. 把新 waitqueue 添加到 epoll->wq 链表里
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
...
//4 让出CPU 主动进入睡眠状态
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
...
}
- 总结
2.5 异步IO
异步IO模型执行流程:
用户线程通过系统调用,向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后,通知用户程序,执行后续的业务操作
在异步IO模型中,整个内核的数据处理过程中,包括数据从 网卡 读取到内核缓存区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
异步IO模型如下图所示:
异步IO 在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。当内核的IO操作(等待数据和复制数据)全部完成后,内核会通知应用程序读数据
参考文献及图片来源:
图解网络
图解系统
IO 多路复用
图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的