大纲
Java IO原理
首先要有一个基础的概念,执行IO操作,通常要使用到read/write的系统调用,而这个系统调用,并不是直接地从物理设备将数据读到内存/直接从内存中把数据写到物理设备,而是将内核缓冲区里面的数据复制到用户缓冲区/把用户缓冲区里面的数据复制到内核缓冲区。
同步和异步、阻塞和非阻塞
同步和异步
同步和异步关注的是消息的通信机制,也就是它关注的是消息以哪种方式告知你。
同步:当发起调用时,在没有得到结果时,该调用不会返回。
异步:发起调用后,调用立即返回,也就是说返回空,而被调用者会通过通知的方式,比如回调函数,把结果返回给调用者。
阻塞和非阻塞
阻塞和非阻塞关注的是用户在等待结果时候的状态。
阻塞:调用线程在没有得到返回结果前,该线程会被阻塞挂起。
非阻塞:调用线程在不能马上得到返回结果时,该线程不会被挂起。
由于同步\异步和阻塞\非阻塞两者关注点不同,所以两者之间并没有关系
四种IO模型
BIO(Blocking IO)
同步阻塞IO;在linux下的java进程,socket是默认阻塞的,如果通过socket进行系统调用read,如下图
进程会等待内核缓存区的数据和内核缓冲区的数据被复制到用户缓冲区,在这个过程中,因为消息(要获取的数据)是从调用方发起的调用中返回的,所以该模型是同步的。而又因为在线程等待返回结果时的状态是处于被剥夺cpu执行时间片的挂起状态,所以该模型是阻塞的。
优点:由于在内核缓冲区数据还没准备好时,线程会被阻塞,不会占有cpu资源
缺点:由于每一个IO流都需要由一条线程维护,在高并发下不可用。
NIO(None Blocking IO)
同步非阻塞IO模型;在该模型下,发起系统调用read,如果发现内核缓冲区还没有准备好的数据,则马上返回错误的返回码;线程在发起调用和收到错误返回码并没有被挂起,所以是非阻塞的;由于数据也是从调用中返回的,所以是同步的。
优点:由于线程不会被阻塞,所以用一个线程维护多个IO操作
缺点:需要大量轮询操作,耗费cpu时间
IO multiplexing(IO多路复用模型)
为了减少非阻塞模型的轮询次数,提出了多路复用的系统调用(select/poll/epoll);在多路复用模型中,由一条线程负责监控多个文件描述符的就绪状态(文件描述符FD下文会提到),当某个文件描述符达到就绪的状态(比如可读),该线程会通知另外的线程去执行相应的操作,如非阻塞的read调用。
优点:可以同时处理成千上万个连接。
缺点:在select系统调用中,如果没有文件描述符处于就是,还是需要阻塞的。
AIO(asynchronous IO)
异步IO模型;这个模型,用户线程调用某个IO操作,然后立即返回;系统会在IO操作完成后,通过通知的方法,将结果返回给用户线程,例如回调函数。由于在调用IO操作时马上返回,线程没有被阻塞,而且结果是通过回调函数的方式返回,所以该模型是异步非阻塞的。
socket
什么是socket?
socket是网络编程套接字,它封装了复杂的网络协议,并暴露出一些api让程序员能够轻松进行网络编程。比如通过bind来绑定ip和端口,通过accept处理客户端发来的连接请求等等。
什么是socketfs?
我们会调用read系统调用,来复制socket内核缓冲区的数据到用户缓冲区,它和文件调用有相同的语义。这是因为socketfs是一个文件系统,它和其它文件系统一样,位于操作系统管理的虚拟文件系统下面;所以用户线程可以像操作文件一样操作socket;
虚拟文件系统
虚拟文件系统统一了不同的文件系统,并向上提供了POSIX接口,其中包括open、read、write、Iseek等。从而用户可以通过统一的接口来操作文件。
FD(文件描述符)
一句话,它就是一个数组的下标。所以fd是一个整形的数值。下面是linux对进程抽象的结构体
struct task_struct {
// ...
/* Open file information: */
struct files_struct *files;
// ...
}
结构体当中省略了大量的字段,其中有一个files_struct的指针,下面为files_struct的结构体
/*
* Open file table structure
*/
struct files_struct {
// 读相关字段
atomic_t count;
bool resize_in_progress;
wait_queue_head_t resize_wait;
// 打开的文件管理结构
struct fdtable __rcu *fdt;
struct fdtable fdtab;
// 写相关字段
unsigned int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
unsigned long full_fds_bits_init[1];
struct file * fd_array[NR_OPEN_DEFAULT];
};
这个结构体维护着当前进程打开的文件,而我们注意到最下面的一个file指针,下面为file的结构体
struct file {
// ...
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
// ...
}
这个结构体就表示了一个打开的文件,其中包含很多描述文件的字段,例如inode(就是虚拟文件系统中的i节点)、f_pos(表示对文件操作的偏移量,改变它会从而影响对文件read/write);
回归正题,fd(文件描述符)就是files_struct结构体当中file数组的下标,通过这个下标(fd),我们就可以在当前进程的打开文件file数组中,找到对应的file。
所以总结一下,通过fd找到一个当前进程打开的文件的过程是这样的。
inode(i节点)
在file的结构体当中,有一个inode字段,它指向的并不是某一个文件系统的i节点,而是vfs(虚拟文件系统)的i节点,下面是i节点的结构体
struct inode {
// 文件相关的基本信息(权限,模式,uid,gid等)
umode_t i_mode;
unsigned short i_opflags;
kuid_t i_uid;
kgid_t i_gid;
unsigned int i_flags;
// 回调函数
const struct inode_operations *i_op;
struct super_block *i_sb;
struct address_space *i_mapping;
// 文件大小,atime,ctime,mtime等
loff_t i_size;
struct timespec64 i_atime;
struct timespec64 i_mtime;
struct timespec64 i_ctime;
// 回调函数
const struct file_operations *i_fop;
struct address_space i_data;
// 指向后端具体文件系统的特殊数据
void *i_private; /* fs or device private pointer */
};
i节点包括了文件的一些信息,其中i_op字段就注册了文件系统的函数。这个i节点是vfs通用的,而特殊的文件结构也会有特殊的i节点,我们看下面的结构体
struct ext4_inode_info {
// ext4 inode 特色字段
// ...
// 重要!!!
struct inode vfs_inode;
};
我们发现ext4文件系统的i节点里面定义了一个vfs的i节点,我们可以通过强制类型转换,将其中的vfs_inode转为ext4_inode_info,只需要知道vfs_inode的偏移量就行。假如该vfs_inode的地址为a,那么ext4_inode_info的地址如下
(struct ext4_inode_info *)(a - 64)
socketfs文件系统
有了上面的铺垫,我们发现既然socketfs是一个特殊的文件系统,那么它也就会有自己的i节点。下面是socket_alloc结构体
struct socket_alloc {
struct socket socket;
struct inode vfs_inode;
};
我们发现里面有socket字段和vfs_inode字段,所以说vfs层用就返回vfs_inode给他,socket层用就返回socket给它。
通过下面的方法
#include<sys/socket.h>
int socket(int family, int type, int protocol)
创建了socket结构体(实际上是创建了socket_alloc结构体返回了socket)和与之关联的sock结构体,sock结构体下面就是具体协议的结构体。还有创建了file结构体,并与socket关联起来。
我们之所以可以通过套接字的文件描述符,来做像文件系统一样read/write操作,是因为file->ops中注册了下层文件系统的函数。而套接字文件系统在上面注册的叫socket_file_ops如下
static const struct file_operations socket_file_ops = {
.llseek = no_llseek,
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
// ...
}
所以调用vfs_write方法到底是调用了sock_write_iter方法,所以说从vfs回到了sock里,从而完成了一些write和read操作。
select/poll/epoll
select
select主要通过轮询一个fd_set的结构体,判断哪个fd是否就绪(可读或可写),而fd_set是一个长度为1024的比特位,我们可以下面几个定义的宏,来操作这个fd_set
void FD_CLR(int fd, fd_set *set); // 清空fd在fd_set上的映射,说明select不在处理该fd
int FD_ISSET(int fd, fd_set *set); // 查询fd指示的fd_set是否是有事件请求
void FD_SET(int fd, fd_set *set); // 把fd指示的fd_set置1
void FD_ZERO(fd_set *set); // 清空整个fd_set,一般用于初始化
在遍历fd_set主要就是通过FD_ISSET这个宏来判断这个fd是否有事件。
poll
select的方式缺点是一个select只能监控最多1024个文件描述符,而poll解决了这点。poll底层操作的结构体是pollfd
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
而pollfd会组成一个队列,所以不再有最大的文件描述符监控大小;但poll和select的缺点依然明显,即使要通过遍历来判断fd是否有就绪(有事件)
epoll
epoll不同于上诉两者需要轮询查询fd的就绪状态,它是通过回调函数的方式感知到fd的就绪,所以是异步的,也是最高效,因为没有浪费任何的循环遍历时间。
epoll高效的第一个秘诀
首先我们先要把我们想要监控的fd交给epoll管理,我们通常使用下面这个函数,其中epfd是epoll的文件描述符、op表面该操作是什么(如果是EPOLL_CTL_ADD,就是往epoll里增加fd的意思)、而e
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
而epoll管理这些fd使用的数据结构是红黑树,所以增删改fd的效率都比较高
epoll高效的第二个秘诀
epoll使用了回调函数的机制来感知fd的就绪。在套接字对应的file结构体下,其中的f_op字段下就注册了很多函数,如下
struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
int (*open) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
// ....
};
而socketfs文件系统就会在f_op->poll下面注册函数,也就是socketfs实现了poll,而没有实现poll的文件系统的fd是不能被epoll管理的。所以说,当调用file_operations->poll时,回调函数就注册好了,到时对应的fd就绪的事件就会以通知的形式返回。其中回调函数会把就绪的fd放进就绪队列里面,同时唤醒epoll。