C语言基础知识

C语言基础知识

函数指针和指针函数的区别

指针函数:指针函数本质是一个函数,只不过返回值为某一类型的指针(地址值)。函数返回值必须用同类型的变量来接受,也就是说,指针函数的返回值必须赋值给同类型的指针变量。指针函数的定义格式:类型名 *函数名(函数参数列表);

函数指针:函数指针本质是一个指针,只不过这个指针指向一个函数。常见的函数都有其入口,比如main()函数是整个程序的入口,我们调用的其他函数都有其特定的入口,正如我们可以通过地址找到相应的变量一样,我们也可以通过地址找到相应的函数。而这个存储着函数地址的指针就是函数指针。换言之,我们所说的指针变量通畅指向一个整形、字符型、或者数组等变量,而函数指针指向的是函数。正如我们可以通过指针访问相应的变量,函数指针也可以像函数一样用于调用函数、传递参数。

函数指针定义格式:类型名 (*函数名)(函数参数列表)

epoll vs select

程序编译知识

高级语言程序 → 预处理 → 编译程序 → 目标汇编代码 → 汇编程序 → 可再装配的机器码 → 可在装配的目标文件 → 绝对的机器码

编译过程需要:词法分析(lex)语法分析(yacc, Bison)语义分析中间代码生成代码优化目标代码生成这几个阶段。

程序分段

Linux中目标文件遵循ELF文件格式。程序加载进内存之后,特别喜欢提及的段有代码段数据段BSS段

代码段:在内存中被映射为只读。它是由编译器在编译链接时自动计算的。通常是用来存放程序执行的指令。代码段属于静态内存分配

数据段:通常用来存放程序中已初始化的(非0)全局变量和静态局部变量。数据段的起始位置由链接定位文件确认,大小在编译链接时自动分配。数据段属于静态内存分配

BSS段:通常用来存放程序中未初始化和初始化为0的全局变量的一块内存区域,在程序载入时由内核清零。数据段属于静态内存分配

堆:保存函数内部动态分配(malloc或new)的内存,是另外一种用来保存程序信息的数据结构。堆是先进先出(FIFO)数据结构。堆的地址空间是向上增加,即当堆上保存的数据越多,堆的地址越高。动态内存分配(注意:堆内存需要程序员手动管理内存,通常适用于较大的内存分配,如频繁的分配较小的内存,容易导致内存碎片化)

