IO模型

UNIX Network Programming第六章节

前序概念:

文件描述符(File Descriptor)

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

用户空间(User Space) Vs 内核空间(Kernal Space)

在操作系统的定义当中,内核程序运行在内核空间,而用户的应用程序运行在用户控件。两个实际上是虚拟概念,只是逻辑上的划分,真正的RAM其实不存在这个区分。用户空间就像是一个沙盒,它限制了程序可以操作的内存范围,从而保护其他应用以及操作系统内核(资源,硬件)不受影响。而内核空间则对外提供了系统调用方法使应用获取一些操作系统级别资源。

6.1 简介

在第五章中,我们看到TCP客户端同时处理两个输入:TCP socket和标准输入。在客户端调用fgets被阻塞的以后,服务器进程被kill掉了,我们就会遇到问题。服务器正确的发送一个FIN到客户端TCP,但是因为客户端进程被阻塞住于从标准输入读的过程,它看不到这个EOF,直到它从socket中读为止(可能要很久)。当一个或者多个I/O条件就绪时, 我们需要内核能够通知我们(比如输入已经可读,描述符(descriptor)可以承载更多输出)。这个能力我们叫做I/O多路复用, 由select和poll来提供。我们还有一个新的POSIX版本,叫做pselect。

对于进程等待一系列的事件,有些系统提供了更为先进的方式。轮询(poll)设备是其中一种机制。这个机制在第14章会进行详述。

I/O 多路复用在网络应用中通常有以下一些场景:

  • 当一个客户端处理多个描述符(通常是交互式输入和网络socekt)
  • 可能但是少见的情况,一个客户端同时处理多个socket,在16.5章中结合网络客户端有例子
  • TCP服务端同时处理一个监听socket和已经连接的socket, 如6.8中的例子
  • 服务端处理多个服务或者多个协议,比如13.5章中提到的inetd守护进程

I/O多路复用并不局限于网络编程,很多重要应用也会用到这些技巧

6.2 I/O模型

在Unix中,有五种不同的I/O模型,我们来具体看一下他们的区别

  • Blocking I/O 阻塞式I/O
  • Nonblocking I/O 非阻塞式I/O
  • I/O multiplexing (select and poll) I/O多路复用
  • signal driven I/O (SIGIO) 信号驱动I/O
  • asynchronous I/O (the POSIX aio_functions) 异步I/O

在本文提到的所有例子中,对于一个输入操作,通常有两个不同的阶段 等待数据就绪 将数据从内核拷贝到进程中

对于socket上的输入操作, 第一步通常包含等待数据从网络传输。当数据包到达时,它会被拷贝到内核中的一段缓冲区中。第二步是将数据从内核缓冲区拷贝到我们应用的缓冲区中。

Blocking I/O Model

最为常见的I/O模型就是阻塞式I/O,例如上面提到的例子。所有的socket默认都是阻塞的。在下图中,我们使用一个socket中的数据报文进行示例:


figure_6.1.png

我们在例子中使用UDP而不是TCP,因为数据"准备好"的概念比较简单: 收到的数据报文是否完整。TCP则更为复杂,会有一些额外的变量,比如socket的low-water mark等。

在本文中的例子中,我们同样也把recvfrom当做一个系统调用,因为我们要区分应用进程和内核。无论recvfrom是怎样实现的,通常会从"在应用中运行"切换到"在内核中运行",一段时间以后再返回应用。

在上图中,进程调用recvfrom,系统调用不会返回,直到数据报文到达并且被拷贝到应用缓冲区中,或者出现错误。最常见的错误就是系统调用被信号量中断。我们的进程从调用recvfrom开始直到获得返回整个周期会被阻塞。当recvfrom成功返回以后,我们的程序开始处理数据报文。

Nonblocking I/O Model

如果我们将socket设置为非阻塞,我们告诉内核"当一个I/O操作只有把进程sleep才能完成的时候,不要让进程sleep,而是返回一个错误"。在第十六章中会详细描述,但是下图是一个关于非阻塞概念的总结:


figure_6.2.png

前三次我们调用recvfrom并没有返回数据,内核直接返回一个EWOULDBLOCK 错误。第四次我们调用recvfrom时一个数据报文已经就绪,拷贝到我们的应用缓冲区中,recvfrom返回成功,然后我们处理数据。

