前言
同步异步:
同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)
同步:在发出一个调用时,在没有得到结果之前,该调用就不返回。一旦调用返回,就得到返回值了。调用者主动等待这个调用的结果。
异步:调用在发出之后就直接返回了,没有立刻得到返回结果。在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
I/O
通常来说,IO操作包括:对硬盘的读写、对socket的读写以及外设的读写,并且需要进行用户空间和内核空间的区分(用户空间就是普通的用户进程,内核空间就是内核进程,只有内核空间才可以直接范围磁盘等物理 I/O设备,操作系统层面的了)
用户空间产生一个读请求,请求再转交由内核空间执行
1. 内核检查读取的数据是否就绪
2. 如果就绪,内核将数据从内核空间复制到用户空间(内存上拷贝)
阻塞I/O与非阻塞I/O
阻塞I/O:内核在检查数据未就绪时,会一直等待,直到数据就绪。
非阻塞I/O:如果数据没有就绪,则会返回一个标志信息告知用户线程当前要读的数据没有就绪
它们的区别在于I/O的第一阶段,阻塞是选择等待,非阻塞是返回一个标志信息
那么非阻塞I/O的优势在哪里呢?使用阻塞I/O处理网络连接时,有10000个连接就要开10000个线程,无论有没有数据到来,处理某一连接的线程必须“忠实地阻塞”。而非阻塞I/O就不需要这样,它可以维护一个1000个线程的线程池,当有数据就绪时,启动一个线程去接受数据,当没有数据时,线程不需要等待,直接就可以回到池中,等待被调度到去接受其它连接。因此非阻塞I/O非常适合连接多但传输的数据内容不大的情况,如果连接少数据多,阻塞I/O更容易编程
同步I/O和异步I/O
事实上,同步IO和异步IO模型是针对用户线程和内核的交互来说的,即数据是否就绪的消息传递机制。
同步IO:当用户发出IO请求操作之后,如果数据没有就绪,需要通过用户线程或者内核不断地去轮询数据是否就绪,当数据就绪时,再将数据从内核拷贝到用户线程。
异步IO:只有IO请求操作的发出是由用户线程来进行的,内核自动完成检查数据是否就绪和将数据拷贝到用户空间的过程,然后发送通知告知用户线程IO操作已经完成。
上述概念参考博文:https://www.cnblogs.com/aeolian/p/10773786.html下面从Java代码中感受BIO与NIO
BIO(同步阻塞式IO)
Java中ServerSocket和Socket就是阻塞式的。代码如下
服务器端:
public class Server {
public static void main(String[] args) {
try {
//建立服务器 BIO这个Socket是专门用于侦听连接的。并不是用来与客户端通信的
ServerSocket server = new ServerSocket(54188);
byte[] bytes = new byte[1024];
while(true) {
System.out.println("等待连接");
long start = System.currentTimeMillis();
/**
* 侦听客户端连接 注意:此处会阻塞!!!(放弃CPU资源)如果侦听不到连接程序就阻塞在这不会往下进行
* 这个Socket才是专门用于与客户端通信的
*/
Socket client = server.accept();
long end = System.currentTimeMillis();
System.out.println("连接成功,用时:" + (end - start) + " 毫秒");
//注意:此处会阻塞!!! read方法返回的是读取的字节数
int factLen = client.getInputStream().read(bytes);
System.out.println("读取成功:" + new String(bytes, 0, factLen));
System.out.println("读取数据用时 : " + (System.currentTimeMillis() - end)+ " 毫秒");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
上述为简单的测试服务器端代码。需要注意的是accept()方法和read()方法都是会阻塞线程的,服务器如果侦听不到连接,或者读取不到消息程序就无法往下进行。
public class Client {
public static void main(String[] args) {
try {
Socket socket = new Socket();
SocketAddress address = new InetSocketAddress("127.0.0.1", 54188);
socket.connect(address); //连接服务器
//休息一会再发数据
Thread.sleep(3000);
socket.getOutputStream().write("===你好===".getBytes());
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
客户端做的事很简单就是连接服务器然后发送一条消息,先启动服务器端再启动客户端看下结果:
启动两次客户端确实可以看出accept和read的阻塞性。如果服务器端那样写,是不能支持并发地访问服务器的,即如果将客户端代码中的发数据注释起来。开启两次客户端则会出现下面的情况
第二次的时候并没有连接上。因为第一次时候客户端连接上之后一直没有发送消息,导致服务器端程序一直阻塞在read()方法上,根本就没有办法去侦听下一个连接,程序运行不到那里。解决这个问题的办法就是:多线程处理。服务器端利用单独的一个线程来专门侦听连接(只负责侦听连接)。如果发现每有一个客户端连接上来就再开一个专门用作与该客户端的通信的线程。这样就能保证每个客户端都能连接得上,也能确保与每个客户端都能实时进行交互。
但是又有一个问题。假如10000个客户端都连接上服务器。可能真正做数据交互的就那么100个。 其他就只想单纯的连接一下。即使是这样服务器端也开启了大量线程(且是无意义的线程)造成资源的巨大浪费。
如果就上述服务器端代码而言client.getInputStream().read(bytes); 这行代码不阻塞。那么不需要多线程就可以让客户端们并发的访问服务器。单单这还不够。因为read()方法不阻塞了。但是accept()方法还是阻塞的呀。确实是客户端们可以连接上了,但是每次连接上read()方法不阻塞。服务器端又去侦听连接了(会阻塞)。再侦听连接期间,一直在accept()上阻塞着,就执行不到read()方法上,那就不能实时读取到客户端发来的消息呀。所以如果要达到并发的效果,就需要对accpet()和read()方法都解阻塞。
假设现在accept()和read()都解阻塞了,要想与每个连接上的客户端都能进行交互,那就需要保存每个客户端的Socket。将其放入到集合中,不停地遍历集合即可,如果有收到消息就处理,没有就看下一个客户端是否发过来消息。accpet()也解阻塞,如果有客户端请求连接就将其加入Socket池,如果没有就进行下面的执行。大概过程如下:
public class WServer {
static List<Socket> clientList = new ArrayList<Socket>();
public static void main(String[] args) {
try {
ServerSocket server = new ServerSocket(54188);
/**
* 假如说 有一个方法可以将server设置为非阻塞假如是这样 server.fzs(true)。那么accept()和read()就可以解阻塞。
* 即程序不会阻塞在accept()和read()上。while循环就可以一直快速运行。
* 可以将每次连接到的客户端Socket保存起来,要不然循环一次就会丢掉上一次的连接。
*/
byte[] bytes = new byte[1024];
while(true) {
/**每次轮询“客户端连接池”如果有人发消息,我就处理。
* for(Socket clientItem : clientList) {
* int factLen = client.getInputStream().read(bytes);
* System.out.println("读取成功:" + new String(bytes, 0, factLen));
* }
*/
Socket client = server.accept();
/**我可以将连接上的客户端保存起来,如果没有连接,那也不阻塞,程序往下执行
* if(client != null) {
* clientList.add(client);
* }
*/
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
而Java没有在Socket基础上增加这种功能,而是提供了一个专门的类来解决这个问题。
NIO
BIO要处理并发访问,就需要多线程。NIO的设计是避免线程资源的浪费。单线程处理并发。
在Java中ServerSocketChannel和SocketChannel是可以设置为非阻塞的。
public class ServerSocketChannelTest {
static List<SocketChannel> clientList = new ArrayList<SocketChannel>();
//ByteBuffer一个字节缓冲区,capacity 指定缓冲区大小
static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
public static void main(String[] args) {
try {
//打开服务器连接通道。
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false); //服务器通道设置为非阻塞
SocketAddress address = new InetSocketAddress("127.0.0.1", 54188);
//设置服务器地址
server.bind(address);
while(true) {
//此处可以轮询SocketChannel检测是否有客户端发送来消息
SocketChannel client = server.accept();
if(client != null) {
System.out.println("连接成功");
clientList.add(client);
//将此通道设置为非阻塞通道
client.configureBlocking(false);
}
System.out.println("=================");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
执行结果:
可以体现出accept()已经被解阻塞了。不过上述没有进行“轮询操作”。主要是这里牵扯性能问题:
假如现在有10000个客户端连接上了,没问题。但是活跃的只有1000个。那么岂不是每次都要在程序中进行无意义的轮询9000次。这显然是不好的。这样性能就不好。而且如果将轮询写在上述程序中,那是直接在Jvm上进行轮询。而且IO设备是只能由操作系统直接访问。如果直接交给内核处理那么效率就会提升很多。所以为了提高这个“轮询的性能”。Java又提出解决办法。
selector和epoll 多路复用函数
其实这两个函数本质上是C语言实现的。Java调用的就是底层的C函数。
由于操作系统的不同,所以提供了两个函数,selector是基于windows系统的。epoll是基于Liunx系统的(Liunx也可以使用selector)。
针对selector:它并不是非常完美的解决了上述问题,它也需要轮询10000次,只不过将轮询处理交给操作系统去做。这样性能也能提升。它内部维持了一个数据结构(bitmap),用来存储已经接受的socket对象。然后将此数据复制一份,交给内核去轮训每个socket对象是否有期待的事件发生,如果有就会进行置位(用户态到内核态的复制)。然后返回给用户态,由用户态完成事件的处理。
对于epoll:epoll(Linux下):epoll针对selector进行了优化,采用红黑树来存储accept的socket对象,同时还维持着一个list,存放有发生的事件的socket以及事件,然后将此任务队列返回。用户态无需遍历所有的socket对象。