栈:栈保存函数的局部变量(不包括 static 修饰的变量),参数以及返回值。是一种后进先出(LIFO)的数据结构。在调用函数或过程后,系统会清除栈上保存的局部变量、函数调用信息及其他信息。栈的另外一个重要特征是,它的地址空间向下减少,即当栈上保存的数据越多,栈的地址越低。静态内存分配(注意,由于栈的空间通常比较小,一般linux程序为8M(ulimit -s查看),故局部变量,函数入参应该避免出现超大栈内存使用,比如超大结构体,数组等,避免出现stack overflow

静态全局变量和非静态全局变量的区别

静态变量与非静态变量的区别在作用域:静态变量只在定义它的文件内有效,而非静态全局变量的作用域是整个源程序。

内存分配有哪几种方式

内存的三种分配方式:

  1. 从静态存储区分配:此时的内存在程序编译的时候已经分配好,并且在程序的整个运行期间都存在。全局变量,static变量等在此存储。
  2. 在栈区分配:相关代码执行时创建,执行结束时被自动释放。局部变量在此存储。栈内存分配运算内置于处理器的指令集中,效率高,但容量有限。Linux中默认的栈大小为8M,可通过ulimit -s查看
  3. 在堆区分配:动态分配内存。用new/malloc时开辟,delete/free时释放。生存期由用户指定,灵活。但有内存泄露等问题。

Linux多线程与多进程的区别

Linux中的线程分为LinuxThreadsNGPT(Next Generation POSIX Threads)NPTL(Native POSIX Threads Library)三种模型。LinuxThreadsNPTL都是采用一对一的线程模型,NGPT采用的是多对多的线程模型。

多对一用户线级程模型:多对一线程模型中,线程的创建、调度、同步的所有细节全部由进程的用户空间线程库来处理。用户态线程的很多操作对内核来说都是透明的,因为不需要内核来接管,这意味不需要内核态和用户态频繁切换。线程的创建、调度、同步处理速度非常快。当然线程的一些其他操作还是要经过内核,如IO读写。这样导致了一个问题:当多线程并发执行时,如果其中一个线程执行IO操作时,内核接管这个操作,如果IO阻塞,用户态的其他线程都会被阻塞,因为这些线程都对应同一个内核调度实体。在多处理器机器上,内核不知道用户态有这些线程,无法把它们调度到其他处理器,也无法通过优先级来调度。这对线程的使用是没有意义的。

一对一内核极线程模型:一对一模型中,每个用户线程都对应各自的内核调度实体。内核会对每个线程进行调度,可以调度到其他处理器上面。当然由内核来调度的结果就是:线程的每次操作会在用户态和内核态切换。另外,内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响。但该模型的实用性还是高于多对一的线程模型。

多对多两极线程模型:多对多模型中,结合了1:1和M:1的优点,避免了它们的缺点。每个线程可以拥有多个调度实体,也可以多个线程对应一个调度实体。听起来好像非常完美,但线程的调度需要由内核态和用户态一起来实现。可想而知,多个对象操作一个东西时,肯定要一些其他的同步机制。用户态和内核态的分工合作导致实现该模型非常复杂。NPTL曾经也想使用该模型,但它太复杂,要对内核进行大范围改动,所以还是采用了一对一的模型。

现在版本的Linux中一般使用的是NPTL模型,可以使用命令getconf GNU_LIBPTHREAD_VERSION查看当前使用的线程库。

我们从创建上下文切换共享内存的缓存一致性分别分析其区别:

创建:创建进程和线程的过程类似,都是用clone syscall实现的。差别只是创建进程时会把相关的数据结构都复制一份,而创建线程时部分数据结构是共享的,如页表。因此,创建线程自然比创建进程快。

进程上下文切换:进程上下文切换会更换页表(cr3),且导致相关缓存失效。线程上下文切换不需要。

共享内存的缓存一致性:共享内存的缓存一致性和线程或进程没有本质的关联,只和是否使用可修改的共享内存有关。

信号

数组指针和指针数组的区别

数组指针表示变量是个指针,指向一个数组;指针数组表示变量是个数组,里面存储的数据为指针

sizeof和strlen的区别

sizeof是一个操作符,可以用来获取一个变量或类型的字节数,不受变量值的影响。例如,sizeof(int)返回4,sizeof(char)返回1。strlen是一个函数,用于获取一个字符串的长度,即字符数组中的字符个数,不包括字符串结束符'\0'。例如,strlen("hello")返回5。

const和static变量存放位置

我们将以下面的例子介绍const变量和static变量的存放位置:

static int val_a = 1 ; // 初始化的静态变量
int val_b = 2 ; // 全局变量
const int val_c = 3 ; // const 全局变量
static int val_d ; // 未初始化的静态变量
int val_e ; // 未初始化的全局变量
int main() {
  static int val_f = 5; // 初始化的局部静态变量
  static int val_g; //未初始化局部静态变量
  int val_h = 6; //初始化局部变量
  int val_i; //未初始化局部变量
  const int val_j = 7; //const局部变量
  return 0;
}
  • static无论是全局变量还是局部变量都存储在全局/静态区域,在编译期就为其分配内存,在程序结束时释放,例如:val_a、val_d、val_f、val_g。
  • const全局变量存储在只读数据段,编译期最初将其保存在符号表中,第一次使用时为其分配内存,在程序结束时释放,例如:val_c;const局部变量存储在栈中,代码块结束时释放,例如:val_j。
  • 全局变量存储在全局/静态区域,在编译期为其分配内存,在程序结束时释放,例如:val_b、val_e。
  • 局部变量存储在栈中,代码块结束时释放,例如:val_h、val_i。

注:当全局变量和静态局部变量未赋初值时,系统自动置为0。

  1. 我们说的data(初始化的段)bbs段(未初始化的段)都是针对全局变量和静态变量来说的
  2. data又分为读写数据段(rw data)只读数据段(ro data)
  3. bbs是未初始化的全局变量和静态变量和初始化为0的全局变量和静态变量
  4. 我们在函数里面的定义的局部变量(除static xxx)都是在stack or heap里面
  5. bbs段不影响a.out的大小,bbs段的数据用占位符标示而已,不在a.out里面运行才产生
  6. stack heap也都是在运行的时候产生

堆栈区(stack),堆栈是由编译器自动分配释放,存放函数的参数值,局部变量的值等。栈的申请是由系统自动分配,如在函数内部申请一个局部变量 int h,同时判别所申请空间是否小于栈的剩余空间,如若小于的话,在堆栈中为其开辟空间,为程序提供内存,否则将报异常提示栈溢出。

其次是堆(heap),堆一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。堆的申请是由程序员自己来操作的,在C中使用malloc函数,而C++中使用new运算符,但是堆的申请过程比较复杂。

HTTPS如何保证数据的安全

HTTPS通过使用SSL/TLS协议来保证数据传输的安全性。这个协议主要提供三个基本安全保护:加密(确保数据的隐私性和安全性,防止数据在传输过程中被窃听)、身份验证(通过证书来验证服务器身份,确保数据发送到正确的服务器和客户端)、完整性(确保数据在传输过程中未被更改)。

TCP建立连接需要3次握手?为什么不是2和4

TCP使用三次握手来建立一个可靠的连接,确保双方都准备好发送和接收数据。三次握手的过程是:

  • 客户端发送一个带SYN标志的数据包到服务器以建立连接
  • 服务端回应一个带SYN/ACK标志的数据包以确认连接
  • 客户端再次发送ACK包响应,此时连接建立成功

如果是两次握手,无法确认客户端接收能力是否准备好。四次握手则是多余的,会增加延迟和复杂性,而且不会带来额外的好处。

UDP需要握手吗

不需要。UDP是一种无连接的协议,不像TCP那样在发送数据之前需要建立连接。UDP直接发送数据包,不保证数据包的顺序或是否丢失,适用于实时性要求高的应用。

TIME_WAIT的作用

TIME_WAIT是TCP连接断开后的一个状态,主要目的是确保所有数据包都正确完成传输。在这个状态下,连接会保持打开一段时间(通常是2分钟,可通过sysctl net.ipv4.tcp_fin_timeout命令查看),以确保网络上的任何延迟或重复的数据包都可以被正确处理,并且确保连接的可靠终止。

TIME_WAIT的作用是确保最后一个ACK包能够到达对方。当客户端发送完最后一个ACK包后,它会进入TIME_WAIT状态,等待一段时间,以确保服务器能够收到这个ACK包。如果服务器没有收到这个ACK包,客户端收到后会重新发送ACK包。这样可以确保数据的可靠性。

TCP拆包粘包这种说法对吗

这种说法是不对的,粘包并不是TCP协议造成的,它的出现是因为应用层协议设计者对TCP协议的错误理解,忽略了TCP协议的定义并且缺乏设计应用层协议的经验。

  • TCP 协议是面向字节流的协议,它可能会组合或者拆分应用层协议的数据;
  • 应用层协议的没有定义消息的边界导致数据的接收方无法拼接数据;

TCP协议是面向连接的、可靠的、基于字节流的传输层通信协议,应用层交给TCP协议的数据并不会以消息为单位向目的主机传输,这些数据在某些情况下会被组合成一个数据段发送给目标的主机。当应用层协议通过TCP协议传输数据时,实际上待发送的数据先被写入了 TCP 协议的缓冲区,如果用户开启了Nagle算法,那么TCP协议可能不会立刻发送写入的数据,它会等待缓冲区中数据超过最大数据段(MSS)或者上一个数据段被ACK时才会发送缓冲区中的数据。除了 Nagle 算法之外,TCP 协议栈中还有另一个用于延迟发送数据的选项TCP_CORK,如果我们开启该选项,那么当发送的数据小于 MSS 时,TCP 协议就会延迟 200ms 发送该数据或者等待缓冲区中的数据超过MSS。

TCP 协议是基于字节流的,这其实就意味着应用层协议要自己划分消息的边界。在应用层协议中,最常见的两种解决方案就是基于长度或者基于终结符(Delimiter)。

TCP RST包的作用

RST: TCP协议中重置、复位连接的标志位,用来关闭异常连接。发送RST包关闭连接时,不等缓冲区的包发送完成,会丢弃缓冲区中的包,直接发送RST。同样接收端收到RST包后,也不必发送ACK确认包。RST的使用场景有:

  • 当服务器收到没有监听端口的连接请求时会返回RST包
  • 异常终止一个连接
  • 检测半打开连接

TCP是可靠传输吗?如果是还需要在业务层保证幂等吗

是的,TCP是一种可靠的传输协议,它通过序列号、确认响应、重传等机制保证数据的正确传输。尽管如此,在业务层面上还是需要保证幂等性,以防止应用层的操作(如数据库操作)由于网络重试等原因重复执行。

Linux已经提供了Keep-Alive,为什么还需要应用层做心跳检测

虽然TCP的Keep-Alive机制可以帮助检测死链接,但这通常是在非活动连接上才会进行。应用层的心跳检测可以更灵活和及时的处理更多种类的断开情况,比如应用程序挂起、进程卡死等,从而提供更高层次上的保活功能。这对于需要高可靠性的应用来说尤为重要。

TCP理论带宽计算

以千兆网为例,首先要明确千兆网的速度为1000Mbps,即每秒可传输1000M个bit,且这儿1M = 1000 K = 1000000。那么千兆网的速度就是125MB/s,即每秒传输125000000个字节。(如果按MiB计算,就是119MiB/s)

在TCP/IP网络,一般使用以太网传输,在以太网中传输的是以太网帧。而TCP段是封装在IP包中然后封装在以太网帧中的。所以需要计算每秒中能传输多少个以太网帧。

通常以太网帧的最大长度是1518字节(不考虑jumbo frame),而以太网传输时需要在帧前传输7个字节的preamble一个字节的SFD,帧之间还需要96 bit的IFG(Inter-Frame Gap),即12字节,这样千兆网每秒可以传输的以太网帧的数量至少为125000000/(1518+7+1+12)=81274个。(另外,由于以太网帧最小为64字节,所以千兆网每秒最多能传输的以太网帧数量为125000000/(64+20)=1488095,这个值是帧速率)

回到TCP带宽的计算,带宽是针对负载的,所以要去掉TCP,IP头,加上TCP的timestamp option,共52字节,那么千兆网的TCP带宽 = 81274*(1500-52) = 117684752,约为117MB/s (或112 MiB/s)。

AIMD的目的和局限性

AIMD:additive increase multiplicative decrease,加法增加乘法减少。用于TCP拥塞控制(目标是缓解并解除网络拥塞,让所有流量公平共享带宽,合在一起就是公平收敛)

  • 拥塞窗口cwnd(Congestion Window)
    • 发送方维持一个叫做拥塞窗口的状态变量,拥塞窗口的大小取决于网络的拥塞程度且动态变化。发送方让自己的发送窗口等于拥塞窗口,如果还考虑接收方的接受能力,那么发送窗口还可能小于拥塞窗口
    • 发送方控制拥塞窗口的原则:只要网络没有出现拥塞,就增大拥塞窗口,以便将更多的分组发送出去;只要网络出现拥塞(发送方没有按时收到应当到达的确认报文),就减少拥塞窗口,以减少注入到网络中的分组数
  • 传输轮次:一个传输轮次所经历的时间是往返时间RTT。这里更加强调是将拥塞窗口cwnd所允许发送的报文段都连续发送出去,并收到对发送的最后一个字节的确认

TCP拥塞控制分为慢启动拥塞避免快速重传快速恢复

  • 慢启动:初始化一个很大的慢开始门限(ssthresh)和通常为一个最大分段大小(MSS)拥塞窗口(cwnd)。开始传输,每经过一个传输轮次(transmission round),将cwnd加倍,呈指数增长,以快速探测网络的容量。
  • 拥塞避免:当发送方达到慢开始门限(ssthresh)时,进入拥塞避免阶段。此时,发送方以线性增加的速率逐渐增加发送窗口的大小,以缓慢探测网络的容量,并避免网络拥塞。
  • 快速重传:当接收方收到乱序分组时,立即发送最新的合法ACK,再收到乱序分组再发最新的合法ACK(暗示说该ACK的下一个分组未到)。当发送方连续收到三次相同的ACK时,就会意识到该ACK的下一个分组未到,则重发按个分组,而无需等待超时。
  • 快速恢复:发送端响应快速重传,重发分组后,将ssthresh减半,将cwnd 从该值开始加法增长(拥塞避免算法)。

流程为:

  • 初始化一个很大的慢开始门限ssthresh(slow start thresh),初始化拥塞窗口cwnd(congestion window),通常为一个最大分段大小MSS(maximum segment size)。
  • 新建的TCP连接会尝试慢开始,每经过一个传输轮次(transmission round),将cwnd加倍,呈指数增长。
  • 当遭遇一个拥塞,将ssthresh设置为当前cwnd的一半,然后从头开始慢开始乘法减小
  • cwnd大于ssthresh之后还没有遭遇拥塞,cwnd的增长策略切换为拥塞避免,每经历一个传输轮次后cwnd加一个MSS,呈线性增长,即加法增大

AIMD算法就是上面所说的加法增大和乘法减少的结合。

AIMD拥塞窗口更新策略也存在一些缺陷,加法增加策略使发送方发送数据流的拥塞窗口在一个往返时延(RTT)内增加了一个MSS的大小,因此,当不同的数据流对网络瓶颈带宽进行竞争时,具有较小RTT的TCP数据流的拥塞窗口增加速率将会快于具有大RTT的TCP数据流,从而将会占有更多的网络带宽资源。

ACK为何要延迟?带来什么问题?怎么解决?

TCP延迟确认是传输控制协议的一些实现用于提高网络性能的一种技术。本质上,几个ACK响应可以合并为单个响应,从而减少协议开销。

在Linux中,#define TCP_DELACK_MAX ((unsigned)(HZ/5))#define TCP_DELACK_MIN ((unsigned)(HZ/25))。Linux内核每隔固定周期会发出timer interrupt(IRQ 0),HZ是用来定义每秒有几次timer interrupts的。举例来说,HZ为1000,代表每秒有1000次timer interrupts。HZ可在编译内核时设置,一般HZ值为250。以此可知,最小的延迟确认时间为40ms。随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。具体调整算法,可以参考linux-2.6.39.1/net/ipv4/tcp_input.c, Line 564的tcp_event_data_recv函数

带来的问题:

延迟ACK引入的额外等待时间在与某些应用程序和配置交互时可能导致进一步的延迟。如果发送方正在使用Nagle的算法,则数据将由发送方排队,直到收到ACK。如果发送方未发送足够的数据来填充最大分段大小(例如,如果它执行两次小的写操作,然后执行阻塞读取),则传输将暂停到ACK延迟超时。

Linux内核从版本2.4.4开始支持禁用延迟ACK的TCP_QUICKACK套接字选项。 由此可以得知,如果发送方的套接字层的数据少于一个完整数据包,根据Nagle的算法,在收到已发送数据的ACK之前,是不会发送这段数据的。与此同时,接收方的应用层在获取所有数据之前不会发送响应;如果接收方启用了延迟的ACK,则它的套接字层将不会发送ACK,直到达到超时。

如果应用程序以较小的块发送数据并期望定期ACK,则可能发生这种负面交互。为防止此延迟,应用程序层需要连续发送数据而无需等待ACK,或者Nagle的算法可能被发送方的应用程序禁用。

解决办法有:

  • Linux2.4.4开始,可以修改代码,在程序每次recv之后立即调用setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, (int[]){1}, sizeof(int));每次重新设定的原因是设定的TCP_QUICKACK不是永久的(man 7 tcp查看手册)
  • 在RHEL系发行版本中,可调整系统级别的最小延迟确认时间:echo 1 > /proc/sys/net/ipv4/tcp_delack_min

