《HarmonyOS技术精讲-Core File Kit》第16篇:性能优化——批量操作与并发控制
《HarmonyOS技术精讲-Core File Kit》第16篇:性能优化——批量操作与并发控制

文件操作性能瓶颈到底是什么
HarmonyOS NEXT 开发中,很多刚接触 Core File Kit 的开发者,第一个直觉是按顺序逐个处理文件。来一个文件读一次,来一个文件删一次。逻辑上没错,但实测会发现,当文件数量超过几十个时,UI 卡顿明显,操作耗时从几十毫秒膨胀到几百毫秒甚至秒级。
问题根源有两个:一是频繁的文件 I/O 调用会带来巨大的上下文切换开销;二是同步阻塞模式会让主线程或异步任务线程长时间占用,导致其他操作被挂起。实际上,Core File Kit 提供的 fs 接口天然支持异步 Promise,但大多数人在使用时并没有发挥出并发优势。
本文不讲概念,直接落地。核心目标:实现一个小工具,能批量删除 100 个小文件,并比较逐个删除和批量删除的时间差异。同时会演示大文件流式读写的优化写法,以及如何避免高频小写操作对性能的影响。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现:从逐个删除到批量删除
代码逻辑分成三部分:创建测试文件、逐个删除、批量删除。最后打印耗时对比。
先创建一个测试目录,生成 100 个小文件。这里使用 fs.access 判断目录是否存在,避免重复创建。
import { fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
// 获取应用沙箱路径
let context = getContext() as common.UIAbilityContext;
let sandboxPath = context.filesDir;
// 创建测试目录
async function prepareTestFiles(dirPath: string, count: number): Promise<string[]> {
let testDir = dirPath + '/test_perf/';
try {
await fs.access(testDir);
} catch (err) {
await fs.mkdir(testDir, true);
}
let filePaths: string[] = [];
for (let i = 0; i < count; i++) {
let filePath = testDir + `file_${i}.txt`;
let file = await fs.open(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
await fs.write(file.fd, 'hello');
await fs.close(file.fd);
filePaths.push(filePath);
}
return filePaths;
}
这一段的核心注意点是 fs.open 的第二个参数。OpenMode.CREATE 必须与 WRITE_ONLY 或 READ_WRITE 组合使用,不能单独使用。否则文件创建后无法写入,会报错。
接下来是逐个删除的实现。用 for 循环依次调用 fs.unlink,每次删除都等待返回。
// 逐个删除
async function deleteOneByOne(filePaths: string[]): Promise<number> {
let start = Date.now();
for (let path of filePaths) {
await fs.unlink(path);
}
let end = Date.now();
return end - start;
}
这个写法最直接,但也是性能最差的。每次 unlink 都要发起一次系统调用,并且当前异步函数要等到该次调用完成才继续。100 次就是 100 次上下文切换。
批量删除则使用 Promise.all 并发执行。
// 批量删除
async function deleteBatch(filePaths: string[]): Promise<number> {
let start = Date.now();
let promises = filePaths.map(path => fs.unlink(path));
await Promise.all(promises);
let end = Date.now();
return end - start;
}
代码变化只有一行,但效果差异显著。Promise.all 会将所有 unlink 调用并发提交,底层会充分利用多核能力并行处理 I/O 请求,总耗时基本等于最慢的一个文件删除时间,而不是所有文件删除时间之和。
最后在页面中调用并打印结果。
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct PerfTestPage {
@State resultOneByOne: number = 0;
@State resultBatch: number = 0;
build() {
Column() {
Text('逐个删除耗时: ' + this.resultOneByOne + 'ms')
.fontSize(18)
.margin(20)
Text('批量删除耗时: ' + this.resultBatch + 'ms')
.fontSize(18)
.margin(20)
Button('开始测试')
.onClick(async () => {
// 确保测试目录存在且无干扰文件
let testDir = sandboxPath + '/test_perf/';
try {
await fs.rmdir(testDir, true);
} catch (e) {
// 目录不存在属于正常情况
}
let files = await prepareTestFiles(sandboxPath, 100);
this.resultOneByOne = await deleteOneByOne(files);
// 重新准备文件,避免已删除
files = await prepareTestFiles(sandboxPath, 100);
this.resultBatch = await deleteBatch(files);
})
}
.width('100%')
.height('100%')
.padding(20)
}
}
大文件流式读取与避免频繁小写
批量删除解决了小文件场景,但大文件读写又是另一个坑。很多人直接使用 fs.read 和 fs.write,每次读写固定大小的 buffer。如果文件很大且读取次数很多,性能会急剧下降。
推荐使用 fs.createReadStream 和 fs.createWriteStream,基于流式处理。只在必要时才读写。
// 流式读取大文件
async function readLargeFile(filePath: string): Promise<string> {
let stream = await fs.createReadStream(filePath, { encoding: 'utf-8' });
let chunks: string[] = [];
let buf = new ArrayBuffer(4096);
let readLen = -1;
while (readLen !== 0) {
let result = await stream.read(buf);
readLen = result.bytesRead;
if (readLen > 0) {
let decoder = new util.TextDecoder('utf-8', { ignoreBOM: true });
chunks.push(decoder.decodeWithStream(buf.slice(0, readLen)));
}
}
await stream.close();
return chunks.join('');
}
流式读写的核心优势是不需要一次性将整个文件加载到内存,而且 read 环节是异步的,不会阻塞其他任务。
频繁小写操作是另一个容易被忽视的性能陷阱。比如每收到一条小数据就调用一次 fs.write。建议的做法是累积到一定量(比如 4KB 或 8KB)后再写入一次。
// 避免频繁小写:批量写入
async function writeBatchToFile(filePath: string, chunks: ArrayBuffer[], batchSize: number = 4096) {
let file = await fs.open(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
let mergedBuffer = new Uint8Array(batchSize);
let offset = 0;
for (let chunk of chunks) {
let chunkU8 = new Uint8Array(chunk);
if (offset + chunkU8.length > batchSize) {
// 先写入已累积的数据
await fs.write(file.fd, mergedBuffer.buffer.slice(0, offset));
offset = 0;
}
mergedBuffer.set(chunkU8, offset);
offset += chunkU8.length;
}
// 写入剩余数据
if (offset > 0) {
await fs.write(file.fd, mergedBuffer.buffer.slice(0, offset));
}
await fs.close(file.fd);
}
常见问题 1:Promise.all 并发导致系统资源枯竭
现象:使用 Promise.all 一次性提交上百个文件操作时,部分操作返回错误,错误信息为 EMFILE(打开文件过多)或 EBUSY。
原因:虽然并发提高了吞吐量,但每个异步操作都会消耗一个文件描述符。如果并发数超过系统限制(通常是 1024 或更少),后续操作就会失败。
解决方案:控制并发数。可以使用类似 p-limit 的思路,手动分割批次。
// 限流批量删除
async function deleteBatchWithLimit(filePaths: string[], concurrency: number = 20): Promise<void> {
for (let i = 0; i < filePaths.length; i += concurrency) {
let batch = filePaths.slice(i, i + concurrency);
await Promise.all(batch.map(path => fs.unlink(path)));
}
}
这样既保证了并发度,又不会一次性耗尽文件描述符。推荐 concurrency 设置在 20-50 之间,具体可根据设备性能调整。
常见问题 2:单个文件操作失败影响整个批量
现象:批量删除时,某个文件已被其他进程删除或路径不存在,导致 Promise.all 整体 reject,后续所有操作中断。
原因:Promise.all 的 fail-fast 机制。任何一个 Promise reject,整体就 reject。
解决方案:每个异步操作加上 try-catch,让失败不影响整体。
// 容错批量删除
async function deleteBatchSafe(filePaths: string[]): Promise<number> {
let start = Date.now();
let promises = filePaths.map(path =>
fs.unlink(path).catch(err => {
console.warn('删除失败', path, err.message);
return null;
})
);
await Promise.all(promises);
let end = Date.now();
return end - start;
}
这样做的好处是:即使某个文件删除失败,其余文件仍能正常删除,且整体耗时不会因为单个失败而异常。
最佳实践
1. 优先使用 Promise.all 处理独立 I/O 任务
为什么:顺序等待会让总耗时呈线性增长,而并发能大幅缩减等待时间。但要注意并发数量上限。
2. 不要在主线程上发起同步 I/O
HarmonyOS 的 fs 接口提供了异步版本,但有些人习惯用同步 API。同步 I/O 会阻塞当前线程,如果当时在主线程上,会导致 ArkUI 无法刷新,出现页面“假死”。
3. 频繁小写操作一定要合并写入
一次写入 4KB 比四次写入 1KB 快很多。合并写入能减少系统调用次数和内存搬运开销。
Demo 入口
以下为完整可运行页面代码,基于新建的 ArkTS 应用工程,直接替换 Index.ets 即可测试。
// pages/Index.ets
import { fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
let context = getContext() as common.UIAbilityContext;
let sandboxPath = context.filesDir;
async function prepareTestFiles(dirPath: string, count: number): Promise<string[]> {
let testDir = dirPath + '/test_perf/';
try {
await fs.access(testDir);
} catch (err) {
await fs.mkdir(testDir, true);
}
let filePaths: string[] = [];
for (let i = 0; i < count; i++) {
let filePath = testDir + `file_${i}.txt`;
let file = await fs.open(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
await fs.write(file.fd, 'hello');
await fs.close(file.fd);
filePaths.push(filePath);
}
return filePaths;
}
async function deleteOneByOne(filePaths: string[]): Promise<number> {
let start = Date.now();
for (let path of filePaths) {
await fs.unlink(path);
}
let end = Date.now();
return end - start;
}
async function deleteBatch(filePaths: string[]): Promise<number> {
let start = Date.now();
let promises = filePaths.map(path =>
fs.unlink(path).catch(err => {
console.warn('批量删除失败:', path, err.message);
return null;
})
);
await Promise.all(promises);
let end = Date.now();
return end - start;
}
@Entry
@Component
struct Index {
@State resultOneByOne: number = 0;
@State resultBatch: number = 0;
build() {
Column() {
Text('逐个删除耗时: ' + this.resultOneByOne + 'ms')
.fontSize(18)
.margin(20)
Text('批量删除耗时: ' + this.resultBatch + 'ms')
.fontSize(18)
.margin(20)
Button('开始测试')
.onClick(async () => {
let testDir = sandboxPath + '/test_perf/';
try {
await fs.rmdir(testDir, true);
} catch (e) {
// 目录不存在
}
let files = await prepareTestFiles(sandboxPath, 100);
this.resultOneByOne = await deleteOneByOne(files);
files = await prepareTestFiles(sandboxPath, 100);
this.resultBatch = await deleteBatch(files);
})
}
.width('100%')
.height('100%')
.padding(20)
}
}
FAQ
Q:为什么真机上批量删除比逐个删除快很多,但模拟器上差异不大?
A:模拟器的文件 I/O 是模拟的,底层可能没有真正的并发多路复用。真机的文件系统是物理硬件,并行 I/O 利用多核能力的收益更明显。建议性能测试以真机为准。
Q:批量操作时,如果其中一个文件被其他进程持锁,会阻塞整个批次吗?
A:如果文件被持锁,fs.unlink 会返回错误。在容错写法下,当前操作失败不影响其他操作。但如果系统文件锁机制导致 unlink 阻塞等待(这种情况较少),则会影响该批次。建议对于高并发关键路径,使用 try-catch 并设置超时机制。
Q:使用流式读取时,发现内存占用依然很高,怎么回事?
A:检查是否在每次 read 后保留了 buffer 引用,导致无法 GC。推荐把 chunks 数组中的 buffer 及时释放,或者使用 TransformStream 完成拼接后丢弃中间数据。
示例代码地址:项目地址
更多推荐

所有评论(0)