当应用像这样对应非阻塞描述符进行循环调用recvfrom的操作叫做轮询。应用一直轮询内核查看是否有操作准备就绪。这通常十分消耗CPU时间片,但事实这个模型偶尔也使用在专门提供某一功能的系统中。

I/O Multiplexing Model

在I/O多路复用模型中,我们使用select或者poll,并且阻塞在这两个系统调用中的一个中,而不是真正的I/O系统调用。如下图所示:


figure_6.3.png

我们阻塞在select系统调用中,等待有可读的socket数据报文。当select返回socket可读时,我们再调用recvfrom将数据报文拷贝到应用缓冲区中。

和阻塞式I/O相比,似乎看不出有什么优势,并且能很明显看出I/O复用的劣势就是使用select需要两次系统调用而不是一次。当多个描述符(descriptor)准备就绪的时候,我们会在后面看到使用select的优势,

另一个很相近的I/O模型是在阻塞式I/O上使用多线程。这个模型跟上面所说的多路复用很像。只不多路复用模型使用select阻塞在多个文件描述符上,而多线程阻塞模型使用多个线程(每个描述符一个),每个线程都可以使用阻塞式系统调用,比如recvfrom。

Signal-Driven I/O Model

我们也可以使用信号量,在描述符就绪时让内核使用SIGIO信号通知我们。我们称之为信号驱动,如下图所示:


figure_6.4.png

我们首先启用信号驱动I/O的socket,然后使用sigaction系统调用安装一个信号处理器。sigaction调用会立刻返回,我们的程序继续执行而不会阻塞。当数据报文可读,内核为我们的应用生成SIGIO信号。我们可以在信号处理器中调用recvfrom读取数据报文,然后通知主程序数据可以被处理;也可以通知主程序来读取数据报文。

无论我们如何处理信号,这个模型的优势在于我们无须等待数据报文到来。主程序可以继续执行,只需要等待被信号通知,数据可读甚至已经可以处理。

Asynchronous I/O Model

异步I/O在POSIX规范中定义,早期不同版本中的实时函数经过演变已经达成一致。总而言之,这些函数告诉内核开始进行I/O操作,并且当整个操作(包括将数据从内核拷贝到应用缓冲区)完成以后通知我们。异步模型和信号驱动模型最主要的区别是在信号模型中,内核告诉我们什么时候可以进行操作,异步模型告诉我们什么时候I/O完成。如下图所示:


figure_6.5.png

我们调用aio_read(POSIX异步模型函数以aio_或者lio_打头)并且将描述符,缓冲区指针,缓冲区大小(前三个参数和read相同),文件偏移量(file offset, 类似于lseek) 和 当整个操作完成以后如何通知我们传参给内核。aio_read立刻返回,我们的程序也不会阻塞在等待I/O完成。我们假设在这个例子中当操作完成后让内核生成一些信号。和信号驱动不同的是,信号直到数据被拷贝到应用缓冲区中以后才会被生成。.

此时(很久以前了),只有部分系统支持POSIX 异步I/O。我们并不确定某个系统在socket中是否支持异步I/O。这里只是为了跟信号驱动I/O做个比较。

I/O Model对比

下图是5中不同I/O模型的对比。前四种模型主要的区别在第一阶段(等待数据),而第二阶段是相同的:进程阻塞在recvfrom调用中,等待数据从内核拷贝到应用缓存中。异步模型则处理了两阶段的任务。


figure_6.6.png

POSIX 定义了两种概念:

  • 同步I/O操作导致请求进程阻塞,直到I/O操作完成。
  • 异步I/O操作不导致请求进程阻塞

从这个定义来讲,前四种模型在第二阶段都会阻塞,所以属于同步I/O,只有最后一种I/O属于异步I/O。

Select, Poll和Epoll

这三者都属于I/O多路复用,我们来对这三者进行比较,这里参考了这篇文章

当使用非阻塞I/O socket来设计高性能网络程序的时候,架构师需要决定选用哪种轮询方法来监控socket生成的事件。不同方式的适用场景并不一样,选择正确的方式来满足应用的需求非常重要。

使用Select 进行轮询

我们把传统的socket称为Berkeley sockets。它在上世纪80年代出现,并且这个接口就没有变过。只不过因为当时还没有非阻塞I/O这个概念,它并没有行程最初的规范。

