网络IO并发的底层分析

一、IO

1、IO 类型

大部分的socket接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。select系统调用是用来让我们的程序监视多个文件句柄的状态变化的,需要指明select函数与【阻塞/非阻塞socket】没有关系。select函数本身调用是阻塞的(与socket是否阻塞并没有关系), 直到有监测时间发生(返回 > 0)、 超时(返回0)、select函数错误 (返回-1)。一个套接字socket阻塞或者不阻塞,select就在那里,它可以针对这2种套接字使用,对任何一种套接字的轮询检测,超时时间都是有效的,区别就在于:当select完毕,认为该套接字可读时:阻塞的套接字,会让read阻塞,直到读到所需要的所有字节;非阻塞的套接字,会让read读完fd中的数据后就返回,但如果原本你要求读10个数据,这时只读了8个数据,如果你不再次使用select来判断它是否可读,而是直接read,很可能返回EAGAIN或=EWOULDBLOCK(BSD风格) ,此错误由在非阻塞套接字上不能立即完成的操作返回。
在这里插入图片描述

2、select

当进程调用一个阻塞的系统函数时,该进程被置于睡眠(Sleep)状态,这时内核调度其它进程运行,直到该进程等待的事件发生了(比如网络上接收到数据包,或者调用sleep 指定的睡眠时间到了)它才有可能继续运行。与睡眠状态相对的是运行(Running)状态,在Linux内核中处于运行状态的进程分为两种情况: 正在被调度执行和就绪状态。假设一个进程同时监视多个设备,如果read(设备1)是阻塞的,那么只要设备1没有数据到达就会一直阻塞在设备1的read 调用上,即使设备2有数据到达也不能处理,使用非阻塞I/O就可以避免设备2得不到及时处理。在open 一个设备时指定了O_NONBLOCK 标志,read / write 就不会阻塞。以read 为例,如果设备暂时没有数据可读就返回-1,同时置errno 为EWOULDBLOCK(或者EAGAIN,这两个宏定义的值相同),表示本来应该阻塞在这里,那么调用者不是阻塞在这里死等,这样可以同时监视多个设备。 非阻塞I/O有一个缺点,如果所有设备都一直没有数据到达,调用者需要反复查询做无用功,如果阻塞在那里操作系统可以调度别的进程执行,就不会做无用功了。select 函数可以阻塞地同时监视多个设备,还可以设定阻塞等待的超时时间,从而圆满地解决了这个问题。

3、进程IO阻塞睡眠之后怎么醒来进入就绪状态的:
  • 阻塞的本质就是将进程的task_struct移出运行队列,添加到等待队列,并且将进程的状态的置为TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE,重新触发一次 CPU 调度让出 CPU。那线程怎么唤醒呢?线程在加入到等待队列的同时向内核注册了一个回调函数,告诉内核我在等待这个 Socket 上的数据,如果数据到了就唤醒我。这样当网卡接收到数据时,产生硬件中断,内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的task_struct从等待队列移到运行队列,并且将task_struct的状态置为TASK_RUNNING,这样进程就有机会重新获得 CPU 时间片。**等待队列机制:**1.等待队列机制本质目的就是实现进程在内核空间进行休眠操作;当然休眠的原因是等待某个事件到来! 2.一旦事件到来,驱动能够主动唤醒休眠的进程,当然也可以通过信号来唤醒; 3.信号量机制就是靠等待队列机制来实现的。

  • 每一个打开的文件均对应一个驱动程序,例如普通文件基于磁盘文件系统驱动,socket则对应网络协议栈,设备文件则基于对应的设备驱动。驱动程序最核心的就是通过read/write等操作为用户提供操作该设备的接口。读写操作并不总是能够立即返回的,因此一般地,驱动程序会为读和写分别维护一个等待队列。当进程执行阻塞型read/write并且不能立即返回时,就会被加入到等待队列(wait_queue),并且自己陷入睡眠(wait_event_interruptable)。当条件满足时,驱动程序调用wake_up_interruptable就会唤醒等待队列里的进程。

  • 进程可以转入休眠状态以等待某个特定事件,当该事件发生时这些进程能够被再次唤醒。内核实现这一功能的技术要点是把等待队列(wait queue)和每一个事件联系起来。需要等待事件的进程在转入休眠状态后插入到队列中。当事件发生之后,内核遍历相应队列,唤醒休眠的任务让它投入运行状态。任务负责将自己从等待队列中清除。注册当前进程的回调函数为__pollwait,并挂在设备的等待队列,当设备IO准备就绪时,调用该回调函数唤醒进程。

  • _wait_event_interruptible()这个函数先将 当前进程的状态设置成TASK_INTERRUPTIBLE,然后调用schedule(), 而schedule()会将位于TASK_INTERRUPTIBLE状态的当前进程从runqueue 队列中删除。从runqueue队列中删除的结果是,当前这个进程将不再参与调度,除非通过其他函数将这个进程重新放入这个runqueue队列中, 这就是wake_up()的作用了。 首先定义并初始化一个wait_queue_t变量__wait,其中数据为当前进程current,并把__wait入队。在无限循环中,__wait_event_interruptible()将本进程置为可中断的挂起状态,反复检查condition是否成立,如果成立则退出,如果不成立则继续休眠;条件满足后,即把本进程运行状态置为运行态,并将__wait从等待队列中清除掉,从而进程能够调度运行。

