https://blog.csdn.net/animatelife/article/details/125037180
https://www.bilibili.com/video/BV1r54y1f7bU/
socket和fd的关系
socket是进程之间通信的两个端点的抽象,类似一个queue的front和back
fd指向代表该socket的文件,类似指针、索引,使用fd进行资源和内核的访问和资源管理
服务端每创建一个socket,就会创建一个fd指向该socket的文件
用户态,内核态
用户空间只能进行一些相对安全的cpu指令,内核空间通常执行一些特权cpu指令
socket操作都是在内核空间完成的,用户空间主要是调用了一些接口比如read,write完成socket操作
用户和内核态的切换:
用户->内核:调用read函数,会将涉及的fd文件和对应进程从用户空间拷贝到内存空间
内核->用户:在完成socket操作后,先将数据从网卡拷贝到内核空间(socket缓冲区)
再作为返回值从内核空间拷贝到用户空间,进程也会从内核到空间
多线程的同步IO(有地方错误)
同步阻塞io:(在内核中)
如果有两个客户端访问服务器,在前一个连接的操作尚未完成之前,第二个客户端的请求都是被阻塞的
并且第一个客户端的操作也是有可能存在阻塞情况的,导致阻上加阻
缺点:
在单线程时,会导致上面的阻塞情况
在多线程时,同一时刻的就绪状态不确定所以需要等待,并且线程调度,空间切换都会导致效率低下
同步非阻塞io:(在内核中)
在建立连接或数据传输阶段都不会阻塞,一个socket阻塞不会影响另一个socket,会在accept循环中做一个判断
如果连接建立不成功或无数据传输就返回负数fd继续轮询下一个fd,直到返回的fd为正数说明就绪进行操作
缺点:
客户端调用用户空间的fd的read时,需要先将该fd拷贝到内核空间,再判断就绪+轮询
当一直没有就绪的fd返回时,就挨个拷贝用户空间中不同fd并判断状态
因为涉及到了用户和内核的切换,所以需要一定的开销
二者的区别就是当前没有就绪事件时,同步阻塞io会在当前位置阻塞等待,而非阻塞io会继续轮询下一个就绪的fd
IO多路复用
select, poll, epoll:可以阻塞进程,也可以同时阻塞、监听多个fd,本身是阻塞的
当有fd就绪时,才会调用真正的io操作函数,这样就不会在单个io上面阻塞了
fd_set底层是long类型的位图
比如传入* readfds为0000 0101,说明1和3位是我想要检测的fd,返回0000 0100说明3位已经就绪了
select
每次调用select都需要把所有fd从用户态拷贝到内核态,并且在内核中轮询所有fd,检测就绪事件并返回,前后的用户进程操作不会被阻塞
避免频繁的单个fd的用户-内核切换问题,是o(n)的无差别轮询复杂度
使用fd_set结构。默认水平触发,不会丢数据和事件,但开销大
缺点:
1 单个进程最大监视fd个数为1024,因为进程的文件描述符上限默认时1024,Linux内核的宏限制了fd_set最多支持1024个,可以修改但需要重新编译内核,并且fd过多时会占用内核态开销,费时
2 内核-用户空间内存拷贝问题,因为每次调用都要将所有fd拷贝到内核态,会产生大量开销
3 在监听事件中,内核中select只检测符合条件的个数并返回,不去标记具体哪个fd已经就绪,应用程序还需要去用户空间再次遍历谁就绪了,并且每调用一次都要初始化三个fd_set
4 默认水平触发方式,如果应用程序没有对一个已经就绪的fd进行io操作,那么下次select调用会再次通知该fd
// 检测指定的fd是否可读可写,是否有异常条件待处理
int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, struct timeval* timeout);
/*
nfds:监听的文件描述符个数,为最大fd值+1(因为从0开始),不必检查fd_set的所有1024位
readfds,writefds,exceptfds:检查文件描述字的可读性,可写性,是否有异常。只当可读可写有异常时,才会将fd位置1
timeout:超时时间,=NULL阻塞,=0不等待,=数字为等待固定时间
返回值:返回已就绪的fd个数,超时返回0,错误返回负数
*/
poll
基本类似于select,使用链表保存多个fd,而不是select的类数组,避免1024最大限制
使用一个结构体pollfd存储需要监听的fd,避免每次都需要重置select的三个fd_set
使用链表存储pollfd,默认水平触发
其他优缺点跟select一样,只解决了1024的问题
struct pollfd {
int fd; // 监听的文件描述符
short events; // 请求的事件
short revents; // 返回的事件
};
int poll(struct pollfd* fds, unsigned int nfds, int timeout);
/*
fds:保存文件描述符
nfds:限定fds数组中的元素个数
timeout:等待时间,-1永远等待,0不等待
返回值:返回就绪的描述符个数,超时返回0,出错返回-1
*/
epoll
使用一个epfd管理多个fd,将用户关系的事件存放到内核的一个事件表里,这样用户-内核的拷贝只需要做一次
(使用epoll_ctl将fd添加到内核态o(1)时间复杂度,并用红黑树去维护)
Epoll是事件触发的,自己的就绪列表,知道哪些fd就绪了,不必o(n)轮询所有fd
epoll每次循环都需要o(1)进行epoll_wait(),没有最大并发的连接限制,一个epfd就够了
Epoll详细一点的过程:
在select和poll中,进程需要先调用一定的方法,内核才能对所有监视的fd进行扫描
而epoll事先使用epoll_ctl注册一个epfd,一旦某个fd就绪时,内核就会采用类似callback的回调机制激活该fd
当进程调用epoll_wait时就得到了该fd就绪的通知
缺点:
1 在监听连接数和事件较少的场景下,select可能更好
2 跨平台性不够好,只支持linux
// 创建epoll,并返回epfd表示该epfd监听size个fd
int epoll_create(int size);
// 在内核epfd上注册需要监视fd和事件event,针对fd的一系列操作等等
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
/*
epfd:epoll的fd,epoll_create的返回值
op:操作类型:新增1,删除2,更新3
fd:本次要操作的文件描述符
event:要监听的事件,读写事件等
返回值:调用成功返回0,失败返回-1
*/
// 根据epfd获取就绪事件events和个数
int epoll_wait(int epfd, struct epoll_events* events, int maxevents, int timeout);
/*
events:回传内核中就绪事件
maxevente:每次能处理的最大事件数
timeout:超时时间,阻塞-1,非阻塞0
返回值:返回已就绪的文件描述符个数,超时返回0,错误返回负数
*/
LT,ET
LT:Level - triggered,水平触发(默认)
epoll_wait检测到事件后,会进行通知,但如果没有处理或没有处理完这个事件,那么后续每次调用都会通知该事件
ET:Edge - triggered,边缘触发
epoll_wait检测到事件后,只会在当次返回该事件,不管该事件是否被处理完毕(只做一次),所以应用程序得知该事件就绪后必须要处理,不然下次找不到了
LT牺牲了一定的性能但更注重安全
ET牺牲了安全性但更注重性能
三种复用方法的总结
1 一个进程所能打开的最大连接数
Select:1024个,可以修改blabla
Poll:没有上限,因为是用链表存储fd
Epoll:有上限,但是极大
2 fd剧增后的io效率问题
Select:线性遍历,所以会逐步的降低效率
Poll:同上
Epoll:性能下降的概率较低,因为epoll在内核中是根据每个fd的callback回调函数实现的,只有就绪的socket才会主动调用callback,所以没有太大影响
3 消息传递方式
Select:需要将消息传递到用户空间,需要用户-内核拷贝
Poll:同上
Epoll:通过内核和用户空间的共享实现的
select和poll缺点
大量的fd内容被复制在用户态和内核态之间,不管是否有用