理解IO,NIO及网络编程与Reactor模型

IO和NIO

 

I/O流的概念

  在计算机系统中,I/O即Input/Output,流是一种抽象概念,代表了数据的无结构化传输。而一个流可以是文件,内存,socket,pipe等等可以进行I/O操作的内核对象,不管是文件、管道还是套接字,我们都可以把它们看作流。数据被当作无结构的字节或字符序列按照流的方式进行输入输出。通过read操作可以从流中取得数据,而write操作可以向流中写入数据。用来进行input/output操作的流就被称为IO流。

I/O模型

IO操作一般都会涉及到系统调用,而Linux下IO操作主要是通过以下几个函数实现的:

  • open(const char *path, int oflags):打开和创建文件;
  • close(int fd):关闭一个已打开的文件;
  • read(int fd, void *buff, size_t nbytes):从文件描述符相关的文件里读入指定个字节的数据,并放到数据区buff中;
  • write(int fd, const void *buf, size_t nbytes):把缓冲区buf的前nbytes个字节写入与fd相关的文件中;

注:read()和write()函数在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户   空间缓冲区与内核缓冲区高速缓存(页缓存)之间复制数据

  • send(int sockfd, const void *buf, size_t len, int flags):把应用层buffer的数据拷贝进socket的内核发送buffer中(并非发送到对端);
  • recv(int sockfd, const void *buf, size_t nbytes, int flags):把内核socket接收缓冲区中的数据拷贝到应用层用户的buffer,如果接收缓冲区内没有数据或正在接受数据则一直等待;

 

而Linux系统将I/O分为内核准备数据和将数据从内核缓冲区拷贝到用户空间两个阶段。 

大多数文件系统的默认IO操作都是缓存IO,在Linux的缓存IO机制中,操作系统会先将IO的数据缓存在文件系统的页缓存,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲入拷贝到应用程序的地址空间中(因为程序无法直接操作硬件,减少系统调用的次数),而这两个过程是需要时间的,这就造成了阻塞,所以称为阻塞IO。

 

 

 

 

同步与异步,阻塞与非阻塞

因为一次输入操作通常包括两个阶段,等待数据传到内核空间和从内核向进程复制数据,而对一个socket的操作则通常涉及等待数据从网络中到达,当所有等待分组到达时,它被复制到内核的某个缓冲区;第二步就是把数据从内核缓冲区复制到应用程序缓冲区,因此在阻塞IO模型下,这两步都会导致进程(线程)挂起,直到数据准备完成。

我们以recv函数举例,假如我们来实现这个API,这个API的核心逻辑是读出内核socket接收缓冲区里的数据,然后返回。实现大致就是一个memcpy(系统调用),但问题是,如果缓冲区是空的怎么办?我们有如下选择:

  • 挂起调用方线程,等待内核在缓冲区里填入数据再memcpy;
  • 立即返回EAGAIN(Resource temporarily unavailable,35),什么也不做,让调用方在稍后的某个时刻再调用recv;
  • 保存传入参数buff,并立刻返回PENDING,并且在内核在缓冲区填入了数据后自动调用memcpy;

显然,第一种方式是同步阻塞的,第二种实现是同步非阻塞,第三种方式为异步非阻塞。同时这里也有几个细节需要考虑:在第一种方式中怎么挂起线程?当内核填入数据后怎么唤醒线程?第二种方式中调用者怎么知道什么时候可以再调用read?第三种方式中,当内核自动调用memcpy之后,怎么通知调用者这个操作真正完成了?下面我们来介绍几种IO模型。

阻塞IO模型

最常用的也就是阻塞io模型。默认情况下,所有文件操作都是阻塞的。我们以套接字接口为例来讲解此模型,在进程空间调用recvfrom,其系统调用知道数据包到达并且被复制到进程缓冲中或者发生错误时才会返回,在此期间会一直阻塞,所以进程在调用recvfrom开始到它返回的整段时间都是阻塞的,因此称之为阻塞io模型。 

非阻塞IO模型

在第一个阶段中,也就是内核准备好数据之前,如果内核还没有准备好数据,就返回一个EWOULDBLOCK错误。不断的轮询检查,直到发现kernel中的数据准备好了,就返回,然后进行系统调用将数据从内核拷贝到进程缓冲区中。有点类似busy-waiting的方法,但是会造成CPU资源浪费,所以较少用到。 

