文档教程知识库【免费下载链接】CS-Base图解计算机网络、操作系统、计算机组成、数据库共 1000 张图 50 万字破除晦涩难懂的计算机基础知识让天下没有难懂的八股文 在线阅读https://xiaolincoding.com项目地址https://gitcode.com/GitHub_Trending/cs/CS-Base点击查看免费下载导读本文是 CS-Base 图解计算机基础仓库《图解系统》网络系统章节的核心内容之一以「文件传输」为切入点从 DMA 技术的诞生讲起逐步拆解传统 I/O 的四大开销4 次上下文切换 4 次数据拷贝再到mmap write、sendfile两种零拷贝实现方案最后落到 PageCache 的缓存与预读机制、大文件场景下「异步 I/O 直接 I/O」的选型策略。读完本文你将掌握零拷贝的原理、内核版本与网卡硬件前提SG-DMA、ethtool与 Nginx 的实测配置方法以及 Kafka、Nginx 两大开源项目落地零拷贝的具体代码与配置证据并能依据文件大小在 Nginx 中做出正确的传输方案选型。为什么要有 DMA 技术磁盘可以说是计算机系统最慢的硬件之一读写速度相差内存 10 倍以上。因此针对优化磁盘的技术非常多比如零拷贝、直接 I/O、异步 I/O 等这些优化的目的都是为了提高系统的吞吐量另外操作系统内核中的磁盘高速缓存区可以有效地减少磁盘的访问次数。理解这些优化手段首先要从 I/O 的搬运工——DMA 技术说起。没有 DMA 的时代CPU 亲自搬数据在没有 DMA 技术前一次 I/O 的过程是这样的CPU 发出对应的指令给磁盘控制器然后返回磁盘控制器收到指令后开始准备数据会把数据放入磁盘控制器的内部缓冲区中然后产生一个中断CPU 收到中断信号后停下手头的工作接着把磁盘控制器缓冲区中的数据一次一个字节地读进自己的寄存器然后再把寄存器里的数据写入内存——而在数据传输的期间CPU 是无法执行其他任务的。可以看到整个数据的传输过程都需要 CPU 亲自参与搬运数据而且这个过程中 CPU 不能做其他任何事情。简单的几个字符数据搬运没问题但如果用千兆网卡或者硬盘传输大量数据时都用 CPU 来搬运肯定忙不过来。DMA 技术的诞生把搬运交给专门的控制计算机科学家们发现了问题的严重性后发明了 DMA 技术也就是直接内存访问Direct Memory Access技术。什么是 DMA简单理解就是在进行 I/O 设备和内存的数据传输时数据搬运的工作全部交给 DMA 控制器而 CPU 不再参与任何与数据搬运相关的事情这样 CPU 就可以去处理别的事务。使用 DMA 控制器进行数据传输的具体过程如下用户进程调用read方法向操作系统发出 I/O 请求请求读取数据到自己的内存缓冲区中进程进入阻塞状态操作系统收到请求后进一步将 I/O 请求发送给 DMA然后让 CPU 执行其他任务DMA 进一步将 I/O 请求发送给磁盘磁盘收到 DMA 的 I/O 请求把数据从磁盘读取到磁盘控制器的缓冲区中当磁盘控制器的缓冲区被读满后向 DMA 发起中断信号告知自己缓冲区已满DMA 收到磁盘的信号将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中此时不占用 CPUCPU 可以执行其他任务当 DMA 读取了足够多的数据就会发送中断信号给 CPUCPU 收到 DMA 的信号知道数据已经准备好于是将数据从内核拷贝到用户空间系统调用返回。可以看到整个数据传输的过程中CPU 不再参与数据搬运的工作而是全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的传输什么数据、从哪里传输到哪里都需要 CPU 来告诉 DMA 控制器——DMA 只是执行者决策仍由 CPU 做出。值得补充的历史背景是早期 DMA 只存在于主板上如今由于 I/O 设备越来越多数据传输的需求也不尽相同所以每个 I/O 设备磁盘控制器、网卡等里面都集成了自己的 DMA 控制器。传统的文件传输有多糟糕如果服务端要提供文件传输的功能我们能想到的最简单方式是将磁盘上的文件读取出来然后通过网络协议发送给客户端。传统 I/O 的工作方式是数据读取和写入在用户空间与内核空间之间来回复制而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入的。代码通常如下一般需要两个系统调用read(file, tmp_buf, len); write(socket, tmp_buf, len);代码很简单虽然就两行代码但是里面发生了不少的事情。4 次用户态与内核态的上下文切换期间共发生了4 次用户态与内核态的上下文切换因为发生了两次系统调用一次是read()一次是write()。每次系统调用都得先从用户态切换到内核态等内核完成任务后再从内核态切换回用户态。上下文切换的成本并不小一次切换需要耗时几十纳秒到几微秒虽然时间看上去很短但是在高并发的场景下这类时间容易被累积和放大从而影响系统的性能。4 次数据拷贝其次还发生了4 次数据拷贝其中两次是 DMA 的拷贝另外两次是通过 CPU 拷贝的第一次拷贝把磁盘上的数据拷贝到操作系统内核的缓冲区里这个拷贝过程是通过 DMA 搬运的第二次拷贝把内核缓冲区的数据拷贝到用户的缓冲区里于是应用程序就可以使用这部分数据了这个拷贝过程由 CPU 完成第三次拷贝把刚才拷贝到用户缓冲区里的数据再拷贝到内核的 socket 缓冲区里这个过程依然由 CPU 搬运第四次拷贝把内核的 socket 缓冲区里的数据拷贝到网卡的缓冲区里这个过程由 DMA 搬运。我们回过头看这个文件传输的过程只是搬运一份数据结果却搬运了 4 次。过多的数据拷贝无疑会消耗 CPU 资源大大降低了系统性能。结论很明确这种简单又传统的文件传输方式存在冗余的上下文切换和数据拷贝在高并发系统里是非常糟糕的多了很多不必要的开销会严重影响系统性能。要想提高文件传输的性能就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。如何优化文件传输的性能方向一减少上下文切换本质是减少系统调用读取磁盘数据的时候之所以要发生上下文切换是因为用户空间没有权限操作磁盘或网卡内核的权限最高这些操作设备的过程都需要交由操作系统内核来完成所以一般要通过内核去完成某些任务时就需要使用操作系统提供的系统调用函数。而一次系统调用必然会发生 2 次上下文切换首先从用户态切换到内核态当内核执行完任务后再切换回用户态交由进程代码执行。所以要想减少上下文切换的次数就要减少系统调用的次数。方向二减少数据拷贝消灭无意义的用户缓冲区在前面我们知道了传统的文件传输方式会历经 4 次数据拷贝其中「从内核的读缓冲区拷贝到用户的缓冲区里再从用户的缓冲区里拷贝到 socket 的缓冲区里」这个往返过程是没有必要的。因为在文件传输的应用场景中用户空间并不会对数据「再加工」数据实际上可以不用搬运到用户空间因此用户的缓冲区是没有必要存在的。这也正是零拷贝技术后续得以成立的前提——传输即转发不做中间加工。如何实现零拷贝零拷贝技术实现的方式通常有 2 种mmap writesendfile下面分别来看它们是如何减少「上下文切换」和「数据拷贝」次数的。mmap write在前面我们知道read()系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里。为了减少这一步开销可以用mmap()替换read()系统调用函数buf mmap(file, len); write(sockfd, buf, len);mmap()系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间这样操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。具体过程如下应用进程调用了mmap()后DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着应用进程跟操作系统内核「共享」这个缓冲区应用进程再调用write()操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中这一切都发生在内核态由 CPU 来搬运数据最后把内核的 socket 缓冲区里的数据拷贝到网卡的缓冲区里这个过程由 DMA 搬运。由此可得通过使用mmap()来代替read()可以减少一次数据拷贝的过程省去了「内核缓冲区 → 用户缓冲区」这一次拷贝因为用户空间与内核空间共享同一份内核缓冲区。但这还不是最理想的零拷贝因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里而且仍然需要4 次上下文切换系统调用还是 2 次。从更底层的视角看mmap之所以能做到零拷贝是因为它把内核缓冲区PageCache 中的文件页通过虚拟内存机制映射到进程地址空间进程通过缺页中断按需访问文件页而不必像read那样显式地把数据搬进用户缓冲区。仓库中 深入理解 Linux 虚拟内存管理 一文对虚拟内存的映射与缺页机制有更详细的图解说明。sendfile在 Linux 内核版本2.1中提供了一个专门发送文件的系统调用函数sendfile()函数形式如下#include sys/socket.h ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它的前两个参数分别是目的端和源端的文件描述符后面两个参数是源端的偏移量和复制数据的长度返回值是实际复制数据的长度。首先它可以替代前面的read()和write()这两个系统调用这样就减少了一次系统调用也就减少了 2 次上下文切换的开销。其次该系统调用可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里不再拷贝到用户态这样就只有2 次上下文切换和3 次数据拷贝。但这还不是真正的零拷贝技术——如果网卡支持SG-DMAThe Scatter-Gather Direct Memory Access技术和普通的 DMA 有所不同我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。你可以在自己的 Linux 系统通过下面这个命令查看网卡是否支持 scatter-gather 特性$ ethtool -k eth0 | grep scatter-gather scatter-gather: on当输出为scatter-gather: on时说明该网卡支持 SG-DMA。于是从 Linux 内核2.4版本开始对于支持 SG-DMA 技术的网卡sendfile()系统调用的过程发生了变化第一步通过 DMA 将磁盘上的数据拷贝到内核缓冲区里第二步缓冲区描述符和数据长度传到 socket 缓冲区这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里。此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中这样就减少了一次数据拷贝。所以这个过程之中只进行了2 次数据拷贝。这就是所谓的零拷贝Zero-copy技术我们没有在内存层面去拷贝数据全程没有通过 CPU 来搬运数据所有的数据都是通过 DMA 来传输的。零拷贝技术的文件传输方式相比传统文件传输的方式减少了 2 次上下文切换和数据拷贝次数——只需要 2 次上下文切换和 2 次数据拷贝就可以完成文件的传输而且这 2 次数据拷贝都不需要经过 CPU全部由 DMA 来搬运。所以总体来看零拷贝技术可以把文件传输的性能提高至少一倍以上。两种零拷贝方案的对比小结实现方案系统调用次数上下文切换数据拷贝CPU 参与拷贝传统read write24 次4 次2 次CPU 2 次DMAmmap write24 次3 次1 次CPU 2 次DMAsendfile无 SG-DMA 网卡12 次3 次1 次CPU 2 次DMAsendfile SG-DMA 网卡真零拷贝12 次2 次0 次全部 DMA使用零拷贝技术的项目Kafka基于 Java NIO 的transferTo事实上Kafka 这个开源项目就利用了「零拷贝」技术从而大幅提升了 I/O 的吞吐率这也是 Kafka 在处理海量数据为什么这么快的原因之一。如果追溯 Kafka 文件传输的代码你会发现它最终调用了 Java NIO 库里的transferTo方法这里展示的是其transferFrom适配代码内部委托给FileChannel.transferToOverride public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { return fileChannel.transferTo(position, count, socketChannel); }如果 Linux 系统支持sendfile()系统调用那么transferTo()实际上最后就会使用到sendfile()系统调用函数——也就是说Java 的FileChannel.transferTo在 Linux 上的底层实现正是内核的sendfileKafka 正是借由这条链路获得了零拷贝的收益。曾经有大佬专门写过程序测试过在同样的硬件条件下对比传统文件传输和零拷贝文件传输的性能差异使用了零拷贝能够缩短65%的时间该测试数据出自 IBM 官方 zero-copy 技术专题文章大幅提升了机器传输数据的吞吐量。Nginx默认开启的sendfile配置另外Nginx 也支持零拷贝技术一般默认开启零拷贝技术这样有利于提高文件传输的效率。是否开启零拷贝技术的配置如下http { ... sendfile on ... }sendfile配置的具体含义设置为on使用零拷贝技术来传输文件sendfile这样只需要 2 次上下文切换和 2 次数据拷贝设置为off使用传统的文件传输技术read write这时就需要 4 次上下文切换和 4 次数据拷贝。当然要使用 sendfileLinux 内核版本必须是 2.1 以上。PageCache 有什么作用回顾前面的文件传输过程其中第一步都是先把磁盘文件数据拷贝到「内核缓冲区」里这个「内核缓冲区」实际上是磁盘高速缓存PageCache。由于零拷贝使用了 PageCache 技术可以使得零拷贝进一步提升了性能接下来看看 PageCache 是如何做到这一点的。PageCache 的本质用内存换磁盘读写磁盘相比读写内存的速度慢太多了所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是我们会通过 DMA 把磁盘里的数据搬运到内存里这样就可以用读内存替换读磁盘。但是内存空间远比磁盘要小内存注定只能拷贝磁盘里的一小部分数据。那问题来了选择哪些磁盘数据拷贝到内存呢两大优势缓存最近访问的数据 预读我们都知道程序运行的时候具有「局部性」所以通常刚被访问的数据在短时间内再次被访问的概率很高于是我们可以用PageCache 来缓存最近被访问的数据当空间不足时淘汰最久未被访问的缓存。所以读磁盘数据的时候优先在 PageCache 里找如果数据存在则可以直接返回如果没有则从磁盘中读取然后缓存到 PageCache 中。还有一点读取磁盘数据时需要找到数据所在的位置对于机械磁盘来说就是通过磁头旋转到数据所在的扇区再开始「顺序」读取数据而旋转磁头这个物理动作非常耗时。为了降低它的影响PageCache 使用了「预读功能」。比如假设read方法每次只会读 32 KB 的字节虽然 read 刚开始只会读 0 ~ 32 KB 的字节但内核会把其后面的 32 ~ 64 KB 也读取到 PageCache这样后面读取 32 ~ 64 KB 的成本就很低。如果在 32 ~ 64 KB 被淘汰出 PageCache 前进程读取到它了收益就非常大。所以PageCache 的优点主要是两个缓存最近被访问的数据预读功能。这两个做法将大大提高读写磁盘的性能。大文件场景PageCache 反而有害但是在传输大文件GB 级别的时候PageCache 会不起作用那就白白浪费 DMA 多做的一次数据拷贝造成性能降低即使使用了 PageCache 的零拷贝也会损失性能。原因在于如果有很多 GB 级别文件需要传输每当用户访问这些大文件时内核就会把它们载入 PageCache 中于是 PageCache 空间很快被这些大文件占满。另外由于文件太大可能某些部分的文件数据被再次访问的概率比较低这样就会带来 2 个问题PageCache 由于长时间被大文件占据其他「热点」的小文件可能就无法充分使用到 PageCache于是磁盘读写的性能就会下降PageCache 中的大文件数据由于没有享受到缓存带来的好处却耗费 DMA 多拷贝到 PageCache 一次。所以针对大文件的传输不应该使用 PageCache也就是说也不应该使用零拷贝技术。因为可能由于 PageCache 被大文件占据而导致「热点」小文件无法利用到 PageCache这样在高并发的环境下会带来严重的性能问题。关于 PageCache 更底层的机制仓库中 进程写文件时进程发生了崩溃已写入的数据会丢失吗 一文做了系统讲解PageCache 的本质是由 Linux 内核管理的内存区域由多个 4KB32/64 位系统默认页大小的 page 构成对应磁盘上的若干数据块file-backed pages可以通过cat /proc/meminfo与free命令实时观测 PageCache 占用公式为Page Cache Buffers Cached SwapCachedPageCache 还承担了脏页回写write-back的职责可用fsync、fdatasync、sync强制落盘这与本文讨论的缓存 I/O 与直接 I/O 的取舍一脉相承。大文件传输用什么方式实现那针对大文件的传输应该使用什么方式呢先看阻塞 I/O 的问题先来看最初的例子当调用read方法读取文件时进程实际上会阻塞在read方法调用因为要等待磁盘数据的返回。具体过程当调用read方法时会阻塞着此时内核会向磁盘发起 I/O 请求磁盘收到请求后便会寻址当磁盘数据准备好后就会向内核发起 I/O 中断告知内核磁盘数据已经准备好内核收到 I/O 中断后就将数据从磁盘控制器缓冲区拷贝到 PageCache 里最后内核再把 PageCache 中的数据拷贝到用户缓冲区于是read调用就正常返回了。异步 I/O发起请求不等待就绪后通知对于阻塞的问题可以用异步 I/O来解决。它把读操作分为两部分前半部分内核向磁盘发起读请求但是可以不等待数据就位就可以返回于是进程此时可以处理其他任务后半部分当内核将磁盘中的数据拷贝到进程缓冲区后进程将接收到内核的通知再去处理数据。而且可以发现异步 I/O 并没有涉及到 PageCache所以使用异步 I/O 就意味着要绕开 PageCache。补充同步 / 异步 I/O 的概念边界无论read是阻塞还是非阻塞都属于同步调用——因为内核将数据从内核空间拷贝到用户空间的过程都需要等待而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待发起请求后立即返回由内核自动完成数据拷贝后再通知应用程序。这一组概念在仓库的 高性能网络模式Reactor 和 Proactor 一文中有更完整的辨析文中还提到 Linux 下的aio系列函数仅支持本地文件、不支持 socket因此本文讨论的「异步 I/O 直接 I/O」正是针对大文件磁盘读取场景的有效组合。绕开 PageCache直接 I/O绕开 PageCache 的 I/O 叫直接 I/O使用 PageCache 的 I/O 则叫缓存 I/O。通常对于磁盘异步 I/O 只支持直接 I/O。前面也提到大文件的传输不应该使用 PageCache因为可能由于 PageCache 被大文件占据而导致「热点」小文件无法利用到 PageCache。于是在高并发的场景下针对大文件的传输方式应该使用「异步 I/O 直接 I/O」来替代零拷贝技术。直接 I/O 应用场景常见的两种应用程序已经实现了磁盘数据的缓存那么可以不需要 PageCache 再次缓存减少额外的性能损耗。在 MySQL 数据库中可以通过参数设置开启直接 I/O默认是不开启传输大文件的时候由于大文件难以命中 PageCache 缓存而且会占满 PageCache 导致「热点」文件无法充分利用缓存从而增大了性能开销因此这时应该使用直接 I/O。另外由于直接 I/O 绕过了 PageCache就无法享受内核的这两点优化内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中最后「合并」成一个更大的 I/O 请求再发给磁盘这样做是为了减少磁盘的寻址操作内核也会「预读」后续的 I/O 请求放在 PageCache 中一样是为了减少对磁盘的操作。于是传输大文件的时候使用「异步 I/O 直接 I/O」就可以无阻塞地读取文件了。按文件大小分流Nginx 的阈值配置所以传输文件的时候要根据文件的大小来使用不同的方式传输大文件的时候使用「异步 I/O 直接 I/O」传输小文件的时候则使用「零拷贝技术」。在 Nginx 中可以用如下配置根据文件的大小来使用不同的方式location /video/ { sendfile on; aio on; directio 1024m; }当文件大小**大于directio值示例为 1024m**后使用「异步 I/O 直接 I/O」否则使用「零拷贝技术」。总结回顾整条文件传输优化的演进脉络可以梳理出如下结论1. DMA 解放了 CPU。早期 I/O 操作中内存与磁盘的数据传输工作都由 CPU 完成此时 CPU 不能执行其他任务会特别浪费 CPU 资源。DMA 技术出现后每个 I/O 设备都有自己的 DMA 控制器CPU 只需要告诉 DMA 控制器要传输什么数据、从哪里来、到哪里去就可以放心离开后续的实际数据传输都由 DMA 控制器完成。2. 传统 I/O 的开销是 4 4。传统 I/O 的工作方式从硬盘读取数据再通过网卡向外发送需要进行4 次上下文切换和4 次数据拷贝其中 2 次数据拷贝发生在内存缓冲区和对应硬件设备之间由 DMA 完成另外 2 次发生在内核态和用户态之间由 CPU 完成。3. 零拷贝用一次系统调用换掉两次。为了提高文件传输性能零拷贝技术通过一次系统调用sendfile方法合并了磁盘读取与网络发送两个操作降低了上下文切换次数另外拷贝数据都发生在内核中天然就降低了数据拷贝的次数。在支持 SG-DMA 的网卡上全程无 CPU 参与搬运仅 2 次上下文切换 2 次 DMA 拷贝。4. Kafka 与 Nginx 是落地的标杆。Kafka 通过 Java NIO 的transferTo底层映射 Linuxsendfile大幅提升 I/O 吞吐率Nginx 默认开启sendfile on两种开关对应的性能开销差异清晰可测。5. 零拷贝的根基是 PageCache。零拷贝技术是基于 PageCache 的PageCache 会缓存最近访问的数据提升了访问缓存数据的性能同时为了解决机械硬盘寻址慢的问题它还协助 I/O 调度算法实现了I/O 合并与预读这也是顺序读比随机读性能好的原因。这些优势进一步提升了零拷贝的性能。6. 零拷贝有明确的适用边界。需要注意零拷贝技术不允许进程对文件内容做进一步的加工比如压缩数据再发送另外当传输大文件时不能使用零拷贝因为 PageCache 可能被大文件占据导致「热点」小文件无法利用 PageCache且大文件的缓存命中率不高这时就需要使用「异步 I/O 直接 I/O」的方式。在 Nginx 里可以通过directio配置设定文件大小阈值针对大文件使用异步 I/O 和直接 I/O对小文件使用零拷贝。延伸阅读CS-Base 图解系统网络系统章节总览查看《图解系统》网络系统章节的完整目录零拷贝与 I/O 多路复用、Reactor/Proactor 同属网络系统性能优化主题进程写文件时进程发生了崩溃已写入的数据会丢失吗深入 PageCache 本质、缓存 I/O 与直接 I/O 的取舍、脏页回写机制I/O 多路复用select/poll/epoll理解高性能网络服务并发处理大量连接的系统调用基础高性能网络模式Reactor 和 Proactor辨析阻塞/非阻塞、同步/异步 I/O 概念理解异步网络模式为何在 Linux 上受限于本地文件 I/O深入理解 Linux 虚拟内存管理理解mmap映射机制背后的虚拟内存与缺页中断原理。赞分享文档教程知识库【免费下载链接】CS-Base图解计算机网络、操作系统、计算机组成、数据库共 1000 张图 50 万字破除晦涩难懂的计算机基础知识让天下没有难懂的八股文 在线阅读https://xiaolincoding.com项目地址https://gitcode.com/GitHub_Trending/cs/CS-Base点击查看免费下载相关推荐konyshe/gogo的DMA零拷贝网络传输优化从内核态到用户态的性能革命konyshe/gogo的DMA零拷贝网络传输优化从内核态到用户态的性能革命 ? 性能瓶颈传统网络传输的4次CPU拷贝 在传统Linux网络协议栈中数据包后端Web框架Go语言精进之路网络编程深度解析TCP/UDP、HTTP/2与WebSocketGo语言精进之路网络编程深度解析TCP/UDP、HTTP/2与WebSocket Go语言凭借其卓越的并发模型和简洁的语法在网络编程领域展现出强大实力。本文Apache Fury零拷贝技术揭秘为什么它比传统序列化快170倍Apache Fury零拷贝技术揭秘为什么它比传统序列化快170倍 Apache Fury是一个基于JIT和零拷贝技术的多语言序列化框架能够提供极致的性能表基础架构开发工具上一篇终极指南如何使用JNA在Java中调用OpenGL实现高性能图形渲染下一篇三步搞定PDF文字识别让扫描文档秒变可搜索文件创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?