Linux网络IO模型

发布于 2020-06-02 · 本文总共 6841 字 · 阅读大约需要 20 分钟

网络IO模型

阻塞/非阻塞&&同步/异步

是针对IO请求的两个阶段来说的:即等待资源阶段和使用资源阶段

阻塞/非阻塞—-等待IO资源(wait for data)

比如等待网络传输数据可用的过程

阻塞

指的是在数据不可用时I/O请求一直阻塞,直到数据返回;

非阻塞

指的是数据不可用时I/O请求立即返回,直到被通知资源可用为止。

同步/异步—-使用IO资源(copy data from kernel to user)

比如从网络上接收到数据,并且拷贝到应用程序的缓冲区里面

同步。

指的是I/O请求在读取或者写入数据时会阻塞,直到读取或者写入数据完成;

异步

指的是I/O请求在读取或者写入数据时立即返回, 当操作系统处理完成I/O请求并且将数据拷贝到用户提供的缓冲区后, 再通知应用I/O请求执行完成。

五种IO模型

以烧水(wait for data)—-喝水(copy data from kernel to user)为例

同步阻塞I/O(blocking IO)

io_blocking

一直等着(等待资源)水烧开,然后倒水(使用资源)

Linux默认情况下所有的socket都是blocking; 当用户进程调用了recvfrom这个系统调用,

wait for data:

kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。

copy data from kernel to user:

当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

同步非阻塞I/O

io_blocking

在烧水的时候躺在沙发上看会儿电视(不再时时刻刻等待资源),但是还是要时不时地去看看水开了没有,一旦水开了,马上去倒水(使用资源)

用户进程发出read操作时,如果kernel中的数据还没有准备好, 那么它并不会block用户进程,而是立刻返回一个error; 用户进程需要不断的主动询问kernel数据好了没有

同步多路I/O复用

io_blocking

同时烧好多壶水,那你就在看电视的间隙去看看哪壶水开了(等待多个资源), 哪一壶开了就先倒哪一壶,这样就加快了烧水的速度,这就是同步多路 I/O 复用

select/epoll的优势并不是对于单个连接能处理得更快, 而是在于能处理更多的连接。

单个process就可以同时处理多个网络连接的IO。 它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket, 当某个socket有数据到达了,就通知用户进程

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

信号驱动I/O

io_blocking

给水壶加一个报警器(信号),只要水开了就马上去倒水,这就是信号驱动I/O

异步I/O

io_blocking

发明一个智能水壶,在水烧好后自动就可以把水倒好,这就是异步 I/O

用户进程发起read操作之后,立刻就可以开始去做其它的事, kernel完成后会给用户进程发送一个signal,告诉它read操作完成了

总结

io_blocking

IO多路复用:select、poll、epoll详解

I/O多路复用就是通过一种机制,一个进程可以监视多个描述符, 一旦某个描述符就绪(一般是读就绪或者写就绪), 能够通知程序进行相应的读写操作

select

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

调用后select函数会阻塞,直到有描述副就绪(有数据可读、可写、或者有except), 或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。 当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

优点: 几乎在所有的平台上支持

缺点:单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

poll返回后,需要轮询pollfd来获取就绪的描述符。

select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。 事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本

epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中, 这样在用户空间和内核空间的copy只需一次。

int epoll_create(int size);//创建一个epoll的句柄,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);

int epoll_create(int size); 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大

当创建好epoll句柄后,它就会占用一个fd值,在linux下查看/proc/进程id/fd/

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 对指定描述符fd执行op操作: 添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。 分别添加、删除和修改对fd的监听事件

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 等待epfd上的io事件,最多返回maxevents个事件。

epoll_pwait(4, [], 128, 0, NULL, 0) = 0

需要知道的

进程&线程

task_struct就是 Linux 内核对于一个进程的描述,也可以称为”进程描述符”

struct task_struct {
    // 进程状态
    long              state;
    // 虚拟内存结构体
    struct mm_struct  *mm;
    // 进程号
    pid_t              pid;
    // 指向父进程的指针
    struct task_struct __rcu  *parent;
    // 子进程列表
    struct list_head        children;
    // 存放文件系统信息的指针
    struct fs_struct        *fs;
    // 一个数组,包含该进程打开的文件指针
    struct files_struct        *files;
};

mm指向的是进程的虚拟内存,也就是载入资源和可执行文件的地方; files指针指向一个数组,这个数组里装着所有该进程打开的文件的指针—-文件描述符

无论线程还是进程,都是用task_struct结构表示的,唯一的区别就是共享的数据区域不同。

文件描述符

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

每个Unix进程(除了可能的守护进程)应均有三个标准的POSIX文件描述符,对应于三个标准流:

