Linux:文件IO

本文最后更新于 2024年5月13日 晚上

一、I/O

同步/异步

  • 同步、异步强调的是消息的通信机制(同步通信/异步通信)

    • 同步,就是在发出一个”调用”时,在没有得到结果之前,该“调用”就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由“调用者”主动等待这个“调用”的结果。

    • 异步则是相反,”调用”在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在”调用”发出后,”被调用者”通过状态、通知来通知调用者,或通过回调函数处理这个调用。

阻塞/非阻塞

  • 阻塞、非阻塞强调的是程序在等待调用结果(消息,返回值)时的状态

    • 阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务

    • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程

五种IO模型

输入图片描述

同步阻塞IO blocking IO

  • 首先采用主动等待调用返回的这种方式;然后在等待期间,需要将进程阻塞

  • 在等待数据到处理数据的两个阶段,整个进程都是被阻塞的

同步非阻塞IO nonblocking IO

  • 依然是主动等待调用返回的方式,但是这里不再是通过阻塞的方式来等待调用函数的返回,而是调用函数直接返回,但是这里返回的是一个error,进程需要不断调用这个函数来查看运行情况

  • 这样的过程通常被称为轮询,轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。

  • 但是这里只是轮询等待数据准备好,将数据拷贝到用户进程依然是通过阻塞的方式来进行的(这里需要重点注意,数据传送过程依然是阻塞的)

IO多路复用 IO multiplexing

  • 其实是同步非阻塞IO的升级版,通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求,可以等待多个socket,能实现同时对多个IO端口进行监听

信号驱动式IO signal-driven IO

  • 安装一个信号处理函数,在信号处理函数中调用IO操作函数处理数据

  • 但是在数据拷贝的阶段依然是采用的阻塞的方式

异步非阻塞IO asynchronous IO

  • 无论是等待数据阶段,还是数据拷贝阶段都是采用非阻塞式的方法

  • 内核准备好数据之后,会发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉进程read操作完成了

1.1 阻塞/非阻塞I/O

1.1.1 read/write

  • 由Linux操作系统直接提供的基础IO方式是read/write两个系统调用接口,默认状态下这两个接口都是阻塞方式的。如果在打开文件的时候配置了O_NONBLOCK参数,这个文件描述符的read/write将变成非阻塞方式,调用之后会立刻返回。

  • 读数据时,需要提供一段用户空间的空闲内存,然后进入内核态进行文件读取,先检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在内核的缓存中。

  • 写操作时,将数据从用户空间复制到内核空间的缓存中,这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令。

  • 在内核中用来缓存从磁盘读出的数据或者写入数据的是PageCache:

    • PageCache提供预读功能来加快磁盘访问的性能;

    • 同时内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;

1.1.2 fread/fwrite

  • 这个是C标准库中提供的文件接口,对read/write的进一步封装,在用户空间中又分配了一块内存用于缓冲用户的读写调用。

  • 相较于每次都是系统调用的read/writefread/fwrite可以先将用户的请求缓存到用户态下。然后收集到一部分的请求之后统一调用一次read/write系统调用,从而降低系统调用切换用户态和内核态带来的开销。

  • 如果使用了fread/fwrite写入操作可能被缓存在用户态,如果进程崩溃也有可能造成数据的丢失;但使用read/write的程序因为写入数据被直接写入到内核态,及时进程崩溃内核中依然可以保留以写入的数据。

1.1.3 direct I/O

  • 如果在打开文件的时候配置了O_DIRECT参数,这个文件将被打开用于直接I/O

  • 应用程序直接读写文件,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理IO缓存区,这样做的目的是减少一次内核缓冲区到用户程序缓存的数据复制。

1.1.4 mmap

  • mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。

  • 实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。

  • 在mmap之后,并没有在将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时(通过mmap在写入或读取时),若虚拟内存对应的page没有在物理内存中缓存,则产生”缺页”,由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4K)加载到物理内存,注意是只加载缺页,但也会存在预读。

  • 因此是通过“缺页”异常来读取硬盘数据的,所以本质来说这是个阻塞同步I/O

1.2 多路复用I/O

  • 主要是用来处理网络socket读写的,让单线程可以同时处理多个socket连接的系统调用

