文章目录
- 参考资料在前
- 1. 前置知识
- 2. 进程概述
- 2.1 fork()函数
- 2.2 守护进程
- 3. 浅谈printf()函数与write()函数
- 3.1 printf()函数缓存问题
- 3.2 write()函数思考
- 4. 网络编程剖析
- 4.1 listen()监听套接字
- 4.2 阻塞/非阻塞IO
- 4.3 同步/异步IO
- 4.4 TCP/IP设计
- 4.4.1 三次握手
- 4.4.2 四次挥手
- 5. epoll技术
- 5.1 epoll_create()
- 5.2 epoll_ctl()
- 5.3 epoll_wait()
- 5.4 epoll工作模式
参考资料在前
Linux C++网络编程
关于 TCP 三次握手和四次挥手,满分回答在此
面试官:说说TCP第三次握手丢失会发生什么?
TCP协议规定2MSL等待的原因
一篇文章让你真正搞懂epoll机制
1. 前置知识
- 查看linux系统的处理器核心数
grep -c processor /proc/cpuinfo
- 为了服务器更加高效,避免频繁的进程切换,我们将worker进程数设置为处理器核心数
- 每开启一个终端,都会对应一个bash进程
- 一个终端对应一个bash进程,终端上开启的进程为bash通过fork开启子进程
- 每个进程还属于一个进程组,每个进程组有一个唯一的进程组编号pgrp
- session会话:包含一个或多个进程组,session编号SID
- 终端关闭,系统会给session的主线程发送SIGHUP信号,主线程会把信号发送个session中的所有进程,进程收到信号默认执行退出操作
zyq@zyq-ThinkStation-P348:/usr/local/nginx$ ps -ef | grep bash zyq 2673 2630 0 16:50 pts/0 00:00:00 bash zyq 3101 2630 0 16:51 pts/2 00:00:00 bash zyq 25978 3101 0 21:16 pts/2 00:00:00 grep --color=auto bash
- 终端关闭进程不退出的方法
- nginx进程拦截SIGHUP信号signal(SIGHUP, SIG_IGN),收到该信号不退出(nginx的父进程变为bash的父进程)
- nginx进程与bash进程不放在同一个session里, setsid();自己成为新session的组长,为session的组长则无效
- setsid ./nginx启动进程会启用新的session
- nohup ./nginx启动进程会忽略SIGHUP信号,该命令会默认把输出重定向到当前目录的nohup.out。
- 后台执行 ./nginx &,仍然可以执行其他命令,但是信息会输出到当前终端,fg切换到前台运行
- strace工具跟踪进程
- 可以跟踪程序执行时进程的系统调用以及所收到的信号
- 跟踪nginx进程,sudo strace -e trace=signal -p 20268
zyq@zyq-ThinkStation-P348:~$ ps -eo pid,ppid,sid,tty,pgrp,comm | grep -E 'bash|PID|nginx'PID PPID SID TT PGRP COMMAND 2673 2630 2673 pts/0 2673 bash 23838 12633 23838 pts/4 23838 bash 31380 23838 23838 pts/4 31380 nginxzyq@zyq-ThinkStation-P348:~/av/cpp2024/epoll_server$ sudo strace -e trace=signal -p 31380 strace: Process 31380 attached --- SIGHUP {si_signo=SIGHUP, si_code=SI_USER, si_pid=23838, si_uid=1000} --- +++ killed by SIGHUP +++zyq@zyq-ThinkStation-P348:~/av/cpp2024/epoll_server$ sudo strace -e trace=signal -p 23838 [sudo] password for zyq: strace: Process 23838 attached --- SIGHUP {si_signo=SIGHUP, si_code=SI_USER, si_pid=12633, si_uid=1000} --- rt_sigreturn({mask=[CHLD]}) = -1 EINTR (Interrupted system call) kill(-31380, SIGHUP) = 0 # 发送SIGHUP信号给31380所在的进程组(杀死了nginx进程) rt_sigprocmask(SIG_BLOCK, [CHLD TSTP TTIN TTOU], [CHLD], 8) = 0 rt_sigprocmask(SIG_SETMASK, [CHLD], NULL, 8) = 0 rt_sigprocmask(SIG_BLOCK, [CHLD], [CHLD], 8) = 0 rt_sigprocmask(SIG_SETMASK, [CHLD], NULL, 8) = 0 rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=31380, si_uid=1000, si_status=SIGHUP, si_utime=0, si_stime=0} --- rt_sigreturn({mask=[]}) = 0 rt_sigaction(SIGHUP, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7dff0f442520}, {sa_handler=0x631f91d08530, sa_mask=[HUP INT ILL TRAP ABRT BUS FPE USR1 SEGV USR2 PIPE ALRM TERM XCPU XFSZ VTALRM SYS], sa_flags=SA_RESTORER, sa_restorer=0x7dff0f442520}, 8) = 0 kill(23838, SIGHUP) = 0 # 杀死自己 --- SIGHUP {si_signo=SIGHUP, si_code=SI_USER, si_pid=23838, si_uid=1000} --- +++ killed by SIGHUP +++
- 内存泄漏检查工具valgrind
- 安装工具
sudo apt install valgrind
- 参数说明
- 使用内存泄漏检查工具
--tool=memcheck
- 完全检查内存泄漏
--leak-check=full
- 显示内存泄漏的位置
--show-reachable=yes
- 是否跟入子进程
--trace-children=yes
- 指定信息输出文件,默认是当前终端
--log-file=log.txt
- 使用内存泄漏检查工具
- 使用示例,检查nginx程序执行过程中的内存泄露问题
valgrind --tool=memcheck --leak-check=full --show-reachable=yes ./nginx
2. 进程概述
2.1 fork()函数
- 基本概念
- 进程是程序执行的实例,多个进程可以共享同一个可执行程序
- fork()函数创建子进程,子进程和父进程从fork()返回,开始执行与父进程相同的代码,根据返回值判断父(1)子(0)进程
- kill -9 子进程,父进程收到了SIGCHLD信号,子进程变成了僵尸进程
- fork()出来的子进程与父进程共享内存,直到出现任何进程执行写操作时,才会复制新的内存给子进程,因此fork()很快
- 僵尸进程的产生
- 子进程结束,父进程没有调用wait/waitpid来进行额外的处理,子进程就会变成一个僵尸进程
- 父进程可能还需要该子进程的一些信息,内核保留子进程的信息
- 如何处理僵尸进程
- 杀死父进程
- 一个进程被杀死时,会给父进程发送SIGCHLD信号,需要自定义SIGCHLD处理函数,调用waitpid回收子进程
include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>#include <sys/types.h>
#include <sys/wait.h>void do_sign(int sign) {int status;pid_t pid;printf("收到了SIGCHLD,当前进程ID: %d!\n", getpid());// -1 表示等待所有子进程// status 保存子进程的状态信息// WNOHANG 表示不要阻塞,让waitpid立即返回pid = waitpid(-1, &status, WNOHANG);// pid == 0 子进程没结束// pid == -1 waitpid调用错误return;
}int main(int argc, char *const *argv) {pid_t pid;int count = 0;printf("当前进程ID: %d!\n", getpid());// 处理子进程被杀死变成僵尸进程的问题if (signal(SIGCHLD, do_sign) == SIG_ERR) {printf("设置SIGCHLD信号自定义处理逻辑失败!\n");exit(1);}pid = fork();if (pid < 0) {printf("Fail to fork()!\n");exit(1);}// pid == 1 父进程// pid == 0 子进程// 父子进程共同执行的代码,不确定哪个进程先执行while (1) {sleep(2);printf("%d 休息了2秒,当前进程ID: %d!\n", count++, getpid());} printf("再见\n");return 0;
2.2 守护进程
- 基本概念
- 在后台运行,不跟任何的控制终端关联,没有控制终端
- 一般生存周期长,随着操作系统启动(PPID == 0)
- cmd列带着[],为内核守护进程
- init进程,系统守护进程
- 一般拥有root权限
- 守护进程编写规则
- fork()子进程,父进程退出
- 子进程调用setsid()建立新会话,脱离终端和父进程(否则crtl + c/终端关闭时父进程退出前会关闭子进程)
- umask(0) 标识不要限制或屏蔽文件权限
- 将子进程的标准输入输出重定向到空设备/dev/null,不与终端挂钩,不接收键盘输入,也不输出到屏幕
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>#include <sys/stat.h>
#include <fcntl.h>int main(int argc, char *const *argv) {pid_t pid;int fd;// fork()子进程pid = fork();if (pid < 0) {printf("Fail to fork()!\n");exit(1);}if (pid > 0) {// 父进程退出exit(0);}// 子进程才会执行 pid == 0// 子进程调用setsid()建立新会话if (setsid() == -1) {return -1;}// `umask(0)` 标识不要限制或屏蔽文件权限umask(0);// 打开空设备fd = open("/dev/null", O_RDWR);if (fd == -1) {return -1;}// 标准输入 重定向到 空设备if (dup2(fd, STDIN_FILENO) == -1) {return -1;}// 标准输出 重定向到 空设备if (dup2(fd, STDOUT_FILENO) == -1) {return -1;}// fd正常是大于3if (fd > STDERR_FILENO) {if (close(fd) == -1) {return -1;}}while (1){sleep(2);}return 0;
}
- 文件描述符
-
打开或创建一个新文件时,操作系统都会返回一个文件描述符,三个特殊的文件描述符号,数字分别为0,1,2
- 0: 标准输入,对应的符号常量为STDIN_FILENO
- 1: 标准输出,对应的符号常量为STDOUT_FILENO
- 2: 标准错误,对应的符号常量为STDERR_FILENO
-
输出重定向,在命令行中用>, ls -la > out.txt
-
输入重定向,在命令行中用<, cat < in.txt
-
联合使用,cat < in.txt > out.txt
-
空设备 /dev/null是一个空设备,与黑洞类似,丢弃一切输入
- 守护进程信号
- SIGHUP 守护进程不会收到来自内核的SIGHUP信号,只能是其他进程发给它的(nginx -s reload就算给master进程发送SIGHUP)
- SIGINT(ctrl+c),SIGWINCH(终端大小改变) 守护进程不会收到来自内核的SIGINT,SIGWINCH信号
- 守护进程和后台进程的区别
- 守护进程不与终端挂勾,不能在终端输出东西,而后台进程则相反
- 守护进程不受终端关闭影响,而后台进程随着终端关闭而退出
3. 浅谈printf()函数与write()函数
3.1 printf()函数缓存问题
printf()
末尾不加\n
就无法及时的将信息显示到屏幕 ,这是因为存在行缓存(windows上一般没有,类Unix上才有),也就是输出的数据不直接显示到终端,而是首先缓存到某个地方,当遇到行刷新标识或者该缓存已满的情况下,才会把缓存的数据显示到终端设备;
ANSI C
中定义\n
为行刷新标记,所以,printf()
函数没有带\n
是不会自动刷新输出流,直至行缓存被填满才显示到屏幕上。所以用printf的时候,注意末尾要添加\n
;当然也可以printf()
之后调用fflush(stdout)
强制刷出数据;或者是使用函数setvbuf(stdout,NULL,_IONBF,0);
,这个函数. 直接将printf缓冲区禁止, printf就直接输出了。
3.2 write()函数思考
- 如何保证多个进程写日志文件不会出现错乱?
- open((const char *)plogname,O_WRONLY|O_APPEND|O_CREAT,0644); 打开文件使用O_APPEND标记,能够保证多个进程操作同一个文件时不会相互覆盖;
- 内核wirte()写入时是原子操作;
- 父进程fork()子进程是亲缘关系。会共享文件表项,
- fwrite()与write()
- C语言的标准库fwrite()相比与系统调用write()多了一层缓冲区,缓冲区满了之后才会调用系统调用write()
- fwrite()是标准I/O库一般在stdio.h文件
- write():系统调用;
- 所有系统调用都是原子性的
4. 网络编程剖析
4.1 listen()监听套接字
- 创建套接字
int socket(int domain, int type, int protocol);
- domain:使用的地址协议族,如 AF_INET、AF_INET6分别表示IPv4、IPv6格式。
- type:套接字类型,如 SOCK_STREAM(流式传输协议)表示TCP套接字,SOCK_DGRAM(报式传输协议)表示UDP套接字。
- protocol:通常为0,表示自动选择协议。
- IP和端口号绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:套接字的文件描述符,通过socket调用得到的返回值。
- addr:包含要绑定的IP地址和端口号的结构体。
- addrlen:addr 结构体的大小,sizeof(addr)。
- 开始监听
int listen(int sockfd, int backlog);
- sockfd:套接字的文件描述符,通过socket调用得到的返回值。
- backlog:在队列中等待接受的最大连接数(已完成连接队列 + 未完成连接队列)
- 从已完成连接队列中获取连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd:套接字的文件描述符。
- addr:用于存储客户端地址信息的结构体。
- addrlen:addr 结构体的大小。
- 客户端连接到服务端的监听套接字
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:套接字的文件描述符,通过socket调用得到的返回值。
- addr:用于存储服务端地址信息的结构体,这个IP和端口也需要转换为大端然后再赋值。
- addrlen:addr 结构体的大小。
- 返回时机:
客户端调用connect只要收到服务端发来的SYN报文就返回了,也就是第二次握手包时就返回了
- 发送数据
size_t send(int sockfd, const void *buf, size_t len, int flags);
- sockfd:套接字的文件描述符。
- buf:包含要发送数据的缓冲区。
- len:要发送的数据的长度。
- flags:发送标志,通常为0。
- 接收数据
size_t recv(int sockfd, void *buf, size_t len, int flags);
- sockfd:套接字的文件描述符。
- buf:包含要发送数据的缓冲区。
- len:要发送的数据的长度。
- flags:发送标志,通常为0。
- 关闭套接字
int close(int sockfd);
TCP会为每个监听套接字维护两个队列,通过accept获取的是已经完成三次握手的连接。
往返时间RTT是指未完成连接队列中连接的留存时间。对于客户端,这个RTT时间是第一次和第二次握手加起来的时间;对于服务器,这个RTT时间实际上是第二次和第三次握手加起来的时间;如果一个恶意客户,迟迟不发送三次握手的第三个包。那么这个连接就建立不起来,那么连接一直处于SYN_RCVD队列【服务器端的未完成队列中】,这个停留时间大概是75秒,如果超过这个时间,这一项会被操作系统干掉。
-
如果两个队列之和【已完成连接队列,和未完成连接队列】达到了listen()所指定的第二参数,也就是说队列满了。此时,再有一个客户发送syn请求,服务器怎么反应?
- 实际上服务器会忽略这个syn,不给回应; 客户端这边,发现syn没回应,过一会会重发syn包;
- 从连接被扔到已经完成队列中去,到accept()从已完成队列中把这个连接取出这个之间是有个时间差的,如果还没等accept()从已完成队列中把这个连接取走的时候,客户端如果发送来数据,这个数据就会被保存再已经连接的套接字的接收缓冲区里,这个缓冲区有多大,最大就能接收多少数据量;
-
syn攻击【syn flood】:典型的利用TCP/IP协议涉及弱点进行坑爹的一种行为
- 拒绝服务攻击(DOS/DDOS);
- backlog:进一步明确和规定了:指定给定套接字上内核为之排队的最大已完成连接数【已完成连接队列中最大条目数】;
- 大家在写代码时尽快用accept()把已完成队列里边的连接取走,尽快 留出空闲为止给后续的已完成三路握手的条目用,那么这个已完成队列一般不会满;
- 一般这个backlog值给300左右;
参考资料:套接字-Socket网络编程4(TCP通信流程)
4.2 阻塞/非阻塞IO
- 阻塞IO
调用阻塞式函数,这个函数就卡在这里,整个程序流程不往下走了【休眠sleep,不会占用CPU】,该函数卡在这里等待一个事情发生,只有这个事情发生了,这个函数才会往下走;这种函数,就认为是阻塞函数;accept(); 这种阻塞,并不好,效率很低;一般我们不会用阻塞方式来写服务器程序,效率低; - 非阻塞IO
不会卡住,充分利用时间片,执行效率更高;非阻塞模式的两个鲜明特点:- 不断的调用accept(),recvfrom()函数来检查有没有数据到来,如果没有,函数会返回一个特殊的错误标记来告诉你,这种标记可能是EWULDBLOCK,也可能是EAGAIN;
- 如果数据没到来,那么这里有机会执行其他函数,但是也得不停的再次调用accept(),recvfrom()来检查数据是否到来,非常累;
- 如果数据到来,那么就得卡在这里把数据从内核缓冲区复制到用户缓冲区,所以复制这个阶段是卡着完成的;
4.3 同步/异步IO
-
异步IO
- 调用一个异步I/O函数时,我门要给这个函数指定一个接收缓冲区,我还要给定一个回调函数;
- 调用完一个异步I/O函数后,该函数会立即返回。
- 其余判断交给操作系统,操作系统会判断数据是否到来,如果数据到来了,操作系统会把数据拷贝到你所提供的缓冲区里,然后调用你所指定的这个回调函数来处理数据。
-
同步I/O
- 调用select()判断有没有数据,有数据,走下来,没数据卡在那里;
- select()返回之后,用recvfrom()去取数据;当然取数据的时候也会卡那么一下;同步I/O感觉更麻烦,要调用两个函数才能把数据拿到手;但是同步I/O和阻塞式I/O比,就是所谓的 I/O复用【用两个函数来收数据的优势】 能力;
-
I/O复用
- 所谓I/O复用,就是我多个socket【多个TCP连接】可以弄成一捆【一堆】,我可以用select这种同步I/O函数在这等数据;
- select()的能力是等多条TCP连接上的任意一条有数据来;然后哪条TCP有数据来,我再用具体的比如recvfrom()去收。
- 这种调用一个函数能够判断一堆TCP连接是否来数据的这种能力,叫I/O复用,英文I/O multiplexing【I/O多路复用】
-
非阻塞和异步I/O的差别
- 非阻塞I/O要不停的调用I/O函数来检查数据是否来,如果数据来了,就得卡在I/O函数这里把数据从内核缓冲区复制到用户缓冲区,然后这个函数才能返回;
- 异步I/O根本不需要不停的调用I/O函数来检查数据是否到来,只需要调用一次,然后就可以干别的事情去了;内核判断数据到来,拷贝数据到你提供的缓冲区,调用你的回调函数来通知你,你并没有被卡在那里的情况;
4.4 TCP/IP设计
4.4.1 三次握手
-
为什么是三次握手?
三次握手的目的是建立可靠的通信信道,也就是要确保双方的发送和接收能力都是正常的- 第一次握手,客户端向服务端发送SYN报文,在服务端的视角看,可以确认客户端发送能力正常,服务端的接收能力正常
- 第二次握手,服务端向客户端发送ACK+SYN报文,在客户端的视角看,可以确认客户端的发送和接收能力是正常的,服务端的发送和接收能力正常
- 第三次握手,客户端向服务端发送ACK包报文,在服务端的视角看,可以确认客户端发送和接收能力正常,服务端的发送和接收能力正常
如果只有两次握手,在客户端的视角看,可以确认双反的收发能力正常;但在服务端的视角看,只能确认客户端的发送能力和服务的接收能力正常。
-
初始序列号为什么是随机的?
当一端为建立连接而发送它的 SYN 时,它会为连接选择一个初始序号。ISN 随时间而变化,因此每个连接都将具有不同的 ISN。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。 -
为什么只有第三次握手可以携带数据?
- 假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据,然后疯狂重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。
- 对于第三次的话,完成第二次握手后,客户端已经处于 ESTABLISHED 状态,已经知道服务器的接收、发送能力是正常的了,所以能携带数据。
-
如果第三次握手丢失了,服务端会如何处理
服务器发送完 SYN-ACK 包,如果未收到客户端响应的确认包,也即第三次握手丢失。需要分两种情况处理。- 第一种是客户端没有发送其他数据包,那么服务器就会重传SYN-ACK包,若等待一段时间仍未收到客户确认包,就进行第二次重传。如果重传次数超过系统规定的最大重传次数,则系统将该连接信息从半连接队列中删除。重传等待一般是指数增长1s,2s,4s,8s…
- 第二种是客户端有发送其他数据包,那么这个数据包会被当做是携带了数据包的第三次握手。TCP规定,除了前两次握手报文之外,其他所有报文都将ACK标志位设置为1,所有服务端会把数据包当做是ack确认包从而完成了三次握手。
4.4.2 四次挥手
对于每个TCP连接,操作系统需要分别开辟一个收发缓冲区来处理数据的收发。当关闭一个TCP连接时,如果发送缓冲区有数据,操作系统会把发送缓冲区中残留的数据发完再发FIN包表示连接关闭。
-
为什么是四次挥手?
四次挥手的目的是确保双方的数据都已发送完毕,确保连接安全关闭。- 第一次挥手,客户端向服务端发送FIN报文,此时代表客户端数据已经发送完毕
- 第二次挥手,服务端向客户端发送ACK报文,此时客户端到服务端的连接关闭
- 第三次挥手,服务端向客户端发送FIN报文,此时代表服务端数据已经发送完毕
- 第四次挥手,客户端向服务端发送ACK报文,此时双方连接关闭
-
为什么主动关闭(客户端)的一方需要TIME-WAIT等待2MSL?
- 保证TCP协议的全双工连接能够可靠关闭。如果第四次挥手ACK包丢失,服务端重发FIN包, 由于客户端已经关闭
,客户端就找不到与重发的FIN对应的连接,就会向服务端连接复位RST包,导致TCP协议不符合可靠连接的要求。 - 保证这次连接的重复数据段从网络中消失。如果客户端直接CLOSED,然后又再向Server发起一个新连接,我们不能保证这个新连接与刚关闭的连接的端口号是不同的,特别是如果开启了快速重用端口选项SO_REUSEPORT。也就是说有可能新连接和老连接的端口号是相同的。假设新连接和已经关闭的老连接端口号是一样的,如果前一次连接的某些数据仍然滞留在网络中,这些延迟数据在建立新连接之后才到达Server,由于新连接和老连接的端口号是一样的,新旧连接的数据包发生混淆了。等待2MSL,这样可以保证本次连接的所有数据都从网络中消失。
- 保证TCP协议的全双工连接能够可靠关闭。如果第四次挥手ACK包丢失,服务端重发FIN包, 由于客户端已经关闭
-
浅谈SO_REUSEPORT选项
SO_REUSEADDR:主要解决TIME_WAIT状态导致bind()失败的问题
//setsockopt(SO_REUSEADDR)用在服务器端,socket()创建之后,bind()之前 //setsockopt():设置一些套接字参数选项; //参数2:是表示级别,和参数3配套使用,也就是说,参数3如果确定了,参数2就确定了; //参数3:允许重用本地地址 int reuseaddr=1; //开启 if(setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR, (const void *) &reuseaddr,sizeof(reuseaddr)) == -1) {char *perrorinfo = strerror(errno); printf("setsockopt(SO_REUSEADDR)返回值为%d,错误码为:%d,错误信息为:%s;\n",-1,errno,perrorinfo); }
- SO_REUSEADDR允许启动一个监听服务器并捆绑其端口,即使以前建立的将端口用作他们的本地端口的连接仍旧存在;【即便TIME_WAIT状态存在,服务器bind()也能成功】
- 允许同一个端口上启动同一个服务器的多个实例,只要每个实例捆绑一个不同的本地IP地址即可;
- SO_REUSEADDR允许单个进程捆绑同一个端口到多个套接字,只要每次捆绑指定不同的本地IP地址即可;
- SO_REUSEADDR允许完全重复的绑定:当一个IP地址和端口已经绑定到某个套接字上时,如果传输协议支持,同样的IP地址和端口还可以绑定到另一个套接字上;一般来说本特性仅支持UDP套接字[TCP不行];
- 所有TCP服务器都应该指定本套接字选项,以防止当套接字处于TIME_WAIT时bind()失败的情形出现
5. epoll技术
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。
epoll可以理解为event poll,它是一种事件驱动的I/O模型,可以用来替代传统的select和poll模型。epoll的优势在于它可以同时处理大量的文件描述符,而且不会随着文件描述符数量的增加而降低效率。
epoll的实现机制是通过内核与用户空间共享一个事件表,这个事件表中存放着所有需要监控的文件描述符以及它们的状态,当文件描述符的状态发生变化时,内核会将这个事件通知给用户空间,用户空间再根据事件类型进行相应的处理。
epoll的接口和工作模式相对于select和poll更加简单易用,因此在高并发场景下被广泛使用。
epoll底层维护两个数据结构,一个是红黑数,保存所有监听的socket,一个是双向链表,保存当前有事件发生的socket,当epoll监听的socket有事件发生时,内核调用epoll_event_callback()向双向链表增加节点。
5.1 epoll_create()
- 函数声明int epoll_create(int size)
- 参数 size 大于0就行
- 功能 创建一个epoll对象,返回该对象的描述符【文件描述符】,这个描述符就代表这个epoll对象
- 函数实现
- 分配内存创建eventpoll结构体
- eventpoll->rbr指向红黑数的根节点
- eventpoll->rdlist双向链表的头节点
5.2 epoll_ctl()
- 函数声明int epoll_ctl(int efpd,int op,int sockid,struct epoll_event *event);
- 参数
- efpd:epoll_create()返回的epoll对象描述符;
- op:动作,添加/删除/修改 ,对应数字是1,2,3, EPOLL_CTL_ADD, EPOLL_CTL_DEL ,EPOLL_CTL_MOD
- EPOLL_CTL_ADD添加事件:等于你往红黑树上添加一个节点,每个客户端连入对应一个socket,这个socket就是红黑树中的key,把这个节点添加到红黑树上去;
- EPOLL_CTL_MOD:修改事件;你 用了EPOLL_CTL_ADD把节点添加到红黑树上之后,才存在修改;
- EPOLL_CTL_DEL:是从红黑树上把这个节点干掉;这会导致这个socket【这个tcp链接】上无法收到任何系统通知事件;
- sockid:表示客户端连接,就是你从accept();这个是红黑树里边的key;
- event:事件信息,这里包括的是 一些事件信息;EPOLL_CTL_ADD和EPOLL_CTL_MOD都要用到这个event参数里边的事件信息;
- 功能
把一个socket以及这个socket相关的事件添加到这个epoll对象描述符中去,目的就是通过这个epoll对象来监视这个socket【客户端的TCP连接】上数据的来往情况;当有数据来往时,系统会通知我们; - 函数实现
- 【EPOLL_CTL_ADD】增加节点到红黑树中,添加新的监听socket
- 【EPOLL_CTL_DEL】从红黑树中把节点干掉,移除监听的socket
- 【EPOLL_CTL_MOD】找到红黑树节点,修改这个节点中的内容,修改socket的监听事件(读/写/关闭)
5.3 epoll_wait()
- 函数声明 int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
- 参数
- epfd:是epoll_create()返回的epoll对象描述符;
- events:是内存,也是数组,长度 是maxevents,表示此次调用可以收到maxevents个已经准备好的读写事件;说白了,就是返回的是 实际 发生事件的tcp连接数目;
- timeout:阻塞等待的时长;
- 功能
- 阻塞一小段时间并等待事件发生,返回事件集合,也就是获取内核的事件通知;
- 说白了就是遍历这个双向链表,把这个双向链表里边的节点数据拷贝出去,拷贝完毕的就从双向链表里移除;双向链表里记录的是所有有数据/有事件的socket【TCP连接】;
- epitem结构设计的高明之处:既能够作为红黑树中的节点(rbr),又能够作为双向链表中的节点(rdlink);
5.4 epoll工作模式
- LT 水平触发,低速模式,效率差(缺省),一个事件不处理,它会一直触发
- ET 边缘触发,高速模式,速度快,只对非阻塞socket生效,内核只会通知一次(不管是否处理),编码难度加大