IO多路复用模型

IO多路复用模型的目的是:因为阻塞模型在没有收到数据时会被阻塞,如果一次需要接受多个socketfd时(如IM ,Instant Messaging),就会导致必须处理完前面的fd,才能处理后面的fd,即使后面的fd比前面的要先准备好,所以会造成客户端的严重延迟。当然我们可以用多线程来处理多个socketfd,但是这样又回启动大量的线程,造成资源的浪费,所以这时就出现了IO多路复用技术,用一个进程来处理多个fd的请求,而IO多路复用模型本质上也是系统调用,即select(),poll()和epoll()。

以select()为例,当用户进程调用了select,那么整个进程会被阻塞,而同时,内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。所以I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

虽然IO多路复用的函数也是阻塞的,但是多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的IO系统调用上。

 

 

 

 

 

Java中的I/O

Java中的IO一般包含两个部分,java.io包下的阻塞型IO(BIO)和java.nio包下的非阻塞型IO(NIO)。

 

java.io

Java的IO包主要关注的是从原始数据源的读取以及输出数据到目标媒介,且IO包下的流都是阻塞型的,阻塞型IO在读入典型的数据源和目标媒介有:文件,管道,网络连接,内存缓存,System.in,System.out,System.error(Java标准输入、输出、错误输出);下面这张图描绘了一个程序从数据源读取数据,然后将数据输出到其他媒介的过程:

  • Java IO中包含了许多InputStream、OutputStream、Reader、Writer的子类。这样设计的原因是让每一个类都负责不同的功能。这也就是为什么IO包中有这么多不同的类的缘故。各类用途汇总如下:文件访问
  • 网络访问
  • 内存缓存访问
  • 线程内部通信(管道)
  • 缓冲
  • 过滤
  • 解析
  • 读写文本 (Readers / Writers)
  • 读写基本类型数据 (long, int etc.)
  • 读写对象 

典型的InputStream读取操作: 

 

 

 

java.nio

标准的BIO是基于字节流和字符流进行操作的,数据在管道里像水流一样流入或流出。而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作的,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。同时Java NIO引入了选择器(Selector)的概念,Selector用于监听多个通道的事件(accept,connect,read,write),因此单个线程可以监听多个Channel。Channel、Buffer和Selector构成了NIO的核心组件。 

 


Buffer

Java NIO中Buffer用于和NIO Channel进行交互,数据总是从Channel读入Buffer,或者从Buffer写入Channel中。Buffer的本质是一块可以写入或读取数据的内存(数组,DirectByteBuffer除外),这块内存被包装成Buffer对象,并提供了一组方法用来方便的访问该块内存。Buffer读写数据一般遵循以下步骤:

  1. 写入数据到Buffer
  2. 调用filp()方法
  3. 从Buffer中读取数据
  4. 调用clear()方法或者compact()方法 

当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

为了理解Buffer的工作原理,需要熟悉它的三个属性:capacity,position,limit。

position和limit的含义取决于Buffer处于读模式还是写模式,而不管Buffer处在什么模式capacity的含义总是一样的。 

  • capacity:作为一个内存块,Buffer有一个固定的大小值,表示只能往里写入capacity个byte、long、char等类型。一旦Buffer满了,需要将其清空(通过读取数据或者清除数据)才能继续向里写数据,capacity是非负且永远不变的。
  • position:position表示下一个即将被写入或读取的元素的下标。
  • limit:表示最大的(不能)被写入或读取的元素的下标

当调用clear()方法时,position会被置为0,limit会被置为capacity;

当调用flip方法时,limit会被置为position,position会被置为0;

或许Buffer分为读/写模式会让我们觉得很麻烦,我们也可以不调用flip方法,直接通过Buffer#limit(int newLimit)和Buffer#position(int newPosition)方法来使用buffer。

 

Java NIO有以下类型的Buffer:

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