DECLARE_WAITQUEUE(wait, current); //定义等待队列,current 指当前进程
while (!condition) // condition 指事件到来,如上节中的中断
{
    
    
  // 事件没有到来时
  set_current_state(TASK_INTERRUPTIBLE); // 将当前用户空间的进程置为睡眠状态
  prepare_to_wait(&q, &wait,TASK_INTERRUPTIBLE);
  schedule(); // 该进程睡眠,进行进程调度,切换到进程红黑树中的下一个进程
}
finish_wait(&q, &wait); //将进程设置为 TASK_RUNNING 并移出等待队列,进程被唤醒
copy_to_user(buffer, len); // 网卡数据从 buffer 复制到用户空间
4、epoll中触发类型和io阻塞类型的注意事项
  • 假设buffer有1024字节大小,一次读取的数据较小,小于1024:这种情况下LT、ET、阻塞和非阻塞其实都一样,没有区别。因为阻塞和非阻塞都是调用recv()函数**,并且一次就能返回,并不会阻塞在这里。由于一次性读取完,epoll_wait上只会出现一次可读事件,这样ET和LT就没有任何区别了。假设buffer有1024字节,一次读取的数据很大,超过1024的大小:这种情况就需要读取多次,也就是要调用多次recv函数,通常我们会用while循环一直读取,无论ET还是LT,这样LT模式就显得很鸡肋,所以一般不用LT。在ET模式下,(1)、如果用阻塞IO+while循环**,当最后一个数据读取完后,程序是无法立刻跳出while循环的,因为阻塞IO会在 while(true){ int len=recv(); }这里阻塞住,除非对方关闭连接或者recv出错,这样程序就无法继续往下执行,这一次的epoll_wait没有办法处理其它的连接,会造成延迟、并发度下降。(2)、如果是非阻塞IO+while循环当读取完数据后,recv会立即返回-1,并将errno设置为EAGAIN或EWOULDBLOCK,这就表示数据已经读取完成,已经没有数据了,可以退出循环了。这样就不会像阻塞IO一样卡在那里,这就减少了不必要的等待时间,性能自然更高。所以每次读取小数据,所有组合都一样,,不用while就可以一次读完返回。读大数据,LT用的很少,几乎都是ET,阻塞IO会卡在while里,非阻塞IO会立即返回,性能更好。

  • 对于监听的 sockfd,最好使用水平触发模式,边缘触发模式会导致高并发情况下,由于事件只通知一次容易遗漏了待处理的数据,有的客户端就会连接不上。如果非要使用边缘触发,可以用 while 来循环 accept()。在 LT 模式下,如果写缓冲有空,那么可写事件不停的触发, 在ET 模式下,只在一开始触发一次。使用ET模式,特定场景下会比LT更快,因为它可以便捷的处理EPOLLOUT事件,省去打开与关闭EPOLLOUT的epoll_ctl(EPOLL_CTL_MOD)调用。从而有可能让你的性能得到一定的提升。例如你需要写出1M的数据,写出到socket 256k时,返回了EAGAIN,ET模式下,当再次epoll返回EPOLLOUT事件时,继续写出待写出的数据,当没有数据需要写出时,不处理直接略过即可。而LT模式则需要先打开EPOLLOUT,当没有数据需要写出时,再关闭EPOLLOUT(否则会一直返回EPOLLOUT事件)。ET处理EPOLLOUT方便高效些,LT不容易遗漏事件、不易产生bug。如果server的响应通常较小,不会触发EPOLLOUT,那么适合使用LT,例如redis等。而nginx作为高性能的通用服务器,网络流量可以跑满达到1G,这种情况下很容易触发EPOLLOUT,则使用ET。用了epoll不能保证你的进程在读写的时候不会阻塞,最好把IO设置成非阻塞的形式。

  • 只有边沿触发才必须设置为非阻塞。边沿触发的问题:sockfd 的边缘触发,高并发时,如果没有一次处理全部请求,则会出现客户端连接不上的问题。不需要讨论 sockfd 是否阻塞,因为 epoll_wait() 返回的必定是已经就绪的连接,所以不管是阻塞还是非阻塞,accept() 都会立即返回。

  • 阻塞 connfd 的边缘触发,如果不一次性读取一个事件上的数据,会干扰下一个事件,所以必须在读取数据的外部套一层循环,这样才能完整的处理数据。但是外层套循环之后会导致另外一个问题:处理完数据之后,程序会一直卡在 recv() 函数上,因为是阻塞 IO,如果没数据可读,它会一直等在那里,直到有数据可读。但是这个时候,如果用另一个客户端去连接服务器,服务器就不能受理这个新的客户端了。

  • 非阻塞 connfd 的边缘触发,和阻塞版本一样,必须在读取数据的外部套一层循环,这样才能完整的处理数据。因为非阻塞 IO 如果没有数据可读时,会立即返回,并设置 errno。这里我们根据 EAGAIN 和 EWOULDBLOCK 来判断数据是否全部读取完毕了,如果读取完毕,就会正常退出循环了。


