文章目录


​超级好文-https的故事​

select

poll

epoll

存储方式

数组

链表

红黑树

操作方式

轮询机制

轮询机制

通知机制(回调)

最大连接数

linux下为1024

无上限

上限很大

fd拷贝

每次调用都需要全部fd数据从用户态到内核态拷贝

每次调用都需要全部fd数据从用户态到内核态拷贝

仅操作时拷贝进内核一次,后续​​epoll_wait()​​无需拷贝

IO效率

每次调用均线性遍历,时间复杂度为O(n)

每次调用均现行遍历,时间复杂度为O(n)

每次调用​​epoll_wait()​​均为对就绪fd操作,时间复杂度为O(1)

1.Socket

基础概念

(0)文件描述(file description表示一个打开文件的上下文信息,如文件的大小、内容、编码等),如同一个抽屉,该文件描述由内核管理,而文件描述和文件描述符不同——回顾​​OS笔记​​​第23点可知​​open()​​​系统调用后,内核分配给用户空间一个文件描述符fd——即抽屉的把手(socket网络编程(select & epoll)_等待队列,翻译为句柄,fd其实就是一个整数)。
一个抽屉可以有多个把手(即文件描述可对应多个fd);只有多个fd都关了,内核才知道该文件描述没人用了(可回顾硬链接),所以就回收。
(1)socket是一种特殊接口(也是一种文件描述符fd),如插座端口上的孔,端口不能被其他进程占用。Socket即为实现操作某个IP地址上的某个端口达到点到点通信的目的,需要绑定到某个具体的进程中和端口中。
(2)客户端和服务器之间的通信都需要唯一的socket,每个socket都由 {协议、本地地址、本地端口} 表示,一个完整的套接字则由{协议、本地地址、本地端口、远程端口}表示。
(3)socket也有类似打开文件的函数调用,该函数返回一个整型的socket描述符。

struct sockaddr 
{
unsigned short sa_family; /*地址族*/
char sa_data[14]; /*14 字节的协议地址,包含该 socket 的 IP 地址和端口号。*/
};
struct sockaddr_in
{
short int sin_family; /*地址族*/
unsigned short int sin_port; /*端口号*/
struct in_addr sin_addr; /*IP 地址*/
unsigned char sin_zero[8]; /*填充 0 以保持与 struct sockaddr 同样大小*/
};
struct in_addr
{
unsigned long int s_addr; /* 32 位 IPv4 地址,网络字节序 */
};

分类

(1)流式socket——TCP通信;
(2)数据报socket——UDP通信;
(3)原始socket。

2.接收data流程

1)网卡接收数据

最终就是网卡读取数据后,存入内存中,该过程用到DMA、IO通路:
(1)网卡收到网线传来的数据;
(2)硬件电路传输;
(3)将数据写入到内存中的某个地址上。

2)如何知接收到data

当网卡将data写入内存后,网卡向CPU发出一个中断信号,CPU从而知道有新数据到来,从而CPU执行中断处理程序。

3)进程阻塞和CPU关系

阻塞是进程调度的关键一环,指进程在等待某件事(如接收到网络数据)前的等待状态,方法有recv、select、epoll等。
服务器端的简化流程

//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)

可以回顾OS的进程状态转换图:

​​

socket网络编程(select & epoll)_面试_02


等待态即阻塞态,从简化流程中看出,一开始创建socket语句,即创建一个由文件系统管理的socket对象(该对象包含发送缓冲区、接收缓冲区与等待队列等,其中等待队列指向所有需要等待该socket事件的进程)上面的​​recv​​是一个阻塞方法,当运行到recv时会一直等待,直到接收到数据为止;

socket网络编程(select & epoll)_leetcode_03


当socket接收到数据后,OS将socket等待队列上的进程重新放回到工作队列,即该进程由【阻塞态】变为【就绪态】,之后变为【运行态】。

socket的接收缓冲区已经有了data,recv返回接收到的数据。

中断程序作用
(1)先将网络数据写入到对应socket的接收缓冲区里面;
(2)之后唤醒在等待队列中的进程,重新将该进程放入到工作队列中。

而唤醒进程后,OS如何知道网络数据对应哪个socket?
答:网络数据报的首部字段包含IP和端口,而socket和端口号是一一对应关系。

4.同时监视多个socket

回顾多路复用的“复用”和分用:

socket网络编程(select & epoll)_位运算_04


而最后一个概念就提到I/O多路复用。现在服务器端要管理多个客户端连接,而recv只能监视一个​​socket​​​,为了解决这个问题先后出现了​​select​​​和​​epoll​​。

1.select

1)设计思想

一个fds数组存放所有需要监视的socket,然后调用​​select​​​——如果fds中所有socket都没有数据,select会阻塞,直到有一个socket接收到数组,select返回,唤醒进程。
用户可以遍历fds数组,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...);
listen(s, ...);
int fds[] = 存放需要监听的socket;
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}

