计算机网络(三)网络协议栈与epoll的底层原理


layout: post
title: 计算机网络(三)网络协议栈与epoll的底层原理
description: 计算机网络(三)网络协议栈与epoll的底层原理
tag: 计算机网络


文章目录

  • POSIX API与网络协议栈
  • epoll的底层实现
    • epoll使用的数据结构
    • 协议栈与epoll通信
      • epoll对于不同事件的处理
    • 协议栈触发回调通知epoll的时机
      • 协议栈通知epoll的时机总结
      • 从回调机制看epoll与select/poll的异同
    • epoll三个API的实现
      • epoll_create
      • epoll_ctl
      • epoll_wait
    • epoll的线程安全
    • ET与LT如何实现
      • ET与LT的使用场景

POSIX API与网络协议栈

常用的POSIX API按照C/S模式有以下8种:

  • 服务端:
    • socket():socket仅仅返回一个fd,fd是对这个连接的唯一标识符,对于tcp而言,每个连接背后都有一个TCB(tcp control block,TCP控制块),操作系统是通过TCB来控制每个fd代表的tcp连接的。int socket(int domain, int type, int protocol);
    • bind():刚刚创建的socket,底层的TCB是没有被初始化的,bind的作用就是绑定本地的ip和端口。bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    • listten():服务端调用listen()后,开始监听网络上发送给socket的连接请求。listen(fd,size),fd是socket的文件描述符,size在Linux是指全连接队列的长度,即一次最多能保存size个连接请求。
    • accept():accept()函数只做两件事,将建立好的连接从全连接队列中取出,给他分配一个fd(与listen_fd不同,这里是client_fd)并返回。int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    • recv():send和recv在TCP连接生命周期中占用的时常最长,主要负责数据的收发。send函数负责将数据拷贝到内核,内核协议栈主要是利用TCB中的发送缓冲区进行数据缓存,然后根据内核自己的策略决定何时将数据发送。接收端数据也是先到达TCB的接收缓冲区,然后才是通过recv拷贝到用户空间。
    • send():send函数负责将数据拷贝到内核,内核协议栈主要是利用TCB中的发送缓冲区进行数据缓存,然后根据内核自己的策略决定何时将数据发送。
    • close():close(fd),最简单函数,参数是要关闭的clientfd,实际上tcp的关闭需要完成4次挥手的复杂过程,不过这些都是内核帮我们实现好了。
  • 客户端
    • socket()
    • connect():连接服务端ip地址和端口

计算机网络(三)网络协议栈与epoll的底层原理_第1张图片计算机网络(三)网络协议栈与epoll的底层原理_第2张图片

抛开C/S模式,POSIX API还提供了一些socket控制函数:

  • socket参数设置函数
    • setsockop():set socket option,设置socket选项
    • getsockopt():get socket option,获取option选项
    • shutdown():切换半关闭状态
    • fcntl():file control,设置fd参数,例如更改阻塞类型

下面是服务端构建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使用的数据结构

epoll至少需要两个集合:

  • 所有fd的总集;
  • 就绪fd的集合;
      一个fd,底层对应一个TCB(TCP控制块),那么也就是说key = fd,val = TCB,是一个典型的kv型数据结构,对于kv型数据结构,我们可以使用以下三种数据结构进行存储。
  1. hash表
  2. 红黑树
  3. b/b+树

  epoll选择的是红黑树,它的查找速度很快为(O(log(N))),其次在调用epoll_create()的时候,只需要创建一个红黑树根节点即可,无需浪费额外空间。不使用hash表是因为epoll表示的连接范围很大,可能只有几个连接,也可能有百万连接,如使用hash表,我们并不清楚,一开始底层的数组应该创建多大比较合适。不使用b/b+tree,是因为它主要用于在磁盘索引中降低层高,没有红黑树的增删改查效率高。

  就绪队列不是以查找为主,主要作用是将里边的元素拷贝给用户进行处理,没有优先级,因此可以采用线性的数据结构,双端队列。

  epoll中红黑树的节点和就绪队列的节点是同一个节点,所谓加入就绪队列,就是将节点的前后指针联系在一起

计算机网络(三)网络协议栈与epoll的底层原理_第3张图片

计算机网络(三)网络协议栈与epoll的底层原理_第4张图片

协议栈与epoll通信