二、C10k(concurrent/client 10 000)并发问题

最初的服务器都是基于进程/线程模型的,新到来一个TCP连接,就需要分配1个进程(或者线程)。而进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。如果是C10K就要创建1万个进程,那么操作系统是无法承受的。创建的进程线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞), 进程/线程上下文切换消耗大, 导致操作系统崩溃,这就是C10K问题的本质。可见,解决C10K问题的关键就是尽可能减少这些CPU等核心计算资源消耗,从而榨干单台服务器的性能,突破C10K问题所描述的瓶颈。

三、C10k(concurrent/client 10 000)问题的解决方案

1、为每个连接分配一个独立的线程/进程

比较直接原始的解决方案是为每个连接分配一个独立的线程/进程。由于申请进程/线程会占用相当可观的系统资源,同时对于多进程/线程的管理会对系统造成压力,因此,这种方案不具备良好的可扩展性。这一思路在服务器资源还没有富裕到足够程度的时候,是不可行的。即便资源足够富裕,效率也不够高。总之,此思路技术实现会使得资源占用过多,可扩展性差,在实际应用中已被抛弃。

2、同一个线程/进程同时处理多个连接**(IO多路复用)**: 直接循环处理多个连接

循环逐个处理各个连接,每个连接对应一个 socket。当所有 socket 都有数据的时候,这种方法是可行的。但是,当应用读取某个 socket 的文件数据不 ready 的时候,整个应用会阻塞在这里,等待该文件句柄ready,即使别的文件句柄 ready,也无法往下处理。

3、 同一个线程/进程同时处理多个连接(IO多路复用):非阻塞IO以及水平触发方式的就绪通知