若进程A同时监视三个socket(sock1、2、3),调用select后A进程加入到这3个socket的等待队列,而如果此时sock2收到了数据后,利用中断处理程序,进程被唤醒从而离开等待队列,重新调入工作队列中(如下图),程序只需遍历一遍socket列表,就可以得到就绪的socket。

socket网络编程(select & epoll)_数据_05


上面的例子是只有一个socket接收缓冲区有数据,如果有多个socket接收缓冲区有数据,则​​select​​直接返回,不会阻塞,select的返回值也就可能大于0。

2)select的缺点

(1)每次调用 select 都需要将进程加入到所有监视 socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历;
而且每次都要将整个 fds 列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定 select 的最大监视数量,默认只能监视 1024 个 socket。

(2)进程被唤醒后,程序并不知道哪些 socket 收到数据,还需要遍历一次。

2.epoll特点

为了减少遍历次数。

1)功能分离

socket网络编程(select & epoll)_leetcode_06


多数情况下,需要监视的socket相对固定,因此epoll将【维护等待队列】和【阻塞进程】分离——先用​​epoll_ctl​​​维护等待队列,再调用​​epoll_wait​​阻塞进程。

int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)

int epfd = epoll_create(...);//(1)create创建了一个epoll对象epfd
epoll_ctl(epfd, ...); //(2)将所有需要监听的socket添加到epfd中

while(1){
int n = epoll_wait(...)//(3)等待数据
for(接收到数据的socket){
//处理
}
}

2)就绪列表

为了避免像select一样程序不知道哪些socket收到数据而需要逐个遍历,epoll就在内核维护一个就绪列表(引用收到数据的socket)。只要获取该就绪列表​​rdlist​​的内容,就能知道哪些socket收到数据。

socket网络编程(select & epoll)_数据_07

5.epoll详解

1)创建epoll对象

当某个进程调用​​epoll_create​​后,内核会创建一个eventpoll对象(和socket一样会等待队列)。内核要维护就绪列表等数据,就绪列表可以作为eventpoll的成员。

socket网络编程(select & epoll)_位运算_08

2)维护监视列表(epoll_ctl)

创建了eventpoll对象后,利用​​epoll_ctl​​​添加或删除所要监听的socket。
如下图通过epoll_ctl添加sock1、2、3的监听,内核会将eventpoll添加到这三个socket的等待队列中。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210125221031832.png#pic_center #pic_center =400x)
添加所要监听的socket

3)接收数据

当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程.

当socket接收到数据后,中断程序会给eventpoll对象的就绪列表添加socket的引用,如下图红线。

eventpoll对象相当于socket和进程之间的中介,socket的数据接收后不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态

socket网络编程(select & epoll)_等待队列_09

4)阻塞和唤醒进程

当程序执行到​​epoll_wait​​​时,如果rdlist已经引用了socket,那么​​epoll_wait​​直接返回,如果rdlist为空,阻塞进程。

假如计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。如下图所示,内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程。

socket网络编程(select & epoll)_等待队列_10


当 socket 接收到数据,中断程序一方面修改 rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下图)。也因为 rdlist 的存在,进程 A 可以知道哪些 socket 发生了变化。

socket网络编程(select & epoll)_面试_11

6.eventpoll的DS

eventpoll包含了lock、mtx、wq(等待队列)与rdlist等成员,其中rdlist就绪列表和 rbr兴趣列表最重要。

从《Linux/Unix系统编程手册》中提到,epoll实例实现了2个目的:
(1)记录了在进程中声明过的刚兴趣的文件描述符列表——socket网络编程(select & epoll)_位运算_12(兴趣列表),其实就是上面介绍的eventpoll添加所要监听的socket。
(2)维护了处于I/O就绪态的文件描述符列表——socket网络编程(select & epoll)_leetcode_13(就绪列表),其实就是上面介绍rdlist列表。

1)就绪列表的结构

eventpoll的就绪列表socket网络编程(select & epoll)_leetcode_13双向链表实现,为啥呢——就绪列表引用着就绪的socket,需要能够快速插入数据,即利用​​epoll_ctl​​实现监听socket,也要能够快速删除。

2)兴趣列表的索引结构

​epoll​​使用【红黑树】作为索引结构,追踪当前监听的所有文件描述符——即兴趣列表用了红黑树。

epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的 socket,至少要方便地添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N))。

PS:

因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist 并非直接引用 socket,而是通过 epitem 间接引用,红黑树的节点也是 epitem 对象。同样,文件系统也并非直接引用着 socket。

参考资料

(1)​​​​​(2)​​https://www.ivdone.top/article/1659.html​​ (3)​​select poll epoll解读​