应用程序只能通过三个api接口:epoll_create、epoll_cntl、epoll_wait来操作epoll,当一个io准备就绪的时候,epoll是怎么知道io准备就绪了呢?是由协议栈将数据解析出来触发回调通知epoll的。也就是说可以把epoll的工作环境看出三部分,左边应用程序的api,中间的epoll,右边协议栈的回调,中间的vfs不是本篇重点,这里直接忽略了。
计算机网络(三)网络协议栈与epoll的底层原理_第5张图片

epoll对于不同事件的处理

  socket有两类,一类是监听listenfd,一类是客户端clientfd。对于sockfd而言,我们一般比较关注EPOLLIN和EPOLLOUT这两个事件,所以如果是listenfd,我们通常的做法就是accept。对于clientfd来说,如果可读我们就recv,如果可写我们就send。即处理的IO事件类型包含三类:

  • 对于listenfd的accept
  • 对于clientfd如果是读事件,则recv
  • 对于clientfd如果是写事件,则send

对应者我们在程序中的写法:

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和具体事件类型,然后做下面两个操作:

  1. 通过fd找到对应节点
  2. 把节点加入到就绪队列

协议栈触发回调通知epoll的时机

  1. 三次握手完成后,会往全连接队列中添加一个TCB节点,触发一个回调函数,通知epoll有个EPOLLIN事件:(通知有新的连接需accept)
    计算机网络(三)网络协议栈与epoll的底层原理_第6张图片
  2. 客户端发送一个数据包,协议栈接收后回复ACK,之后触发一个回调函数,通知epoll里边有个EPOLLIN事件:(通知可读)
    计算机网络(三)网络协议栈与epoll的底层原理_第7张图片
  3. 每个连接的TCB里边都有一个sendbuf(发送区缓存),在对端接收到数据并返回ACK,sendbuf就可以将这部分已经确认接收的数据清空,此时sendbuf里边必定就有新的剩余空间,此时触发一个回调函数,通知epoll里边有个EPOLLOUT事件:(通知可写)
    计算机网络(三)网络协议栈与epoll的底层原理_第8张图片
  4. 当对端发送close,服务端在接收FIN后,回复ACK,如果接收客户端发送 FIN 报文后,服务端仍有未读取的数据,会调用回调函数,通知epoll有个EPOLLIN事件:(通知可读)
    这是因为,当客户端发送 FIN 报文后,服务端还能够继续接收来自客户端的未读取数据,直到收到客户端的 ACK 报文关闭连接。
    计算机网络(三)网络协议栈与epoll的底层原理_第9张图片
  5. 当接收到RST标志位(重置连接请求),回复ACK后,也会触发回调函数,通知epoll有一个EPOLLERR事件:(通知连接出现了错误)
    内核会产生EPOLLERR事件,并将其放入等待事件队列中,等待进程调用epoll_wait函数时返回处理。
    计算机网络(三)网络协议栈与epoll的底层原理_第10张图片

协议栈通知epoll的时机总结

  1. 三次握手完成之后,EPOLLIN,通知可以accept
  2. 接收到数据,回复ACK后,EPOLLIN,通知可读
  3. 发送数据收到ACK之后,对端收到数据,清除确认接收数据的缓存,一定产生了新的可写空间,EPOLLOUT,通知可写
  4. 接收FIN报文,回复ACK后,此时如果服务端还有未读取的数据,触发EPOLLIN,通知可读
  5. 接收到RST,回复ACK后,通知EPOLLERR

从回调机制看epoll与select/poll的异同

  • 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三个API的实现

epoll_create

在用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll 的内核对象,初始化epoll的红黑树根节点,等待队列,就绪队列等,它的结构如下:

计算机网络(三)网络协议栈与epoll的底层原理_第11张图片

struct eventpoll {

    //sys_epoll_wait用到的等待队列
    wait_queue_head_t wq;

    //接收就绪的描述符都会放到这里
    struct list_head rdllist;

    //每个epoll对象中都有一颗红黑树
    struct rb_root rbr;

    ......
}

eventpoll 这个结构体中的几个成员的含义如下:

  • wq: 等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
  • rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
  • rdllist: 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。

epoll_ctl

epoll_ctl()负责add,del,mod 增加、删除、修改结点。
这里以EPOLL_CTL_ADD添加fd为例:
内核会完成下边三件事:

  1. 分配一个红黑树节点对象epitem
  2. 添加等待事件到等待队列wq中,设置回调函数ep_poll_callback
  3. 将epitem插入到epoll对象的红黑树中

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

epoll_wait把就绪队列的结点copy到用户态放到events里面,返回就绪队列节点的个数ready。