0 Standard input STDIN_FILENO stdin

1 Standard output STDOUT_FILENO stdout

2 Standard error STDERR_FILENO stderr

一个进程会从files[0]读取输入,将输出写入files[1],将错误信息写入files[2]

每个进程被创建时,files的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。 「文件描述符」就是指这个文件指针数组的索引, 所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。

缓存IO

缓存IO又被称作标准IO,大多数文件系统的默认IO操作都是缓存IO。 在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中, 也就是说,数据会先被拷贝到操作系统内核的缓冲区中, 然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存IO的缺点:

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

C10K

在 C10K 以前,Linux 中网络处理都用同步阻塞的方式,也就是每个请求都分配一个进程或者线程。 请求数只有 100 个时,这种方式自然没问题, 但增加到 10000 个请求时,10000 个进程或线程的调度、上下文切换乃至它们占用的内存,都会成为瓶颈。

每个请求分配一个线程的方式不合适;

1.怎样在一个线程内处理多个请求,也就是要在一个线程内响应多个网络 I/O。

2.怎么更节省资源地处理客户请求,也就是要用更少的线程来服务这些请求。 是不是可以继续用原来的 100 个或者更少的线程,来服务现在的 10000 个请求呢?

I/O 模型优化

两种 I/O 事件通知的方式:
水平触发和边缘触发,它们常用在套接字接口的文件描述符中。

1.水平触发:
只要文件描述符可以非阻塞地执行 I/O ,就会触发通知。 也就是说,应用程序可以随时检查文件描述符的状态, 然后再根据状态,进行I/O 操作。

2.边缘触发:
只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时,才发送一次通知。 这时候,应用程序需要尽可能多地执行 I/O,直到无法继续读写,才可以停止。 如果I/O 没执行完,或者因为某种原因没来得及处理,那么这次通知也就丢失了。

I/O 多路复用的方法:

1.使用非阻塞 I/O 和水平触发通知,比如使用 select 或者 poll。

select和poll需要从文件描述符列表中,找出哪些可以执行I/O,然后进行真正的网络 I/O 读写。 由于I/O是非阻塞的,一个线程中就可以同时监控一批套接字的文件描述符,这样就达到了单线程处理多请求的目的。

这种方式的最大优点,是对应用程序比较友好,它的API非常简单。 但是,应用软件使用select和poll时,需要对这些文件描述符列表进行轮询,这样,请求数多的时候就会比较耗时。 并且,select 和 poll 还有一些其他的限制。

select使用固定长度的位相量,表示文件描述符的集合,因此会有最大描述符数量的限制。

poll改进了select的表示方法,换成了一个没有固定长度的数组, 这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)。 但应用程序在使用poll时,同样需要对文件描述符列表进行轮询,这样,处理耗时跟描述符数量就是O(N) 的关系。

应用程序每次调用select和poll时,还需要把文件描述符的集合, 从用户空间传入内核空间,由内核修改后,再传出到用户空间中。 这一来一回的内核空间与用户空间切换,也增加了处理成本。

2.使用非阻塞I/O和边缘触发通知,比如 epoll。

epoll使用红黑树,在内核中管理文件描述符的集合,这样,就不需要应用程序在每次操作时都传入、传出这个集合。

epoll使用事件驱动的机制,只关注有I/O事件发生的文件描述符,不需要轮询扫描整个集合。

3.使用异步I/O(Asynchronous I/O,简称为 AIO)

异步I/O允许应用程序同时发起很多I/O 操作,而不用等待这些操作完成。 而在I/O 完成后,系统会用事件通知(比如信号或者回调函数)的方式,告诉应用程序。 这时,应用程序才会去查询 I/O 操作的结果。

工作模型优化

使用I/O多路复用后,就可以在一个进程或线程中处理多个请求,其中,又有下面两种不同的工作模型。

1.主进程 + 多个 worker 子进程,这也是最常用的一种模型。(Nginx)

  • 主进程执行 bind() + listen() 后,创建多个子进程;

  • 然后,在每个子进程中,都通过 accept() 或 epoll_wait() ,来处理相同的套接字。

accept() 和 epoll_wait() 调用,还存在一个惊群的问题。 换句话说,当网络 I/O 事件发生时,多个进程被同时唤醒, 但实际上只有一个进程来响应这个事件,其他被唤醒的进程都会重新休眠。 其中,accept() 的惊群问题,已经在 Linux 2.6 中解决了; 而 epoll 的问题,到了 Linux 4.5 ,才通过 EPOLLEXCLUSIVE 解决。

