相关知识
- Linux中一切类型都被抽象成文件,故Linux都是文件描述符
- 内存被划分为:内核态和用户态,数据在内核态和用户态之间拷贝,内核态可以访问用户态数据,反之不可以
- 只要内核可以操作硬件资源(网卡、磁盘等),内核提供syscall函数,故用户空间的程序,通过调用系统函数来访问操作系统软硬件资源
文件描述符
-
文件描述符是内核创建的方便管理已打开文件的索引,指代被打开的文件。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
image.png -
所有执行I/O操作的系统调用都通过文件描述符,故提供网络IO能力的不是Java/Python高级语言而是Linux Kernel
image.png
Unix共5种I/O模型
- 阻塞I/O
- 非阻塞I/O
- I/O多路复用
这两个本节先不做考虑
- 信号驱动
- 异步I/O
几种模型对比图:
image.png
阻塞IO
阻塞IO非阻塞IO
非阻塞IOI/O多路复用
I/O多路复用是指把非阻塞IO中需要用户线程在每个IO通路上,不断轮询IO状态,来判断是否有可处理的数据-->改为提出一个单独的线程来对其进行管理。
I/O多路复用
多路复用在内核中提供了select,poll,epoll三种方式:
- select 和 poll 监听文件描述符list,进行一个线性的查找 O(n)
- epoll: 使用了内核文件级别的回调机制O(1)
select
- select只能处理有限个socket(不同系统参数:1024/2048)
-
select监控socket时不能准确告诉用户是哪个socket有可用数据,需要轮询判断
select
poll
-
采用链表实现,取消了文件个数的限制
poll
epoll
- epoll推出是为了替换select
- 目录/proc/sys/fs/epoll/max_user_watches表示用户能注册到epoll实例中的最大文件描述符的数量限制。
image.png
epoll 关键函数:
epoll_create创建epoll
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
- epoll_create创建一个epoll实例
- 返回值【success:返回一个非0 的未使用过的最小的文件描述符;error:-1 errno被设置】
如果不需要使用这个描述符,需要close关闭,否则会耗尽内存。
- epoll_create(int size)
size的作用是告诉内核需要使用多少个文件描述符。内核会使用size的大小去申请对应的内存,在linux内核版本大于2.6.8后,size参数就被弃用了,但是传入的值必须大于0,内核会动态的申请需要的内存。
- epoll_create1(int flags)
flags=0,等价于poll_create(0)
EPOLL_CLOEXEC:这是这个参数唯一的有效值,如果这个参数设置为这个。那么当进程替换映像的时候会关闭这个文件描述符,这样新的映像中就无法对这个文件描述符操作,适用于多进程编程+映像替换的环境里
epoll_ctl设置epoll事件
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
调用epoll_ctl函数能够控制给定的文件描述符epfd指向的epoll实例,op是添加事件的类型,fd是目标文件描述符。
- EPOLL_CTL_ADD 在epfd中注册指定的fd文件描述符并能把event和fd关联起来。
- EPOLL_CTL_MOD 改变*** fd和evetn***之间的联系。
- EPOLL_CTL_DEL 从指定的epfd中删除fd文件描述符。在这种模式中event是被忽略的,并且为可以等于NULL。
event这个参数是用于关联制定的fd文件描述符的。它的定义如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events这个参数是一个字节的掩码构成的。下面是可以用的事件:
- EPOLLIN - 当关联的文件可以执行 read ()操作时。
- EPOLLOUT - 当关联的文件可以执行 write ()操作时。
- EPOLLRDHUP - (从 linux 2.6.17 开始)当socket关闭的时候,或者半关闭写段的(当使用边缘触发的时候,这个标识在写一些测试代码去检测关闭的时候特别好用)
- EPOLLPRI - 当 read ()能够读取紧急数据的时候。
- EPOLLERR - 当关联的文件发生错误的时候,epoll_wait() 总是会等待这个事件,并不是需要必须设置的标识。
- EPOLLHUP - 当指定的文件描述符被挂起的时候。epoll_wait() 总是会等待这个事件,并不是需要必须设置的标识。当socket从某一个地方读取数据的时候(管道或者socket),这个事件只是标识出这个已经读取到最后了(EOF)。所有的有效数据已经被读取完毕了,之后任何的读取都会返回0(EOF)。
- EPOLLET - 设置指定的文件描述符模式为边缘触发,默认的模式是水平触发。
- EPOLLONESHOT - (从 linux 2.6.17 开始)设置指定文件描述符为单次模式。这意味着,在设置后只会有一次从epoll_wait() 中捕获到事件,之后你必须要重新调用 epoll_ctl() 重新设置。
epoll_wait等待epoll事件
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
epoll_wait是用来等待epfd中的事件,events指向调用者可以使用的事件的内存区域。maxevents告知内核有多少个events,必须大于0.
timeout阻塞毫秒数,timeout=-1会无限期阻塞下去,timeout=0就算没有任何事件,也会立刻返回。
官方demo
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* Code to set up listening socket, 'listen_sock',
* (socket(), bind(), listen()) omitted */
epollfd = epoll_create1( 0 );
if ( epollfd == -1 )
{
perror( "epoll_create1" );
exit( EXIT_FAILURE );
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, listen_sock, &ev ) == -1 )
{
perror( "epoll_ctl: listen_sock" );
exit( EXIT_FAILURE );
}
for (;; )
{
nfds = epoll_wait( epollfd, events, MAX_EVENTS, -1 );
if ( nfds == -1 )
{
perror( "epoll_wait" );
exit( EXIT_FAILURE );
}
for ( n = 0; n < nfds; ++n )
{
if ( events[n].data.fd == listen_sock )
{
conn_sock = accept( listen_sock,
(struct sockaddr *) &local, &addrlen );
if ( conn_sock == -1 )
{
perror( "accept" );
exit( EXIT_FAILURE );
}
setnonblocking( conn_sock );
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, conn_sock,
&ev ) == -1 )
{
perror( "epoll_ctl: conn_sock" );
exit( EXIT_FAILURE );
}
} else {
do_use_fd( events[n].data.fd );
}
}
}
BIO
在不同的java版本中存在差异
Java5:
accept会阻塞直到数据到达
Java6/Java7/Java8:
poll会一直阻塞,直到有一个事件event到达,再去调用accept去接受新的连接
以socket.read()为例,传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据后返回。
对于NIO,如果TCP RecvBuffer里没有数据,函数会返回0,如果有数据,就把数据从网卡读到内存,并且返回给用户。
最新的AIO,不但等待数据是非阻塞的,就连数据从网卡到内存的过程也是异步的。
通俗来讲就是,BIO里用户关心“我要读”,NIO里用户关心“我能读”,AIO里用户关心“读完了”。
NIO
NIO(Non-blocking I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,NIO中socket的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,只有真正的I/O操作是同步阻塞的。
NIO由三部分组成:
- Buffer缓冲区
- Channel通道
- Selector 选择器
传统的IO是基于字节流和字符流的,而NIO基于Channel和Buffer进行操作,数据总是从通道读到缓冲区,或者从缓冲区写入通道中。选择器用于监听多个通道的事件(如:连接打开,数据到达),因此可以监听多个数据通道。
NIOJava NIO Buffer用于和NIO Channel交互,我们从Channel中读取数据到Buffer里,从Buffer把数据写入到Channel。
当写入数据到Buffer中时,Buffer会记录已经写入的数据大小。当需要读数据时,通过flip()方法把Buffer从写模式调整为读模式;
Buffer的实现
java.nio.Buffer 中定义了4个成员变量:
- mark:初始值为-1,用于备份当前的position
- position:初始值为0,用于记录当前可以写入或读取数据的位置,当写入或读取一个数据后,position向前移动到下一个位置
- limit:写模式下,limit表示最多能往Buffer里写多少数据,等于capacity;读模式下,limit表示最多可以读取多少数据
- capacity:缓存数组大小
读写模式,通过调用flip切换读写,实际上是调整position,limit的值
读写模式切换
Buffer对象
实际上,会存在一块内存区,用来写入数据,稍后读取处理
以字节缓冲区为例,ByteBuffer是一个抽象类,不能直接通过new语句来创建,只能通过一个static方法allocate来创建:
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
在JVM中创建对象是放入堆中,JVM垃圾回收时,会把堆中的对象,在不同的分区中来回拷贝,内存地址会频繁发生变化,本身Buffer会频繁读写,这样会导致内存整理繁琐 ,Direct Buffer脱离JVM对象管理而存在,直接来看,allocate()创建了一个HeapByteBuffer,调用allocateDirect()创建的是DirectByteBuffer,看名字,一个是堆内存,一个是直接内存。
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
allocateDirect使用了 unsafe.allocateMemory 来分配内存,而 allocateMemory 是一个 native 方法,会调用 malloc 方法在 JVM 外分配一块内存空间。
结合事件模型使用NIO同步非阻塞特性
回忆BIO,能不能进行读写,只能“傻等”,即使可以估算,也没办法在read或write函数中返回,这两个函数无法进行有效的终端,所以除了多开线程,没有好的办法利用CPU。
NIO的读写函数可以立刻返回,这给我们不开线程利用CPU提供了好的机会:如果一个连接不能读写(socket.read()返回0或者socket.write()返回0),我们可以把这件事记下来,记录的方式通常是在Selector上注册标记位,然后切换到其他就绪的(Channel)继续进行读写。
NIO的主要事件有几个:读就绪、写就绪、有新连接到来。
我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器:我对这个事件感兴趣。对于写操作,就是写不出去的时候对写事件感兴趣;对于读操作,就是完成连接和系统没有办法承载新读入的数据的时;对于accept,一般是服务器刚启动的时候;而对于connect,一般是connect失败需要重连或者直接异步调用connect的时候。
其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。
其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。
注意,select是阻塞的,无论是通过操作系统的通知(epoll)还是不停的轮询(select,poll),这个函数是阻塞的。所以你可以放心大胆地在一个while(true)里面调用这个函数而不用担心CPU空转。
NIO总结
- 事件驱动模型
- 避免多线程
- 单线程处理多任务
- 非阻塞I/O,I/O读写不再阻塞,而是返回0
- 基于block的传输,通常比基于流的传输更高效
- 更高级的IO函数,zero-copy
- IO多路复用大大提高了Java网络应用的可伸缩性和实用性
伪代码:
interface ChannelHandler{
void channelReadable(Channel channel);
void channelWritable(Channel channel);
}
class Channel{
Socket socket;
Event event;//读,写或者连接
}
//IO线程主循环:
class IoThread extends Thread{
public void run(){
Channel channel;
while(channel=Selector.select()){//选择就绪的事件和对应的连接
if(channel.event==accept){
registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
}
if(channel.event==write){
getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
}
if(channel.event==read){
getChannelHandler(channel).channelReadable(channel);//如果可以读,则执行读事件
}
}
}
Map<Channel,ChannelHandler> handlerMap;//所有channel的对应事件处理器
}
这也是最简单的Reactor模式,注册所有感兴趣的事件处理器,单线程轮询选择就绪事件,执行事件处理器。
Reactor
- Reactor是一种设计模式,是NIO的高级版
- 事件处理模式
- 一次处理一个或多个输入
-
多路分解,分发
reactor-单线程
多reactor
一些概念性问题
阻塞与非阻塞:指的是当不能进行读写(网卡满时的写/网卡空时的读)的时候,I/O操作是立即返回还是阻塞;
同步与异步:描述的是用户线程与内核的交互方式;
- 同步:用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行
- 异步:用户线程发起IO请求后扔要继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数
所以阻塞I/O,非阻塞I/O,I/O多路复用,都属于同步调用。只有实现了特殊API的AIO才是异步调用。
相关原文链接:
https://xie.infoq.cn/article/0e36ad9712c8d9ad8f7a7c570
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
https://zhuanlan.zhihu.com/p/93609693
https://www.jianshu.com/p/ee381d365a29
https://zhuanlan.zhihu.com/p/23488863
网友评论