让KIO快速复制大量文件

Lobsters Hottest 工具

摘要

一位KDE开发者优化了KIO,显著加快了复制大量小文件的速度,通过消除socket往返和使用进程内线程来减少开销,实现了接近rsync的性能。

<p><a href="https://lobste.rs/s/ewifzf/making_kio_copy_many_files_fast">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/28 10:24

# 让 KIO 快速复制大量文件 来源:https://blogs.kde.org/2026/07/28/making-kio-copy-many-files-fast/ KDE 的 bug 追踪器中有一个 bug,其年龄几乎和一些贡献者一样大:bug 342056(https://bugs.kde.org/show_bug.cgi?id=342056),"文件复制速度极其缓慢(多个小文件)",由 Alexander Nestorov 于 2014 年报告。报告内容直截了当:在 KDE 中复制一个包含约 300 万个小文件的 15 GB 文件夹需要 5 到 10 小时,而使用 `rsync` 只需要大约 20 分钟。一位评论者总结了大家的感受: > 对于超过几个文件的情况,我通常使用 cp 和 rsync,而不是 Dolphin。 这个 bug 在我脑海中盘旋了很久,所以这篇文章是关于最终修复它的。我一直在研究 KIO 的复制路径,并想分析一下时间花在了哪里,一些历史背景解释了原因,并展示了 KIO 各个版本的性能数据,包括用于参考的普通 `cp`。 在着手优化之前,我们先进行测量: KIO 6.28 复制 5000 个小文件的非 CPU 火焰图,socket 往返帧已高亮显示(https://blogs.kde.org/2026/07/28/making-kio-copy-many-files-fast/offcpu-socket.svg) *KIO 6.28 复制 5000 个小文件的阻塞时间分布,没有单一热点。每个文件都会在一连串短系统调用中阻塞:读取挂载表(`/proc/self/mountinfo`)、打开源文件和目标文件、以及通过内部 socket 向 worker 发送命令并等待回复。红色高亮的帧是 socket 往返(发送、接收及等待每个回复),约占此处阻塞时间的 15%,以细条形式分布在两个线程上,因为每个文件都要经历这些。绿色的帧是复制过程中无法避免的实际文件系统工作:`statx` 和 `openat` 调用、移动数据的 `copy_file_range`,以及底层的 `ext4` 元数据更新。正是这种每个文件的系统调用风暴(而非单个调用)构成了本文讨论的核心。(非 CPU 火焰图来自 `sched:sched_switch`,按阻塞切换次数加权;点击打开完整 SVG。)* ## 一点历史 KIO 是 KDE 软件中几乎所有文件操作的基础层,从 Dolphin 的复制粘贴到网络透明性(让你像打开本地文件一样打开 `sftp://` 或 `smb://` 链接)。它的设计可以追溯到 KDE 2 时代,大约 2000 年。当时,“如何在 UI 不冻结的情况下进行 I/O” 的答案不是线程,而是独立的进程:一个 *worker*(我们以前称之为 kioslave)为某个协议启动,并通过 socket 与应用程序通信。这早于 Linux 上可用的线程(本地 POSIX 线程库(https://en.wikipedia.org/wiki/Native_POSIX_Thread_Library)直到 2003 年的 Linux 2.6 才出现),因此进程加 socket IPC 是可移植且稳健的选择,对于今天的网络协议仍然如此:如果某个 `smb://` worker 崩溃,你的文件管理器不会随之崩溃。 另一面是成本。每个请求及其回复都被序列化并通过那个 socket 发送,两端都需要经过事件循环跳转。对于 `file://` 来说,这种开销毫无意义,因为另一端不是不可信的网络,只是本地磁盘。 2022 年,David Faure 改变了这一点:实现了使用线程在进程内运行 KIO worker(https://invent.kde.org/frameworks/kio/-/merge_requests/740),该功能在 Frameworks 5.95 中落地。从那时起,`file` worker 在应用程序内部的线程上运行,而不是作为单独的进程(其他协议为了稳健性仍保持在进程外)。这消除了进程启动和上下文切换的成本。 ## 线程与应用程序之间不再有 socket(6.29) 但还有一个显而易见的问题一直隐藏着:即使在进程内,worker 线程和应用程序仍然通过 socket 对进行通信,像对待独立进程一样序列化每个命令。一旦使用了线程,这个 socket 就成了纯粹的开销。 这就是第一张火焰图中的红色高亮部分。 因此,现在进程内的 worker 使用真正的内存传输,而不是环回 socket。新的 `ThreadConnectionBackend` 处理命令,对于读取操作,直接将实际数据缓冲区传递给对端线程,无需序列化,无需系统调用,这使得进程内的文件读取实现了零拷贝。这是下面数据中最大的一次跳跃,已进入 6.29 的主分支(kio!2279(https://invent.kde.org/frameworks/kio/-/merge_requests/2279))。 ## 下一步:批量复制和批量 stat(审查中) 上述改动使得每个命令的开销变得很小。剩下的成本在于命令的数目仍然很多:复制 N 个文件意味着 N 个独立的 `file_copy` 作业,每个作业都要经过作业调度器,并经历一次到 worker 的往返。对于许多小文件来说,占主导地位的是固定的每文件开销,而不是数据本身。 `CopyJob` 可以将整批纯本地到本地的文件作为一个命令分发给 worker,worker 复制它们(使用 `copy_file_range`,并在支持 reflink 的文件系统如 Btrfs 或 XFS 上使用 reflink)。每个文件仍然会收到其进度信号、字节计数、撤销条目,任何 worker 无法盲目处理的情况(如目标文件已存在、源文件不可读、需要重命名或跳过)都会回退到正常的单文件路径。该门控有意保持保守,以防止一个挂起的挂载点冻结整个批次(kio!2282(https://invent.kde.org/frameworks/kio/-/merge_requests/2282))。 批量复制后的非 CPU 火焰图,socket 往返帧已消失(https://blogs.kde.org/2026/07/28/making-kio-copy-many-files-fast/offcpu-batch.svg) *6.29 上使用批量复制的相同复制操作:红色 socket 往返已消失。这得益于两件事:上一节的内存传输(完全没有 socket 对)以及批量复制将一整套文件折叠成一个命令,而不是每个文件一次往返。剩下的是真正的文件系统工作:绿色帧是复制本身,`copy_file_range` 移动字节,`openat` 和 `statx` 调用,以及底层的 `ext4` 元数据。不是绿色的宽阔平台是基准测试循环在每次运行前删除前一批文件(`unlink`),这是清理工作,而非复制的一部分。* 在复制之前,`CopyJob` 会 stat 每个源文件,这又是一次每文件往返。接下来的改进将在相同的进程内通道上批量处理 stat 阶段。这就是下表中 `batch-copy-stat` 列对应的内容。 这两项改进都在审查中,计划在 6.29 之后发布,因此表格中的相关列应视为预览,而非当前可安装的版本。 ## 数据 方法:一台 13 代 Intel Core i7-1365U 笔记本电脑,所有代码以 `RelWithDebInfo` 构建(无任何 sanitizer),复制到真实的 ext4 卷(因此没有 reflink 捷径,`copy_file_range` 移动真实字节)。每个 KIO 版本都提供自己的 `libKIOCore` 和 `kio_file` worker。时间为经过一次丢弃的预热后,5 次运行的中位数(大文件集为 3 次),以确保缓存已热身,各版本条件相同。`cp -r --preserve=mode,timestamps` 作为基准,因为 bug 报告者选择使用它而不是 Dolphin。 5000 个 4 KB 文件(bug 中的多个小文件场景): | 版本 | 中位时间 | |------|----------| | `cp -r`(coreutils) | 81 ms | | KIO 6.28.0 | 1616 ms | | KIO 6.29-dev(master) | 396 ms | | KIO 6.29-dev + 批量复制 | 88 ms | | KIO 6.29-dev + 批量复制-stat | 93 ms | 1000 个 5 MB 文件(大量数据,复制受限于 I/O): | 版本 | 中位时间 | |------|----------| | `cp -r`(coreutils) | 1312 ms | | KIO 6.28.0 | 1997 ms | | KIO 6.29-dev(master) | 1687 ms | | KIO 6.29-dev + 批量复制 | 1506 ms | | KIO 6.29-dev + 批量复制-stat | 1454 ms | 小文件场景是关键。KIO 6.28 比 `cp` 慢大约 20 倍,这正是 2014 年报告中提到的比例。移除进程内 worker 的 socket,再加上不再每次探测目标文件系统,时间从约 1.6 秒降至 0.4 秒(大约 4 倍)。批量复制将其降至 88 毫秒,基本达到 `cp` 的速度,比 6.28 快约 18 倍。那句“我用 cp 而不是 Dolphin”终于有了回应。 大文件场景在此之前已经处理得很好,表格也显示了这一点:复制受限于移动字节,因此即使是 KIO 6.28 也与 `cp` 接近,每文件优化几乎不改变数字(批量复制在 `cp` 的约 15% 以内)。这正是本工作针对小文件的原因,因为占主导地位的是固定的每文件开销,而非数据。 批量复制和批量复制-stat 在这里结果相当:基准测试复制单个目录,因此其条目在一次列举中一次性到达,批量 stat 没有可批量处理的内容。它在另一种情况下有用:复制大量分散的单个文件,否则每个文件需要一次 stat 往返。由于它消除了往返而非 `stat` 调用本身,因此当 stat 很快时收益最大,而不是磁盘慢时。在这个测试中,两者视为相等。 而且 `cp` 几乎肯定会保持略微领先,这是设计使然:KIO 做的不仅仅是复制字节。它会报告每文件进度、可以被暂停和取消、保留撤销记录、保持权限和时间戳、解决冲突,并通知任何打开的文件管理器更改内容以刷新其视图。这些工作不是免费的,目标从来不是超越 `cp`,而是让本地复制足够快,使选择 `cp` 而非 Dolphin 不再是明显的选择。 ## 注意事项与后续计划 这些数据来自一台使用 ext4 磁盘且缓存热身的笔记本电脑,目的是比较版本,而非绝对性能。在 Btrfs 或 XFS 上,批量路径会使用 reflink 而非复制,这将完全改变大文件的图像。批量快速路径仅适用于在响应迅速的文件系统上的纯本地到本地复制;其他情况(网络、FAT/NTFS、需要对话框处理的冲突等)会回退到现有的单文件路径,保持不变。 感谢 David Faure 实现进程内 worker 线程,使得这一切成为可能;感谢 Frank Reininghaus 早期的列表修复;感谢 Alexander Nestorov 提交了一个经久不衰、仍值得修复的 bug 报告。 好了,就这些。

相似文章

Go中的零拷贝: sendfile、splice 以及 io.Copy 的成本

Hacker News Top

本文解释了Go的io.Copy如何自动使用sendfile和splice进行TCP上的零拷贝文件传输,并展示了将文件包装成普通的io.Reader(例如用于日志记录)会悄然禁用此优化,导致性能显著下降。基准测试和strace输出说明了这种影响。

我们通过删除文件系统使其速度提升了47倍

Hacker News Top

microsandbox将其缓慢的用户空间FUSE文件系统替换为内核挂载的EROFS磁盘映像,在文件系统操作上实现了几何平均47倍的速度提升,并消除了虚拟机/主机往返瓶颈。

oioi

Product Hunt

一款快速、透明的剪贴板管理器,适用于 macOS、Windows 和 Linux。

dmtrKovalenko/fff

GitHub Trending (daily)

fff 是一个快速、抗拼写错误的文件搜索工具包,采用 frecency 排序并配备面向 AI 代理的 MCP 服务器,提供高效的、具备 Git 感知的文件与内容搜索功能。