这些Buffer代表了不同的数据类型,而内存文件映射Buffer MappedByteBuffer可以通过MappedByteBuffer#allocateDirect(int capacity)或FileChannel#map(MapMode mode, long position, long size)方法分配,主要适用于对大文件的读写,操作系统虚拟地址空间有一块区域是在内存映射文件时将某一段虚拟地址和文件对象的某一部分建立起的映射关系,此时并没有拷贝数据到内存中,而当进程代码第一次引用这段代码的虚拟地址时,触发了缺页异常,这时内核会根据映射关系直接将文件的相关部分数据拷贝到用户的私有空间中,跳过了将数据先拷贝到内核缓冲区这一步,所以效率比标准IO高。

Channel

Java NIO的通道类似流,但又有些不同:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
  • 通道可以异步地读写。
  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。

 

 Channel和Buffer有好几种类型,下面是Java NIO中一些主要Channel的实现:

  • FileChannel:从文件中读写数据;
  • DatagramChannel:能通过UDP读写网络中的数据;
  • SocketChannel:能通过TCP读写网络中的数据;
  • ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel; 

Selector

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,甚至可以只用一个线程处理所有的通道。

通过调用Selector.open()方法可以创建一个Selector,使用Selector则必须将Channel注册到selector上,可以通过SelectableChannel.register()方法来实现,与selector一起使用时,Channel必须处于非阻塞模式下,所以FileChannel不能与Selector一起使用,而SocketChannel都可以。register方法的第二个参数是一个 “interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件,常量定义在SelectionKey中:

  • OP_CONNECT:接收连接继续事件,表示服务器监听到了客户连接,服务器可以接收这个连接了;
  • OP_ACCEPT:一个ServerSocketChannel准备好接收新进入的连接被就绪事件,表示客户与服务器的连接已经建立成功;
  • OP_READ:读就绪事件,表示通道中已经有了可读的数据,可以执行读操作了(通道目前有数据,可以进行读操作了);
  • OP_WRITE:写就绪事件,表示已经可以向通道写数据了(通道目前可以用于写操作); 

如果不止对一种事件感兴趣,可以用”位或“操作符将常量连接起来。

可以将一个对象或者更多信息附着到SelectionKey上,或者在注册时添加对象,这样就能方便的识别某个给定的通道。例如,可以附加与通道一起使用的Buffer,或是包含聚集数据的某个对象。

selectionKey.attach(theObject);

Object attachedObj = selectionKey.attachment();

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

 

当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。同时程序调用selector.select()方法时会阻塞当前线程,select()本质是系统调用(即上面提到的select(),poll(),epoll()函数)。

  • int select():阻塞到至少有一个通道在你注册的事件上就绪了;
  • int select(long timeout):和select()一样,除了最长会阻塞timeout毫秒(参数)
  • int selectNow():不会阻塞,不管什么通道就绪都立刻返回,如果没有通道就绪返回0

如果select()方法返回,则表示有一个或多个Channel就绪,就可以调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。

Set<SelectionKey> selectionKeys = selector.selectedKeys();

SelectionKey.channel()方法返回的通道需要转型成你要处理的类型,如ServerSocketChannel或SocketChannel等。

某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。

使用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例失效。通道本身并不会关闭。

 

Why NIO

如果使用传统的BIO模型对每个连接都建立一个线程去等待read/write事件的发生,在并发程度较大时,线程越来越多,线程的切换、同步、数据的移动会引起性能问题。Reactor机制中每次读写已经能保证非阻塞读写,这里可以减少一些线程的使用。有一篇论文(SEDA: Staged Event-Driven Architecture - An Architecture for Well-Conditioned, Scalable Internet Service)对随着线程的增长带来性能降低做了一个统计: 

图2:线程服务器吞吐量下降:此基准测试用于测量简单的线程服务器,该服务器为管道中的每个任务创建单个线程。收到任务后,每个线程从磁盘文件执行8 KB读取;所有线程都从同一个文件中读取,因此数据总是在缓冲区缓存中。线程在服务器中预先分配,以消除测量中的线程启动开销,并在内部生成任务以消除网络效应。该服务器采用C语言实现,运行在Linux 2.2.14下的4路500 MHz Pentium III和2 GB内存上。随着并发任务数量的增加,吞吐量会增加,直到线程数量增大,之后吞吐量会大幅下降。随着任务队列长度的增加,响应时间变得无限;为了进行比较,我们已经显示了理想的线性响应时间曲线(注意z轴上的对数刻度)。

