深入探索网络IO模型

深入探索网络IO模型

同步、异步、阻塞、非阻塞

同步和异步,是针对调用结果是如何返回给调用者来说的,即调用的结果是调用者主动去获取的(比如一直等待recvfrom或者设置超时等待select(),则为同步,而调用结果是被调用者在完成之后通知调用者的,则为异步(比如windows的IOCP)

  • 同步通信是指:发送方和接收方通过一定机制,实现收发步调协调。如:发送方发出数据后,等接收方发回响应以后才发下一个数据包的通讯方式
  • 异步通信是指:发送方的发送不管接收方的接收状态,如:发送方发出数据后,不等接收方发回响应,接着发送下个数据包的通讯方式。

阻塞和非阻塞,是针对调用者所在线程是否在调用之后主动挂起来说的,即如果在线程中调用者发出调用之后,再被调用这返回之前,该线程主动挂起,则为阻塞,若线程不主动挂起,而继续向下执行,则为非阻塞。阻塞和非阻塞主要讨论的是被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。

  • 阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回
  • 非阻塞:非阻塞调用指在结果返回前,该调用不会阻塞当前线程

首先一个IO操作其实分成了两个步骤:1.发起IO请求,2.实际的IO操作。

阻塞IO和非阻塞IO的区别在于第一步,发起IO请求是否会被阻塞,如果阻塞直到完成那么就是传统的阻塞IO,如果不阻塞,那么就是非阻塞IO。

同步IO和异步IO的区别就在于第二个步骤是否阻塞。如果实际的IO读写阻塞请求进程,那么就是同步IO,因此阻塞IO、非阻塞IO、IO复用、信号驱动IO都是同步IO;如果不阻塞,而是操作系统帮你做完IO操作再将结果返回给你,那么就是异步IO。

网络IO

当一个网络IO发生时,通常涉及两个对象,一个是调用IO的进程,一个是系统内核。

一个输入操作通常包括两个不同的阶段:

  • 等待数据准备好;
  • 从内核向进程复制数据。

对于网络IO的套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

四种网络IO模型:

  • 阻塞IO模型
  • 非阻塞IO模型
  • 多路IO复用模型
  • 异步IO模型
阻塞IO模型

阻塞IO是socket的默认设置,其模型如下图所示:
在这里插入图片描述

应用程序调用recvfrom产生一个系统调用,系统内核开启IO的第一个阶段:准备数据。 对于网络 IO 来说,很多时候数据在一开始还没到达时(比如还没有收到一个完整的 TCP 包),系统内核就要等待足够的数据到来。 此时整个用户进程会被阻塞。 当内核一直等到数据准备好了,它就会将数据从系统内核中拷贝到用户内存中,然后内核返回结果给用户进程,返回到用户空间,用户进程解除阻塞的状态,重新运行起来。

因此,阻塞 IO 模型的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据)都被阻塞了。

实际上,大多数socket接口都是阻塞型的,也就是指系统调用时(一般是 IO 接口) 却不返回调用结果,并让当前线程一直处于阻塞状态,只有当该系统调用获得结果或者超时出错时才返回结果。 这便引起了一个问题,即在调用 send() 的同时,线程处于阻塞状态,则在此期间,线程将无法执行任何运算或响应任何网络请求,这样,socket程序几乎失去了实际意义,也就是只能一对一并且固定发消息流程才行。

解决这个办法的最直接的方式就是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。因为进程的开销远大于线程,所以如果数量比较大的话不建议多进程。但是如果单个连接需要进行比较长时间或者大规模的CPU计算或者文件访问等,采用进程比较合适一些,因为进程更加稳定。

一个socket是可以accept多次的,accept的函数接口:

int accept(int fd, struct sockaddr *addr , socklen_t *addr len) 

输入参数 fd 是从 socket()、 bind() 和 listen() 中沿用下来的 socket 句柄值。 执行完 bind() 和 listen()后,操作系统已经开始在指定的端口处监昕所有的连接请求,如果有请求,则将该连接请求加入请求队列(即全连接队列)。调用accept的作用就是从全连接队列中抽取第一个连接的信息,创建一个与 “同类的新的 socket 返回句柄,这个新的 socket 句柄即是后续 read() 和 recv() 的输入参数。 如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进 入队列。