开发人员需要初始化fd_set参数(描述符和监听事件等),然后调用select方法,大致流程如下:

fd_set fd_in, fd_out;
struct timeval tv;

// 重置sets
FD_ZERO( &fd_in );
FD_ZERO( &fd_out );

// 监控socket1的输入事件
FD_SET( sock1, &fd_in );

// 监控socket2的输出事件
FD_SET( sock2, &fd_out );

//找出哪个socket值最大(select需要)
int largest_sock = sock1 > sock2 ? sock1 : sock2;

// 等待10秒
tv.tv_sec = 10;
tv.tv_usec = 0;

// 调用socket
int ret = select( largest_sock + 1, &fd_in, &fd_out, NULL, &tv );

// 检查select是否成功
if ( ret == -1 )
    // 报告错误并且中止
else if ( ret == 0 )
    // 超时;没有检测到事件
else
{
    if ( FD_ISSET( sock1, &fd_in ) )
        // socket1输入事件

    if ( FD_ISSET( sock2, &fd_out ) )
         // socket2输出事件
}

设计select接口的时候,没人会想到会有多线程应用服务着成千上万个连接所以select有很多设计缺陷,使得它在现在网络应用中并不是一个合格的轮询机制。主要缺陷如下:

  • select修改了传入的fd_sets使得他们不可以被重用,即使你不需要改变任何东西 - 比如一个描述符接收到了数据,并且需要接收更多数据 - 整个set要么需要重新创建,要么使用FD_COPY从备存中恢复。上述过程在每次select调用中都会需要。
  • 为了找到哪个描述符发起了事件,我们需要手动遍历set中的所有描述符,并且对每一个调用FD_ISSET方法。当你有2000个描述符但是只有一个是活跃的,假设是最后一个...我们浪费了太多CPU时间片。
  • 我们刚才提到了2000个描述符?但是select并不支持这么多,至少在Linux中,它把FD_SETSIZE这个常量设为了1024。有些操作系统可能支持使用某种黑科技重新定义这个大小,但是Linux不行~~
  • 我们不能在等待的时候从另一个线程中修改这个描述符set。假设一个线程正在执行上述代码,然后有一个清理线程认为socket1等待输入数据时间太长了,需要将socket1释放出来。清理线程想关闭这个socket从而这个socket可以被重用来服务其他客户端。但是这个socket目前在select等待的fd_set中。那么这个socket被关闭以后会发生什么?select手册告诉我们一个不喜欢的答案:"如果被select监控的文件描述符被另一个线程关闭了,那么结果不可预知..."
  • 如果另一个线程突然觉得通过socket1发送点什么,我们会有同样的问题。除非select返回,不然没有办法开始监控socket的输出事件。
  • 事件等待时候的选择十分有限;例如:要判断远程socket是否关闭,我们需要 a) 监控它的输入, b)真正尝试从socket读取数据来判断是否关闭(read会返回0)。如果你正好想从socket读数据的话这还好,但是如果你只是发送文件并不关注输入呢?
  • select 还有额外的开销,你需要传入描述符列表来计算最大描述符代表的数字。

当然操作系统开发者明白这些缺点,并且在设计轮询方法的时候处理了大部分。你或许会问,为什么我们还要使用select?为什么不把它放在电脑博物馆中?主要原因有两个,这个原因或许对你来说很重要,或许你根本无需关心:

  1. 第一个原因是移植性,select已经使用了很久,我们周边支持网络和非阻塞socket的设备会有select的实现,甚至可能根本没有轮询方法。
  2. 第二个原因,select可以以ns的精度处理超时,poll和epoll的精度在ms。对于桌面应用或者服务器系统来说这不是个问题,但是对于实时嵌入式系统来说可能很有用。比如用来控制关闭核反应堆的系统,对于这种系统我们需要确保绝对安全...

上面的例子或许是仅有的我们必须使用select的情况。但是如果应用不会超过一定量的socket,比如200个,poll和select的性能差异并不显著,选择哪个主要还是看个人偏好或者其他原因。

使用Poll进行轮询

poll is a newer polling method which probably was created immediately after someone actually tried to write the high performance networking server. It is much better designed and doesn’t suffer from most of the problems which select has. In the vast majority of cases you would be choosing between poll and epoll/libevent.

