[Linux] Linux IO模式及 select、poll、epoll详解

2 篇文章 0 订阅
1 篇文章 0 订阅

注:本文是对众多博客的学习和总结,可能存在理解错误。请带着怀疑的眼光,同时如果有错误希望能指出。

同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。

本文讨论的背景是Linux环境下的network IO。

一、概念说明

  • 用户空间和内核空间
  • 进程切换
  • 进程的阻塞
  • 文件描述符
  • 缓存 I/O

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

注:操作系统只是将虚拟地址空间中连续的高1GB空间留作内核使用,这1GB空间在实际的物理内存中并不一定是连续的,虚拟地址通过计算机硬件转换为实际内存的物理地址

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器;
  2. 更新PCB信息;
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列;
  4. 选择另一个进程执行,并更新其PCB;
  5. 更新内存管理的数据结构;
  6. 恢复处理机上下文;

注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。在围绕socket的网络编程中,socket也有两个属于内核空间的缓冲区,分别为socket发送缓冲区和socket接收缓冲区,通常我们编写的网络应用程序需要先将要传输的数据拷贝至socket发送缓冲区,然后发送到网络中,从网络中接收的数据先要到达socket接收缓冲区,然后再拷贝至我们的应用程序当中。

缓存 I/O 的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

二、IO模式

刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready);
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process);

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:

  1. 阻塞 I/O (blocking IO);
  2. 非阻塞 I/O (nonblocking IO);
  3. I/O 多路复用 (IO multiplexing)
  4. 信号驱动 I/O (signal driven IO);
  5. 异步 I/O (asynchronous IO);

注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

阻塞 I/O(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
blockingIO
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段(操作系统准备数据和将数据从内核空间拷贝至用户空间)都被block了。

非阻塞 I/O(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking(使用fcntl设置socket为非阻塞)。当对一个non-blocking socket执行读操作时,流程是这个样子:
nonblockingIO
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有,并且在操作系统将数据准备好后,用户进程在调用recvfrom将数据拷贝至用户空间期间依然是阻塞的,即图中copy data from kernel to user这个过程用户进程依然是阻塞的。

I/O 多路复用( IO multiplexing)

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
multiplexingIO
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

异步 I/O(asynchronous IO)

Linux下的asynchronous IO其实用得很少。先看一下它的流程:
AsynchronousIO
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

总结

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
  • An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

IOModel
通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

三、I/O 多路复用之select、poll、epoll详解

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。(这里啰嗦下)

select

select相关api如下:

/* 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);

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);

fd_set、FD_SET、FD_ISSET、FD_CLR、FD_ZERO说明

在说明参数之前我们有必要先了解下fd_set是个什么东东,在Linux内核源码中关于fd_set的相关定义如下:

typedef long int __fd_mask;   // __fd_mask是一个long int类型
 
 // 一个__fd_mask可以表示的bit数量,如果__fd_mask为4个字节,那么__NFDBITS就代表32 bit
#define __NFDBITS       (8 * (int) sizeof (__fd_mask))  
// 在select中d表示fd_set的第d个bit,除以__NFDBITS的值代表fd_set数组中对应的元素索引
#define __FD_ELT(d)     ((d) / __NFDBITS)
// fd_set数组第__FD_ELT(d)个元素第(d) % __NFDBITS位的掩码
#define __FD_MASK(d)    ((__fd_mask) 1 << ((d) % __NFDBITS))
 
/* fd_set for select and pselect.  */
 typedef struct {
     /* XPG4.2 requires this member name.  Otherwise avoid the name
        from the global namespace.  */
 #ifdef __USE_XOPEN
     __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
 # define __FDS_BITS(set) ((set)->fds_bits)
 #else
 // 可以看到fd_set本质上是一个long int类型的数组,__FD_SETSIZE大小为1024,
 // long int如果是4字节大小则值为32,此时数组大小为32,即为一个大小为32的long int数组
     __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
 // __FDS_BITS宏的作用是取出fd_set中的__fds_bits数组
 # define __FDS_BITS(set) ((set)->__fds_bits)
 #endif
 } fd_set;
 
/* Maximum number of file descriptors in `fd_set'.  linux中默认大小为1024*/
#define FD_SETSIZE              __FD_SETSIZE

// sys/select.h
#define FD_SET(fd, fdsetp)      __FD_SET (fd, fdsetp)
#define FD_CLR(fd, fdsetp)      __FD_CLR (fd, fdsetp)
#define FD_ISSET(fd, fdsetp)    __FD_ISSET (fd, fdsetp)
#define FD_ZERO(fdsetp)         __FD_ZERO (fdsetp)

// bits/select.h  汇编代码看不懂O(∩_∩)O,将fd_set清零
# define __FD_ZERO(fdsp) \
  do {                                                                        \
    int __d0, __d1;                                                           \
    __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS                         \
                          : "=c" (__d0), "=D" (__d1)                          \
                          : "a" (0), "0" (sizeof (fd_set)                     \
                                          / sizeof (__fd_mask)),              \
                            "1" (&__FDS_BITS (fdsp)[0])                       \
                          : "memory");                                        \
  } while (0)
 
 // 取出set中__fds_bits数组的第__FD_ELT (d)个long int元素,并将该元素的第__FD_MASK (d) 比特位设为1
#define __FD_SET(d, set) \
   ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))