因此采用多个线程去accept看起来可以解决为多个客户机提供连接的要求,但是如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据 系统资源,降低系统对外界响应的效率,而线程与进程本身也更容易进入假死状态。

线程池或连接池技术可以在一定程度上缓解频繁调用IO接口带来的资源占用情况。“线程池”旨在降低创建和销毁 线程的频率,使其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。 “连接池”是指维持连接的缓存池,尽量重用已有的连接,降低创建和关闭连接的频率。使用"池”必须考虑其面临的响应规模,并根据响应规模调整“池” 的大小。

多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞模型来尝试解决这个问题。

例子:就像订外卖,你订过外卖后手机就关机了,因此你选择在门口一直等,直到拿到外卖后再去忙别的事情,这就是阻塞IO。

非阻塞IO模型

socket使用非阻塞IO模型需要对socket进行另行设置,非阻塞IO模型如下所示:
在这里插入图片描述

内核收到系统调用后,若数据未准备好立即返回error,用户进程收到error会继续产生系统调用,直到数据准备好了并被拷贝到用户空间。

整个过程解析:当用户进程发出 read 操作时,如果内核中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个错误。 从用户进程角度讲,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。 当用户进程分析错误结果可以知道是数据还没有准备好,于是它之后再次发送read 操作。一旦内核中的数据准备好了, 并且又再次收到了用户进程的系统调用,那么它马上就将数据复制到了用户内存中,然后返回正确的返回值。

在非阻塞式 IO 中,用户进程需要不断地主动询问 kernel 数据是否准备好。也就是必须进行的一个操作就是 轮询 。应用进程持续轮询内核,以查看数据是否就绪。这样做往往会耗费大量的CPU时间,这种模型通常会在专门提供某种功能的系统才有。

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

fcntl( fd, F_SETFL, O_NONBLOCK ); 

在非阻塞状态下, recv()接口在被调用后立即返回,返回值代表了不同的含义

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

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

例子:还是订外卖的例子,订过外卖后手机关机了,没办法联系外卖员,因此你选择每过一分钟就出门看一下,直到成功拿到外卖。这就是非阻塞IO。

多路IO复用模型

多路 IO 复用,它的基本原理就是有个函数(例如 select)会不断地轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程,多路 IO 复用模型的流程如下所示:
在这里插入图片描述

当用户进程调用了 select,那么整个进程会被阻塞,而同时,内核会“监视”所有 select 负责的 socket,一旦有一个socket数据准备好了,kernel即返回,用户再去recvfrom产生系统调用将数据从内核空间读到用户空间。

select/epoll的优势在于能够处理更多的连接。select调用是内核级别的,select的轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket,能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程。select或poll调用之后,会阻塞进程,与blocking IO阻塞不同在于,进程是被 select 这个函数阻塞,而不是被 socket IO 阻塞,此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。对于数据到达的处理,这种监视的事情是内核负责的。

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

多路 IO 复用模型的一个执行周期:
在这里插入图片描述

使用 select() 的事件驱动模型只用单线程(进程) 执行,占用资源少,不消耗太多CPU 资源,同时能够为多客户端提供服务。但是多路IO复用也有一些问题,例如一个事件的处理需要特别长时间的话之后的事件就需要等待很久,因此会降低及时性。同时select接口也需要消耗大量时间去轮询各个句柄。

异步IO模型

异步IO模型是无阻塞的,如图所示:
在这里插入图片描述
过程:用户进程发起 read 操作之后,立刻就可以开始去做其他的事;而另一方面,从内核的角度,当它收到一个异步的 read 请求操作之后,首先会立刻返回,所以不会对用户进程产生任何阻塞。 然后,内核会等待数据准备完成,然后将数据拷贝到用户内存中,当这一切都完成之后,内核会给用户进程发送一个信号,返回 read 操作已完成的信息。

模型对比

