声明:以下内容为学习刘超老师的极客时间专栏——《趣谈网络协议》时的学习笔记,不代表刘超老师的任何个人观点,如描述有误,可在评论中指出。同时,通过这门课程也学到了很多知识,刘超老师讲得通俗易懂,如果有需要的话,可自行订阅学习,直达链接。
TCP 的全称是 Transmission Control Protocol,传输控制协议。
通过三次握手来建立 TCP 连接,三次握手就是用来启动和确认 TCP 连接的过程。一旦连接建立后,就可以发送数据了,当数据传输完成后,会断开连接。
主要特点:
三次握手相关名词释义
消息类型 | 描述 |
---|---|
SYN | 这个消息是用来初始化和建立连接的 |
ACK | 帮助对方确认收到的SYN消息 |
SYN-ACK | 本地的SYN消息和较早的ACK数据包 |
FIN | 用来断开连接 |
用现实生活来举例的话就是(小明-客户端,小红-服务端)
5. 小明给小红打电话,接通了后,小明说喂,能听到吗,这就相当于是连接建立
。
6. 小红给小明回应,能听到,你能听到我说的话吗,这就相当于是请求响应
。
7. 小明听到小红的回应后,说,可以的,这相当于是连接确认
。
在这之后小明和小红就可以通话/交换信息了。
三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。
所以三次握手就能确认双发收发功能都正常,缺一不可。
FIN_WAIT_1
状态。当客户端处于 FIN_WAIT_1 状态时,它会等待来自服务器的 ACK 响应。ACK 确认消息
。FIN_WAIT_2
状态,然后等待来自服务器的 FIN 消息。TIME_WAIT
状态。处于 TIME_WAIT 状态的客户端允许重新发送 ACK 到服务器为了防止信息丢失。客户端在 TIME_WAIT 状态下花费的时间取决于它的实现,在等待一段时间后,连接关闭,客户端上所有的资源(包括端口号和缓冲区数据)都被释放。补充说明:等待的时间设为 2MSL,MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等。
为什么要设置等待时间即 TIME_WAIT 状态?
(这里 A 代表上图中左边的一端,B 代表上图中右边的一端)
还是可以用上面那个通过的例子来进行描述
任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了TCP连接。
举个例子:A 和 B 打电话,通话即将结束后,A 说“我没啥要说的了”,B 回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话,于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了”,A 回答“知道了”,这样通话才算结束。
将连接建立和连接断开的两个时序状态图综合起来就是著名的 TCP 的状态机了。
顺序问题、丢包问题、流量控制都是通过滑动窗口来解决的。
为了保证顺序性,TCP 发送每一个包时都有一个 ID。在建立连接的时候,双方会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但这个应答也不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式成为累计确认或者累计应答(cumulative acknowledgement)。
为记录所以发送的包和接收的包,TCP 需要发送到和接收端都有缓存来保存这些记录。发送端的缓存里按照包的 ID 一个个排列,根据处理的情况分为四个部分:
为什么还要区分第三和第四部分,而不分为同一部分?
因为需要考虑到接收方的处理能力,这也是 TCP 做流量控制的策略之一。在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window。这个窗口的大小应该等于上面的第二部分加上第三部分,超过这个窗口的,接收端做不过来,就不能发送了。
于是,发送端需要保持下面的数据结构:
接收端缓存记录的内容如下:
第二部分的窗口有多大?
NextByteExpected 和 LastByteRead 的差就是还没有应用层读取的部分占用占用掉的 MaxRcvBuffer 的量,我们定义为 A,即 A = NextByteExpected - LastByteRead,对应图中的 1~6。
AdvertisedWindow 就是 MaxRcvBuffer - A,即:AdvertisedWindow = MaxRcvBuffer - ((NextByteExpected - 1) - LastByteRead,对应图中就是:AdvertisedWindow = 14 - ((6 - 1) - 0 = 9,即 AdvertisedWindow 为图中的 6~14。
(NextByteExpected - 1)+ AdvertiseWindow 就是第二部分和第三部分的分界,也就是 LastByteRead + MaxRcvBuffer,即 0 + 14 = 14。
第二部分由于收到的包可能不是顺序的,会出现空档,只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。
接下来结合一个例子来看:
在发送端,1 ~ 3 已经发送并确认,4 ~ 9 是发送了还没确认,10 ~ 12 是还没发出的,13 ~ 15 是接收方没有空间,不准备发的,如下图:
在接收端,1 ~ 5 是已经完成 ACK 但没有读取的,6 ~ 7 是等待接收的,8 ~ 9 是已经接收但没有 ACK 的,如下图:
发送端和接收端当前的状态如下:
根据这个例子可以看出,顺序问题和丢包问题都有可能发生,那 TCP 是如何解决的?
确认与重发机制。
假设 4 的 ACK 到了,5 的 ACK 丢了,6、7 的数据包丢了,TCP 会如何处理?
超时重试:对每一个发送但没有 ACK 的包,都设置一个定时器,超过了一定的时间,就重新尝试。这个时间不宜过短也不宜过长,时间必须大于往返时间 RTT,否则会引起不必要的重传,过长会导致超时时间变长,访问就会变慢了。
如何确定这个时间?
自适应重传算法(Adaptive Retransmission Algorithm):估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还会不断变化,因为网络状况不断变化,除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。
例如:如果过一段时间,5、6、7 都超时了,就会重新发送。接收端发现 5 原来接收过,于是丢弃 5;6 收到了,发送 ACK,要求下一个是 7,7 又丢了。
当 7 再次超时需要重传时,TCP 的策略是超时间隔加倍:每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
超时触发重传存在的问题:超时周期可能相对较长,有没有更快的方式?
快速重传机制:当接收端收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是接收端就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段(其实就是告诉发送端想接收哪个包)。而当接收端收到三个冗余的 ACK 后,就会在定时器过期之前,重传丢失的报文段。
例如,接收端发现 6、8 收到了,但 7 还没到,那肯定是丢了,于是发送 6 额 ACK,并要求下一个是 7。接下来,收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。如果收到 3 个重复 ACK,就会认为 7 丢了,不等超时,马上重发。
除了超时重试、快速重传机制,有没有其他机制?
Selective Acknowledgment(SACK)
在 TCP 头里加一个叫 SACK 的东西,可以将缓存的地图发送给对方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送端一下子就能看出来是 7 丢了,就可以进行重发。
TCP 在对于包的确认中,同时会携带一个窗口的大小,以此来实现流量控制。
假设窗口不变,窗口始终为 9。4 的 ACK 到达时,会右移一个,这时第 13 包也可以发送了。
这个时候,假设发送端发送过猛,将第三部分的 10、11、12、13 全部发送完毕,之后就停止发送了,未发送可发送部分为 0。
当 5 的 ACK 到达时,窗口再滑动了一个,这时,第 14 个包可以发送了。
如果接收端处理得太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至设置为 0,则发送端将暂时停止发送。
假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9了,就要变为 8。
新的窗口 8 通过 6 的 ACK 到达发送端时,发送端的窗口并没有平行右移,而是左面的边左移了,窗口的大小从 9 改为 8.
如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为 0。
当这个窗口通过包 14 的 ACK 到达发送端的时候,发送端的窗口也调整为 0,停止发送。
如果这样的话,发送端会定时发送窗口探测数据包,看是否有机会调整窗口大小。当接收端比较慢时,要防止低能窗口综合征,就是别空出一个字节来就赶快告诉发送端,然后马上又填满了。
当窗口达到一定大小或者缓冲区为一半为空,才更新窗口,窗口太小的时候不更新。
拥塞控制是通过拥塞窗口来解决的。TCP 发送包相当于往管里面倒水,快了容易溢出,慢了浪费带宽, TCP 的拥塞控制就是在不堵塞、不丢包的情况下,尽量发挥带宽,找到最优值。(有点要摸着石头过河的意思)
对于发送方来讲,判断网络快慢是比较难的,对于 TCP 来说,它根本不知道整个网络路径会经历什么,对它来讲就是一个黑盒。
水管有粗细,网络有带宽,即每秒钟能否发送多少数据;
水管有长度,网络有时延,在理想状态下,水管里水的量 = 水管粗细 x 水管长度,对应网络就是:通道的容量 = 带宽 x 往返延迟。
什么是带宽、往返延迟?
如果设置发送窗口,使得发送但未确认的包为通道的容量,就能够撑满整个管道。
如上图,假设往返时间 8s,去 4s,回 4s,每秒发送一个包,每个包 1024 bytes。已经过了 8s,则 8 个包都发出去了,其中 4 个包已到达接收端,但 ACK 还没有返回,不能算发送成功。
5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好满了,在发送端,已发送未确认的 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以返回时间 8s。
如果在此基础上再调大窗口,使得单位时间可以发送更多的包,可能会出现包丢失和超时重传的现象。
包丢失:原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间耗费 1s,所以到达另一端需要耗费 4s,如果发送的更加快速,则单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃。
超时重传:例如这个四个设备本来每秒处理一个包,但在这些设备上加缓存,处理不过来的在队列里面排着,这样包就不会丢失,但是缺点是会增加时延,这个缓存的包,4s 肯定到达不了接收端了,如果时延达到一定程度,就会超时重传。
TCP 的拥塞控制可以避免上述两种现象,一旦出现这种情况,就说明,发送速度太快了,得慢点。
那如何知道速度有多快?如何调整窗口大小?
慢启动:举个例子,如果我们通过漏斗往瓶子里灌水,不可能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢倒,就可以越倒越快。
对应到网络中(指数性增长):
涨到什么时候是个头?
有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,可能水管快满了,要慢下来。
那如何慢下来?
每收到一个确认后,cwnd 增加 1/cwnd 而不是增加 1 了,如上例子,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。
但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。
拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。
但这种方式一旦超时重传,就会马上回到解放前,太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。
那有没有更好的办法解决?
快速重传算法:当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。
正是这种知进退,使得时延很重要的情况下,反而降低了速度。
但 TCP 的拥塞控制主要来避免的两个现象都是有问题的:
如何优化上述两个问题?
TCP BBR 拥塞算法:它企图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。
UDP 的全称是 User Datagram Protocol,用户数据报协议。
它不需要所谓的握手操作,从而加快了通信速度,允许网络上的其他主机在接收方同意通信之前进行数据传输。数据报是与分组网络关联的传输单元。
主要特点:
TCP | UDP |
---|---|
面向连接的协议 | 无连接的协议 |
在发送数据前先需要建立连接,然后再发送数据 | 无需建立连接就可以直接发送大量数据 |
按照特定顺序重新排列数据包 | 数据包没有固定顺序,所有数据包都相互独立 |
传输的速度比较慢 | 传输会更快 |
头部字节有20字节 | 头部字节只有8字节 |
重量级,在发送任何用户数据之前,TCP需要三次握手建立连接 | 轻量级,没有跟踪连接,消息排序等 |
会进行错误校验,并能够进行错误恢复 | 也会错误检查,但会丢弃错误的数据包 |
有发送确认 | 没有发送确认 |
会使用握手协议,例如SYN,SYN-ACK,ACK | 无握手协议 |
提供可靠交付,可以确保将数据传送到路由器 | 无可靠交付,不能保证将数据传送到目标 |
综上所述,可以这样比喻:
如果 MAC 层定义了本地局域网的传输行为,IP 层定义了整个网络端到端的传输行为,这两层基本定义了这样的基因:网络传输是以包为单位的,二层叫帧,网络层叫包,传输层叫段。笼统地称为包。包单独传输,自行选路,在不同的设备封装解封装,不保证到达。基于这个基因,生下来的孩子 UDP 完全继承了这些特性,几乎没有自己的思想。
Socket 可以理解为插口或者插槽,可以想象为弄一根网线,一头插在客户端,一头插在服务端,然后进行通信,所以在通信之前,双方都要建立一个 Socket。
在建立 Socket 时,Socket 编程进行的是端到端的通信,往往意识不到中间经过多少局域网、路由器等,因而能够设置的参数,也只能是端到端协议之上网络层和传输层。
在网络层,Socket 函数需要设置 AF_INET 和 AF_INET6 分别对应 IPv4 和 IPv6。
TCP 协议是基于数据流的,所以设置为 SOCK_STREAM 指定为 TCP 传输;UDP 是基于数据报的,因而设置为 SOCK_DGRAM 指定为 UDP 传输。
如下图为基于 TCP 协议的 Socket 程序函数调用过程:
过程解析:
TCP 的服务端要先监听一个端口,一般是先调用 bind 函数,给这个 Socket 赋予一个 IP 地址和端口。
为什么需要端口?
我们写的是应用程序,当一个网络包来的时候,内核要通过 TCP 头里面的这个端口,来找到这个应用程序。
为什么需要 IP 地址?
有时一台机器会有多个网卡,也就有多个 IP 地址,我们可以选择监听一个或者所有的网卡,你监听了哪个网卡,发往这个网卡的包,才会发给你。
当服务端有了 IP 和端口号,就可以调用 listen 函数进行监听。在 TCP 的状态图中,有一个 listen 状态,当调用这个函数之后,服务端就进入了这个状态,这时客户端就可以发起请求了。
在内核中,为每个 Socket 维护两个队列:
接下来,服务端调用 accpet 函数,拿出一个已经完成的连接进行处理。如果还没有完成,就要等着。
在服务端等待时,客户端可以通过 connect 函数发起连接。先在参数中指明要连接的 IP 地址和端口号,然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务的 accept 就会返回一个 Socket。
注意:监听的 Socket 和真正用来传数据的 Socket 是两个:监听 Socket、已连接 Socket。
连接建立成功后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
TCP 的 Socket 就是一个文件流,因为 Socket 在 Linux 中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。
在内核中,Socket 是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构 task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。
这个数组的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个 inode,只不过 Socket 对应的 inode 不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个 inode 中,指向了 Socket 在内核中的 Socket 结构。
在这个结构里面,主要是两个队列:发送队列、接收队列。在这两个队列里面保存的是一个缓存 sk_buff。这个缓存里面能够看到完整的包的结构。
UDP 是没有连接的,所以不需要三次握手,也就不需要调用 listen 和 connect,但 UDP 的交互仍然需要 IP 和端口号,因而也需要 bind。
UDP 是没有维护年假状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正因为没有连接状态,每次通信时,都调用 sendto 和 recvfrom,都可以传入 IP 地址和端口。
下图为基于 UDP 协议的 Socket 程序函数调用过程:
基于上述的几个基本的 Socket 函数,可以写出一个简单的网络交互程序,但基本上只能一对一沟通,如果是一个服务器,只能同时服务一个客户;但在企业级项目中,一个服务器是会有成千上万条连接的,服务于多个客户。就好比一个老板成立一家公司,只有自己一个人,只能自己上来服务客户,只能干完了一家再干下一家,这样赚不了多少钱,如果能连接更多的项目,就能赚更多的钱。
理论值:最大连接数,系统会用一个四元组来标识一个 TCP 连接。
{本机 IP,本机端口,对端 IP,对端端口}
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。
因此,服务端 TCP 连接四元组中只有对端 IP,也就是客户端的 IP 和对端的端口,也即客户端的端口是可变的,因此,最大 TCP 连接数 = 客户端 IP 数 x 客户端端口数。
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。
服务端最大并发 TCP 连接数远不能达到理论上限,原因有两个:
所以,在资源有限的情况下,服务端想要有更多的连接数,就需要降低每个连接的资源消耗。(相当于:作为老板,在自由有限的情况下,想接更多的项目赚跟多的钱,就要降低每个项目消耗的资源数目。)
使用此方式时,服务端相当于一个代理,在那里监听客户端发来的请求。
一旦建立了连接,就会有一个已连接 Socket,这时就可以创建一个子进程,然后将基于已连接 Socket 的交互交给这个新的子进程来做。(就好比新来了一个项目,你作为老板,项目不一定是自己做,可以再注册一家子公司,找点人,然后把项目转包给这家子公司做,以后对接就交给这家子公司了,你作为老板又可以去接新的项目了,赚更多的钱。)
那如何创建子公司,如何将项目移交给子公司?
在 Linux 下,创建子进程使用 fork 函数,通过在父进程的基础上完全拷贝一个子进程。
在 Linux 内核中,拷贝子进程会复制文件描述符的列表,复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程。
复制的时候在调用 fork,复制完毕之后,父进程和子进程都会记录当前刚刚执行完 fork。
这两个进程刚复制完的时候,几乎一模一样,只是根据 fork 的返回值来区分到底是父进程,还是子进程。如果返回值是 0,则是子进程;如果返回值为其他整数,则为父进程。
进程复制过程如下:
因为复制了文件描述符列表,而文件描述符都是指向整个内核统一的打开文件列表的,因而父进程刚才因为 accept 创建的已连接 Socket 也是一个文件描述符,同样也会被子进程获得。
接下来,子进程就可以通过这个已连接 Socket 和客户端进行互通了,当通信完毕之后,就可以退出进程。
父进程如何知道子进程要退出?
在 fork 子进程时,父进程会获得子进程的 ID,父进程可以通过子进程 ID 查看子进程是否需要退出。
多进程处理的缺点
进程频繁创建与销毁会销毁很多资源。进程创建会消耗很多资源,所有的东西都要复制,容易造成资源的浪费。
举个例子,如果每次接一个项目,都申请一个新公司,然后干完了,就注销掉这个公司,实在是太麻烦了。毕竟一个新公司要有新公司的资产,有新的办公家具,每次都买了再买,不划算。
对应到服务器处理连接的过程就是,服务器每处理一条新的连接,就创建一个新的进程去处理,连接处理完成后,就销毁进程。但进程的创建与销毁时比较浪费资源的,创建进程会有 CPU、内存等资源的消耗。
线程相比进程会比较轻量级。如果创建进程相当于成立新公司,购买新办公家具,创建线程就相当于在同一个公司成立项目组。一个项目组做完了就可以被解散,然后组成另外的项目组,办公家具可以共用。(即线程可以共用同一个进程中的资源。)
在 Linux 下,通过 pthread_create 创建一个线程,也是调用 do_fork。不同的是,虽然新的线程在 task 列表会新创建一项,但是很多资源,例如文件描述符列表、进程空间,还是共享的,只不过多了一个引用而已。
新的线程也可以通过已连接 Socket 处理请求,从而达到并发处理的目的。
多线程处理的缺点
每新到来一个 TCP 连接,就需要分配一个进程或者线程。一台机器无法创建很多线程或者进程。
有个 C10K,它的意思是一台机器要维护 1 万个连接,就要创建 1 万个进程或者线程,那么操作系统是无法承受的。如果维持 1 亿用户在线需要 10 万台服务器,成本就太高了。
C10K 的问题就是,接的项目太多了,如果每个项目都成立单独的项目组,就要招聘 10 万人,那这人力成本非常巨大!
一个项目组可以看多个项目。这时,每个项目组有应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展,就派人去盯一下。
由于 Socket 都是文件描述符,因而每个线程盯的所有的 Socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度墙,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。
发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯着下一轮的变化。
select 函数的缺点
当每次 Socket 所在的文件描述符集合中有 Socket 发生变化的时候,都需要通过轮询的方式,即将全部项目都过一遍来查看进度,这大大影响了一个项目组能够支撑的最大的项目数量。因而使用 select,能够同时盯的项目数量由 FD_SETSIZE 限制。
上述 select 函数的缺点可以使用 epoll 函数来解决。使用 epoll 函数就相当于改成事件通知的方式,项目组不需要轮询每个项目,而是当项目进度发送变化时,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。
什么是 epoll 函数?
epoll 函数在内核中的实现不是通过轮询的方式,而是通过注册 callback 函数的方式,当某个文件描述符发送变化时,就会主动通知。
如上图,假设进程打开了 Socket m,n,x 等多个文件描述符,现在需要通过 epoll 来监听这些 Socket 是否都有事件发生。
其中 epoll_create 创建一个 epoll 对象,也是一个文件,也对应一个文件描述符,同样也对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树里,保存着这个 epoll 要监听的所有 Socket。
当 epoll_ctl 添加一个 Socket 时,其实是加入这个红黑树,同时红黑树里面的节点指向一个结构,将这个结构挂在被监听的 Socket 的事件列表中。
当一个 Socket 来了一个事件的时候,可以从这个列表中得到 epoll 对象,并调用 callback 通知它。
这种通知方式使得监听的 Socket 数据增加的时候,效率不会大幅度降低,能够同时监听的 Socket 的数据也非常的多了。上限就为系统定义的、进程打开的最大文件描述符个数。
因而,epoll 被称为解决 C10K 问题的利器。