基本概念
用户空间和内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
详解
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
- 保存处理机上下文,包括程序计数器和其他寄存器。
- 更新PCB信息。
- 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另一个进程执行,并更新其PCB。
- 更新内存管理的数据结构。
- 恢复处理机上下文。
进程阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。(所以太多的cas会损耗cpu的太多性能?todo)
文件描述符(File descriptor)
文件描述符是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
IO模型
Linux内核将所有外部设备都看作一个文件来操作,对一个文件的读写会调用 内核 提供的系统命令,返回一个文件描述符(file descriptor),对一个socket读写也有对应的描述符,叫做socketfd,描述符就是一个数字,指向内核中的一个结构体(内核为各个进程维护的打开文件的记录表)
一次IO操作会将数据先写入内核缓冲区 再将数据转移至进程缓冲区
ex: 发生一次read操作后,会发生两件事
- 等待数据包到达就绪
- 数据被复制到应用进程的缓冲区
网络应用需要处理的无非就是两大类问题,网络IO,数据计算。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。
关于同步非同步 异步非异步的简单理解
同步模型
阻塞IO(blocking io)
在这个IO模型中,用户空间的应用程序执行一个系统调用(recvform),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞。不能处理别的网络IO。调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。在调用recv()/recvfrom()函数时,发生在内核中等待数据和复制数据的过程,大致如下图:
非阻塞IO(non-blocking io)
在网络IO时候,非阻塞IO会进行recvform系统调用,检查数据是否准备好,与阻塞IO不一样,可以重复调用recvfrom进行轮训。
优点:多个任务可以同时执行。
缺点:数据在轮训期间准备完毕,会拉长整个流程的时间,降低吞吐量。
多路复用IO(multiplexing io)
IO多路复用有两个特别的系统调用select、poll、epoll(效率更高)函数。select调用是内核级别的,select轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket,能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。select或poll调用之后,会阻塞进程,与blocking IO阻塞不同在于, 此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。(单任务全部完成之后才会复制到用户进程,可以同时对多个任务进行操作)
I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时(注意不是全部数据可读或可写),才真正调用I/O操作函数。
对于多路复用,也就是轮询多个socket。多路复用既然可以处理多个IO,也就带来了新的问题,多个IO之间的顺序变得不确定了,当然也可以针对不同的编号。具体流程,如下图所示:
IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
上面的图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。(select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。所以IO多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。
在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降底了系统的维护工作量,节省了系统资源,I/O多路复用的主要应用场景如下:
信号驱动式IO(signal-driven io)(同步?revfrom是用户进程调用)
首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。过程如下图所示:
异步模型
异步非阻塞
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。
Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv。异步过程如下图所示:
用户进程发起aio_read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉它read操作完成了。
异步阻塞
- 有时我们的 API 只提供异步通知方式,例如在 node.js 里,但业务逻辑需要的是做完一件事后做另一件事,例如数据库连接初始化后才能开始接受用户的 HTTP 请求。这样的业务逻辑就需要调用者是以阻塞方式来工作。为了在异步环境里模拟 “顺序执行” 的效果,就需要把同步代码转换成异步形式,这称为 CPS(Continuation Passing Style)变换。BYVoid 大神的 continuation.js 库就是一个 CPS 变换的工具。用户只需用比较符合人类常理的同步方式书写代码,CPS 变换器会把它转换成层层嵌套的异步回调形式。
(callback的时候执行剩余逻辑? 主线程卡死? 使用回调来返回?) cps相关 - 另外一种使用阻塞方式的理由是降低响应延迟。如果采用非阻塞方式,一个任务 A 被提交到后台,就开始做另一件事 B,但 B 还没做完,A 就完成了,这时要想让 A 的完成事件被尽快处理(比如 A 是个紧急事务),要么丢弃做到一半的 B,要么保存 B 的中间状态并切换回 A,任务的切换是需要时间的(不管是从磁盘载入到内存,还是从内存载入到高速缓存),这势必降低 A 的响应速度。因此,对实时系统或者延迟敏感的事务,有时采用阻塞方式比非阻塞方式更好。
NIO 和 IO的区别
区别
IO | NIO |
---|---|
Stream oriented | Buffer oriented |
Blocking IO | Non blocking IO |
Selectors |
概念
- IO 阻塞IO
- 标准IO是面向Stream的,每次从Stream中读取出字节,它不能在流中的数据前后移动,不能说已经读到后面了,再跳到前面去。
- Stream是阻塞的,当一个线程调用read或write时,线程是阻塞的,只有等数据读取/写入完成,期间线程不能做任何事。
- NIO 非阻塞IO
- NIO把数据读到一个待处理的Buffer中,也可以在buffer中来回移动,但是不保证Buffer中的数据是所需要的完整数据,需要去校验。
- NIO使用一个线程从某个通道中读取数据,但是仅能从Buffer中获得当前的部分数据,而数据变为完整之前,线程并不会被阻塞,而是可以去做其他的事情。写入也是这样,不需要等待它完全写入,就可以去做其他的事情。这里的“其他事情”大部分也是指在其它通道上执行IO操作,所以一个线程可以管理多个输入/输出通道,Selector就是跑在这个线程。
- 好处:一个Selector能同时对多个Socket连接进行管理。而不会阻塞当前的Selector线程。如果是原有的IO模型,在一个read或者write出现网络问题的时候不能进行下一步的操作,要解决这个问题只能开辟新的线程。这样就会导致线程数太多。而线程池的方案也会有出现多个线程阻塞而拖慢整个系统的问题。
类图
例子
|
|
- IO
|
|
- NIO
|
|
需要循环来读取buffer中的所有的值。
NIO实现相关
NIO主要的组件有Channel、Buffer、Selector
Channel和Buffer
和Stream相比,你可以同时对一个Channel进行读和写操作,而Stream只能读或写。
- Channel可以异步进行读写。
- Channel只和Buffer进行交互。
Channel子类(todo 实现)
- FileChannel 只能阻塞 详细原因
- DatagramChannel 通过UDP 向网络中读写数据
- SocketChannel 通过TCP 向网络中读写数据
- ServerSocketChannel 允许监听即将到来的TCP连接
|
|
buffer
- ByteBuffer
- CharBuffer
- IntBuffer
buffer 属性
- position
- limit
- capacity
基本用法
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用flip()方法
- 从Buffer中读取数据
- 调用clear()方法或者compact()方法
Buffer初始化时需要调用各个Buffer的allocate方法,比如
CharBuffer cb = CharBuffer.allocate(48); //分配48个char大小的buffer
- 可以通过buffer.put(x)自己写入数据,也可以调用channel.read把channel中数据写入buffer
- 可以通过buffer.read()读出数据,也可以channel.write(buf)把buffer中的数据读出来 写入到Channel中
源码分析
position和limit的意义在读模式和写模式下是不同的
Invariants: mark <= position <= limit <= capacity
(在切换模式和清空buffer的时候都需要重置mark标志位,保证上述关系)
filp()
flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
rewind()
Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。
|
|
clear()与compact()方法
一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成。
如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。
如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。
|
|
如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。
compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
(Buffer类中不存在,但是在ByteBuffer中有个抽象方法)
选取DirectByteBuffer实现
mark()与reset()方法
通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。例如:
|
|
Scatter/Gather
基本概念
- 分散(scatter)从Channel中读取是指在读操作时将读取的数据写入多个buffer中。因此,Channel将从Channel中读取的数据“分散(scatter)”到多个Buffer中。1234ByteBuffer header = ByteBuffer.allocate(128);ByteBuffer body = ByteBuffer.allocate(1024);ByteBuffer[] byteArray = {header, body};channel.read(byteArray);
注意buffer首先被插入到数组,然后再将数组作为channel.read() 的输入参数。read()方法按照buffer在数组中的顺序将从channel中读取的数据写入到buffer,当一个buffer被写满后,channel紧接着向另一个buffer中写。
Scattering Reads在移动下一个buffer前,必须填满当前的buffer,这也意味着它不适用于动态消息(消息大小不固定)。换句话说,如果存在消息头和消息体,消息头必须完成填充(例如 128byte),Scattering Reads才能正常工作。
- 聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel。1234ByteBuffer header = ByteBuffer.allocate(128);ByteBuffer body = ByteBuffer.allocate(1024);ByteBuffer[] byteArray = {header, body};channel.write(byteArray);
buffers数组是write()方法的入参,write()方法会按照buffer在数组中的顺序,将数据写入到channel,注意只有position和limit之间的数据才会被写入。因此,如果一个buffer的容量为128byte,但是仅仅包含58byte的数据,那么这58byte的数据将被写入到channel中。因此与Scattering Reads相反,Gathering Writes能较好的处理动态消息。
Selector
基本概念
向Selector注册通道
为了将Channel和Selector配合使用,必须将channel注册到selector上。通过SelectableChannel.register()方法来实现,如下:
与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel(上文有讲)与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。
事件类型有Connect、Accept、Read、Write,都是说这个事件已经ready的时候才触发。
如果不止对一种事件感兴趣,可以用“位或”操作符将常量连接起来.
SelectionKey
interest集合
|
|
与操作就能判断监听了什么事件类型。
ready集合
判断哪些事件已经完成
selector + channel
|
|
attachment(附加对象)
可以在注册时附加对象,在selectionKey中获取对象
例子
|
|
|
|
Java I/O模型的演进
BIO通信模型
当新建一个Socket连接时,由于读/写都是阻塞的,那么就需要一条线程来完成读写操作,即一条线程对应一个Socket连接,如果Socket连接数过多时,线程数过多会导致系统性能下降,栈溢出、创建新线程失败等问题。尝试使用线程池来控制线程资源的数量的方法,但是底层本质上还是阻塞通信,
Socket读写在操作完全完成之前,线程会阻塞在I/O操作上,如果 对方发送请求 或者 应答消息 缓慢,或者 网络传输较慢,读写操作所在的线程就会长时间被阻塞,在此期间,其它线程只能在消息队列中排队。
比如OutputStream的write操作,线程会被阻塞直到 所有要发送的字节 全部写入完毕,在TCP中,当消息方接受缓慢的时候,不能及时从TCP缓冲区中读出数据,那么发送端就会调整减小window size,写入速度也会越来越慢,此时使用的是阻塞IO,线程就会被阻塞很长时间,如果线程池中所有的线程都被长时间阻塞了,新来的任务就会排队进入任务队列,而任务队列满了之后,再继续提交任务就会导致提交任务的线程阻塞,新的请求会被拒绝,客户端大量超时。(所以适合的超时机制很重要。)
我们无法保证 生产环境的网络状况,对方应用处理速度,如果我们的系统依赖于对方的处理速度,可靠性就无法保证。
NIO通信模型
在面向Stream的IO中数据直接写入或读到Steram中,而NIO中,引入了Buffer缓冲区的概念,让Buffer和Channel进行交互。
SocketChannel和ServerSocketChannel可以被设置为unblocking的模式,connect/read/write操作不会阻塞当前线程,这样使用selector一个线程管理多个网络IO操作成为可能。
Java的NIO使用epoll实现,性能也不会随着客户端增加而下降。
Java 传统IO主要包括字节流和字符流两种
Java NIO 和 Netty
作为一个NIO服务端,需要能够处理 网络的闪断、客户端的重复接入、客户端的安全认证、消息编解码、半包读写等情况,难度是比较大的。
1.Java NIO的API使用起来比较复杂,而netty的api简单,大部分只用关注业务逻辑,灵活扩展。
2.必须对多线程和网络编程非常熟悉,才能写出高质量的NIO程序。
3.实现上述的诸多功能工作量和难度较大,而netty做好了封装也很成熟
java io
java io操作类包含在java.io包下,将近80个类,大概分为4组:
1.基于字节操作的I/O接口,如InputStream、OutputStream
2.基于字符操作的I/O接口,如Reader、Write
3.基于磁盘操作的I/O接口,File
4.基于网络操作的I/O接口,如Socket
前两种是根据传输数据的格式(字节流/字符流),后两种是传输数据的方式(磁盘/网络)
Java 传统IO主要包括字节流和字符流两种
ByteArrayInputStream
12345ByteArrayInputStream {byte[] buf; //inputStream的内容int pos; //当前读到的位置int mark; //调用mark标记当前pos,调用reset后可以把pos置回标记的mark处}FileInputStream 对文件的读取,其中大部分的实现调用native完成
- FilterInputStream 本身只是把请求委派给内部的InputStream,做一个Decorator,其子类做了各种包装
- BufferedInputStream 把内部包装的InputStream读取一些存在一个byte[] buf中,读的时候可以直接中自己的buf中读
- DataInputStream 可以读取若干个字节,转换成int、byte、short、UTF字符等类型返回
- ObjectInputStream把字节流反序列化为对象,包装内部的一个DataInputStream的数据源,也是一个Decorator
IO流详解