一端持续发送另一端不recv会发生什么?一段时间后恢复recv又会发生什么?

在TCP(传输控制协议)通信中,数据传输是双向的,并且每一方都有自己的接收缓冲区。当一端(我们称之为发送方)持续发送数据,而另一端(接收方)不调用recv来读取数据时,会发生以下几个步骤:

  1. 发送方的发送缓冲区填满:发送方会继续将数据放入其本地的发送缓冲区,直到该缓冲区被填满。当缓冲区被填满后,如果继续尝试写入数据,会发生以下几种情况,具体取决于该套接字的设置和行为:

    1. 阻塞模式:如果套接字处于阻塞模式(默认设置),那么当发送缓冲区满时,写入会阻塞。这意味着写入将停止执行,直到有足够的空间在发送缓冲区中释放出来,使得新的数据能够被写入。
    2. 非阻塞模式:如果套接字被设置为非阻塞模式,那么在发送缓冲区满的情况下,写入将不会阻塞调用线程。相反,它会立即返回一个错误,通常是 EWOULDBLOCKEAGAIN,表明操作当前无法完成,建议稍后重试。
    3. 使用select或poll:在开发中,可以使用 selectpollepoll 等系统调用来检查套接字的状态。这些工具可以用来检测何时发送缓冲区有可用空间,从而避免在写入操作上阻塞或错误地退出。

    因此,继续尝试写入数据时的具体行为将取决于套接字的配置以及你的错误处理策略。在实际应用中,通常需要准备处理这种情况,确保数据的正确发送和应用的稳定运行。

  2. 接收方的接收缓冲区填满:接收方的TCP栈会不断地从网络中读取数据到其本地的接收缓冲区,即使应用程序没有调用recv函数。当接收缓冲区被填满后,TCP栈将不再确认新的数据包,这导致发送方的TCP栈停止发送更多数据。

  3. TCP流量控制:TCP使用流量控制机制(如滑动窗口协议)来确保发送方不会溢出接收方的缓冲区。当接收方的缓冲区满时,它的窗口大小将会被设置为0,发送方将停止发送数据直到接收方再次读取数据并释放缓冲区空间。

  4. 持续发送方的重传超时:如果发送方在发送缓冲区满的情况下仍然有未被确认的数据,它可能会因为超时重传机制而尝试重传这些数据。但是,如果接收方的窗口大小仍为0,这些重传也不会被确认。

