网络io与select,poll,epoll

一个形象的类比

水龙头等水

水龙头就是内核进程 等水复制到内核区

学生就是进行io的进程或线程

阻塞io 学生在那里 等水来

非阻塞io 学生看数据没准备好,先回寝室,一会儿再过来检查下,看水准备好没

多路复用io 阿姨帮忙看着水龙头,等来水的时候通知学生

前面三个都是同步io,因为水还要学生自己来接

异步io 阿姨帮忙看着水龙头,当水来了 ,阿姨帮忙接水,等接好水 通知同学 已经接好并放在宿舍门口

信号驱动io 某个水龙头数据准备好,会给同学手机发个信息

基本原理

网络io需要调用外部设备,需要系统调用

通常涉及到两个系统对象,一个是用户空间调用io的进程和线程.

另一个是内核空间的内核系统

网络io与select,poll,epoll_第1张图片

阻塞io

阻塞io 学生在那里 等水来

  • 主要问题就是接受一个连接后,发送或接收时,阻塞住,接受不了其他连接,其他连接也发不了,所以采用多线程,每个连接分配一个线程. 但当大量连接到来,系统资源撑不住了.所以可以采用线程池连接池这种池化技术,复用申请的线程.但当然在 万计的连接需求面前,池化技术也是浮云~

有两个阻塞过程,第一个要等数据到内核空间,第二个是等数据拷贝完成

阻塞io代表两个过程都被阻塞了

当用户进程调用了 read 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于 network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的数据包), 这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果, 用户进程才解除 block 的状态,重新运行起来。 所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了。 几乎所有的程序员第一次接触到的网络编程都是从listen()、send()、recv() 等接口开始的, 这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器/客户机的模型。下面是一 个简单地“一问一答”的服务器。

大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口) 不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返 回。 实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给 网络编程带来了一个很大的问题,如在调用 send()的同时,线程将被阻塞,在此期间,线程 将无法执行任何运算或响应任何的网络请求。 一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的 是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连 接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远 大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问, 则进程较为安全。通常,使用 pthread_create ()创建新线程,fork()创建新进程。 我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。

在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新 线程,并在新线程中提供为前例同样的问答服务。 很多初学者可能不明白为何一个 socket 可以 accept 多次。实际上 socket 的设计者 可能特意为多客户机的情况留下了伏笔,让 accept()能够返回一个新的 socket。下面是 accept 接口的原型:

int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

输入参数 s 是从 socket(),bind()和 listen()中沿用下来的 socket 句柄值。执行完 bind()和 listen()后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请 求,则将该连接请求加入请求队列。调用accept()接口正是从 socket s 的请求队列抽 取第一个连接信息,创建一个与 s 同类的新的 socket 返回句柄。新的 socket 句柄即是后 续read()和recv()的输入参数。如果请求队列当前没有请求,则accept() 将进入阻塞 状态直到有请求进入队列。 上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实 并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据 系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。

很多程序员可能会考虑使用**“线程池连接池。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。 但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓始终有其上限,当请求大大超过上限时,构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用必须考虑其面临的响应规模,并根据响应规模调整”**的大小。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连 接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的 解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻 塞接口来尝试解决这个问题。

非阻塞io

  • 不被推荐,没必要每次都去查询,会占用cpu,其实操作系统提供了更高效的方式,比如通过select可以检查多个文件句柄

Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读

操作时,流程是这个样子:

从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那 么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据 准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用 户内存,然后返回,所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有。

  • recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;
  • recv() 返回 0,表示连接已经正常断开;
  • recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;
  • recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno。

非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下 的函数可以将某句柄 fd 设为非阻塞状态。

fcntl( fd, F_SETFL, O_NONBLOCK );

下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据的模型。

可以看到服务器线程可以通过循环调用 recv()接口,可以在单个线程内实现对所有连 接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv()将大幅度推高 CPU 占用率;此外,在这个方案中 recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select()多路复用模式, 可以一次检测多个连接是否活跃。

多路复用io

  • select 可以检查多个文件句柄,是否可读可写,是个系统调用,会阻塞,不建议使用,有更好的方式epoll.select需要每次查询时,需要把文件句柄复制到内核,然后遍历,还有个数限制. epoll有三个函数 epoll_create epoll_ctl epoll_wait 每次加入句柄时,就加入到内核的缓存中,会在那里创建一个红黑树,并且为每个文件句柄注册回调函数,当有响应的时候,就会自动把这个节点加入到双端队列中,epoll_wait 只需要检查双端队列即可.有两种模式 水平模式和边缘模式,

    水平模式就是上次每处理的事件,下次epoll_wait 还会给你推过来.一碗水端平

    边缘模式就是上次没处理,这次就不给你推它了,除非它有更新.