从图中可以看出,随着线程的增长,吞吐量在线程数为8个左右的时候开始线性下降,并且到64个以后而迅速下降,其相应延迟也在线程达到256个后指数上升。因为线程上下文切换、同步、数据移动会有性能损失,线程数增加到一定数量时,这种性能影响效果会更加明显。

 

 

NIOReactor

Reactor(反应堆)是一种基于事件驱动的设计模式,是一种并发编程模型(或者说是一种思想),Reactor设计模式用于处理由一个或多个客户端并发传递给应用程序的的服务请求。同步事件分发器(Synchronous Event Demultiplexer)将请求分离(demultiplex)和调度(dispatch)给应用程序(ConcreteHandler),同步的、有序的处理同时接收多个服务请求。

优点:

关注点分离:Reactor模式将与应用程序无关的解复用和调度机制与特定于应用程序的钩子方法功能分离。独立于应用程序的机制成为可重用的组件,它们知道如何解复用事件并分派由事件处理程序定义的适当的钩子方法。相反,钩子方法中特定于应用程序的功能知道如何执行特定类型的服务。

改进事件驱动应用程序的模块化,可重用性和可配置性:该模式将应用程序功能分离到单独的类中。例如,日志记录服务器中有两个单独的类:一个用于建立连接,另一个用于接收和处理日志记录。这种解耦使得能够为不同类型的面向连接的服务(例如文件传输,远程登录和视频点播)重用连接建立类。因此,修改或扩展日志记录服务器的功能只会影响日志记录处理程序类的实现。

提高应用程序的可移植性:Initiation Dispatcher的接口可以独立于执行事件多路分解的OS系统调用重用。这些系统调用检测并报告可能在多个事件源上同时发生的一个或多个事件的发生。常见的事件源可能包括I / O句柄,计时器和同步对象。在UNIX平台上,事件多路分解系统调用称为select和poll [1]。在Win32 API [16]中,WaitForMultipleObjects系统调用执行事件多路分解。

提供粗粒度并发控制:Reactor模式在事件多路分解和在进程或线程内调度的级别上序列化事件处理程序的调用。 Initiation Dispatcher级别的序列化通常消除了在应用程序进程中进行更复杂的同步或锁定的需要。

解耦、提升复用性、模块化、可移植性、事件驱动、细力度的并发控制等,最大因素是性能上的提升。

缺点:

1. 相比传统的简单模型,Reactor增加了一定的复杂性,并且不易于调试。
2. Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
3. Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用Proactor模式。

 

架构图 

构成

  • Handle:表示操作系统管理的资源,是对资源在操作系统层面上的一种抽象,它可以是打开的文件、一个连接(Socket)、Timer等。由于Reactor模式一般使用在网络编程中,因而这里一般指Socket Handle,即一个网络连接(Connection,在Java NIO中的Channel)。这个Channel注册到Synchronous Event Demultiplexer中,以监听Handle中发生的事件,对ServerSocketChannnel可以是CONNECT事件,对SocketChannel可以是READ、WRITE、CLOSE事件等。
  • Synchronous Event Demultiplexer :同步事件分离器,无限循环等待新事件的到来,一旦发现有新的事件到来,就会通知初始事件分发器去调取特定的事件处理器(本质为系统调用selec(),poll(),epoll())。
  • Initiation Dispatcher :初始分派器,用于管理Event Handler,定义注册、移除EventHandler等。它还作为Reactor模式的入口调用Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,当阻塞等待返回时,根据事件发生的Handle将其分发给对应的Event Handler处理,即回调EventHandler中的handle_event()方法Event Handler :事件处理器的接口,以供Initialization Dispatcher回调使用;
  • Concrete Event Handler :事件处理器的实际实现,而且绑定了一个Handle。因为在实际情况中,我们往往不止一种事件处理器,因此这里将事件处理器接口和实现分开;

 

 