如果在一段时间后,接收方开始调用recv函数:

  1. 接收缓冲区释放空间:接收方的应用程序通过recv读取数据,释放接收缓冲区中的空间。
  2. 窗口大小更新:接收方的TCP栈将新的窗口大小(即可用的缓冲区大小)告知发送方,使得发送方可以恢复发送数据。
  3. 数据传输恢复:发送方根据接收到的窗口更新开始发送数据,数据传输恢复正常。

需要注意的是,如果接收方长时间不调用recv,而发送方的TCP栈持续尝试重传未被确认的数据,这可能会导致发送方的TCP栈最终超时并关闭连接。这个超时时间通常是可配置的,并且会因操作系统和网络配置的不同而异。如果连接被关闭,那么后续的recv调用将会返回错误,表明连接已经不再可用。

从网卡中断到recv中间发生了什么?

当网卡上收到数据以后,首先会以DMA的方式把网卡上收到的帧写到内存的RingBuffer中。再向CPU发起一个中断,以通知CPU有数据到达。当CPU收到中断请求后,会去调用网络驱动注册的中断处理函数。 网卡的中断处理函数并不做过多工作,发出软中断请求,然后尽快释放CPU。ksoftirqd检测到有软中断请求到达,调用网卡驱动注册的poll开始轮询收包,收到后交由各级协议栈处理。Linux内核驱动会处理链路层数据、协议栈会处理网络层和传输层的数据。对于UDP包来说,会被放到用户socket的接收队列中等待recv调用读取。

可以参阅**图解Linux网络包接收过程**

