nio和bio的原理_深入剖析BIO和NIO底层原理
在学习IO之前需要理解四个基础概念: 同步,异步,阻塞,非阻塞
(1)同步/异步:同步是指一个时间点只能有一个程序在占用CPU,而异步是指可以有多个程序并行(可以很好的利用操作系统的多核)
(2)阻塞/非阻塞:阻塞是指操作系统发出一个调用/操作之后,必须等到此调用返回结果后才返回。而非阻塞是指在操作系统发出一个调用/操作后,不用等到执行完毕得到结果才返回,而是立即返回,然后可以执行其他的调用/操作(例如:在遇到比较耗时的IO操作时,无需等待此IO操作完成再返回)。
BIO: 同步阻塞模型
BIO会话可以理解为由三个socket组成: 一个是客户端的socket, 一个是服务端用于监听的socket,一个是用于服务端接收数据的socket。
在BIO中用于监听的API是ServerSocket类。
BIO中两个阻塞: (1)等待socket连接时,accept方法会阻塞
(2)等待客户端发送数据时,read方法会阻塞
由于这两个阻塞方法的存在,BIO时无法在单线程下处理并发的。如果硬要在程序中使用BIO来处理多个连接请求,则需要开多线程(将read操作以及后续的数据处理操作放到Thread中去执行,从而避免主线程的阻塞),但是多线程的弊端是非常大的:因为可能有一些线程其实不是活跃的,然后还是专门开了一个线程来处理它,会发生CPU资源浪费的问题。
手写一个BIO:
客户端:
public class Client {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1", 8080);
// socket.connect(new InetSocketAddress(8080));
//下面的代码也会阻塞 Scanner scanner = new Scanner(System.in);
String txt = scanner.next();
socket.getOutputStream().write(txt.getBytes());
//socket.getOutputStream().write("111".getBytes()); socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
服务端代码:
public class QQServer {
static byte[] bytes = new byte[1024];
public static void main(String[] args) {
try {
//用于监听 ServerSocket serverSocket = new ServerSocket();
//绑定服务器的ip和端口,ip为本机,所以省略 serverSocket.bind(new InetSocketAddress(8080));
while(true) {
System.out.println("wait conn");
//监听,会阻塞->当前线程回放弃CPU线程,就意味着不会再向下执行了 //这个socket是专门用于与客户端通信的socket Socket socket = serverSocket.accept();
System.out.println("conn success");
System.out.println("wait data");
//专门用于接受客户端发来的byte数组,返回一个int类型的值,用于表示返回多少字节 int read = socket.getInputStream().read(bytes);
System.out.println("data success");
String content = new String(bytes);
System.out.println(content);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
由于阻塞的原因,无法处理批量线程。所以引出NIO模型:
NIO的逻辑比较复杂,涉及到操作系统的内核调用,先来个简单的,手写一个通过应用程序进行轮训的:
客户端:
public class Client {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1", 8080);
// socket.connect(new InetSocketAddress(8080));
//下面的代码也会阻塞 Scanner scanner = new Scanner(System.in);
String txt = scanner.next();
socket.getOutputStream().write(txt.getBytes());
//socket.getOutputStream().write("111".getBytes()); socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}服务端:
public class QQServerNIO {
static byte[] bytes = new byte[1024];
static List list = new ArrayList();
//申请对外内存 static ByteBuffer byteBuffer = ByteBuffer.allocate(512);
public static void main(String[] args) {
try {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
//设置为serversocketchannel为非阻塞 serverSocketChannel.configureBlocking(false);
while(true){
//非阻塞 SocketChannel socketChannel = serverSocketChannel.accept();
if(socketChannel == null){
Thread.sleep(500);
System.out.println("no conn");
for(SocketChannel client : list) {
int k = client.read(byteBuffer);
if (k > 0) {
byteBuffer.flip();
System.out.println(byteBuffer.toString());
}
}
}else{
System.out.println("conn----");
socketChannel.configureBlocking(false);
list.add(socketChannel);
for(SocketChannel client : list){
int i = client.read(byteBuffer);
if(i > 0){
byteBuffer.flip();
System.out.println(byteBuffer.toString());
}
}
}
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
注意理解BIO和NIO的实现区别!
NIO是将read和accept都设置为非阻塞了!
但是这样做存在一个问题:如果现在list中有100000个socket,那么每次循环都要用for去轮训这么多元素吗? so stupid!
所以最好不要程序去主动轮训,而是能够主动感知哪个socket有新的数据发送过来了!
所以就进入到了真实使用的NIO,它是不将轮训交给应用程序,而是交给操作系统。linux内核会调用epoll方法。
接下来介绍IO多路复用的三种机制:
(1)select
(2) poll
(3) epoll
select、poll、epoll本质上也都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
在接下来深入介绍之前,先回顾一下Linux操作系统中的几个基本概念:
(1)内核态/用户态:
电脑启动后,第一个程序是操作系统内核(kernel),内核时负责管理计算机的硬件(eg:网卡),应用程序是不能直接与硬件交互的。
当启动之后,os内核进入内存中之后,会注册一个GDT(全局描述符表)—》 它会把内核进程所在的内存空间划分出来,剩余的予以划分。
而如果应用程序想去操作硬件的话,可以调用内核提供的”系统调用”函数(system call),但是又没法直接调,因为处于保护模式(这个方法是在内核的内存空间内),所以有个中断系统。(中断分为软中断和硬中断,而系统调用是软中断),外设例如鼠标的移动,属于硬中断),CPU会根据终端号会有例如网卡的驱动
结论: 若一个程序想要调IO,则必须要经过操作系统内核。
(2)文件描述符:
多路复用实际就是一个进城可以监视多个文件描述符(fd),一旦某个描述符就绪(无论读就绪还是写就绪),能够通知程序进行相应读或写的处理。
select,poll和epoll的本质都是同步I/O(异步IO无需自己负责读写)
Select详解:
看下面程序:
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 5);
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
if(fds[i] > max)
max = fds[i];
}
while(1){
FD_ZERO(&rset);
for (i = 0; i< 5; i++ ) {
FD_SET(fds[i],&rset);
}
puts("round again");
select(max+1, &rset, NULL, NULL, NULL);
for(i=0;i<5;i++) {
if (FD_ISSET(fds[i], &rset)){
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}
select函数的参数:
第一个参数n = max + 1,即为最大文件描述符+ 1 (代表总共的bitmap连接的数量),
第二个参数是读文件描述符集合,
第三个参数是写文件描述符集合,
第四个参数是异常文件描述符集合,
第五个参数是超时时间。
上面程序的逻辑:在while(True)循环之前是建立socket连接,while循环中主要做了以下几个事情:
(1)每次重新循环的时候,都将rset清0
(2)每次将socket连接通过for-loop来放到rset中
(3)循环遍历,看看那个链接中有值
注: &rset其实是一个bitmap,即使1024位的0,1序列
这里的FD置位是指rset中对应的那一位,而不是真正的fds中的元素
这种方式有一定的缺点:
fd_set是利用数组实现的
1.fd_size 有限制 1024 bitmap
fd【i】 = accept()
2.fdset不可重用,新的fd进来,重新创建
3.用户态和内核态拷贝产生开销
4.O(n)时间复杂度的轮询
成功调用返回结果大于 0,出错返回结果为 -1,超时返回结果为 0,具有超时时间
(2)poll函数:
poll中定义了一个结构体pollfd
结构体中包括:(1)fd (2)event:即poll的事件 (pollin表示读,pollout表示写)
(3)revents:当有数据过来的时候 ,会将pollfd中的revents置位(而不是像select中将bitmap置位),revents默认是0,有读的事件进来,则置位为pollin,然后poll返回。
看一段C ++代码:
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN;
}
sleep(1);
while(1){
puts("round again");
poll(pollfds, 5, 50000);
for(i=0;i<5;i++) {
if (pollfds[i].revents & POLLIN){
pollfds[i].revents = 0;
memset(buffer,0,MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}
程序继续往下,if revents == pollin,则读数据,将revents置位为0,所以每次pollfds都是一样的,可以进行重用!!!
从而解决了select中的前两个缺点!!
(3)epoll函数:
不需要轮询,时间复杂度为O(1)
看一段C++代码理解一下:
struct epoll_event events[5];
int epfd = epoll_create(10);
...
...
for (i=0;i<5;i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while(1){
puts("round again");
nfds = epoll_wait(epfd, events, 5, 10000);
for(i=0;i
memset(buffer,0,MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
epoll_create 创建一个白板 存放fd_events
epoll_ctl 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上
epoll_wait 通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。
总而言之,epoll解决了所有select的缺点。