调用阻塞 IO 会一直阻塞住对应的进程直到操作完成,而非阻塞 IO 在内核还在准备数据的情况下会立刻返回。 两者的区别就在于同步 IO 进行 IO 操作时会阻塞进程。因此阻塞 IO、非阻塞 IO 及多路 IO 复用都属于同步 IO,而异步IO属于非阻塞的。
在这里插入图片描述

wakeup callback机制

Linux通过socket睡眠队列来管理所有等待socket的某个事件的process,同时通过wakeup机制来异步唤醒整个睡眠队列上等待事件的process,通知process相关事件发生。通常情况,socket的事件发生的时候,其会顺序遍历socket睡眠队列上的每个process节点,调用每个process节点挂载的callback函数。在遍历的过程中,如果遇到某个节点是排他的,那么就终止遍历,总体上会涉及两大逻辑:(1) 睡眠等待逻辑;(2) 唤醒逻辑。

  • 睡眠等待逻辑:涉及select、poll、epoll_wait的阻塞等待逻辑

    • select、poll、epoll_wait陷入内核,判断监控的socket是否有关心的事件发生了,如果没,则为当前process构建一个wait_entry节点,然后插入到监控socket的sleep_list
    • 进入循环的schedule直到关心的事件发生了
    • 关心的事件发生后,将当前process的wait_entry节点从socket的sleep_list中删除。
  • 唤醒逻辑

    • socket的事件发生了,然后socket顺序遍历其睡眠队列,依次调用每个wait_entry节点的callback函数
    • 直到完成队列的遍历或遇到某个wait_entry节点是排他的才停止
    • 一般情况下callback包含两个逻辑:
      • wait_entry自定义的私有逻辑;
      • 唤醒的公共逻辑,主要用于将该wait_entry的process放入CPU的就绪队列,让CPU随后可以调度其执行

select、poll、epoll

首先理解多路IO复用和阻塞IO的最大区别在于,我们要等待“数据可读”这个事件,而不是去等待“实际的数据”,也就是说,只要这个阻塞解除,就意味着一定有数据可读,意味着接下来调用recv/recvform一定不会阻塞。

poll和select应该被归类为系统调用,它们可以阻塞地同时探测一组支持非阻塞的IO设备,是否有事件发生(如可读,可写,有高优先级的错误输出,出现错误等等),直至某一个设备触发了事件或者超过了指定的等待时间——也就是它们的职责不是做IO,而是帮助调用者寻找当前就绪的设备。而epoll是多路复用IO中的一种方式,linux2.6以上内核支持。可以说epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll也使得用户空间程序有可能缓存IO状态,减少epoll_wait / epoll_pwait的调用,提高应用程序效率。

select

select系统调用的功能是对多个文件描述符进行监视,当有文件描述符的文件读写操作完成,发生异常或者超时,该调用会返回这些文件描述符。

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds,struct timeval *timeout);

结构体 fd_set 可以理解为一个集合,这个 集合中存放的是文件描述符( file descriptor),即文件句柄,可以描述一个socket。

结构体 timeval 用来代表时间值,有两个成员,一个是秒数,另一 个是毫秒数。

select的参数:

  • maxfdp 是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最 大值加 1。
  • readfds 是指向 fd_set 结构的指针,这个集合中应该包括文件描述符。 因为要监视文件描述符的读变化的,即关心是否可以从这些文件中读取数据,如果这个集合中有一个文 件可读, select 就会返回一个大于 0 的值,表示有文件可读。 如果没有可读的文件,则根据timeout参数再判断是否超时:若超出timeout 的时间, select 返回 O ;若发生错误返回负值; 也可以传入 NULL 值,表示不关心任何文件的读变
  • writefds 是指向fd_set 结构的指针,这个集合中应该包括文件描述符。 因为要监视文件描述符的写变化的,即关心是否可以向这些文件中写入数据,如果这个集合中有一个文 件可写, select 就会返回一个大于 0 的值,表示有文件可写。 如果没有可写的文件,则根据 timeout 参数再判断是否超时:若超出 timeout 的时间, select 返回 O ;若发生错误返回负值; 也可以传入 NULL 值,表示不关心任何文件的写变
  • errorfds 与上面两个一样,用来监视文件错误异常
  • timeout 是 select 的超时时间 , 它可以使 select 处于 3 种状态: ①若将 NULL 以形参传入,即不传入时间结构,就是将 select 置于阻塞状态, 一定等到监 视文件描述符集合中某个文件描述符发生变化为止;②若将时间值设为 0,就变成一个纯粹 的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回 0,有 变化返回一个正值; ③ timeout 的值大于 0,这就是等待的超时时间,即 select 在 timeout 时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回,返回值同上述。
  • 返回值:准备就绪的描述符数,若超时则返回 0,若出错则返回-1