bbr/reno/cubic对比

  • TCP Reno:
    • 慢开始(慢启动)
    • 拥塞避免
    • 快重传
    • 快恢复
  • TCP Cubic:
    • cubic与Reno的区别在于,Reno在ssthresh之后的增长为固定值,而bic使用的是二分查找,这样就能快速到达收敛的Wmax,但是bic在短RTT的劣网环境下,激进的增窗会引发带宽的争抢,使得拥塞控制不具备公平性。cubic是一种改进的BIC,BIC在短RTT下增窗快,本质是因为其算法依赖RTT。CUBIC的实现方式整体和BIC一样,只不过增窗不再依赖RTT,而是根据一个公式来决定增窗的值,从而使得整个增窗曲线还是会和BIC保持差不多的形态,只不过不会在短RTT场景下再有快速增窗的问题了。
  • TCP BBR:
    • bbr是google 2016年发布的拥塞算法,2019年更新成bbrv2,也是google内部默认使用的tcp拥塞算法,算是当前最强的拥塞算法了。google论文中提到在youtube中应用后有整体有53%的延迟降低,发展中国家的请求更是降低了80%的延迟。
    • bbr算法显然是更加基于数学模型了,摒弃了一些在现代网络环境下并不是特别严谨的假设,例如遇到重传则认为网络拥堵需要开始减速。
    • 以往大部分拥塞算法是基于丢包来作为降低传输速率的信号,都属于基于丢包反馈的被动式机制(Loss-Based)。主要缺点是:
      • 丢包即拥塞的假设不一定完全正确:现实中网络环境很复杂会存在错误丢包,很多算法无法很好区分拥塞丢包和错误丢包,因此在存在一定错误丢包的前提下在某些网络场景中并不能充分利用带宽。
      • bufferbloat问题:路由器、交换机等设备缓冲区在Loss-Based策略中也计算了,然而现在网络设备都有比较大的网络设备缓冲区,TCP发送端真正感知到拥塞和时机产生的时间会产生比较大的误差。网络已经拥塞,但是发送端在发现之前还在增大窗口,这就使得网络更加拥堵不堪了,在弱网、高延迟的环境中,这个问题会更加严重。
      • 网络负载高、无丢包时Loss-Based策略仍然增窗:增加了网络带宽的竞争
      • 高负载丢包、网络抖动:reno、cubic这种windows based的都有这个问题,就是带宽侵略性,不断增加窗口,最终必然导致乘法减小,网络抖动。
      • 低负载高延迟丢包时带宽利用率低:有些丢包时因为弱网环境下延迟导致,而不是带宽争抢导致的拥塞导致,此时根据loss-based策略,带宽利用率比较低
    • BBR基于模型主动探测。该算法使用网络最近出站数据分组当时的最大带宽和往返时间来创建网络的显式模型。数据包传输的每个累积或选择性确认用于生成记录在数据包传输过程和确认返回期间的时间内所传送数据量的采样率。

分片发生在udp层还是ip层

参阅DPDK负载均衡如何处理转发中的IP分片问题

UDP和ICMP协议么有考虑分片的问题,它的协议可以认为网络层没有长度限制,所以如果我们在UDP协议中,发送大的数据时,就必然触发IP层分片。TCP协议本身支持分段,TCP协议在建立连接时会协商MSS(Maxitum Segment Size),这个协商的过程发生在建立TCP连接的过程中。如下图:

MSS加包头数据就等于MTU。拿TCP包做例子,报文传输MSS=1460字节的数据的话,再加上20字节IP包头,20字节TCP包头,那么MTU就是(1500)。clientreal server分别根据自己MTU计算出支持的最大MSS发送给对方,双方根据最小的MSS达成协议。下图用来说明,TCP协议分段和UDP协议触发的IP层分片的区别。

但是也不能认为,TCP协议就不会触发IP层分片;因为在TCP协议握手时,clientreal server只是取了自己网卡的MTU, 但是它们之间可能经过了很多的路由器,这些子网的MTU可能小于它们的MTU,所以在外部的复杂网络上,很多TCP协议也会触发IP层分片。需要注意的是,被IP层分片的数据包不会带上4层的协议头(TCP头、UDP头)。

网卡硬件的DMA对齐要求和什么有关?

网卡硬件的DMA(Direct Memory Access)对齐要求通常与以下几个因素有关:

  1. 硬件架构: 不同的CPU和平台架构对内存访问的对齐要求不同。例如,有些CPU可能要求数据必须按4字节或8字节边界对齐。
  2. 网络卡设计: 网卡硬件本身可能有特定的对齐要求,这通常是为了最大化数据传输效率和降低处理延迟。比如一些网卡可能要求数据缓冲区按32字节或64字节对齐。
  3. 操作系统: 操作系统中的DMA和驱动程序实现也会影响对齐要求。操作系统可能会提供API或设置,以支持硬件对齐约束。
  4. 总线接口协议: PCI或PCIe等总线接口标准对DMA传输中的数据对齐也有规定。不正确的对齐可能导致性能下降或更高的CPU使用率。
  5. 缓存线大小: 在许多系统中,为了提高缓存效率,DMA缓冲区可能需要按缓存行大小对齐,例如通常是64字节对齐。

为了确保最优性能,通常需要根据具体的硬件文档和开发指南来确定DMA缓冲区的对齐方式。不遵守这些对齐要求可能导致性能下降,甚至在某些硬件上可能导致数据传输错误。

scatter-gather传输对驱动的冲击在哪里?

中断处理应该放在tasklet还是work queue中?

中断处理通常应该放在tasklet中而不是work queue中。这是因为中断处理需要尽可能快速地完成,以尽快响应外部事件并减少系统的延迟。Tasklet是一种轻量级的延迟处理机制,适合用于快速处理中断。它们在中断上下文中执行,不会被抢占,因此可以保证相对较低的延迟。

相比之下,work queue适用于较长时间的延迟处理,因为它们在内核线程上下文中执行,并且可以被抢占。将中断处理放在work queue中可能会导致不确定的延迟,从而影响系统的实时性能。