// 取出set中__fds_bits数组的第__FD_ELT (d)个long int元素,并将该元素的第__FD_MASK (d) 比特位设为0
#define __FD_CLR(d, set) \
   ((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))
// 取出set中__fds_bits数组的第__FD_ELT (d)个long int元素,并判断该元素的第__FD_MASK (d) 比特位是否为1
#define __FD_ISSET(d, set) \
   ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

为画图方便,我们假设long int为4个字节,则__NFDBITS值为32,假设FD_SETSIZE为32,这样fd_set中的__fds_bits数组就只有一个long int元素__fds_bits[0]。那么当执行如下操作后

// 定义fd_set
fd_set set;
// 本示例中将set中的__fds_bits数组清零
FD_ZERO(&set)

__fds_bits[0]元素各个比特位如下:
__fd_bits
当执行FD_SET操作后

FD_SET(5, &set)

__fds_bits[0]元素各个比特位如下:
__fd_bits
此时若执行FD_ISSET(5, &set)返回1

小结:fd_set就是一个long int类型的数组,假设要监听的描述符为n,则FD_SET会将fd_set的long int数组的第(n/sizeof(long int))个long int元素的第(n%sizeof(long int)) bit位设置为1

参数及返回值

了解fd_set后接下来再对select的参数做个说明

  1. nfds:值为所有文件描述符集合中的文件描述符的最大值加1;
  2. readfds、writefds、exceptfds:分别指向可读、可写和异常等事件对应的描述符集合,类型为fd_set,调用时将需要监听的套接字描述符用FD_SET设置到readfds、writefds、exceptfds中,告诉内核需要监听哪些套接字,当select方法返回时,内核会将可读、可写和异常等事件对应的描述符分别设置到readfds、writefds、exceptfds中;
  3. timeout:告内核等待多长时算超时,类型为struct timeval,定义如下:
    struct timeval {      
         long tv_sec;   /*秒 */
         long tv_usec;  /*微秒 */   
    };
    

select可以返回如下值:

  1. 正常情况下返回就绪的文件描述符个数;
  2. 经过了timeout时长后仍无设备准备好,返回值为0;
  3. 如果出错,返回-1并设置相应的errno,如果select被某个信号中断,则设置errno为EINTR,如果文件描述词为无效的或该文件已关闭,则设置errno为EBADF,如果系统核心内存不足,则设置errno为ENOMEM;

select示例的服务端程序

如下示例程序使用select实现一个服务端程序:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

#define ERR_EXIT(m) \
		do { \
			perror(m); \
			exit(EXIT_FAILURE); \
		} while(0)

int main(void) {
	// 忽略SIGPIPE信号
	signal(SIGPIPE, SIG_IGN);
	int sock;
	if((sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
		ERR_EXIT("socket");
	}
	
}

void echo_serever(sock) {
	// 创建可读套接字集合
	fd_set rset;
	FD_ZERO(&rset);
	
	int nready;
	int maxfd;
	int fd_stdin = fileno(stdin);
	
	if(fd_stdin > sock) {
		maxfd = fd_stdin;
	} else {
		maxfd = sock;
	}
	
	char sendbuf[1024] = {0};
	char recvbuf[1024] = {0};

	while(1) {
		FD_SET(fd_stdin, &rset);
		FD_SET(sock, &rset);
		nready = select(maxfd + 1, &rset, NULL, NULL, NULL, NULL);
		if(nready == -1) {
			ERR_EXIT("select");
		}
		if(nready == 0) {
			continue;
		}
		if(FD_ISSET(sock, &rset)) {
			int ret = readline(sock, recvbuf, sizeof(recvbuf));
			if(ret == -1) {
				ERR_EXIT("readline");
			} else if(ret == 0) {
				printf("peer close\n");
				break;
			}
			fputs(recvbuf, stdout);
			memset(recvbuf, 0, sizeof(recvbuf));
		}
		if(FD_ISSET(fd_stdin, &rset)) {
			
		}
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值