问题

  • 服务器可承载的最大连接数,主要会受两个方面的限制:

    • 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;

    • 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的,如果每个连接都需要绑定一个线程来处理,那应该很快就会触及资源上线。

  • 无论是单进程绑定一个socket还是单线程去绑定一个socket都是没法达到C10K的(并发1万请求),因此需要多路复用系统调用,单个进程/线程可以通过一个系统调用函数从内核中获取多个事件。

1.2.1 select

  • 将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生。

  • 文件描述符集合由fd_set表示(其实这是一个数组的宏定义,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄socket、文件、管道、设备等建立联系),而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

  • 检查的方式很粗暴,就是遍历文件描述符集合,即轮询(内核来做这个轮询操作),效率较低。当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里。因此一次select的调用到返回需要两次copy文件描述符集合。

  • select返回之后,调用者仅知道有I/O事件发生,却不知哪几个流,只会无差异轮询所有流,找出能读/写数据的流进行操作。寻找可以读写的socket的时间复杂度是O(n)

1.2.2 poll

  • 和select类似,只是描述fd集合的方式不同,poll使用pollfd结构而非select的fd_set结构。突破了 select 的文件描述符个数限制。

  • 管理多个描述符也是进行轮询,根据描述符的状态进行处理。poll和select一样有大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,其开销也随着文件描述符数量增加而线性增大。

  • 返回到用户态之后也需要通过遍历的方式查找哪些socket是准备好了的。时间复杂度为O(n)

1.2.3 epoll

  • epoll模型修改主动轮询为被动通知,当有事件发生时,被动接收通知。所以epoll模型注册套接字后,主程序可做其他事情,当事件发生时,接收到通知后再去处理。
  • epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)

  • 而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

  • epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。所以用户程序在获得准备好的socket时的复杂度是O(1)

边缘触发和水平触发

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

  • 简单总结一下就是,边缘触发是针对事件到达触发的,socket从没有数据到数据准备好这个状态变化会发生一次边缘触发,因此在后续读取socket中已经准备好的数据时要尽量多读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

  • select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

1.3 信号驱动I/O

  • 预先在内核中设置一个回调函数,当某个事件发生时,内核使用信号(SIGIO)通知进程来处理(运行回调函数)。 它也可以看成是一种异步IO,因为检测fd是否有数据和是否可读写是在两个流程中做的。

  • 优势是进程没有收到SIGIO信号之前,不被阻塞,可以做其他事情。劣势是当数据量变大时,信号产生太频繁,性能会非常低。

  • 信号驱动 I/O 提供的是边缘触发通知,即只有当 I/O 事件发生时我们才会收到通知,且当文件描述符收到 I/O 事件通知时,并不知道要处理多少 I/O 数据。因此和边缘触发模式的epoll一样需要配合非阻塞I/O使用。

1.4 异步I/O

  • 如果使用非直接I/O的方式,每次需要用户线程从内核态内存中拷贝数据到用户态内存,因为内存访问速度相对较快,并不会带来较大的性能瓶颈。

  • 但如果我们使用DIRECT模式,每次都需要直接与磁盘交互,同步IO在数据拷贝阶段的等待会非常长。因此我们需要一种异步IO的机制,让进程去做别的工作,在IO的数据拷贝完成后再通知进程。

1.4.1 kernel aio

  • 大概流程是用户通过 io_submit() 提交 I/O 请求,过一会再调用 io_getevents() 阻塞等待event事件完成,另外linux aio也支持了epoll。(普通文件的IO无法使用epoll来监视,因为ext4格式文件未实现 poll 方法,但是使用aio之后就可以用epoll来监视了)

  • 只支持 O_DIRECT 文件,因此对常规的非数据库应用几乎是无用的。虽然从技术上说接口是非阻塞的,但实际上有很多可能的原因都会导致它阻塞,而且引发的方式难以预料。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#define _GNU_SOURCE
// sudo apt-get install libaio-dev
// gcc -static aio.c -o aio -laio

#include <stdlib.h>
#include <string.h>
#include <libaio.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

#define FILEPATH "./aio.txt"

