参考:同步、异步、阻塞、非阻塞
同步和异步指的是:当前线程是否需要等待方法调用执行完毕
阻塞和非阻塞指的是:当前接口数据还未准备就绪时,当前线程是否被阻塞挂起
关于 I/O 的阻塞、非阻塞、同步、异步:
关于异步的实现方式之一就是多线程,关于线程的创建可以参考 线程创建的过程
所以在使用多线程时通常需要使用线程池,好处:
① 降低资源的消耗,通过重复利用已经创建的线程降低线程创建和销毁造成的消耗。
② 提高相应速度,当任务到达的时候,任务可以不需要等到线程创建就能立刻执行,减少了用户空间栈的创建、减少系统调用(clone等)、用户态内核态切换的过程等
③ 提高线程的可管理性,线程是稀缺资源,使用线程池可以统一的分配、调优和监控。
同步阻塞式IO,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理后续的读。即一个线程只能连接一个Socket连接, 处理一个Socket的请求,accept时阻塞等待有客户端连接, read时阻塞等待有数据输入。如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。
相关代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class SocketBIO {
public static void main(String[] args) throws Exception {
ServerSocket server = new ServerSocket(8090,20); //netstat -natp listen socket
System.out.println("step1: new ServerSocket(8090) ");
while (true) {
Socket client = server.accept(); //阻塞1
System.out.println("step2:client\t" + client.getPort());
new Thread(new Runnable(){
public void run() {
InputStream in = null;
try {
in = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
while(true){
String dataline = reader.readLine(); //阻塞2
if(null != dataline){
System.out.println(dataline);
}else{
client.close();
break;
}
}
System.out.println("客户端断开");
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
缺点:
同步非阻塞式IO,一个线程可以连接多个Socket连接,处理多个Socket的请求; 更好的利用了线程, 避免因一个文件阻塞而导致整个线程啥也不干了。
accept时得不到客户端连接 和 read时得不到数据会直接返回默认值负数, 不会在那一直阻塞着等待。
相关代码
import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;
public class SocketNIO {
public static void main(String[] args) throws Exception {
LinkedList<SocketChannel> clients = new LinkedList<>();
ServerSocketChannel ss = ServerSocketChannel.open(); //服务端开启监听:接受客户端
ss.bind(new InetSocketAddress(8090),2);
ss.configureBlocking(false); //重点 OS NONBLOCKING!!! //只让接受客户端 不阻塞
// 2729 fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK) = 0
// ss.setOption(StandardSocketOptions.TCP_NODELAY, false);
// StandardSocketOptions.TCP_NODELAY
// StandardSocketOptions.SO_KEEPALIVE
// StandardSocketOptions.SO_LINGER
// StandardSocketOptions.SO_RCVBUF
// StandardSocketOptions.SO_SNDBUF
// StandardSocketOptions.SO_REUSEADDR
while (true) {
//接受客户端的连接
// Thread.sleep(1000);
SocketChannel client = ss.accept(); //不会阻塞? -1 NULL
//accept 调用内核了:1,没有客户端连接进来,返回值?在BIO 的时候一直卡着,但是在NIO ,不卡着,返回-1,NULL
//如果来客户端的连接,accept 返回的是这个客户端的fd 5,client object
//NONBLOCKING 就是代码能往下走了,只不过有不同的情况
if (client == null) {
// System.out.println("null.....");
} else {
client.configureBlocking(false); //重点 socket(服务端的listen socket<连接请求三次握手后,往我这里扔,我去通过accept 得到 连接的socket>,连接socket<连接后的数据读写使用的> )
int port = client.socket().getPort();
System.out.println("client..port: " + port);
clients.add(client);
}
ByteBuffer buffer = ByteBuffer.allocateDirect(4096); //可以在堆里 堆外
//遍历已经链接进来的客户端能不能读写数据
for (SocketChannel c : clients) { //串行化!!!! 在这里可以使用多线程!!
int num = c.read(buffer); // >0 -1 0 //不会阻塞
if (num > 0) {
buffer.flip();
byte[] aaa = new byte[buffer.limit()];
buffer.get(aaa);
String b = new String(aaa);
System.out.println(c.socket().getPort() + " : " + b);
buffer.clear();
}
}
}
}
}
优势:规避了多线程带来的内存&cpu问题
弊端:连接数一多,假如只有一个连接有数据,则在用户空间每遍历一次都要对每个连接发起一次系统调用recv,从用户态切换到内核态,那么其他连接的遍历是无意义的,时间复杂度是O(N)。
同步非阻塞式IO,是对NIO的优化。"多路"是指多个Socket连接, “复用” 指的是复用同一个线程
select其实就是把NIO中用户态要遍历的fd数组(List集合)拷贝到了内核态,并且select能感知到有事件发生, 让内核态来遍历,返回fds的状态,再在用户空间去遍历这些fds,进行相应的操作
select监视的socket默认是1024个,poll没有这个限制而已
优势:通过一次系统调用,把fds传递给内核,内核进行遍历,减少了系统调用的次数,这里的调用是O(1)的时间复杂度,然后多路复用器返回的是各连接的状态
弊端:
epoll可以解决上述的两个问题
流程
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
//检查一下rdllist有没有数据
int n = epoll_wait(...)
for(接收到数据的socket){ //这里不是所有socket
//接收数据处理
}
}
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,如下所示:
struct eventpoll {
...
/*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
也就是这个epoll监控的事件*/
struct rb_root rbr;
/*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
struct list_head rdllist;
...
};
调用epoll_create时,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。若是多核cpu,这个将对应的fs放入到双向链表中的操作用另一个cpu操作,这样与epoll_ctl或者epoll_wait是并行的。
当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。
总结:
优势:
注意:无论是BIO还是NIO还是多路复用器,都是需要程序自己读取数据的,那么这些I/O模型都是同步I/O模型,只不过有阻塞和非阻塞之分,不过像Windows 的 IOCP,内核有线程将数据拷贝到程序的内存空间这就是异步I/O模型。
相关代码
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
public class SocketMultiplexingSingleThreadv2 {
private ServerSocketChannel server = null;
private Selector selector = null; //linux 多路复用器(select poll epoll) nginx event{}
int port = 9090;
public void initServer() {
try {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
selector = Selector.open(); // select poll *epoll
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}
public void start() {
initServer();
System.out.println("服务器启动了。。。。。");
try {
while (true) {
// Set keys = selector.keys();
// System.out.println(keys.size()+" size");
while (selector.select(50) > 0) {
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectionKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isAcceptable()) {
acceptHandler(key);
} else if (key.isReadable()) {
// key.cancel(); //现在多路复用器里把key cancel了
System.out.println("in.....");
key.interestOps(key.interestOps() | ~SelectionKey.OP_READ);
readHandler(key);//还是阻塞的嘛? 即便以抛出了线程去读取,但是在时差里,这个key的read事件会被重复触发
} else if(key.isWritable()){ //我之前没讲过写的事件!!!!!
//写事件<-- send-queue 只要是空的,就一定会给你返回可以写的事件,就会回调我们的写方法
//你真的要明白:什么时候写?不是依赖send-queue是不是有空间
//1,你准备好要写什么了,这是第一步
//2,第二步你才关心send-queue是否有空间
//3,so,读 read 一开始就要注册,但是write依赖以上关系,什么时候用什么时候注册
//4,如果一开始就注册了write的事件,进入死循环,一直调起!!!
// key.cancel();
key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
writeHandler(key);
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void writeHandler(SelectionKey key) {
new Thread(()->{
System.out.println("write handler...");
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.flip();
while (buffer.hasRemaining()) {
try {
client.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
buffer.clear();
// key.cancel();
// try {
client.shutdownOutput();
//
client.close();
//
// } catch (IOException e) {
// e.printStackTrace();
// }
}).start();
}
public void acceptHandler(SelectionKey key) {
try {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel client = ssc.accept();
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(8192);
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("-------------------------------------------");
System.out.println("新客户端:" + client.getRemoteAddress());
System.out.println("-------------------------------------------");
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
new Thread(()->{
System.out.println("read handler.....");
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
try {
while (true) {
read = client.read(buffer);
System.out.println(Thread.currentThread().getName()+ " " + read);
if (read > 0) {
key.interestOps( SelectionKey.OP_READ);
client.register(key.selector(),SelectionKey.OP_WRITE,buffer);
} else if (read == 0) {
break;
} else {
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
public static void main(String[] args) {
SocketMultiplexingSingleThreadv2 service = new SocketMultiplexingSingleThreadv2();
service.start();
}
}
用epoll函数来实现IO多路复用, 将连接信息和事件放到队列中, 事件分派器将队列中的事件分发给事件处理器(这一步是单线程的, 所以Redis核心是单线程模型)
Redis基于Reactor模式开发了网络(文件)事件处理器:
具体的redis流程可以看 Redis 究竟是单线程还是多线程呢?
Reactor 模式也叫做反应器设计模式,是一种为处理服务请求并发提交到一个或者多个服务处理器的事件设计模式。当请求抵达后,通过服务处理器将这些请求采用多路分离的方式分发给相应的请求处理器。
主要有以下三种模型:
具体参考以下文章:
Java IO篇:什么是 Reactor 网络模型?
Reactor 模式
参考:
万字长文带你深入理解netty
Netty实战入门详解
Netty是一款基于NIO(Nonblocking I/O,非阻塞IO)开发的网络通信框架, 对 JDK 自带的 NIO 的 API 进行了封装,像ElasticSearch就是使用了Netty框架。
Netty的线程模型就是类似于主从 Reactor 多线程模型
1、如何理解NioEventLoop和NioEventLoopGroup
NioEventLoop实际上就是工作线程,可以直接理解为一个线程。NioEventLoopGroup是一个线程池,线程池中的线程就是NioEventLoop。
实际上bossGroup中有多个NioEventLoop线程,每个NioEventLoop绑定一个端口,也就是说,如果程序只需要监听1个端口的话,bossGroup里面只需要有一个NioEventLoop线程就行了。
2、每个NioEventLoop都绑定了一个Selector,所以在Netty的线程模型中,是由多个Selecotr在监听IO就绪事件。而Channel注册到Selector。
3、一个Channel绑定一个NioEventLoop,相当于一个连接绑定一个线程,这个连接所有的ChannelHandler都是在一个线程中执行的,避免了多线程干扰。更重要的是ChannelPipline链表必须严格按照顺序执行的。单线程的设计能够保证ChannelHandler的顺序执行。
4、一个NioEventLoop的selector可以被多个Channel注册,也就是说多个Channel共享一个EventLoop。EventLoop的Selecctor对这些Channel进行检查。
优点:
1、高性能
2、可靠性
磁盘IO延时:
读操作:
当应用程序调用read()方法时,操作系统检查内核缓冲区是否存在需要的数据,如果存在,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用;如果内核缓冲区没有需要的数据,则通过DMA方式从磁盘中读取数据到内核缓冲区(DMA Copy),然后把内核空间的数据copy到用户空间(Cpu Copy)(上图绿色实线部分)
写操作:
当应用程序调用write()方法时,应用程序将数据从用户空间copy到内核空间的缓冲区(如果用户空间没有相应的数据,则需要从磁盘–>内核缓冲区–>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候把数据再写到磁盘,由操作系统决定。操作系统将要写入磁盘的数据先保存于系统为写缓存分配的内存空间中,当保存到内存池中的数据达到一个程度时,便将数据保存在硬盘中。这样可以减少实际的磁盘操作,有效的保护磁盘免于重复的读写操作而导致的损坏,也能减少写入所需的时间。除非应用程序显式的调用了sync命令,立即把数据写入磁盘。
如果应用程序没准备好写的数据,则必须先从磁盘读取数据才能执行写操作,这时会涉及到四次缓冲区的copy:
a、第一次从磁盘的缓冲区读取数据到内核缓冲区(DMA Copy);
b、第二次从内核缓冲区复制到用户缓冲区(Cpu Copy);
c、第三次从用户缓冲区写到内核缓冲区(Cpu Copy);
d、第四次从内核缓冲区写到磁盘(DMA Copy);(上图绿色实线部分双向箭头)
缓存I/O的缺点:在缓存I/O机制中,DMA方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输。这样的话,数据 在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。对于某些特殊的应用程序来说,避开操作系统内核缓冲区,而直接在应用程序地址空间和磁盘之间传输数据,会比使用操作系统内核缓冲区获取更好的性能,因此引入"Direct I/O"。
凡是通过直接 I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。Direct I/O 本质是 DMA 设备把数据从用户空间拷贝到设备,或是从设备拷贝到用户空间。
进程在打开文件的时候设置对文件的访问模式为 O_DIRECT ,这样就等于告诉操作系统进程在接下来使用 read() 或者 write() 系统调用去读写文件的时候使用的是直接 I/O 方式,所传输的数据均不经过操作系统内核缓存空间。
直接I/O优点:
直接I/O缺点:
通常直接IO与异步IO结合使用,会得到比较好的性能。
mmap 本质是内存共享机制,它把 page cache 地址空间映射到用户空间,换句话说,mmap 是一种特殊的 Buffered I/O,这种方式的目的同样是减少数据在用户空间和内核空间之间的拷贝操作,当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。
参考:Linux I/O操作fsync后数据就安全了么(fsync、fwrite、fflush、mmap、write barriers详解)