poll是一个更新的轮询方法,很可能在有人真正着手开发高性能网络服务器的时候就被创造出来。它设计的更为完善,并且解决了select的大多数问题。大多数情况下,我们需要在poll和epoll/libevent之间做出选择。

To use poll, the developer needs to initialize the members of struct pollfd structure with the descriptors and events to monitor, and call the poll(). A typical workflow looks like that:

开发者需要用描述符和监控的事件来初始化struct pollfd,然后调用poll方法。一个常见的工作流如下:

// The structure for two events
struct pollfd fds[2];

// 监控socket1的输入
fds[0].fd = sock1;
fds[0].events = POLLIN;

// 监控socket2的输出
fds[1].fd = sock2;
fds[1].events = POLLOUT;

// 等待10秒
int ret = poll( &fds, 2, 10000 );
// 检查轮询有没有成功
if ( ret == -1 )
    // 报告错误并且中止
else if ( ret == 0 )
    // 超时;没有检测到事件
else
{
    // If we detect the event, zero it out so we can reuse the structure
    //如果检测到事件,将其置为0,我们可以重用这个结构
    if ( pfd[0].revents & POLLIN )
        pfd[0].revents = 0;
        // socket1的输入事件

    if ( pfd[1].revents & POLLOUT )
        pfd[1].revents = 0;
        // socket2的输出事件 
}

poll主要是为了修复select的问题,所以有以下优点:

  • 对于描述符没有1024这硬限制

  • 没有修改传入的pollfd中的数据。所以这个数据在poll调用中可以不断重用,只要将产生事件的描述符事件的输出设置为0。IEEE规范中提到"在每个pollfd结构中,poll方法应该清除输出"

  • 允许更细粒度的事件控制。比如它可以检测到远端的关闭,而无需监控读事件。

它也有一些缺点,比如poll在vista之前的windows系统中就不存在;在vista以及以上的系统中,它叫做WSAPoll。

还有poll的超时精度在ms级别,这大多时候也都不是问题。

但是下面两个问题我们需要注意:

  • 像select一样,我们仍然只有完全遍历整个列表,检查所有的输出才能知道哪个文件描述符触发了事件。更糟糕的是,在内核空间也是如此。内核需要遍历整个文件描述符列表找到哪些socket被监控,然后再次遍历整个列表来设置事件。
  • 像select一样,无法动态修改set或者关闭正在被轮询的socket

对于大多数客户端网络应用甚至服务端来说,这些都不是问题 - 唯一的例外是类似P2P应用,需要处理成千上万开启的连接。这样的话我们应该选择poll而不是select。在以下情况下,我们甚至应该使用poll而不是更新的epoll:

  • 我们需要支持的不仅仅是Linux,并且不想使用epoll的包装方法,比如libevent(epoll只适用于Linux)
  • 应用需要同时监控的socket不超过1000个(用epoll没有明显好处)
  • 应用需要监控超过1000个socket,但是连接都非常短。
  • 在有线程等待事件的时候,其他并不会修改这个事件。

使用epoll轮询

epoll是Linux中最新,最好的轮询方式。但是也不是那么新,2002年被加入到内核中。它与poll和select都不同,在内核中保存了当前被监控的描述符和关联的事件,并且暴露了增删改的API。

我们需要预先准备很多步骤来使用epoll:

  • 调用epoll_create创建epoll描述符
  • 使用需要的event和上下文数据指针初始化struct epoll,上下文可以是任何东西,epoll将它直接传给返回的事件类型。这里我们将一个指针存储到Connection类中
  • 调用epoll_ctl(EPOLL_CTL_ADD)将描述符添加到监控set中
  • 调用epoll_wait等待20个事件,然后保存到存储空间中。不像之前的方法,这个调用接收空的数据结构,然后使用触发的事件进行填充。例如,如果总共有200个描述符,其中5个有待定事件,epoll_wait会返回5,只有前5个pevent结构会被初始化。如果有50个有待定事件,第一次处理时前20个会被拷贝,剩下的30个会留在队列中(不会丢失)。
  • 遍历返回的东西,这个遍历会很短,因为只有触发的事件会被返回。

一个典型的流程如下:

// 创建epoll描述符。在应用中只需要一个,并且用来监控所有的socket
//方法参数现在被忽略,随便赋值
int pollingfd = epoll_create( 0xCAFE ); 

if ( pollingfd < 0 )
//报告错误

//初始化epoll数据结构,后面可能有更多事件加进来
struct epoll_event ev = { 0 };

//将connection类实例和事件关联,这里我们可以关联任何东西
//这里是我们自己加的,epoll不使用这个信息,存储了一个connection类的指针,pConnection1

ev.data.ptr = pConnection1;

//监控输入,不要在事件以后重新给描述符赋值
ev.events = EPOLLIN | EPOLLONESHOT;

//添加描述符到监控列表,即使其他线程在epoll_wait中等待,我们可以正确添加
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, pConnection1->getSocket(), &ev ) != 0 )
    // 报告错误

//等待直到有20个事件
struct epoll_event pevents[ 20 ];

// 等待10秒
int ready = epoll_wait( pollingfd, pevents, 20, 10000 );
// 检查epoll是否成功
if ( ret == -1 )
    // 报告错误并且中止
else if ( ret == 0 )
    // 超时;没有检测到事件
else
{
    // 检查是否监测到事件
    for ( int i = 0; i < ret; i++ )
    {
        if ( pevents[i].events & EPOLLIN )
        {
            // 返回connection指针
            Connection * c = (Connection*) pevents[i].data.ptr;
            c->handleReadEvent();
         }
    }
}

看到上面的实现,我们可以明白epoll的缺点。它用起来比较复杂。并且和poll相比需要更多库的调用。

epoll和poll/select相比有很多好处:

  • epoll返回触发事件的描述符列表,而不需要遍历整个描述符列表
  • 我们可以给监控的事件附加更有意义的上下文而不是socket文件描述符。在我们的例子中,我们附加了一个class指针,可以直接进行调用,而不需要再次进行查询
  • 我们可以在任何时候为监控添加或者删除socket,即使其他线程正在epoll_wait中。甚至还可以修改描述符事件而不会引发问题。并且实现的文档非常齐全,这使我们在实现中有更多的灵活性
  • 因为内核知晓所有被监控的描述符,它可以在描述符上注册事件,即使没有人在调用epoll_wait。这可以让我们实现一些有趣的功能,比如边沿触发(edge trigger)。
  • 在epoll中,可以有多个线程调用epoll_wait等在相同的epoll队列中,select和poll中就不可以。事实上,不仅仅是可以,而是在边沿触发模式中推荐的方式。

我们需要记住的是,epoll不是一个"更好的poll",和poll相比,它也有缺点:

  • 改变事件flag(比如从READ到WRITE)需要一个epoll_ctl系统调用,如果使用poll,只需在用户空间的一个简单的bitmask操作。epoll将5000个socket从读切换到写则需要5000个系统调用和上下文切换(直到2014年,epoll_ctl还没办法做到批处理,每个描述符需要单个切换)。但是poll只需要在一个pollfd结构中进行循环
  • 当一个accepted socket需要被加到set中,epoll同样需要一个epoll_ctl,这就意味着每个新的连接socket都需要两次系统调用(poll是一次)。如果我们的服务有很多短期的连接,并且只有很少的数据传输,epoll很可能可能需要更多时间进行响应
  • epoll是Linux专有,其他平台有相似机制,但是也不尽相同。比如边沿触发机制
  • 高性能的处理逻辑更为复杂,而且更难调试,尤其是边沿触发更容易出现死锁

因此我们应当在满足以下全部情况的时候使用epoll:

  • 应用使用了线程轮询,通过多个线程处理多个连接。单线程应用则不会利用到epoll的优势
  • 我们预期会有大量socket(多余1000)需要监控,数量再小epoll没有性能优势,再小的话epoll反而会更慢
  • 连接相对生命周期较长,正如之前所说,epoll在新连接只发送少量数据,然后立即断开的情况性能较差。因为epoll需要额外的系统调用将描述符添加到epoll set中
  • 我们的应用依赖Linux特殊的特性,或者我们可以提供epoll的包装类
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,014评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,796评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,484评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,830评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,946评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,114评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,182评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,927评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,369评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,678评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,832评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,533评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,166评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,885评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,128评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,659评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,738评论 2 351

推荐阅读更多精彩内容