int main()
{
io_context_t context;
struct iocb io[1], *p[1] = {&io[0]};
struct io_event e[1];
unsigned nr_events = 10;
struct timespec timeout;
char *wbuf;
int wbuflen = 1024;
int ret, num = 0, i;

posix_memalign((void **)&wbuf, 512, wbuflen);

memset(wbuf, '@', wbuflen);
memset(&context, 0, sizeof(io_context_t));

timeout.tv_sec = 0;
timeout.tv_nsec = 10000000;

int fd = open(FILEPATH, O_CREAT|O_RDWR|O_DIRECT, 0644); // 1. 打开要进行异步IO的文件
if (fd < 0) {
printf("open error: %d\n", errno);
return 0;
}

if (0 != io_setup(nr_events, &context)) { // 2. 创建一个异步IO上下文
printf("io_setup error: %d\n", errno);
return 0;
}

io_prep_pwrite(&io[0], fd, wbuf, wbuflen, 0); // 3. 创建一个异步IO任务

if ((ret = io_submit(context, 1, p)) != 1) { // 4. 提交异步IO任务
printf("io_submit error: %d\n", ret);
io_destroy(context);
return -1;
}

while (1) {
ret = io_getevents(context, 1, 1, e, &timeout); // 5. 获取异步IO的结果
if (ret < 0) {
printf("io_getevents error: %d\n", ret);
break;
}

if (ret > 0) {
printf("result, res2: %d, res: %d\n", e[0].res2, e[0].res);
break;
}
}

return 0;
}
  • 其实Linux IO的“异步”其实比“同步”更好实现,因为它底层调用的那些提交IO的接口本身就是异步的,比如vfs层通过submit_bio提交一个io请求,这个过程本身就是异步的,只不是传统的同步写入中,会等待IO完成才返回, 而aio中调用之后就直接返回了,通过回调来通知请求完成。这就是AIO的总体逻辑。

1.4.2 posix aio

  • kernel aio是Linux提供的一些内核函数来支持异步IO,性能很好,但是没有可移植性。

  • posix aio是c标准库 glibc 中,基于posix接口规范,在用户态用多线程实现的一个aio。它的优点是可移植性好,在满足posix规范的平台上基本上都能跑,缺点是基于多线程实现的性能较差,而且受限于线程池的大小,可能出现任务积压影响IO速度。

  • 很多方式可以判断 posix aio 任务的结束,和 kernel aio 一样,也是有办法通过 epoll 来监视异步IO任务的。

1.4.3 io_uring

参考资料:

Linux 异步 I/O 框架 io_uring

阿里云的 Linux 内核 io_uring 介绍与实践

此前aio实现的问题

  1. 虽然是异步实现,但系统调用非常多在更高负载的场景性能并不好

  2. 并不总是异步的,在很多地方都可能造成阻塞等待

  3. 仅支持O_DIRECT,目前只有数据库领域大量使用这样的接口调用,而在绝大部分的场景都是buffered I/O使用更多

  4. 请求元数据开销很大,尤其是对较小的I/O请求元数据比读写数据更大

  5. 对iopoll的支持方式不好。处理I/O请求的方式主要有两种,一个是通过中断告诉应用程序,而另一种就是应用程序主动轮询去查找I/O请求是否完成,随着设备越来越快, 中断驱动(interrupt-driven)模式效率已经低于轮询模式 (polling for completions)

io_uring的优势

  1. 仅使用简单强大的三个系统调用:

    • io_uring_setup,用于设置 io_uring 上下文

    • io_uring_enter,提交并获取完成任务

    • io_uring_register,注册内核用户共享的缓冲区

  2. 通用型非常强,提供内核统一的异步编程框架,支持任何类型的I/O (cached files、direct-access files、blocking sockets),同时也支持类epoll型编程。

  3. 有更加丰富的特性,支持很多高级特性。同时IO请求带来的overhead比较小,高性能。

基本结构

  • 在设计上是真正异步的(truly asynchronous)。只要设置了合适的flag,它在系统调用上下文中就只是将请求放入队列, 不会做其他任何额外的事情,保证了应用永远不会阻塞。支持任何类型的 I/O:cached files、direct-access files 甚至 blocking sockets。

  • 由于设计上就是异步的(async-by-design nature),因此无需 poll+read/write 来处理 sockets。 只需提交一个阻塞式读(blocking read),请求完成之后,就会出现在 completion ring。

uring.png
  • 每个 io_uring 实例都有两个环形队列(ring),在内核和应用程序之间共享。因为这两个队列是共享的,完全可以做到用户态线程添加任务和获取完成的任务两个操作都不需要系统调用:

    • 提交队列:submission queue (SQ)

    • 完成队列:completion queue (CQ)

  • 都是单生产者、单消费者,所以每个环形队列都是两个线程在竞争使用,提供无锁接口(lock-less access interface),内部使用内存屏障做同步(coordinated with memory barriers)。

