1. Why do we need chunked I/O
当你刚开始处理文件时,Data(contentsOf:) 看起来像是梦想成真:一行代码,就能把整个文件内容握在手里。对于小文件,确实就该这么做:代码短、易读,而且更少出错。但一旦文件变大,这个“梦想”就会变成“为什么我的程序突然把所有内存都吃光然后跑路了?”。
设想一下,你正在读取视频文件、压缩包、大日志或数据库转储。如果文件有 2 GB,那么“把所有内容读入 Data” 就意味着要尝试在内存中分配大约 2 GB(外加额外开销)。在普通机器上,这很容易导致错误、交换分区、卡死,或者那个经典瞬间:风扇开始像飞机涡轮一样轰鸣。
作为对比——下面是一个“一次性放入内存”的小例子(它是正确的,只是不总是合适):
import Foundation
let url = URL(fileURLWithPath: "/tmp/big.bin")
let data = try Data(contentsOf: url)
print(data.count) // for example: 2048
这里的问题不在语法。问题在于,你无法控制内存消耗。而我们希望能够控制。
Chunked I/O 的想法:按块读取,像流水线一样工作
Chunked I/O 是一种“按小块”处理文件的方法:读入一小块(chunk),对它做点什么(或者直接写出去),然后继续读下一块,如此直到结束。这并不是一种“永远更快”的神奇优化。它更像是一种让程序在内存上 更可预测 的方式:你在 RAM 里只保留一个 chunk,而不是整个文件。
把 chunked I/O 理解成一个带有明确结束条件的简单循环会很有帮助:只要还能读到非空块,就继续工作。一旦块为空,文件就结束了。很多初学者在这里通常会松一口气:“哦,所以文件结尾其实就是空数据。”
下面是一个思路层面的示意图(并不追求“底层感”):
flowchart TD
A[Open file for reading] --> B[Open/prepare file for writing]
B --> C{Read chunk}
C -->|chunk is not empty| D[Process chunk]
D --> E[Write chunk]
E --> C
C -->|chunk is empty| F[Close files, finish]
本讲座的主要框架是:我们学习骨架,让你能够清楚地理解解决方案的结构。这不是“我在操作系统内核级别写自己的 cp”课程。
2. Foundation tools: FileHandle and defer
FileHandle: a minimal tool for chunked reading and writing
为了按块读写,我们需要一个能够“给我 N 个字节”的 API。在 Foundation 里,这就是 FileHandle。你可以用它打开读取或写入,然后调用读/写方法。
这里有一个关于纪律的重要点:FileHandle 是一种资源(按含义来说就是“打开的文件”),你必须把它 关闭,否则可能出现资源泄漏和奇怪的 bug。Swift 里有一个非常适合这种纪律的工具——defer:在成功打开之后立刻写上 defer,你几乎就保证了“退出时一定会关闭”。这种写法经常被当作资源清理的基本实践来展示。
在 Swift Evolution 里,有人讨论过把文件句柄看作需要正确按生命周期管理的“资源型”值。这更像是一个“成人世界”的有趣事实,但它很好地强化了直觉:打开,就关闭。
一个小例子:打开文件并读取前 8 个字节。
import Foundation
let url = URL(fileURLWithPath: "/tmp/input.bin")
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let chunk = try handle.read(upToCount: 8) ?? Data()
print(chunk.count) // for example: 8
请注意两点。第一,read(upToCount:) 返回的是 Data?,所以我们通过 ?? 提供一个空的 Data()。第二,我们通过 try? 来关闭 close()——这是一种少见的情况:通常“吞掉”这个错误是可以接受的,因为关闭只是收尾,不是程序的核心逻辑。
Why should defer be placed immediately
初学者常常想这样写:“先读,再到最后某处关闭。”但这种代码有个讨厌的特点:如果中途发生错误、return、throw,或者你又加了一个 if 分支,就很容易忘记关闭。未关闭的句柄就像“忘了关水龙头”:一开始好像没事,后来水就流到地板上了。
defer 通过纪律解决了这个问题:你打开资源,就立刻在旁边写下怎么关闭它。即使后面发生了 throw,在离开作用域时关闭动作也会执行。也正因为如此,defer 经常被用作 CLI 和服务器代码里资源清理的基础实践。
如果你想用一句话记住它:defer 就是“提醒编译器:离开这里时,把灯关上”。
3. The skeleton: copying in chunks
Read chunk → write chunk
最诚实的 chunked I/O 教学例子就是文件复制。它很直观:我们不改变数据,只是把字节从源头转运到目标。是的,在真实场景里有时直接调用 FileManager.copyItem 会更简单,但我们现在的目标是看清 按块读写的结构,并巩固“不要把所有内容都放在内存里”的习惯。
先从函数骨架开始。它接收源和目标的 URL 以及 chunk 大小。这个函数是 throws 的,因为文件读写本来就是错误的常见来源。
import Foundation
func copyChunked(from src: URL, to dst: URL, chunkSize: Int) throws {
try Data().write(to: dst) // create/truncate destination file
let input = try FileHandle(forReadingFrom: src)
defer { try? input.close() }
let output = try FileHandle(forWritingTo: dst)
defer { try? output.close() }
while true {
let chunk = try input.read(upToCount: chunkSize) ?? Data()
if chunk.isEmpty { break }
try output.write(contentsOf: chunk)
}
}
这里有几个“为什么要这么写”。
FileHandle(forWritingTo:) 通常期望文件 已经存在,所以我们先通过 try Data().write(to:) 创建一个空文件。我们在本讲座里不讨论“不要覆盖已有文件”或“做备份”之类的策略——那是另外的大主题。这里先固定一个简单规则:“目标文件先创建/清空”。
当 chunk.isEmpty 时,循环结束。对初学者来说,这一点极其重要:在这种模式下,“文件结束”不是异常,也不是某个神秘常量,而只是一次 空读取。
调用这个函数的方式如下:
import Foundation
let src = URL(fileURLWithPath: "/tmp/input.bin")
let dst = URL(fileURLWithPath: "/tmp/output.bin")
do {
try copyChunked(from: src, to: dst, chunkSize: 64 * 1024)
print("Done") // Done
} catch {
print("Copy failed:", error)
}
64 * 1024(64 KB)是一个很常见的“正常起点”。它不是自然法则,但它是一个不错的出发点。
Integrate into a CLI: minimal wrapper
因为我们在做的是一门会把示例逐步组合成一个应用的课程,所以把 chunked 复制想象成一个小小的“文件操作工具命令”会很方便。即使你的主程序是一个图书馆应用,你也经常会需要工具:复制数据库文件、做备份、把数据搬到另一个目录。
我们不把事情复杂化成完整的命令解析器(那个在课程前面已经出现过了)。我们做一个最简单的“骨架”:从 CommandLine.arguments 里取路径。这非常直接,也很适合演示这个思路。
import Foundation
let args = CommandLine.arguments
if args.count == 3 {
let src = URL(fileURLWithPath: args[1])
let dst = URL(fileURLWithPath: args[2])
do {
try copyChunked(from: src, to: dst, chunkSize: 64 * 1024)
print("OK") // OK
} catch {
print("ERROR:", error)
}
} else {
print("Usage: app <src> <dst>")
}
是的,这不是“完美 UX”,但对于教学目的来说非常好:你能看到 chunked 函数如何像普通的构建块一样接入程序。
4. Chunk size and on-the-fly processing
Chunk size: why “too small” and “too large” are both bad
Chunk 大小是一个折中。很小的 chunk(例如 64 字节)意味着大量读写操作,而 I/O 操作本身并不是免费的。很大的 chunk(例如 100 MB)又会把我们带回内存问题——你几乎又是在“把所有内容都读入内存”,只是每次按 100 MB 一块而已。
重要的是,我们现在不是在追求绝对速度纪录。我们是在学习如何让代码更可预测、更安全。因此,对初学者来说,一个合理的策略是:选一个适中的 chunk,先别动,直到出现真正的理由再说。
下面有一个小表格供参考:
| Chunk size | What it gives you | Typical effect |
|---|---|---|
|
close to memory/file-system page sizes | many I/O calls, but low memory usage |
|
a good “usually works well” balance | generally fast and stable |
|
fewer system calls | faster on some disks, but more memory |
这个表格并不承诺奇迹。它只是帮你不要因为数字好看,就选一个“7 字节”的 chunk。
“Process the chunk”: an example with a simple checksum
在复制文件时,chunked I/O 看起来像是“比需要的更复杂”。但真正有意思的是,当你想在 不读取整个文件 的情况下去计算或修改某些内容时。
例如,可以计算一个“玩具版校验和”:把所有字节相加后对 256 取模。这不是密码学,也不是真正的 checksum,但它是一个非常适合教学的数据流聚合例子。
先写一个更新和的函数:
import Foundation
func updateChecksum(_ checksum: inout UInt8, with chunk: Data) {
for b in chunk {
checksum &+= b
}
}
现在把它接入读取循环:
import Foundation
func checksumChunked(of url: URL, chunkSize: Int) throws -> UInt8 {
let input = try FileHandle(forReadingFrom: url)
defer { try? input.close() }
var checksum: UInt8 = 0
while true {
let chunk = try input.read(upToCount: chunkSize) ?? Data()
if chunk.isEmpty { break }
updateChecksum(&checksum, with: chunk)
}
return checksum
}
然后调用:
import Foundation
let url = URL(fileURLWithPath: "/tmp/input.bin")
let sum = try checksumChunked(of: url, chunkSize: 64 * 1024)
print(sum) // for example: 137
这个例子的意义在于,内存中永远不会存放整个文件。即使处理几十 GB 的数据(在系统允许的合理范围内),程序在 RAM 方面的表现也会很稳定。
5. Common mistakes
Mistake #1: forget that the end of file is an empty chunk.
最常见的逻辑错误是写出 while true { read; write },却没有退出条件。这样循环要么变成死循环,要么你会不断向输出文件写入无穷无尽的零数据(如果你自己去补这些数据)。骨架里正确的锚点是:if chunk.isEmpty { break }。
Mistake #2: open FileHandle for writing to a non-existent file.
FileHandle(forWritingTo:) 通常不会“凭空”创建文件。如果目标路径不存在,你会在打开阶段就得到错误。在教学骨架里,最简单的做法是在打开写入端之前先执行 try Data().write(to:),从而创建一个空文件。
Mistake #3: “swallow” read/write errors with try? in the main logic.
有时初学者会想:“既然 try? 更简单,那我到处都用 try? 吧。” 在 I/O 里这几乎总是坏主意:你会丢失失败原因,而程序会在半坏状态下继续运行。把 try? 只留给 defer 里的清理(例如 try? handle.close()),而真正的读写操作要用正常的 try 和 throws。
Mistake #4: choose chunkSize without common sense (for example, 1 byte or 500 MB).
太小的 chunk 会造成海量 I/O 操作并拖慢程序。太大的 chunk 又会把内存问题带回来。如果你没有做性能分析,也没有针对特定系统优化,那就先用类似 64 * 1024 这样的值,然后安稳地继续。
Mistake #5: treat chunked I/O as something “mandatory” all the time.
有些学生学完这一节后,连 2 KB 的文件也开始按块读,因为“这样才对”。实际上,正确与否要看场景。小文件更适合一次性读取:代码更短,出错点更少。只有当文件大小或处理场景使得整文件读取变得有风险或不合适时,chunked I/O 才是需要的。
GO TO FULL VERSION