总的来说,对于需要快速响应的中断处理,应该优先考虑使用tasklet。但是对于一些需要较长时间处理或不需要实时响应的情况,可以考虑将处理放在work queue中。

为什么Linux的网络传输效率比windows好?

Linux和Windows在网络传输效率方面的差异可以归因于几个关键因素:

  1. 网络堆栈实现:Linux的网络堆栈被广泛认为是高度优化和高效的。它经常被更新以利用最新的网络技术和协议优化。此外,Linux内核允许更深入的定制和优化,这对于需要高性能网络传输的应用场景(如数据中心、高性能计算等)特别有利。
  2. 系统架构和优化:Linux系统通常包括多种可以调整和优化以提高网络性能的工具和选项。例如,网络参数(如TCP窗口大小、队列长度等)可以根据具体需求调整,这有助于提升大规模网络操作的效率。
  3. 社区和开发模型:Linux作为开源操作系统,拥有一个活跃的开发社区,社区成员包括来自全球的众多网络专家和高级程序员。这种开放的发展模型促进了创新和快速采纳新技术,有助于不断改进网络性能。
  4. 驱动程序和硬件支持:Linux在网络硬件驱动方面也具有较强的适应性和性能。许多网络设备制造商和大型云服务提供商都直接为Linux提供优化的驱动程序和工具,这进一步提高了其在服务器和专业应用中的网络效率。
  5. 定制性和灵活性:Linux提供了高度的定制性和灵活性,使得系统管理员可以根据具体的网络环境和性能需求对系统进行精细调整。这种灵活性在高需求的网络环境中尤其重要,比如调整内核行为以减少延迟或增加吞吐量。

然而,实际上在某些应用场景中,Windows可能因其与特定硬件和软件的良好集成而提供相当或更优的网络性能。因此,两者在网络传输效率上的比较往往取决于具体的使用场景、系统配置和网络环境。在企业环境中,选择哪个操作系统还可能受到其他因素的影响,如现有的技术栈、支持的应用程序和企业政策。

TCP滑动窗口在什么情况下会导致发送阻塞?解决方法有哪些?

TCP滑动窗口会在以下情况下导致发送阻塞:

  1. 窗口大小为零: 当接收方的窗口大小变为零时,发送方必须停止发送数据,等待接收方窗口再次开放。这通常发生在接收方处理数据较慢,无法及时消耗接收缓冲区中的数据时。
  2. 网络拥塞: 如果网络中的拥塞导致数据包延迟或丢失,发送方的拥塞窗口大小会减小,可能达到一个点使得发送方不能发送更多的数据,直到网络状况改善。
  3. 接收窗口未及时更新: 如果接收方未能及时发送包含新窗口大小的确认(ACK)包,发送方将不知道已经有更多的缓冲区可用于发送数据。

解决TCP滑动窗口导致的发送阻塞的方法包括:

  1. 窗口更新机制: 接收方可以通过发送窗口更新包来通知发送方其可用窗口大小已增加,这可以在数据处理速度增加后立即通知发送方继续发送数据。
  2. TCP拥塞控制算法: 实现和优化TCP的拥塞控制算法(如Cubic, Reno, BBR等)可以帮助调整拥塞窗口的大小,更有效地处理网络中的拥塞情况。
  3. 调整窗口大小: 调整TCP窗口的大小可以帮助更好地利用网络带宽,尤其是在高延迟网络环境(如卫星通信)中。较大的窗口可能允许在等待确认的同时发送更多的数据。
  4. 使用选择确认(SACK): 通过使用选择确认,即使在发生部分数据包丢失的情况下,也可以避免不必要的数据重传,从而提高整体传输效率。

通过这些方法,可以在多种网络和系统条件下有效管理TCP窗口,避免发送阻塞,从而提高数据传输的效率和稳定性。

在TCP传输中,零窗口是什么意思?它如何影响数据流?

在TCP传输中,"零窗口"是指接收方告知发送方其接收窗口的大小为零,即接收方的缓冲区已满,无法再接受新的数据。这是一种流量控制机制,用来避免发送方发送的数据超过接收方能够处理的速度。

零窗口的影响包括:

  1. 暂停数据传输:当发送方收到零窗口通知时,它必须停止发送数据,直到接收方的窗口重新打开(即接收方处理了部分数据,并通过发送非零窗口大小的确认告知发送方)。
  2. 延迟:零窗口导致数据传输中断,这会增加整个通信的延迟。特别是在高速网络或高数据率应用中,频繁的零窗口事件会显著影响性能。
  3. TCP效率下降:频繁的零窗口事件可能导致TCP连接的效率下降,因为连接多数时间处于等待状态,而不是传输状态。

为了应对零窗口的问题,TCP协议包括一种称为“零窗口探测”(Zero Window Probe, ZWP)的机制。当发送方在接收到零窗口通知后一段时间内没有收到窗口更新,它会定期发送零窗口探测包,这是一种小的数据包,用来检查接收方是否已准备好接收更多数据。一旦接收方的窗口重新打开,它将回复一个更新的窗口大小,发送方随后可以继续数据传输。

这种机制有助于确保即使在接收方窗口长时间关闭的情况下,连接也能维持活性并在可能的情况下恢复数据传输。

epollselect用于IO多路复用。

从阻塞 I/O 到 I/O 多路复用

阻塞 I/O,是指进程发起调用后,会被挂起(阻塞),直到收到数据再返回。如果调用一直不返回,进程就会一直被挂起。因此,当使用阻塞 I/O 时,需要使用多线程来处理多个文件描述符。
多线程切换有一定的开销,因此引入非阻塞 I/O。非阻塞 I/O 不会将进程挂起,调用时会立即返回成功或错误,因此可以在一个线程轮询多个文件描述符是否就绪。
但是非阻塞 I/O 的缺点是:每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。
因此引入了 I/O 多路复用,可以通过一次系统调用,检查多个文件描述符的状态。这是 I/O 多路复用的主要优点,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。

I/O 多路复用相当于将「遍历所有文件描述符、通过非阻塞 I/O 查看其是否就绪」的过程从用户线程移到了内核中,由内核来负责轮询。