重要特性

  • IORING_SETUP_SQPOLL:创建一个内核线程来进行sqe的提交,几乎完全消除用户态内核态的上下文切换。并真正得将IO逻辑offload,业务逻辑和IO逻辑完全分离。

  • IORING_SETUP_IOPOLL:配合blk_mq多类型硬件队列映射机制,利用这个特性内核io协议栈开始真正支持iopoll

  • IORING_FEAT_FAST_POLL:网络编程环境下使用的,类似于epoll这样的接口,但是不再需要用户程序做数据的拷贝工作,性能会更优。

中断驱动模式(interrupt driven)

  • 默认模式,可通过 io_uring_enter() 提交 I/O 请求SQE,然后通过io_uring_wait_cqe等待完成IO请求的CQE。

轮询模式(polled)

  • Busy-waiting for an I/O completion,而不是通过异步 IRQ(Interrupt Request)接收通知。

  • 这种模式需要文件系统和块设备(block device)支持轮询功能。 相比中断驱动方式延迟更低(连系统调用都省了), 但可能会消耗更多 CPU 资源。

  • 目前,只有指定了 O_DIRECT flag 打开的文件描述符,才能使用这种模式。当一个读 或写请求提交给轮询上下文(polled context)之后,应用(application)必须调用 io_uring_enter() 来轮询 CQ 队列,判断请求是否已经完成。

内核轮询模式(kernel polled)

  • 创建一个内核线程(kernel thread)来执行 SQ 的轮询工作。

  • 使用这种模式的 io_uring 实例,应用无需切到到内核态就能触发I/O 操作。 通过 SQ 来提交 SQE,以及监控 CQ 的完成状态,应用无需任何系统调用,就能提交和收割 I/O(submit and reap I/Os)。

  • 如果内核线程的空闲时间超过了用户的配置值,它会通知应用,然后进入 idle 状态。 这种情况下,应用必须调用 io_uring_enter() 来唤醒内核线程。如果 I/O 一直很繁忙,内核线性是不会 sleep 的。

二、零拷贝

2.1 传统文件传输

传统文件传输.webp
  • 将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

  • 发生了 4 次用户态与内核态的上下文切换,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的。

  • 要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

2.1 mmap优化

mmap + write 零拷贝.webp
  • mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

  • 减少一次数据拷贝的过程,仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换。

2.2 sendfile

1
2
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • 前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

  • 替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。但依然是三次数据拷贝,不是真正的零拷贝技术。

sendfile-零拷贝.webp
  • 如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

  • 所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

  • 零拷贝技术的文件传输方式相比传统文件传输的方式,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

应用案例

  • Kafka文件传输最终它调用了 Java NIO 库里的 transferTo 方法,如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。

  • Nginx也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率。

三、网络模式

  • 市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等

  • 基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。

  • Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。

3.1 Reactor

组成

  • Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

    • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;

    • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

  • Reactor 模式是灵活多变的,Reactor 的数量可以只有一个,也可以有多个;处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;

单Reactor 单进程/线程

单Reactor单进程.webp
  • 进程里有 Reactor、Acceptor、Handler 这三个对象:Reactor 对象的作用是监听和分发事件;Acceptor 对象的作用是获取连接;Handler 对象的作用是处理业务。select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。

  • 代码流程大概是:

    • Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;

    • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;

    • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;

    • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

  • 全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。但因为只有一个进程,无法充分利用多核CPU的性能,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟。

  • 单Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单Reactor 多线程/多进程

单Reactor多线程.webp
  • Handler对象不再负责业务处理,只负责数据的接收和发送,Handler对象通过read读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;

  • 子线程里的 Processor 对象就进行业务处理,处理完后将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client。

  • 优势在于能够充分利用多核 CPU 的能,但是带来了多线程竞争资源的问题。例如子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。另外因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

多Reactor 多进程/线程

主从Reactor多线程.webp
  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;

  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。

  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

  • 大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。

3.2 Proactor

  • Proactor采用了异步I/O技术,所以被称为异步网络模型。无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。

  • Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。


Linux:文件IO
https://lluvialuo.github.io/2024/05/12/Linux-文件IO/
作者
Lluvia Luo
发布于
2024年5月12日
许可协议