当用户process调用select的时候,select会将需要监控的readfds集合拷贝到内核空间(假设监控的仅仅是socket可读),然后遍历自己监控的socket sk,挨个调用sk的poll逻辑以便检查该sk是否有可读事件,遍历完所有的sk后,如果没有任何一个sk可读,那么select会调用schedule_timeout进入schedule循环,使得process进入睡眠。如果在timeout时间内某个sk上有数据可读了,或者等待timeout了,则调用select的process会被唤醒,接下来select就是遍历监控的sk集合,挨个收集可读事件并返回给用户了。

select存在以下问题:

  • 为了减少数据拷贝带来的性能损坏,内核对被监控的fds集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)。
  • 被监控的fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次调用sk的poll函数收集可读事件,也就是O(n)
poll

poll只是解决了select的问题的fds集合大小1024限制问题,性能问题并没有得到妥善解决,仍是O(n)

#include<poll.h> 
int poll(struct pollfd * fds,unsigned int nfds ,int timeout);

poll的参数中,直接列出了要监视的文件描述符的信息,而不像select一样要列出从0开始到nfds-1的所有文件描述符。这样的好处是,poll不需要查询很多无关的文件描述符的信息,在一定场合下效率会有所提高。

poll的实现与select非常类似。一个区别是使用的数据结构。poll中采用了poll_list结构体来记录要监视的文件描述符信息。poll_list中,每个pollfd代表一个要监视的文件描述符的信息。这些pollfd以数组的形式链接到poll_list链表中,每个数组的元素个数不能超过POLLFD_PER_PAGE。在把参数拷贝到内核态之后,sys_poll会调用do_poll()。在do_poll()中,函数遍历poll_list链表,然后调用do_pollfd()对每个poll_list节点中的pollfd数组进行遍历。在do_pollfd()中,检查数组中的每个fd,检查的过程与select类似,调用fd对应的poll函数指针:mask = file->f_op->poll(file, *pwait)。

1.如果有必要,把当前进程挂到文件操作对应的等待列队中,同时也放到polltable中。

2.检查文件操作状态,保存到mask变量中。

在遍历了所有文件描述符后,调用timeout = schedule_timeout(timeout);让当前进程进入休眠状态,直到超时或者有文件操作完成,唤醒当前进程才返回

由于poll的参数只包括了用户感兴趣的文件信息,因此一定程度上,poll比select稍微要高效一些。前提是:要监视的文件描述符不连续,非常离散。poll与select共同的问题是,他们都是遍历所有的文件描述符。当要监视的文件描述符很多,并且每次只返回很少的文件描述符时,select/poll每次都要反复地从用户态拷贝文件信息,每次都要重新遍历文件描述符,而且每次都要把当前进程挂到对应事件的等待队列和poll_table的等待队列中。这里做了挺多重复劳动。

epoll

epoll 是在 Linux 2.6 内核中提出的,是 select 和 poll 的增强版本。 相比来说, epoll 更加灵活,没有描述符限制。 epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间之间 的数据拷贝只需一次。

#include <sys/epoll .h>
int epoll_create(int size); 
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);

