Socket
在第一文网络与IO基本概念中说了一下对网络和IO的理解,第二文OSI七层详细的讲解了网络通信的过程,在过程当中感受到了会有一系列的系统调用,也就是IO的过程
换句话说,通信就是IO的过程,我们可以通过socket来完成通信。
我们来通过帮助文档详细的看一下Linux内核中的socket
man 2 socket
可以看到,如果得到socket会给我们返回了一个文件描述符
以及关于socket的一些命令方法
在得到socket过程当中,还有一些属性和选项,SOCK_NONBLOC:非阻塞,也就是说socket可以是阻塞或者非阻塞
再继续看,我们看socket的bind()方法
man 2 bind
可以看到文档有一个example例子,我们大概看一下干的什么
我们来梳理一下逻辑,我们现在是一个服务端,上来调用socket得到的文件描述符是服务端文件描述符,然后绑定地址端口号之后去监听它,如果有客户端连进来,会得到一个新的客户端描述符,也就是说每个客户端连进来都是新的描述符,也就是独立的单独的文件描述符,相当于是不同的连接了。
我们再来看Java中使用Socket
得到一个ServerSocket对象并绑定端口号,实例化以后相当于建立了连接,调用了listen,开启了监听状态,调用accept能拿到Socket,有别人连接进来,就可以拿到不为空的socket,也就是得到了一个新的客户端文件描述符。和linux中的socket基本一致的的。然后我们再继续看,拿到一个连接之后抛出了一个线程去处理这个连接,也就是每个连接让单独的线程去处理。
得到连接之后,可以获取到socket的一个流,得到它的输入流,就可以readerLine(),读取它的数据。得到输出流就可以发送数据。
然后我们回到Linux中来,一切皆文件,我们再来看socket的read方法
man 2 read
可以看到需要传入文件描述符,就可以读取文件描述符
read可以读取文件描述符,而socket的文件描述符可以是阻塞或者非阻塞的,那么阻塞与否是什么意思?
如果读取的是一个阻塞的文件描述符,那么我们这个read方法调起以后,就会在这卡住了,对方什么时候把数据发来,read才能读到东西,也就说我们这个线程可以被阻塞住。这就是BIO。
如果读取的是一个非阻塞的文件描述符,在read方法调起的时候,因为没有数据到达,它可以立刻返回一个没有数据,然后就可以去干别事,所以read就不会被阻塞住,等过一会再去read,就有可能读到了,也就是数据到了有可能需要等一会,但无论如何程序不会阻塞住。这就是NIO。
BIO
上面说的socket的bind,accept,read方法都是来自于kernel也就是系统内核,我们把上面的点连起来,如下图我们假如说要在服务器上跑一个java项目。
我们把项目放到tomcat中,启动tomcat,这时tomcat会先去访问内核,listen开启监听,比如说监听8080,就可以得到一个服务端文件描述符,比如说是6。
然后客户端想访问tomcat中的项目的话,客户端会先和服务器内核通信,在内核中,建立三次握手,握手的时候访问的就是8080,6这个文件描述符就可以监听到,然后会为这个客户端建立一个新文件描述符比如说8。
然后客户端和kernel连接成功以后,tomcat会开启一个线程去从kernel中读文件描述符8,也就是读客户端发过来的数据,完成通信。
如果又一个新的socket连接连进来,会再分配一个新的文件描述符比如说9,这样就可以把socket区分出来了。
在早期linux的kernel的文件描述符或者说socket,是阻塞的,那么read8这个线程读不到数据就会被阻塞了,再监听到fd9,又会开启一个线程去read9,fd9有数据了,T2就开始工作了,T1就得等有数据才会工作否则会一直阻塞着。这就是BIO模型。
它的弊端如下:
-
线程越多,Context Switch就越多,而Context Switch是一个比较重的操作,会无谓浪费大量的CPU。
-
每个线程会占用一定的内存作为线程的栈。比如有1000个线程同时运行,每个占用1MB内存,就占用了1个G的内存。
由于线程阻塞会引起以上问题,那么要是操作IO接口时,操作系统能够总是直接告诉有没有数据,而不是Block去等就好了。于是,NIO登场。
NIO
NIO被翻译为No-Block或者是New-Bolck,让socket能变成非阻塞,那么read就只需要开辟一个线程就够了,比如说它读fd8的时候,没有数据,它不会阻塞住,它会再去读fd9,伪代码就是循环一直监听读取新的连接就可以了。
但NIO虽然可以不阻塞了,我们通过轮询,不断尝试数据是否到达,这比之前BIO好多了,起码程序不会被卡死了。但这样会带来两个新问题:
-
如果有大量文件描述符都要等,那么就得一个一个的read。这会带来大量的Context Switch,因为read是系统调用,每调用一次就得在用户态和核心态切换一次
-
休息一会的时间不好把握。这里是要猜多久之后数据才能到。等待时间设的太长,程序响应延迟就过大;设的太短,就会造成过于频繁的重试,干耗CPU。
要是操作系统能一口气告诉程序,哪些连接有数据到达了就好了。那么read能否只读一次就把所有连接读一遍看看是否有数据呢?
答案是不行的,read一次只能读一个连接,处理不了多个连接,所以内核就要发生变化,开辟一个新的系统调用来弥补资源浪费的情况
这时内核多了一个系统调用,即select,在Java中的NIO中叫Selector选择器。这种一次查询多个连接是否有数据到达的方式就叫多路复用。
多路复用IO
再说多路复用IO之前先来说一下它和NIO的关系,以免很多人会搞混
IO多路复用是要和NIO一起使用的。尽管在操作系统级别,NIO和IO多路复用是两个相对独立的事情。NIO仅仅是指IO API总是能立刻返回,不会被阻塞。
而IO多路复用仅仅是操作系统提供的一种便利的通知机制。操作系统并不会强制必须得一起用,你可以用NIO,但不用IO多路复用。也可以使用IO多路复用 + BIO,但这时效果还是当前线程被卡住。
所以IO多路复用和NIO是要配合一起使用才有实际意义。因此,在使用IO多路复用之前,总是先把fd设为SOCK_NONBLOC。
铺垫完以后我们先在Linux系统上来看select
man 2 select
它接受3个文件描述符的数组,分别监听读取(readfds),写入(writefds)和异常(expectfds)事件,所以select允许监控更多的文件描述符,可以把所有的连接放进去,然后内核会推演哪些连接有数据然后返回给程序,程序再去处理。
详细来说就是为select构造一个fd数组,然后用select监听了read_fds中的多个socket的读取时间。调用select后,程序会Block住,直到一个事件发生了,或者等到最大1秒钟(tv定义了这个时间长度)就返回。之后,需要遍历所有注册的fd,挨个检查哪个fd有事件到达(FD_ISSET返回true)。如果true,就说明数据已经到达了,可以读取fd了。读取后就可以进行数据的处理。
在Java中的NIO也是相似的,JAVA NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区),Selector(选择器)。
传统IO是基于流的形式,而NIO基于Channel和Buffer进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector用于监听多个通道的事件,比如:连接打开,数据到达。因此,单个线程可以监听多个数据通道。
Channel
首先说一下Channel,国内大多翻译成“通道”。Channel和IO中的Stream流是差不多一个等级的。只不过Stream是单向的,譬如:InputStream, OutputStream。而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作,Channel的主要实现有:FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel,通过看名字就可以猜出个所以然来:分别可以对应文件IO、UDP、TCP的Server和Client
Buffer
Buffer是一个缓冲区,是一个用于存储特定基本类型数据的容器,例如它的实现有:ByteBuffer、CharBuffer、 FloatBuffer、IntBuffer分别对应基本数据类型: byte、char、 float、int。可以把它理解成和Channel配合使用的,在进行读操作时,需要使用Buffer分配空间,然后将数据从Channel中读入Buffer中,对于Channel的写操作,也需要先将数据写入Buffer,然后将Buffer写入Channel中。
Selector
Selector 是NIO相对于BIO实现多路复用的基础,Selector 运行单线程处理多个 Channel,也就是说Selector可以监听多个通道的事件,比如连接打开,数据到达。
通过代码可以看到java怎么使用他们,如下:
//创建选择器
S