Linux系统的用户空间和内核空间
User Space 用户空间、Kernel Space 内核空间
Kernel Space 是 Linux 内核的运行空间,User Space 是用户程序的运行空间。为了安全,它们是隔离的,即使用户程序崩溃,内核也不受影响。
Kernel Space 可以执行任意命令,调用系统一切资源;User Space 只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(又称 System Call),才能向内核发出指令。
通过系统接口,进程可以从用户空间切换到内核空间。
str = "my string"; // 用户空间
x = x + 2;
file.write(str); // 切换到内核空间
y = x + 4; // 切换回用户空间
IO模型
现在操作系统都是采用虚拟存储器。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel
),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分供内核使用,称为内核空间,一部分供各个进程使用,称为用户空间。
对于一次IO
访问(以read
举例),数据会先被拷贝到操作系统内核空间的缓冲区中,然后才会从操作系统内核空间的缓冲区拷贝到进程用户空间的缓冲区。所以说,当一个read
操作发生时,它会经历两个阶段:
- 等待数据准备 (
Waiting for the data to be ready
)。数据到内核空间。 - 将数据从内核拷贝到进程中 (
Copying the data from the kernel to the process
)。内核负责把内核态内存中的数据拷贝一份到用户态内存。
缓存IO:数据从磁盘拷贝到内核空间,再从内核空间拷贝到用户空间
直接IO:数据从磁盘拷贝到用户空间
五大类:
- 阻塞IO(
Blocking IO
) - 非阻塞IO(
Non-Blocking IO
) - IO多路复用(
IO Multiplexing
) - 信号驱动IO(
Signal Driven IO
) - 异步IO(
Asynchronous IO
)
BIO代码简单,特定场景(链接数少,并发度低)下 BIO 性能不输 NIO。
阻塞IO
当用户进程调用recvfrom
的系统调用,kernel
就开始 IO 第一个阶段:数据准备。对于网络 IO 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP
包。对于磁盘 IO 来说,就是等待数据从磁盘读取到内核态内存。这个过程需要等待,在用户进程这边,整个进程会被阻塞(进程自己阻塞)。
当kernel
一直等到数据准备完成,kernel
会把数据从内核内存拷贝到用户内存。出于系统安全,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据拷贝到用户态内存。然后kernel
返回结果,用户进程才解除阻塞状态,继续运行。
阻塞 IO 的特点就是执行的两个阶段都被阻塞 block
了。
典型应用:IO、Socket阻塞模式
使用并发量小的应用。
非阻塞IO
当用户进程发出read
操作,如果内核态内存中数据还没有准备好,kernel
立即返回一个error
。从用户进程角度讲,它发起一个read
操作,并不需要等待,而是马上就得到一个结果。用户进程判断结果是一个error
时,就知道数据还没有准备好。
用户进程需要不断地发出read
操作。内核一旦数据准备好,并且接收到用户进程的read
操作,kernel
就把数据拷贝到用户内存,然后返回,其实第二个阶段还是阻塞的。
进程轮询(重复)调用,消耗CPU的资源。
典型应用:Socket非阻塞模式(NIO)
IO多路复用
Linux 提供了 I/O 复用函数select/poll/epoll
,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。系统内核就可以帮我们侦测多个读操作是否处于就绪状态。
select/poll/epoll
的优势在于它可以同时处理多个连接。
select
函数
在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。
Linux 操作系统的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd
)。
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout)
select()
函数监视的文件描述符分 3 类,分别是writefds
(写文件描述符)、readfds
(读文件描述符)以及exceptfds
(异常事件文件描述符)
调用后select
函数会阻塞,直到有描述符就绪或者超时,函数返回全部的文件描述符。当select
函数返回全部的文件描述符后,可以通过函数FD_ISSET
遍历fdset
,来找到就绪的描述符。fd_set
可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset); //清空集合
void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
每次调用select
函数之前,系统需要把fd
集合从用户态拷贝到内核态,给系统带来了一定的性能开销,开销会随着文件描述符数量的增加而线性增大。
单个进程监视的fd
数量默认是 1024
poll
函数
poll
和select
类似,通过轮询管理多个文件描述符,根据描述符的状态进行处理,但poll
使用链表保存文件描述符,没有最大数量的限制。
select/poll
函数,服务器每次把连接告诉操作系统(全量句柄数据从用户空间拷贝到内核空间),让操作系统内核轮询是否有就绪事件,描述符就绪或者超时时,再将全量句柄数据拷贝到用户空间,让进程遍历所有描述符,找到就绪的进行操作。这一过程资源消耗较大,因此select/poll
函数一般只能处理几千的并发连接。
总结:
select/poll
函数缺点如下:
- 每次调用
select/poll
函数,都需要把fd
集合从用户态拷贝到内核态,开销会随fd
数量的增减而增大- 内核轮询传递进来的
fd
,开销会随fd
数量的增减而增大select
支持的fd
数量太少,默认是1024- 函数返回的是含有整个句柄的数量,应用程序需要遍历整个数组才能发现哪些句柄就绪
- 函数的触发方式是水平触发,应用程序如果没有完成对一个就绪的文件描述符进行IO操作,那么每次函数调用还是会将这些文件描述符通知进程
epoll
函数
epoll
使用事件驱动的方式代替轮询扫描fd
。epoll
不是一个单独的系统调用,而是由epoll_create/epoll_ctl/epoll_wait
三个系统调用组成。
- 调用
epoll_create
创建一个epoll
对象 - 调用
epoll_ctl
向epoll
对象中注册文件描述符 - 调用
epoll_wait
收集发生的事件的文件描述符
epoll
将文件描述符存放到内核的一个事件表中,由内核进行维护,这个事件表是基于红黑树实现的,所以在大量 I/O 请求的场景下,插入和删除的性能比select/poll
的数组fd_set
要好,因此epoll
的性能更胜一筹,而且不会受到fd
数量的限制。
总结:
epoll
- 没有最大并发连接限制
- 效率提升,
epoll
调用时不需要全量传参,且只关心活跃的文件描述符- 内存拷贝,
epoll
使用“共享拷贝”
epoll
事先通过epoll_ctl()
来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,由内核进行维护,这个事件表是基于红黑树实现的,所以在大量 I/O 请求的场景下,插入和删除的性能比select/poll
的数组fd_set
要好,因此epoll
的性能更胜一筹,而且不会受到fd
数量的限制。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)
epfd
是由epoll_create()
函数生成的一个epoll
专用文件描述符。op
代表操作事件类型,fd
表示关联文件描述符,event
表示指定监听的事件类型。
一旦某个文件描述符就绪时,内核会采用类似callback
的回调机制,迅速激活这个文件描述符,唤醒等待中的进程(也就是调用epoll_wait()
的进程),之后进程将完成相关I/O
操作。
int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)
epoll
优点:
- 没有最大
fd
的限制 - 效率提升,不是轮询的方式,不会随着
fd
数目的增加效率下降。只有活跃可用的fd
才会调用callback
函数 - 使用
mmap
文件映射内存减少复制开销
在 IO 多路复用模型中,整个用户进程其实是一直被阻塞的,只不过进程是被函数阻塞,而不是被socket
阻塞。
epoll
对文件描述符的操作有两种模式:LT(level trigger)
和ET(edge trigger)
。LT模式是默认模式,LT模式与ET模式的区别如下:
- LT模式:当
epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait
时,会再次响应应用程序并通知此事件。 - ET模式:当
epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait
时,不会再次响应应用程序并通知此事件。
典型应用:Selector、Nginx
Selector
在不同操作系统选择使用不同的函数,在 JDK1.5 版本中,如果程序运行在 Linux 操作系统,且内核版本在 2.6 以上,NIO 中会选择epoll
来替代传统的select/poll
。
信号驱动IO
用户进程向内核注册一个信号处理函数,然后用户进程返回不阻塞。当数据准备好时会发送一个信号给进程,用户进程在信号处理函数中调用 IO 读取数据。
信号驱动IO模型的第二个阶段(从内核空间复制到用户空间)还是阻塞的。
异步IO
以上的四种都是需要进程
用户进程发起aio_read
,给内核传递描述符、缓冲区指针、缓冲区大小和read相同的三个参数以及文件偏移,告诉kernel
当整个操作完成时,如何通知用户进程,并且用户进程可以继续执行不阻塞。
内核角度来说,接收到aio_read
后,它会立刻返回,然后等待数据准备完成,将数据从内核态内存拷贝到用户态内存。最后,内核会给用户进程发送一个信号,告诉它操作完成。
以上四种的模型都是同步的,都是由用户进程等待内核将数据从内核态内存拷贝到用户态内存。异步IO,用户进程不用等待数据准备和到用户态内存。
典型应用:Java7 AIO、高性能服务器应用
同步与异步
用户空间和内核空间之间数据的交互方式
同步:用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪
异步:用户进程触发IO操作后做其他的事情,而当IO操作完成的时候会得到IO完成的通知
同步:数据就绪后需要自己去读。
异步:数据就绪直接读好再回调给程序。
阻塞与非阻塞
用户空间和内核空间IO操作的方式
阻塞:用户空间通过系统调用(system call)和内核空间发送IO操作时,该调用是阻塞的
非阻塞:以上操作是非阻塞的,直接返回,只是返回时可能没有数据
阻塞:没有数据传过来时,读会阻塞直到有数据;缓冲区满时,写操作也会阻塞。
非阻塞:遇到上述情况直接返回
参考:
欢迎小伙伴们积极指正和讨论,一起共同成长。