内核空间&用户空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限
。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间
。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
饭店老板把整个饭店划分成两个部分:大厅和厨房。大厅用于顾客吃饭,厨房用于厨师做饭,厨房的门上面一般还会写着:“厨房重地,闲人免进”,也就是顾客一般不具有直接使用厨房的特性。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
- 保存处理机上下文,包括程序计数器和其他寄存器。
- 更新PCB信息。
- 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另一个进程执行,并更新其PCB。
- 更新内存管理的数据结构。
- 恢复处理机上下文。
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的
。
文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念
。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表
。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO
。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间
。
缓存 IO 的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作
,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
网络IO操作两阶段
网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作
。刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间
。所以说,当一个read操作发生时,它会经历两个阶段:
- 第一阶段:等待数据准备 (Waiting for the data to be ready)。
- 第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。
对于socket流而言,
- 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
- 第二步:把数据从内核缓冲区复制到应用进程缓冲区。
IO 模型
Unix 根据这两个阶段又把 IO 分成了以下五种 IO 模型:
IO 模型 | 说明 | 两阶段特点 |
---|---|---|
同步阻塞 IO | 当用户进程发起请求时,一直阻塞直到数据拷贝到用户空间为止才返回。 | 两个阶段是连续阻塞着的 |
同步非阻塞型 IO | 用户进程不断询问内核数据准备情况,用户进程一直轮询,直到内核说数据准备好了,然后把数据从内核空间拷贝到用户空间,返回成功,开始处理数据 | 第一阶段不阻塞, 第二阶段阻塞。 |
IO 多路复用 | 多个 IO 操作共同使用一个 selector(选择器)去询问哪些 IO 准备好了,selector 负责通知那些数据准备好了的 IO,它们再自己去请求内核数据。 | 第一阶段会阻塞在 selector 上, 第二阶段拷贝数据也会阻塞。 |
信号驱动 IO | 用户进程发起读取请求之前先注册一个信号给内核说明自己需要什么数据,这个注册请求立即返回,等内核数据准备好了,主动通知用户进程,用户进程再去请求读取数据,此时,需要等待数据从内核空间拷贝到用户空间再返回。 | 第一阶段不阻塞, 第二阶段阻塞。 |
异步 IO | 用户进程发起读取请求后立马返回,当数据完全拷贝到用户空间后通知用户直接使用数据。 | 两个阶段都不阻塞。 |
同步阻塞 IO/blocking IO
当用户进程调用了recv()/recvfrom()这个系统调用,kernel就开始了IO的第一个阶段:准备数据
(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。第二个阶段:当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存
,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。
优点:
- 能够及时返回数据,无延迟;
- 对内核开发者来说这是省事了;
缺点:
- 对用户来说处于等待就要付出性能的代价了;
同步非阻塞 IO(nonblocking IO)
非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error
。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询
。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。**需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态**
。
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。
同步非阻塞方式相比同步阻塞方式:
优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。 缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
IO多路复用/IOmultiplexing
多路复用的特点是通过一种机制一个进程能同时等待IO文件描述符
,内核监视这些文件描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll函数就可以返回。对于监视的方式,又可以分为 select, poll, epoll三种方式。
多路”指的是多个网络连接,“复用”指的是复用同一个线程。
select
相关函数定义如下
/* According to POSIX.1-2001, POSIX.1-2008 */
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
select的调用会阻塞到有文件描述符可以进行IO操作或被信号打断或者超时才会返回。
select将监听的文件描述符分为三组,每一组监听不同的需要进行的IO操作。readfds是需要进行读操作的文件描述符,writefds是需要进行写操作的文件描述符,exceptfds是需要进行异常事件处理的文件描述符。这三个参数可以用NULL来表示对应的事件不需要监听。
当select返回时,每组文件描述符会被select过滤,只留下可以进行对应IO操作的文件描述符。
FD_xx系列的函数是用来操作文件描述符组和文件描述符的关系。
FD_ZERO用来清空文件描述符组。每次调用select前都需要清空一次。
fd_set writefds;
FD_ZERO(&writefds)
FD_SET添加一个文件描述符到组中,FD_CLR对应将一个文件描述符移出组中
FD_SET(fd, &writefds);
FD_CLR(fd, &writefds);
FD_ISSET检测一个文件描述符是否在组中,我们用这个来检测一次select调用之后有哪些文件描述符可以进行IO操作
if (FD_ISSET(fd, &readfds)){
/* fd可读 */
}
select可同时监听的文件描述符数量是通过FS_SETSIZE来限制的,在Linux系统中,该值为1024,当然我们可以增大这个值,但随着监听的文件描述符数量增加,select的效率会降低,我们会在『不同IO多路复用方案优缺点』一节中展开。
pselect和select大体上是一样的,但有一些细节上的区别。
打开链接查看完整的使用select的例子
poll
相关函数定义
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
#include <signal.h>
#include <poll.h>
int ppoll(struct pollfd *fds, nfds_t nfds,
const struct timespec *tmo_p, const sigset_t *sigmask);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件。合法的事件可以参考这里。
打开链接查看完整的使用poll的例子
epoll
相关函数定义如下
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
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_create&epoll_create1用于创建一个epoll实例,而epoll_ctl用于往epoll实例中增删改要监测的文件描述符,epoll_wait则用于阻塞的等待可以执行IO操作的文件描述符直到超时。