将所有的网络文件句柄的工作模式都设置成NON-BLOCKING,通过调用select()方法或者poll()方法来告诉应用层哪些个网络句柄有正在等待着并需要被处理的数据。这是一种非常传统的方法。通过这种机制,内核能够告诉应用层一个文件描述符是否准备好了(这里的准备好有着明确的含义,对于读描述符,准备好了意味着此时该描述符的缓冲区内数据已经准备好,读取该描述符的数据不会发生阻塞,而对于写描述符而言,准备好了意味着另外一层含义,它意味着写缓冲区已经准备好了,此时对该操作符的写操作也将不会导致任何阻塞发生),以及你是否已经利用该文件描述符作了相应的事情。因为这里的就绪通知方式是水平触发,也就说如果内核通知应用层某一个文件描述符已经就绪,而如果应用层此后一直没有完整的处理该描述符(没有读取完相应的数据或者没有写入任何数据),那么内核会不断地通知应用层该文件描述符已经就绪。这就是所谓的水平触发:只要条件满足,那内核就会触发一个事件(只要文件描述符对应的数据没有被读取或者写入,那内核就不断地通知你)。
需要注意的是:内核的就绪通知只是一个提示,提示也就意味着这个通知消息未必是100%准确的,当你读取一个就绪的读文件描述符时,实际上你有可能会发现这个描述符对应的数据并没有准备好。这就是为什么如果使用就绪通知的话一定要将文件描述符的模式设置成NOBLOCK的,因为NOBLOCK模式的读取或者写入在文件描述符没有就绪的时候会直接返回,而不是引起阻塞。如果这里发生了阻塞,那将是非常致命的,因为我们只有一个线程,唯一的线程被阻塞了的话,那我们就玩完了。这种方式的一个缺陷就是不适用磁盘文件的IO操作。将磁盘文件的操作句柄的工作模式设置成NOBLOCK是无效的,此时对该磁盘文件进行读写依然有可能导致阻塞。对于缺乏AIO(异步IO)支持的系统,将磁盘IO操作委托给worker线程或者进程是一个好方法来绕过这个问题。一个可行的方法是使用memory mapped file,然后调用mincore(),mincore会返回一个向量来表示相应的page是否在ram缓存中,如果page不在ram缓存中,则意味着读取该页面会导致page falut,从而引起阻塞,那么就需要通过委托的worker线程来进行IO操作。这种方式的实现方法在Linux上就是select,poll这样的系统调用。

4、使用一个线程同时服务很多个客户端,非阻塞IO以及边沿触发的就绪通知。

所谓边沿触发是相对水平触发而言的,也就是说内核只是在文件描述符的状态发生变换的时候才进行通知。这就意味着在大多数情况下,当内核通知某个读描述符就绪后,除非该读描述符内部缓冲区的所有数据已经完全被读取从而使得就绪状态发生了变化,否则内核不会发出任何新的通知,会永远沉默下去。如果该文件描述符的receive操作返回EWOULDBLOCK错误码,这就意味着该描述符的就绪状态已经被打破,你需要等待下一次的边沿触发通知。除了上面所说的问题,一旦使用了边沿触发,另外一个随之而来的问题就是,你需要注意一个常见的“意外事件”的问题。因为os实现边沿触发的一个常见的实现bug就是在某些情况下内核一旦收到新数据包就会通知就绪,不管你上一次的就绪通知是否被用户处理。因此你必须小心组织你的代码,你需要处理好每一个就绪通知,如果某一次就绪通知的数据没有被正确得完整得处理你就急急忙忙得开始等待下一次通知,那么下一次的就绪通知就会覆盖掉前面的数据,那么你就会永远不会恢复了。相比之下,这种方式对于程序员编码的要求可能会更高一些,一旦应用程序错过了一次通知,那么与之对应的客户端就永远崩溃了(意外事件)或者沉默(没有读取完上一次事件产生的数据)。而条件触发则会不断提醒用户缓冲区内还有数据。因此,对于边沿触发方式的就绪通知,应用层必须在每次就绪通知后读取数据,一直读到EWOULDBLOCK为止。这种方式在Linux中主要通过epoll实现。实际上java nio采用的也是这种IO策略。Epoll和poll有一些共同之处,epoll在默认情况下也是水平触发的,此时你可以认为epoll是一个增强版的poll,它的效率更高,这是因为epoll采用了一些优化,比如只关心活跃的连接,通过共享内存空间避免了内存拷贝等等。

5、 用一个线程同时服务很多个客户端,采用异步IO。

这种IO策略实际上Linux并没有原生支持,尽管POSIX定义了它。相比之下windows就提供了很好的支持。异步IO也有内核通知,只不过这种通知不是就绪通知,而是完成通知,这就意味着一旦获得内核通知,那么IO操作就已经完成了,用户无需再调用任何操作来获取数据或者发送数据,此时数据已经好端端得放在用户定义的buffer中或者数据已经妥妥得发送出去了。与前两种方式相比,实际上aio是由内核线程或者底层线程异步地,默默得完成了IO操作,而方式1,方式2还得由用户线程来自己读取数据。相比之下,内核线程自然要高效很多。因此从IO模型的效率上来讲,windows是要优于Linux的。


三、UDP和TCP的并发