进程可以通过selectpollepoll发起 I/O 多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的timeout后,再返回。I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态。
如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。
I/O 多路复用引入了一些额外的操作和开销,性能更差。但是好处是用户可以在一个线程内同时处理多个 I/O 请求。如果不采用 I/O 多路复用,则必须通过多线程的方式,每个线程处理一个 I/O 请求。后者线程切换也是有一定的开销的。

select

函数签名与参数

int select(int nfds,
            fd_set *restrict readfds,
            fd_set *restrict writefds,
            fd_set *restrict errorfds,
            struct timeval *restrict timeout);

readfdswritefdserrorfds 是三个文件描述符集合。select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的描述符。然后用找到的子集替换参数中的对应集合,返回所有就绪描述符的总数。

timeout 参数表示调用 select 时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。

什么是文件描述符 fd

文件描述符(file descriptor)是一个非负整数,从 0 开始。进程使用文件描述符来标识一个打开的文件。

系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。当进程打开(open)或者新建(create)文件时,内核会在该进程的文件列表中新增一个表项,同时返回一个文件描述符 —— 也就是新增表项的下标。

一般来说,每个进程最多可以打开 64 个文件,fd ∈ 0~63。在不同系统上,最多允许打开的文件个数不同,Linux 2.4.22 强制规定最多不能超过 1,048,576。

每个进程默认都有 3 个文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。

这篇文章以图示的方式对文件描述符作了深入地讲解,可以进一步阅读。

socket 与 fd 的关系

socket 是 Unix 中的术语。socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。一个 socket 包含地址、类型和通信协议等信息,通过 socket() 函数创建:

int socket(int domain, int type, int protocol)

返回的就是这个 socket 对应的文件描述符 fd。操作系统将 socket 映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。

可以这样理解:socket 是进程间通信规则的高层抽象,而 fd 提供的是底层的具体实现。socket 与 fd 是一一对应的。通过 socket 通信,实际上就是通过文件描述符 fd 读写文件。这也符合 Unix“一切皆文件”的哲学。

后面可以将 socket 和 fd 视为同义词。

fd_set 文件描述符集合

参数中的 fd_set 类型表示文件描述符的集合。

由于文件描述符 fd 是一个从 0 开始的无符号整数,所以可以使用 fd_set二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set 长度为 1 字节,则一个 fd_set 变量最大可以表示 8 个文件描述符。当 select 返回 fd_set = 00010011 时,表示文件描述符 125 已经就绪。

fd_set 的使用涉及以下几个 API:

#include <sys/select.h>   
int FD_ZERO(int fd, fd_set *fdset);  // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset);   // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set);  // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1

select 使用示例

下图的代码说明:

  1. 先声明一个 fd_set 类型的变量 readFDs
  2. 调用 FD_ZERO,将 readFDs 所有位置 0
  3. 调用 FD_SET,将 readFDs 感兴趣的位置 1,表示要监听这几个文件描述符
  4. readFDs 传给 select,调用 select
  5. select 会将 readFDs 中就绪的位置 1,未就绪的位置 0,返回就绪的文件描述符的数量
  6. select 返回后,调用 FD_ISSET 检测给定位是否为 1,表示对应文件描述符是否就绪

比如进程想监听 1、2、5 这三个文件描述符,就将 readFDs 设置为 00010011,然后调用 select

如果 fd=1fd=2 就绪,而 fd=5 未就绪,select 会将 readFDs 设置为 00000011 并返回 2。

如果每个文件描述符都未就绪,select 会阻塞 timeout 时长,再返回。这期间,如果 readFDs 监听的某个文件描述符上发生可读事件,则 select 会将对应位置 1,并立即返回。

sock_fd = socket(AF_INET,SOCK_STREAM,0);
setsockopt(sock_fd,SOL_SOCKET,SO_REUSEADDR,(int[]){1},sizeof(int));
bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(sock_fd, MAXCLINE);
maxsock = sock_fd;
while(1) {
        FD_ZERO(&fdsr);
        FD_SET(sock_fd, &fdsr); 
        ret = select(maxsock +1,&fdsr,NULL,NULL,&tv);
        for(i = 0; i < conn_amount; i++) {
            if(FD_ISSET(fd[i], &fdsr)) {
                ret = recv(fd[i], buf, sizeof(buf), 0);
                if(ret <= 0) {
                    close(fd[i]);
                    FD_CLR(fd[i], &fdsr);
                    fd[i]=0;
                    conn_amount--;
                } else {
                    if(ret < BUF_SIZE)
                        memset(&buf[ret], 0, 1);
                    printf("client[%d] send:%s\n", i, buf);
                }
            }
        }
        if(FD_ISSET(sock_fd, &fdsr)) {
            new_fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
            if(conn_amount < MAXCLINE) {
                for(i = 0; i < MAXCLINE; i++) {
                    if(fd[i] == 0) {
                        fd[i] = new_fd;
                        break;
                    }
                }
                conn_amount++;
                if(new_fd > maxsock) {
                    maxsock = new_fd;
                }
            } else {
                printf("max connections arrive ,exit\n");
                send(new_fd, "bye", 4, 0);
                close(new_fd);
                continue;
            }
        }
        showclient();
    }
 
    for(i = 0; i < MAXCLINE; i++) {
        if(fd[i] != 0) {
            close(fd[i]);
        }
    }  
    exit(0);
}

select 的缺点

  1. 性能开销大
  2. 调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间
  3. 内核需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪
  4. 同时能够监听的文件描述符数量太少。受限于 sizeof(fd_set) 的大小,在编译内核时就确定了且无法更改。一般是 1024,不同的操作系统不相同

poll

poll 和 select 几乎没有区别。poll 在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储,没有最大数量的限制。

poll 的函数签名如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其中 fds 是一个 pollfd 结构体类型的数组,调用 poll() 时必须通过 nfds 指出数组 fds 的大小,即文件描述符的数量。详细描述见 manpage - poll(2)

从性能开销上看,poll 和 select 的差别不大。

epoll

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。