模块交互

  1. 注册Concrete Event Handler到Initiation Dispatcher中。
  2. Initiation Dispatcher调用每个Event Handler的get_handle接口获取其绑定的Handle。 
  3. Initiation Dispatcher调用handle_events开始事件处理循环。在这里,Initiation Dispatcher会将步骤2获取的所有Handle都收集起来,使用Synchronous Event Demultiplexer来等待这些Handle的事件发生。 
  4. 当某个(或某几个)Handle的事件发生时,Synchronous Event Demultiplexer通知Initiation Dispatcher。 
  5. Initiation Dispatcher根据发生事件的Handle找出所对应的Handler。 
  6. Initiation Dispatcher调用Handler的handle_event方法处理事件。

 

Reactor三种线程模型

单线程模型 

Reactor单线程模型仅使用一个线程来处理所有的事情,包括客户端的连接和到服务器的连接,以及所有连接产生的读写事件,这种线程模型需要使用异步非阻塞I/O,使得每一个操作都不会发生阻塞,Handler为具体的处理事件的处理器,而Acceptor为连接的接收者,作为服务端接收来自客户端的链接请求。这样的线程模型理论上可以仅仅使用一个线程就完成所有的事件处理,显得线程的利用率非常高,而且因为只有一个线程在工作,所有不会产生在多线程环境下会发生的各种多线程之间的并发问题,架构简单明了,线程模型的简单性决定了线程管理工作的简单性。但是这样的线程模型存在很多不足,比如:

  • 仅利用一个线程来处理事件,对于目前普遍多核心的机器来说太过浪费资源;
  • 一个线程同时处理N个连接,管理起来较为复杂,而且性能也无法得到保证,这是以线程管理的简洁换取来的事件管理的复杂性,而且是在性能无 法得到保证的前提下换取的,在大流量的应用场景下根本没有实用性;
  • 根据第二条,当处理的这个线程负载过重之后,处理速度会变慢,会有大量的事件堆积,甚至超时,而超时的情况下,客户端往往会重新发送请求,这样的情况下,这个单线程的模型就会成为整个系统的瓶颈;
  • 单线程模型的一个致命缺陷就是可靠性问题,因为仅有一个线程在工作,如果这个线程出错了无法正常执行任务了,那么整个系统就会停止响应,也就是系统会因为这个单线程模型而变得不可用,这在绝大部分场景(所有)下是不允许出现的;

多线程模型

介于上面的种种缺陷,Reactor演变出了第二种模型,也就是Reactor多线程模型,下面展示了这种模型: 

多线程模型下,接收连接和处理请求作为两部分分离了,而Acceptor使用单独的线程来接收请求,做好准备后就交给事件处理的handler来处理,而handler使用了一个线程池来实现,这个线程池可以使用Executor框架实现的线程池来实现,所以,一个连接会交给一个handler线程来处理其上面的所有事件,需要注意,一个连接只会由一个线程来处理,而多个连接可能会由一个handler线程来处理,关键在于一个连接上的所有事件都只会由一个线程来处理,这样的好处就是消除了不必要的并发同步的麻烦。Reactor多线程模型似乎已经可以很好的工作在我们的项目中了,但是还有一个问题没有解决,那就是,多线程模型下任然只有一个线程来处理客户端的连接请求,那如果这个线程挂了,那整个系统任然会变为不可用,而且,因为仅仅由一个线程来负责客户端的连接请求,如果连接之后要做一些验证之类复杂耗时操作再提交给handler线程来处理的话,就会出现性能问题。

 

主从多线程模型 

主从多线程模型用一组线程/进程接收连接、一组线程/进程处理IO读写事件。它与多线程模型的主要区别在于其使用一组线程或进程在一个共享的监听套接字上accept连接。这么做的原因是为了应付单个线程/进程不足以快速处理内核中监听套接字的已连接套接字队列(并发量极大)的情况。

  • 从主线程池中选择一个Reactor线程作为Acceptor线程,绑定监听端口,接收客户端连接
  • 接收到连接请求后,将其注册到主线程其他线程上,由它们负责接入认证等工作
  • 链路建立完成后,就链路从主线程池的多路复用器上摘除,重新注册到Sub线程池的NIO线程上,负责后续IO操作

可能会触发惊群效应

 

TomcatNettyRedisReactor

 

一些概念

EAGAIN

<errno.h>定义的错误编码 

同步和异步

