layout: post
title: 计算机网络(三)网络协议栈与epoll的底层原理
description: 计算机网络(三)网络协议栈与epoll的底层原理
tag: 计算机网络
常用的POSIX API按照C/S模式有以下8种:
TCB(tcp control block,TCP控制块)
,操作系统是通过TCB来控制每个fd代表的tcp连接的。int socket(int domain, int type, int protocol);
bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
listen(fd,size)
,fd是socket的文件描述符,size在Linux是指全连接队列的长度,即一次最多能保存size个连接请求。与listen_fd不同,这里是client_fd
)并返回。int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
close(fd)
,最简单函数,参数是要关闭的clientfd,实际上tcp的关闭需要完成4次挥手的复杂过程,不过这些都是内核帮我们实现好了。抛开C/S模式,POSIX API还提供了一些socket控制函数:
下面是服务端构建tcp连接监听和接待的demo:
#include
#include
#include
#include
// socket --> socket也是一个进程
// bash --> execve("./server", "") bash 进程执行了函数
int main(){
// 1、用socket()创建一个socket
//socket返回的int是连接的标记符,随着socket连接数从3开始自增,因为0,1,2分别代表了标准输入、输出、错误
int listenfd = socket(AF_INET, SOCK_STREAM, 0); // 参数是历史沿用写法
if (listenfd == -1) return -1; // 创建失败,直接返回,POSIX API特点,返回0是成功,返回负值是失败
// 2、创建绑定服务器地址
struct sockaddr_in servaddr; // 准备绑定一个服务器地址
// POSIX API的又一特点,一般取单词前4个字母作为名字缩写
servaddr.sin_family = AF_INET; // 指定TCP/IP协议簇,这里是IPV4
// htonl: host to net long,网络地址使用long类型存储
// 将主机字节序 转为网络字节序,INADDR_ANY是本机回环地址 : 0.0.0.0
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// htonl: host to net short,端口号使用short类型存储
// 将主机字节序 转为网络字节序,指定端口为8888
servaddr.sin_port = htons(8888);
//3、用bind()绑定创建的地址
if( -1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr))){
// 如果绑定失败,直接返回
return -2;
}
//4、listen()开启监听,设置全连接队列大小为10
listen(listenfd, 10);
//5、准备一个客户端结构体client
struct sockaddr_in client;
socklen_t len = sizeof(client);
//6、用accept()接待客户端连接
int clientfd = accept(listenfd, (struct sockaddr*)&client, &len);
printf("hello TCP socket world!\n");
printf("listenfd: %d\n", listenfd);
return 0;
}
epoll至少需要两个集合:
epoll选择的是红黑树,它的查找速度很快为(O(log(N))),其次在调用epoll_create()的时候,只需要创建一个红黑树根节点即可,无需浪费额外空间。不使用hash表是因为epoll表示的连接范围很大,可能只有几个连接,也可能有百万连接,如使用hash表,我们并不清楚,一开始底层的数组应该创建多大比较合适。不使用b/b+tree,是因为它主要用于在磁盘索引中降低层高,没有红黑树的增删改查效率高。
就绪队列不是以查找为主,主要作用是将里边的元素拷贝给用户进行处理,没有优先级,因此可以采用线性的数据结构,双端队列。
epoll中红黑树的节点和就绪队列的节点是同一个节点,所谓加入就绪队列,就是将节点的前后指针联系在一起
应用程序只能通过三个api接口:epoll_create、epoll_cntl、epoll_wait来操作epoll,当一个io准备就绪的时候,epoll是怎么知道io准备就绪了呢?是由协议栈将数据解析出来触发回调通知epoll的
。也就是说可以把epoll的工作环境看出三部分,左边应用程序的api,中间的epoll,右边协议栈的回调,中间的vfs不是本篇重点,这里直接忽略了。
socket有两类,一类是监听listenfd,一类是客户端clientfd。对于sockfd而言,我们一般比较关注EPOLLIN和EPOLLOUT这两个事件,所以如果是listenfd,我们通常的做法就是accept
。对于clientfd
来说,如果可读我们就recv
,如果可写我们就send
。即处理的IO事件类型包含三类:
对应者我们在程序中的写法:
int main() {
//…………
while (1 ){
int ready = epoll_wait(r->epfd, events, EVENT_LENGTH, -1);
int i = 0;
for (int i = 0; i < nready; ++i) {
int clientfd = events[i].data.fd;
if (is_listenfd(sockfds, clientfd)) {
// accept
//…………
} else if (events[i].events & EPOLLIN) {
// recv
// …………
} else if (events[i].events & EPOLLOUT)
// send
// …………
}
}
return 0;
}
协议栈将数据解析出来触发回调通知epoll,epoll是怎么知道哪个io就绪了呢?
我们从ip报文头部解析出源ip和目的ip
,从tcp报文头部解析出源端口和目的端口
,此时TCP连接的五元组就凑齐了。socket fd --- <源ip地址,源端口,目的ip地址,目的端口,协议>
,一个fd就是一个五元组,知道了fd,我们就能从红黑树中找到对应的节点。
那么协议栈的回调函数需要做什么事情呢?我们传入fd和具体事件类型,然后做下面两个操作:
EPOLLIN
事件:(通知有新的连接需accept)EPOLLIN
事件:(通知可读)EPOLLOUT
事件:(通知可写)EPOLLIN
事件:(通知可读)RST
标志位(重置连接请求),回复ACK后,也会触发回调函数,通知epoll有一个EPOLLERR
事件:(通知连接出现了错误)EPOLLIN
,通知可以acceptEPOLLIN
,通知可读EPOLLOUT
,通知可写EPOLLIN
,通知可读EPOLLERR
select使用fdset管理fd,fdset最多只能处理1024个fd,而poll使用链表管理fd,没有最大连接数限制;
select/poll都是轮询遍历检查每个fd是否就绪,不同的是select会把就绪的fd存放到用户空间的fdset,而poll会把就绪的fd存放到用户空间的就绪链表中。select和poll没有本质区别
每次调用epoll/select需要将fd总集拷贝到内核,而epoll不同epoll只会拷贝需要的东西,没有资源浪费。
select/poll需要循环遍历总集是否有就绪,而epoll是哪个节点就绪了,通过fd找到节点,然后直接将节点加入到就绪队列。
在用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll
的内核对象,初始化epoll的红黑树根节点,等待队列,就绪队列等,它的结构如下:
struct eventpoll {
//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
struct list_head rdllist;
//每个epoll对象中都有一颗红黑树
struct rb_root rbr;
......
}
eventpoll 这个结构体中的几个成员的含义如下:
epoll_ctl()负责add,del,mod 增加、删除、修改结点。
这里以EPOLL_CTL_ADD
添加fd为例:
内核会完成下边三件事:
epitem的数据结构如下:
//file: fs/eventpoll.c
struct epitem {
//红黑树节点
struct rb_node rbn;
//socket文件描述符信息
struct epoll_filefd ffd;
//所归属的 eventpoll 对象
struct eventpoll *ep;
//等待队列
struct list_head pwqlist;
}
新加入的fd,添加到等待队列pwqlist上(注意这里的等待队列是epitem的上的等待队列,即,每个fd上的等待任务列表,并不是eventpoll中的wq)
,并为其注册回调ep_poll_callback;当有IO事件时,内核协议栈就会通过注册的这个ep_poll_callback函数来回调,进而通知到epoll对象。
epoll_wait把就绪队列的结点copy到用户态放到events里面,返回就绪队列节点的个数ready。
int ready = epoll_wait(r->epfd, events, EVENT_LENGTH, -1);
epoll_wait 做的事情不复杂,当它被调用时它观察 eventpoll->rdllist 链表里有没有数据即可。有数据就返回,没有数据就创建一个等待队列项,将其添加到 eventpoll 的等待队列上,然后把自己阻塞掉就完事。
注意:
1、epoll_ctl 添加 socket 时也创建了等待队列项。不同的是这里的等待队列项是挂在 epoll 对象上的,而前者是挂在 socket 对象上的。
2、epoll_wait的第四个参数是timeout,timeout 参数指定了等待时间,它可以让 epoll_wait 函数在等待一定时间后超时返回而不阻塞进程。具体来说,当 timeout 参数为正数时,epoll_wait 函数最多等待 timeout 毫秒,然后返回;当 timeout 参数为零时,epoll_wait 函数立即返回;当 timeout 参数为负数时,epoll_wait 函数将一直等待直到某个事件发生才返回。使用 timeout 参数可以避免 epoll_wait 函数无限期地等待 I/O 事件的发生,从而提高应用程序的响应性和可靠性。但是需要注意的是,timeout 参数并不保证 epoll_wait 函数一定会在指定的时间内返回,因为它可能会受到其他因素(如系统调度、竞争条件等)的影响。
如果有3个线程同时操作epoll,有哪些地方需要加锁?
综上所述:
epoll_ctl() 对红黑树加锁
epoll_wait()对就绪队列加锁
回调函数() 对红黑树加锁,对就绪队列加锁
对于红黑树这种节点比较多的时候,采用互斥锁来加锁。就绪队列就跟生产者消费者一样,结点是从协议栈回调函数来生产的,消费是epoll_wait()来消费。那么对于队列而言,用自旋锁(对于队列而言,插入删除比较简单,cpu自旋等待比让出的成本更低,所以用自旋锁)。
epoll 的 ET(边缘触发)和 LT(水平触发)是通过在内核中实现不同的事件通知机制来实现的。
在 ET 模式下,只在有新的事件到来时,通知一次;
在 LT 模式下,如果没有读完数据就会一直触发;
水平触发和边沿触发代码只需要改一点点就能实现。从协议栈检测到接收数据,就调用一次回调,这就是ET,接收到数据,调用一次回调。而LT水平触发,检测到recvbuf里面有数据就调用回调。所以ET和LT就是在使用回调的次数上面的差异。
具体而言,协议栈流程里面触发回调,是天然的符合ET只触发一次的;如果是LT,在recv之后,如果缓冲区还有数据,那么会将该节点再次加入到就绪队列。
Nginx默认使用ET(边缘触发)模式,因为ET模式可以更好地适用高并发和大规模数据处理场景,具体原因如下:
Redis默认使用LT(水平触发)模式,但也支持ET。这是因为Redis主要用于数据存储和读写操作,对于I/O事件的及时处理并没有特别强烈的要求,具体原因如下:
总之:ET 和 LT 模式各自具有不同的特点和优势,需要根据具体的应用场景来选择合适的模式,并进行相应的优化和调整,对于响应要求更高的使用ET,对于使用简单和数据安全性要求更高的使用LT。