为了避免惊群问题, Nginx 在每个 worker 进程中,都增加一个了全局锁(accept_mutex)。 这些 worker 进程需要首先竞争到锁,只有竞争到锁的进程,才会加入到 epoll 中, 这样就确保只有一个 worker 子进程被唤醒。

  • 进程的管理、调度、上下文切换的成本非常高。那为什么使用多进程模式的Nginx,却具有非常好的性能呢?

这些 worker 进程,实际上并不需要经常创建和销毁,而是在没任务时休眠, 有任务时唤醒。 只有在worker由于某些异常退出时,主进程才需要创建新的进程来代替它。

2.监听到相同端口的多进程模型。(Nginx 1.9.1)

所有的进程都监听相同的接口,并且开启SO_REUSEPORT选项, 由内核负责将请求负载均衡到这些监听进程中去。

C1000K

从物理资源使用上来说,100 万个请求需要大量的系统资源。

比如,假设每个请求需要 16KB 内存的话,那么总共就需要大约 15 GB 内存。 而从带宽上来说,假设只有 20% 活跃连接,即使每个连接只需要 1KB/s 的吞吐量,总共也需要 1.6 Gb/s 的吞吐量。千兆网卡显然满足不了这么大的吞吐量,所以还需要配置万兆网卡,或者基于多网卡 Bonding 承载更大的吞吐量。

从软件资源上来说,大量的连接也会占用大量的软件资源, 比如文件描述符的数量、连接状态的跟踪(CONNTRACK)、 网络协议栈的缓存大小(比如套接字读写缓存、TCP 读写缓存)等等

大量请求带来的中断处理,也会带来非常高的处理成本。 这样,就需要多队列网卡、中断负载均衡、CPU 绑定、RPS/RFS(软中断负载均衡到多个 CPU 核上), 以及将网络包的处理卸载(Offload)到网络设备(如 TSO/GSO、LRO/GRO、VXLAN OFFLOAD)等各种硬件和软件的优化。

C1000K 的解决方法,本质上还是构建在 epoll 的非阻塞 I/O 模型上。 只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化, 特别是需要借助硬件,来卸载那些原来通过软件处理的大量功能。

C10M

在 C1000K 问题中,各种软件、硬件的优化很可能都已经做到头了。特别是当升级完硬件(比如足够多的内存、带宽足够大的网卡、更多的网络功能卸载等)后,你可能会发现,无论你怎么优化应用程序和内核中的各种网络参数,想实现 1000 万请求的并发,都是极其困难的。

Linux 内核协议栈做了太多太繁重的工作。从网卡中断带来的硬中断处理程序开始,到软中断中的各层网络协议处理,最后再到应用程序,这个路径实在是太长了,就会导致网络包的处理优化,到了一定程度后,就无法更进一步了。

跳过内核协议栈的冗长路径,把网络包直接送到要处理的应用程序那里去。这里有两种常见的机制,DPDK 和 XDP。

1.DPDK,是用户态网络的标准。

它跳过内核协议栈,直接由用户态进程通过轮询的方式,来处理网络接收。

2.XDP(eXpress Data Path),

则是Linux内核提供的一种高性能网络数据路径。 它允许网络包,在进入内核协议栈之前,就进行处理,也可以带来更高的性能。 XDP底层跟bcc-tools一样,都是基于 Linux内核的eBPF 机制实现的。

XDP对内核的要求比较高,需要的是Linux 4.8以上版本,并且它也不提供缓存队列。 基于XDP的应用程序通常是专用的网络应用,常见的有IDS(入侵检测系统)、DDoS 防御、 cilium容器网络插件等。

总结

C10K —- epoll

C10K 到 C100K ,可能只需要增加系统的物理资源就可以满足

C100K到C1000K ,就不仅仅是增加物理资源就能解决的问题了。这时,就需要多方面的优化工作了, 从硬件的中断处理和网络功能卸载、到网络协议栈的文件描述符数量、连接状态跟踪、缓存队列等内核的优化, 再到应用程序的工作模型优化,都是考虑的重点。

C10M,就不只是增加物理资源,或者优化内核和应用程序可以解决的问题了。 这时候,就需要用XDP的方式,在内核协议栈之前处理网络包; 或者用DPDK直接跳过网络协议栈,在用户空间通过轮询的方式直接处理网络包。




本博客所有文章采用的授权方式为 自由转载-非商用-非衍生-保持署名 ,转载请务必注明出处,谢谢。
声明:
本博客欢迎转发,但请保留原作者信息!
博客地址:邱文奇(qiuwenqi)的博客;
内容系本人学习、研究和总结,如有雷同,实属荣幸!
阅读次数:

文章评论

comments powered by Disqus


章节列表