IO multiplexing 这个词可能有点陌生,但是提到 select/epoll,大概就都能明白了。有些地方 也称这种 IO 方式为事件驱动 IO(event driven IO)。我们都知道,select/epoll 的好处就在于单 个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select/epoll 这个 function会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所 有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需 要使用两个系统调用(select和read),而blocking IO只调用了一个系统调用(read)。 但是使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请 求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到 在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方 式才能达到这个目的。(多说一句:所以,如果处理的连接数不是很高的话,使用 select/epoll的web server不一定比使用multi-threading + blocking IO的web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得 更快,而是在于能处理更多的连接。)

在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如 上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这 个函数 block,而不是被 socket IO 给 block。因此 select()与非阻塞 IO 类似。

大部分 Unix/Linux 都支持 select 函数,该函数用于探测多个文件句柄的状态变化。 下面给出 select 接口的原型:

这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为 16 的句柄,则该 fd_set 的第 16 个 bit 位被标记为 1。具体的置位、验证 可使用 FD_SET、FD_ISSET 等宏实现。在 select()函数中,readfds、writefds 和 exceptfds 同时作为输入参数和输出参数。如果输入的 readfds 标记了 16 号句柄,则 select()将检测16号句柄是否可读。在select()返回后,可以通过检查readfds有否标 记 16 号句柄,来判断该“可读”事件是否发生。另外,用户可以设置 timeout 时间。

  下面将重新模拟上例中从多个客户端接收数据的模型。

网络io与select,poll,epoll_第2张图片

 

上述模型只是描述了使用 select()接口同时从多个客户端接收数据的过程;由于 select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统.

网络io与select,poll,epoll_第3张图片这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个“可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。

 

上述模型中,最关键的地方是如何动态维护 select()的三个参数 readfds、writefds和 exceptfds。作为输入参数,readfds 应该标记所有的需要探测的“可读事件”的句柄,其中永远包括那个探测 connect() 的那个“母”句柄;同时,writefds 和 exceptfds 应该标记所有需要探测的“可写事件”和“错误事件”的句柄 ( 使用 FD_SET() 标记 )。 作为输出参数,readfds、writefds和exceptfds中的保存了 select() 捕捉到的 所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用FD_ISSET()检查 ),以确定到底哪些句柄发生了事件。

上述模型主要模拟的是“一问一答”的服务流程,所以如果 select()发现某句柄捕捉到了“可读事件”,服务器程序应及时做 recv()操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入 writefds,准备下一次的“可写事件”的 select()探测。同样,如果 select()发现某句柄捕捉到“可写事件”,则程序应及时做 send()操作,并准备好下一次的“可读事件”探测准备。

网络io与select,poll,epoll_第4张图片

 

select的几大缺点:

(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

(3)select支持的文件描述符数量太小了,默认是1024

epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

LT模式:水平模式(一碗水端平)当epoll_wait检测到文件描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wati时,会再次响应应用程序并通知次事件。

ET模式:边缘模式 当epoll_wait检测到文件描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

异步io

Linux 下的 asynchronous IO 用在磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引入。先看一下它的流程

网络io与select,poll,epoll_第5张图片

 

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进 程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当 这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。 用异步 IO 实现的服务器这里就不举例了,以后有时间另开文章来讲述。异步 IO 是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。 到目前为止,已经将四个 IO 模型都介绍完了。现在回过头来回答最初的那几个问题: blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别 在哪。 先回答最简单的这个:blocking 与 non-blocking。前面的介绍中其实已经很明确的 说明了这两者的区别。调用 blocking IO 会一直 block 住对应的进程直到操作完成,而 non-blocking IO 在 kernel 还在准备数据的情况下会立刻返回。

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

服务器模型reactor 和 proactor

对高并发编程,网络连接上的消息处理,可以分为两个阶段:等待消息准备好、消息处理。当使用默认的阻塞套接字时(例如上面提到的 1 个线程捆绑处理 1 个连接),往往是把 这两个阶段合而为一,这样操作套接字的代码所在的线程就得睡眠来等待消息准备好,这导 致了高并发下线程会频繁的睡眠、唤醒,从而影响了 CPU 的使用效率。 高并发编程方法当然就是把两个阶段分开处理。即,等待消息准备好的代码段,与处理 消息的代码段是分离的。当然,这也要求套接字必须是非阻塞的,否则,处理消息的代码段 很容易导致条件不满足时,所在线程又进入了睡眠等待阶段。那么问题来了,等待消息准备 好这个阶段怎么实现?它毕竟还是等待,这意味着线程还是要睡眠的!解决办法就是,线程 主动查询,或者让 1 个线程为所有连接而等待!这就是 IO 多路复用了。多路复用就是处理 等待消息准备好这件事的,但它可以同时处理多个连接!它也可能“等待”,所以它也会导致 线程睡眠,然而这不要紧,因为它一对多、它可以监控所有连接。这样,当我们的线程被唤 醒执行时,就一定是有一些连接准备好被我们的代码执行了。 作为一个高性能服务器程序通常需要考虑处理三类事件: I/O 事件,定时事件及信号。 两种高效的事件处理模型:Reactor 和 Proactor。

reactor

  • reactor 可以利用一个cpu的全部能力,就是 通过 io多路复用(epoll_wait) 不断检查各个事件,并且对于每个类型的事件,调用相应的函数进行处理.

伪代码

# 伪代码示例
class Reactor:
    def __init__(self):
        self.handlers = {}

    def register(self, event_type, handler):
        self.handlers[event_type] = handler

    def run(self):
        while True:
            for event in self.get_events():
                event_type = event.get_type()
                handler = self.handlers.get(event_type)
                if handler:
                    handler(event)

    def get_events(self):
        # 获取事件的代码
        pass

class EventHandler:
    def handle(self, event):
        # 处理事件的代码
        pass

# 使用示例
reactor = Reactor()
reactor.register('read', ReadEventHandler())
reactor.register('write', WriteEventHandler())
reactor.run()




Reactor 模型有三个重要的组件:  多路复用器:由操作系统提供,在 linux 上一般是 select, poll, epoll 等系统调用。  事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中。  事件处理器:负责处理特定事件的处理函数。

网络io与select,poll,epoll_第6张图片

 

具体流程如下:

  1. 注册读就绪事件和相应的事件处理器;
  2. 事件分离器等待事件;
  3. 事件到来,激活分离器,分离器调用事件对应的处理器;
  4. 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制

权。 Reactor 模式是编写高性能网络服务器的必备技术之一,它具有如下的优点:

  •  响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;

  •  编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进

    程的切换开销;

  •  可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;

  •  可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;

    Reactor 模型开发效率上比起直接使用 IO 复用要高,它通常是单线程的,设计目标是 希望单线程使用一颗 CPU 的全部资源,但也有附带优点,即每个事件处理中很多时候可以不考虑共享资源的互斥访问。可是缺点也是明显的,现在的硬件发展,已经不再遵循摩尔定律,CPU 的频率受制于材料的限制不再有大的提升,而改为是从核数的增加上提升能力, 当程序需要使用多核资源时,Reactor 模型就会悲剧, 为什么呢?

    如果程序业务很简单,例如只是简单的访问一些提供了并发访问的服务,就可以直接开启多个反应堆,每个反应堆对应一颗 CPU 核心,这些反应堆上跑的请求互不相关,这是完全可以利用多核的。例如 Nginx 这样的 http 静态服务器。

    proactor模型

    • proactor 使用异步接口,不管io,操作系统内核管io
    • 网络io与select,poll,epoll_第7张图片

     

    1. 从上面的处理流程,我们可以发现 proactor 模型最大的特点就是 Proactor 最大的特点是

    使用异步 I/O。所有的 I/O 操作都交由系统提供的异步 I/O 接口去执行。工作线程仅仅负责业务逻辑。在 Proactor 中,用户函数启动一个异步的文件操作。同时将这个操作注册到多路复用器上。多路复用器并不关心文件是否可读或可写而是关心这个异步读操作是否完成。异步操作是操作系统完成,用户程序不需要关心。多路复用器等待直到有完成通知到来。当操 作系统完成了读文件操作——将读到的数据复制到了用户先前提供的缓冲区之后,通知多路 复用器相关操作已完成。多路复用器再调用相应的处理程序,处理数据.

    Proactor 增加了编程的复杂度,但给工作线程带来了更高的效率。Proactor 可以在 系统态将读写优化,利用 I/O 并行能力,提供一个高性能单线程模型。在 windows 上, 由于没有 epoll 这样的机制,因此提供了 IOCP 来支持高并发, 由于操作系统做了较好的 优化,windows 较常采用 Proactor 的模型利用完成端口来实现服务器。在 linux 上,在 2.6 内核出现了 aio 接口,但 aio 实际效果并不理想,它的出现,主要是解决 poll 性能不 佳的问题,但实际上经过测试,epoll 的性能高于 poll+aio,并且 aio 不能处理 accept, 因此 linux 主要还是以 Reactor 模型为主。

    在不使用操作系统提供的异步 I/O 接口的情况下,还可以使用 Reactor 来模拟 Proactor,差别是:使用异步接口可以利用系统提供的读写并行能力,而在模拟的情况下,这需要在用 户态实现。具体的做法只需要这样:

    1. 注册读事件(同时再提供一段缓冲区)
    2. 事件分离器等待可读事件
    3. 事件到来,激活分离器,分离器(立即读数据,写缓冲区)调用事件处理器
    4. 事件处理器处理数据,删除事件(需要再用异步接口注册)

    我们知道,Boost.asio 库采用的即为 Proactor 模型。不过 Boost.asio 库在 Linux 平台采用

    epoll 实现的 Reactor 来模拟 Proactor,并且另外开了一个线程来完成读写调度。

 

你可能感兴趣的:(网络,linux,运维)