第二和第三的区别。第二得到EAGAIN之后,这个buf属于你,不属于系统,你可以释放掉这个buf,再下次调用read时使用另一个buf。第三的情况下,这个buf的所有权你已经交给了系统,因此这个buf已经不属于你,你不能释放它。你得等到系统明确告诉你read完成之后你才能释放这个buf。buf的所有权转移与否也是判断同步还是异步的一个标志。同步是不转移所有权的,异步则转移所有权(调用时转给系统以及完成时系统还给调用者)。

demultiplexes 

DMA

DMA(Direct Memory Access,直接存储器访问)。在DMA出现之前,CPU与外设之间的数据传送方式有程序传送方式、中断传送方式。CPU是通过系统总线与其他部件连接并进行数据传输。

 

1.1程序传送方式

程序传送方式是指直接在程序控制下进行数据的输入/输出操作。分为无条件传送方式和查询(条件传送方式)两种。

 

1.1.1无条件传送方式

微机系统中的一些简单的外设,如开关、继电器、数码管、发光二极管等,在它们工作时,可以认为输入设备已随时准备好向CPU提供数据,而输出设备也随时准备好接收CPU送来的数据,这样,在CPU需要同外设交换信息时,就能够用IN或OUT指令直接对这些外设进行输入/输出操作。由于在这种方式下CPU对外设进行输入/输出操作时无需考虑外设的状态,故称之为无条件传送方式。

 

1.1.2查询(有条件)传送方式 

 

查询传送也称为条件传送,是指在执行输入指令(IN)或输出指令(OUT)前,要先查询相应设备的状态,当输入设备处于准备好状态、输出设备处于空闲状态时,CPU才执行输入/输出指令与外设交换信息。为此,接口电路中既要有数据端口,还要有状态端口。

 

1.2中断传送方式

中断传送方式是指当外设需要与CPU进行信息交换时,由外设向CPU发出请求信号,使CPU暂停正在执行的程序,转而去执行数据输入/输出操作,待数据传送结束后,CPU再继续执行被暂停的程序。

以上两种方式,均由CPU控制数据传输,不同的是程序传送方式由CPU来查询外设状态,CPU处于主动地位,而外设处于被动地位。这就是常说的----对外设的轮询,效率低。而中断传送法师则是外设主动向CPU发生请求,等候CPU处理,在没有发出请求时,CPU和外设都可以独立进行各自的工作。  需要进行断点和现场的保护和恢复,浪费了很多CPU的时间,适合少量数据的传送。

 

1.3 DMA原理

DMA的出现就是为了解决批量数据的输入/输出问题。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。这样数据的传送速度就取决于存储器和外设的工作速度。

通常系统总线是由CPU管理的,在DMA方式时,就希望CPU把这些总线让出来,即CPU连到这些总线上的线处于第三态(高阻状态),而由DMA控制器接管,控制传送的字节数,判断DMA是否结束,以及发出DMA结束信号。因此DMA控制器必须有以下功能:

 

1、能向CPU发出系统保持(HOLD)信号,提出总线接管请求;

2、当CPU发出允许接管信号后,负责对总线的控制,进入DMA方式;

3、能对存储器寻址及能修改地址指针,实现对内存的读写;

4、能决定本次DMA传送的字节数,判断DMA传送是否借宿。

5、发出DMA结束信号,使CPU恢复正常工作状态。


页缓存

概念:Linux内核实现磁盘缓存的技术就叫页高速缓存。即把磁盘中的数据缓存到物理内存中,把对磁盘的访问转换为对物理内存的访问。(物理内存的最小单位为页,页高速缓存缓存的是内存页面,所以叫高速缓存)

作用:减少对磁盘I/O的操作,提高系统性能;解决不同进程之间或者同一进程的前后不同部分之间对于数据的共享问题。

原理:

    • 访问物理内存的速度远远快于访问磁盘的速度(ns与ms的数量级差距)。所以将数据放入页高速缓存中可以更快的访问数据。
    • 临时局部原理。数据一旦被访问后,短时间内有极大会再一次被访问。短时间内集中访问同一数据的原理就叫做临时局部原理。因此,对于经常需要被访问的数据,如果将其放入缓存中,那就有可能再次被页高速缓存命中。