简而言之,epoll 有以下几个特点:

  • 使用红黑树存储文件描述符集合
  • 使用队列存储就绪的文件描述符
  • 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态

select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_createepoll_ctlepoll_wait

epoll_create

int epoll_create(int size);

epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。

返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctlepoll_wait 会使用这个文件描述符来操作相应的 epoll 实例。

当创建好 epoll 句柄后,它会占用一个 fd 值,在 linux 下查看 /proc/进程id/fd/,就能够看到这个 fd。所以在使用完 epoll 后,必须调用 close(epfd) 关闭对应的文件描述符,否则可能导致 fd 被耗尽。当指向同一个 epoll 实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll 实例。

epoll 实例内部存储:

  • 监听列表:所有要监听的文件描述符,使用红黑树
  • 就绪列表:所有就绪的文件描述符,使用链表

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl 会监听文件描述符 fd 上发生的 event 事件。

参数说明:

  • epfdepoll_create 返回的文件描述符,指向一个 epoll 实例
  • fd 表示要监听的目标文件描述符
  • event 表示要监听的事件(可读、可写、发送错误…)
  • op 表示要对 fd 执行的操作,有以下几种:
    • EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
    • EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
    • EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用

返回值 0 或 -1,表示上述操作成功与否。

epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

这是 epoll 模型的主要函数,功能相当于 select

参数说明:

  • epfdepoll_create 返回的文件描述符,指向一个 epoll 实例
  • events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
  • maxevents 指定 events 的大小
  • timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回

返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents

epoll 的优点

一开始说,epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。

对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。

对于“性能开销大”,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。

相当于时间复杂度从 O(n) 降为 O(1)

此外,每次调用 select 时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl 传递一次,之后 epoll_wait 不需要再次传递。这也大大提高了效率。

水平触发、边缘触发

select 只支持水平触发,epoll 支持水平触发和边缘触发。

水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。

边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。

区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。

水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。

为什么边缘触发必须使用非阻塞 I/O?

关于这个问题的解答,强烈建议阅读这篇文章。下面是一些关键摘要:

  • 每次通过 read 系统调用读取数据时,最多只能读取缓冲区大小的字节数;如果某个文件描述符一次性收到的数据超过了缓冲区的大小,那么需要对其 read 多次才能全部读取完毕
  • select 可以使用阻塞 I/O。通过 select 获取到所有可读的文件描述符后,遍历每个文件描述符,read 一次数据
    • 这些文件描述符都是可读的,因此即使 read 是阻塞 I/O,也一定可以读到数据,不会一直阻塞下去
    • select 采用水平触发模式,因此如果第一次 read 没有读取完全部数据,那么下次调用 select 时依然会返回这个文件描述符,可以再次 read
    • select 也可以使用非阻塞 I/O。当遍历某个可读文件描述符时,使用 for 循环调用 read 多次,直到读取完所有数据为止(返回 EWOULDBLOCK)。这样做会多一次 read 调用,但可以减少调用 select 的次数
  • epoll 的边缘触发模式下,只会在文件描述符的可读/可写状态发生切换时,才会收到操作系统的通知
    • 因此,如果使用 epoll边缘触发模式,在收到通知时,必须使用非阻塞 I/O,并且必须循环调用 readwrite 多次,直到返回 EWOULDBLOCK 为止,然后再调用 epoll_wait 等待操作系统的下一次通知
    • 如果没有一次性读/写完所有数据,那么在操作系统看来这个文件描述符的状态没有发生改变,将不会再发起通知,调用 epoll_wait 会使得该文件描述符一直等待下去,服务端也会一直等待客户端的响应,业务流程无法走完
    • 这样做的好处是每次调用 epoll_wait 都是有效的——保证数据全部读写完毕了,等待下次通知。在水平触发模式下,如果调用 epoll_wait 时数据没有读/写完毕,会直接返回,再次通知。因此边缘触发能显著减少事件被触发的次数
    • 为什么 epoll边缘触发模式不能使用阻塞 I/O?很显然,边缘触发模式需要循环读/写一个文件描述符的所有数据。如果使用阻塞 I/O,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束

三者对比

  • select:调用开销大(需要复制集合);集合大小有限制;需要遍历整个集合找到就绪的描述符
  • poll:poll 采用数组的方式存储文件描述符,没有最大存储数量的限制,其他方面和 select 没有区别
  • epoll:调用开销小(不需要复制);集合大小无限制;采用回调机制,不需要遍历整个集合

selectpoll 都是在用户态维护文件描述符集合,因此每次需要将完整集合传给内核;epoll 由操作系统在内核中维护文件描述符集合,因此只需要在创建的时候传入文件描述符。

此外 select 只支持水平触发,epoll 支持边缘触发。

适用场景

当连接数较多并且有很多的不活跃连接时,epoll 的效率比其它两者高很多。当连接数较少并且都十分活跃的情况下,由于 epoll 需要很多回调,因此性能可能低于其它两者。

Redis 的线程模型

Redis 是一个单线程的工作模型,使用 I/O 多路复用来处理客户端的多个连接。为什么 Redis 选择单线程也能效率这么高?

I/O 设备(如磁盘、网络)等速度远远慢于 CPU,因此引入了多线程技术。当一个线程发起 I/O 请求时,先将它挂起,切换到别的线程;当 I/O 设备就绪时,再切换回该线程。总之,多线程技术是为了充分利用 CPU 的计算资源,适用于下层存储慢速的场景

而 redis 是纯内存操作,读写速度非常快。所有的操作都会在内存中完成,不涉及任何 I/O 操作,因此多线程频繁的上下文切换反而是一种负优化。Redis 选择基于非阻塞 I/O 的 I/O 多路复用机制,在单线程里并发处理客户端的多个连接,减少多线程带来的系统开销,同时也有更好的可维护性,方便开发和调试。

不过 redis 在最新的几个版本中也引入了多线程,目的是:

  1. 异步处理删除操作。当删除超大键值对的时候,单线程内同步地删除可能会阻塞待处理的任务
  2. 应对网络 I/O 的场景,网络 I/O 是慢速 I/O。redis6吞吐量提高了1倍