UDP方式比TCP方式有更强大的容错性,采用UDP的话,它的缓冲速度比TCP快45%,而且可以大大的节省网络共享带宽,当网络出现不稳定时,不会经常出现缓冲,所以不少影视节目采用UDP方式传送。并发连接是TCP的概念,UDP只管收发数据。要支持尽量多的终端,服务器的处理效率要够快,另外尽量避免多终端同时发送数据。udp与tcp不同 它不需要建立连接,自带报头,一对一发送,客户端传输发送消息给服务端的时候,会把自己的ip地址一起发送,因为它不需要建立连接 所以说 它比tcp的传输信息的效率更高,但是很容易丢包,稳定性不如tcp,而且只能传输512个字节的信息,大于512会大大增加丢包的概率。TCP由于提供了安全可靠的流服务,其对计算机、网络资源的消耗是远远大于UDP协议的UDP协议是无连接方式的协议,它的效率高,速度快,占资源少,但是其传输机制为不可靠传送,必须依靠辅助的算法来完成传输控制。

腾讯QQ采用UDP代替TCP通信的原因:QQ采用的通信协议以UDP为主,辅以TCP协议。由于QQ的服务器设计容量是海量级的应用,一台服务器要同时容纳十几万的并发连接,因此服务器端只有采用UDP协议与客户端进行通讯才能保证这种超大规模的服务。QQ客户端之间的消息传送也采用了UDP模式,因为国内的网络环境非常复杂,而且很多用户采用的方式是通过代理服务器共享一条线路上网的方式,在这些复杂的情况下,客户端之间能彼此建立起来TCP连接的概率较小,严重影响传送信息的效率。而UDP包能够穿透大部分的代理服务器,因此QQ选择了UDP作为客户之间的主要通信协议。最本质上UDP的优势还是带宽的利用。这一切要回归到99~03年的网络状况,当时网络的特点就是接入带宽很窄而且抖动特别厉害。所谓抖动可能是多方面的,例如延时突发性地暴增、也有可能是由于路由层面的变化突然导致路由黑洞,还各种等等等等的问题。TCP因为拥塞控制、保证有序等原因,在这种网络状态上对带宽的利用是非常低的。而且因为网络抖动的原因,应用层心跳超时(一般不依靠keepalive)应用层主动断掉socket之后TCP需要三次握手才能重新建立链接,一旦出现频繁的小抖动就会使得带宽利用更低。而等待四次挥手的时间,也会占用服务器上宝贵的资源。总结来说,当网络差到一定程度了,TCP的优势反而会成为劣势。


四、流式rpc

目前支持流式RPC比较成熟的框架是grpc, 支持流式需要框架在协议上支持,比如数据帧中有1 byte 表示请求类型,用 0x0 来表示一发一收,0x1 来表示只发不收,0x2 表示客户端流式请求,0x3 表示服务端流式请求,0x4 表示双向流式请求。流式传输的优点:对于服务端可以在开始接收到请求时做出相应,而不需要等整个请求完成后才响应,这样可以减少整个链路的耗时,对于客户端而言可以减少请求发送数,提高效率和节省带宽。比如一个订单导出的接口有20万条记录,如果使用普通rpc来实现的话。那么我们需要一次性接收到20万记录才能进行下一步的操作。但是如果我们使用流式rpc,那么我们就可以接收一条记录处理一条记录,直到所以的数据传输完毕。这样可以较少服务器的瞬时压力,也更有及时性。

gRPC支持4种服务定义:

1)简单RPC: 此类RPC输入和返回都是单一的消息,和普通的函数调用一致;

rpc SayHello(HelloRequest) returns (HelloResponse){}

2)服务器流式RPC: 当我们想要请求的数据量很大时,服务器端一般采用流式设计,如下所示,这里发送一个请求,服务器会返回一个流,客户端可以一直读取消息,直至消息读完。gRPC会保证消息流的顺序。

rpc SayHello(HelloRequest) returns (stream HelloResponse){}

3)客户端流式RPC: 当客户端需要发送大量数据到服务器时,一般采用客户端流式设计,如下所示,这里客户端会持续写数据直到消息完全发送完毕,server端待消息发送结束后发送response。

rpc SayHello(stream HelloRequest) returns (HelloResponse){}

4)双向流式RPC: 当客户端和服务端都需要发送大量数据时,一般采用双向流式设计,如下所示,客户端和服务器各自独立发送/读取信息,两者的读取发送方式可以按照需求各自实现。

rpc SayHello(stream HelloRequest) returns (stream HelloResponse){}

猜你喜欢

转载自blog.csdn.net/u014618114/article/details/110092719