int ready = epoll_wait(r->epfd, events, EVENT_LENGTH, -1);

epoll_wait 做的事情不复杂,当它被调用时它观察 eventpoll->rdllist 链表里有没有数据即可。有数据就返回,没有数据就创建一个等待队列项,将其添加到 eventpoll 的等待队列上,然后把自己阻塞掉就完事。

计算机网络(三)网络协议栈与epoll的底层原理_第12张图片

注意:
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 函数一定会在指定的时间内返回,因为它可能会受到其他因素(如系统调度、竞争条件等)的影响。

epoll的线程安全

  如果有3个线程同时操作epoll,有哪些地方需要加锁?

  1. 如果同时调用epoll_create(),那就是创建三颗红黑树,没有涉及到资源竞争,没有关系。
  2. 如果同时调用epoll_ctl(),对同一颗红黑树进行增删改,这就涉及到资源竞争,需要对红黑树加锁,对于红黑树的锁设计需要结合具体的应用场景来进行优化,在保证数据一致性和原子性的前提下,尽量减少锁的粒度和范围,并使用适当的锁机制来提高系统的性能和可靠性。
  3. 如果同时调用epoll_wait(),其操作的是就绪队列,所以需要对就绪队列进行加锁。
  4. 我们要扣住epoll的工作环境,在应用程序调用 epoll_ctl() ,协议栈会不会有回调操作红黑树结点?调用epoll_wait() copy出来的时候,协议栈会不会操作操作红黑树结点加入就绪队列?

综上所述:

epoll_ctl() 对红黑树加锁
epoll_wait()对就绪队列加锁
回调函数() 对红黑树加锁,对就绪队列加锁

对于红黑树这种节点比较多的时候,采用互斥锁来加锁。就绪队列就跟生产者消费者一样,结点是从协议栈回调函数来生产的,消费是epoll_wait()来消费。那么对于队列而言,用自旋锁(对于队列而言,插入删除比较简单,cpu自旋等待比让出的成本更低,所以用自旋锁)。

ET与LT如何实现

epoll 的 ET(边缘触发)和 LT(水平触发)是通过在内核中实现不同的事件通知机制来实现的。

  • 在 ET 模式下,只在有新的事件到来时,通知一次;

  • 在 LT 模式下,如果没有读完数据就会一直触发;

水平触发和边沿触发代码只需要改一点点就能实现。从协议栈检测到接收数据,就调用一次回调,这就是ET,接收到数据,调用一次回调。而LT水平触发,检测到recvbuf里面有数据就调用回调。所以ET和LT就是在使用回调的次数上面的差异。

具体而言,协议栈流程里面触发回调,是天然的符合ET只触发一次的;如果是LT,在recv之后,如果缓冲区还有数据,那么会将该节点再次加入到就绪队列。

ET与LT的使用场景

  • Nginx默认使用ET(边缘触发)模式,因为ET模式可以更好地适用高并发和大规模数据处理场景,具体原因如下:

    • 1、提高性能:ET模式可以充分利用多路复用技术,在单个线程中同时处理多个连接,从而提高系统的性能和吞吐量,相比于LT模式,循环反复将就绪fd加到就绪队列,ET模式可以减少对CPU和系统资源的占用。
    • 2、降低延迟:在ET模式下,当某个文件描述符上有I/O事件发生时,内核会立即通知应用程序处理,实现更及时的响应和处理,更加关注epoll中的活跃连接。
  • Redis默认使用LT(水平触发)模式,但也支持ET。这是因为Redis主要用于数据存储和读写操作,对于I/O事件的及时处理并没有特别强烈的要求,具体原因如下:

    • 1、简单易用:LT模式相对于ET模式而言,实现更加简单,可靠性更高,并且对应用 程序的编写和调试也更加友好。
    • 2、兼容性更好:由于LT模式是Linux内核默认的事件通知机制,因此可以更好地兼容各种版本的内核和系统环境。
    • 3、不需要高性能:Redis的主要任务是数据存储和读写操作,并不需要想web服务器等高性能服务那样需要尽可能地减少响应延迟和CPU占用率。

总之:ET 和 LT 模式各自具有不同的特点和优势,需要根据具体的应用场景来选择合适的模式,并进行相应的优化和调整,对于响应要求更高的使用ET,对于使用简单和数据安全性要求更高的使用LT。

你可能感兴趣的:(计算机网络,网络协议,计算机网络,网络)