读一个文件,大致流程如下:

  • 内核开始一个读操作,首先检查页高速缓存中是否有该数据
  • 如果有,则直接读取。这一步叫做缓存命中;如果没有,则直接从磁盘读写,这叫未缓存命中 
  • 内核调度块I/O操作从磁盘中读取数据,并将全部数据或者部分数据放入页高速缓存中。注意:缓存谁是取决于谁被访问到。系统不一定将文件中全部放入内存,可能只是部分内容。

页缺失(缺页中断)

缺页中断就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。malloc()和mmap()等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。

recvfrom()

如果要定位数据发送者,可以使用recvfrom来得到数据发送者的源地址

#include <sys/socket.h>

size_t recvfrom(int sockfd,  //套接字

                 void * buf,  //接收数据缓冲区

                 size_t len,  //接收数据长度

                 int flags,   //标志

                 struct sockaddr * addr, //数据发送者地址,函数调用后该地址结构被填充

                 socklen_t * addrlen  //地址长度指针(注意这里是个指针)

                 );

返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错返回-1.

flag有以下值:

MSG_OOB:     如果协议支持,接收带外数据

MSG_PEER:    返回报文内容而不是真正取走报文

MSG_TRUNC:   即使报文被截断,要求返回的是报文的实际长度

MSG_MAITALL: 等待直到所有数据可用(仅支持SOCK_STREAM)

对于SOCK_STREAM(tcp/ip)套接字,接收的数据可以比请求的少,标志MSG_WAITALL可以阻止这种行文,除非所需数据全部收到,recv函数才返回。对于SOCK_DGRAM和SOCK_SEQPACKET套接字,MSG_WAITALL标志没有什么影响,因为这些基于报文的套接字类型一次读取就返回整个报文。

如果发送者已经调用了shutdown来结束传输,或者网络协议支持默认的顺序关闭且发送端已经关闭,那么所有的数据接收完毕后,recv返回0。

如果addr非空,他将包含数据发送者的套接字地址,当调用recvfrom时,需要设置addrlen参数指向一个包含addr所指套接字缓冲区字节大小的整数。返回时,该整数设为该地址的实际字节大小。因为可以获得发送者的地址,recvfrom通常用于无连接套接字。否则recvfrom等同于recv。

  • SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。
  • SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。
  • SOCK_SEQPACKET 这个协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
  • SOCK_RAW 这个socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
  • SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序

 

文件描述符(File Descriptor

文件描述符通常是一个小的非负整数,内核用以标识一个特定进程正在访问的文件。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。

每个进程在PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针。 


Socket

  Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。  

惊群效应

举一个例子,当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉, 等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。对于操作系统来说,多个进程/线程在等待同一资源是,也会产生类似的效果,其结 果就是每当资源可用,所有的进程/线程都来竞争资源,造成的后果:
1)系统对用户进程/线程频繁的做无效的调度、上下文切换,系统系能大打折扣。
2)为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

对于惊群效应的场景描述,最常见的就是对于socket操作符的accept操作的描述。当多个用户进程/线程同时监听同一个端口时,由于实际上一个请求过来,只有一个进程/线程accept成功,所以就会产生惊群效应。

实际上这是一个很古老的问题。linux操作系统在内核层面很早就解决了这个问题,一个请求过来,内核只会唤醒一个进程来accept,这样就没有惊群现象了。但是在很多场景下,只要有竞争,就可能会出现惊群效应。比如常见的生产者-消费者模型,一般来说消费可能会比较耗时,所以消费者会有多个。当突然有生产者往队列里面投了一个job时,这些消费者就会一哄而上去队列中抢这个job,这就发生了惊群效应。

一个基本的线程池框架也是基于生产者-消费者模型的。也就是说只要用到了进程池或者线程池,你可能就避免不了要处理惊群效应带来的问题。所以你能感觉到惊群效应的无处不在了吗。。。

 

select(),poll(),epoll()

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。(这里啰嗦下)

select

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
 

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

 

poll

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

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};
 

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

 

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

 

 epoll操作过程

epoll操作过程需要三个接口,分别如下:

int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
 

1. int epoll_create(int size);
 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
 当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
 函数是对指定描述符fd执行op操作。
 - epfd:是epoll_create()的返回值。
 - op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
 - fd:是需要监听的fd(文件描述符)
 - epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:

struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
 

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
 等待epfd上的io事件,最多返回maxevents个事件。
 参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值