epoll的设计思路,是把select/poll单个的操作拆分为1个epoll_create+多个epoll_ctrl+一个wait。此外,内核针对epoll操作添加了一个文件系统”eventpollfs”,每一个或者多个要监视的文件描述符都有一个对应的eventpollfs文件系统的inode节点,主要信息保存在eventpoll结构体中。而被监视的文件的重要信息则保存在epitem结构体中。所以他们是一对多的关系。由于在执行epoll_create和epoll_ctrl时,已经把用户态的信息保存到内核态了,所以之后即使反复地调用epoll_wait,也不会重复地拷贝参数,扫描文件描述符,反复地把当前进程放入/放出等待队列。

详细介绍一下三个函数:

  • epoll_create:创建一个 epoll 的句柄, size 用来告诉内核要监听的数目 。 这个参数不同于 select()中的第一个参数,是最大监听的 fd+1 的值。 需要注意的是,当创建好 epoll 句柄后,它就会占用一个fd值,在 Linux 下如果查看/proc/ 进程的 id/fd/,是能够看到这个fd值的,所以在使用完 epoll 后,必须调用 close()关闭,否则可能导致fd被耗尽
  • epoll_ctl:epoll 的事件注册函数,它不同于 select()在监听事件时告诉内核要监听什么类型的事 件,而是先注册要监听的事件类型
  • epoll_wait:等待事件的产生,即等待文件操作完成并返回

epoll机制是针对select/poll的缺陷设计的。通过新引入的eventpollfs文件系统,epoll把参数拷贝到内核态,在每次轮询时不会重复拷贝。通过把操作拆分为epoll_create,epoll_ctl,epoll_wait,避免了重复地遍历要监视的文件描述符。此外,由于调用epoll的进程被唤醒后,只要直接从epitem的完成队列中找出完成的事件,找出完成事件的复杂度由O(N)降到了O(1)。但是epoll的性能提高是有前提的,那就是监视的文件描述符非常多,而且每次完成操作的文件非常少。所以epoll能否显著提高效率,取决于实际的应用场景。

epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者 遇到EAGAIN错误。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

epoll的优点:

  • 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
  • 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;也就是说epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  • 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
三者的对比

select、 poll 和 epoll 都是多路 IO 复用的机制。 多路 IO 复用是通过一种机制,可以监视多个描述符, 一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。 但 select、 poll 和 epoll 本质上都是同步 IO,因为它们都需要在读写事件就绪后自己负责进行读写,即是阻塞的。

select和poll的区别:

  • poll() 在应付大数目的文件描述符的时候速度更快,因为对于 select() 来说内核需要检查大量描述符对应的 fd_set 中的每一个比特位,比较费时
  • select() 可以监控的文件描述符数目是固定的,相对来说也较少( 1024 或 2048 )。 如果需要监控数值比较大的文件描述符,或是分布得很稀疏的较少的描述符,效率也会很低。 而对于 poll()函数来说,就可以创建特定大小的数组来保存监控的描述符,而不受文件描述符值大小的影响,而且 poll()可以监控的文件数目远大于 select()
  • select() 的可移植性更好,对于超时值提供了更好的精度

epoll的优点:

  • 支持一个进程打开大数目的 socket 描述符(FD),它所支持的 FD 上限是最大 可以打开文件的数目
  • IO 效率不随 FD 数目增加而线性下降, 只会对“活跃”的 socket 进行操作----主是因为在内核中实现 epoll 是根据每个fd上面的 callback 函数实现的, 只有“活跃”的 socket 才会主动去调用 callback 函数,其他 idle 状态 socket 则不会.
  • 使用 mmap 加速内核与用户空间的消息传递, epoll 是通过内核与用户空间 mmap 处于同一块内存实现避免不必要的内存拷贝。

FD 上限是最大 可以打开文件的数目

  • IO 效率不随 FD 数目增加而线性下降, 只会对“活跃”的 socket 进行操作----主是因为在内核中实现 epoll 是根据每个fd上面的 callback 函数实现的, 只有“活跃”的 socket 才会主动去调用 callback 函数,其他 idle 状态 socket 则不会.
  • 使用 mmap 加速内核与用户空间的消息传递, epoll 是通过内核与用户空间 mmap 处于同一块内存实现避免不必要的内存拷贝。

猜你喜欢

转载自blog.csdn.net/dingdingdodo/article/details/108215882