《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}fromkit.CoreFileKit;import{common}fromkit.AbilityKit;// 获取应用沙箱路径letcontextgetContext()ascommon.UIAbilityContext;letsandboxPathcontext.filesDir;// 创建测试目录asyncfunctionprepareTestFiles(dirPath:string,count:number):Promisestring[]{lettestDirdirPath/test_perf/;try{awaitfs.access(testDir);}catch(err){awaitfs.mkdir(testDir,true);}letfilePaths:string[][];for(leti0;icount;i){letfilePathtestDirfile_${i}.txt;letfileawaitfs.open(filePath,fs.OpenMode.CREATE|fs.OpenMode.WRITE_ONLY);awaitfs.write(file.fd,hello);awaitfs.close(file.fd);filePaths.push(filePath);}returnfilePaths;}这一段的核心注意点是 fs.open 的第二个参数。OpenMode.CREATE 必须与 WRITE_ONLY 或 READ_WRITE 组合使用不能单独使用。否则文件创建后无法写入会报错。接下来是逐个删除的实现。用 for 循环依次调用 fs.unlink每次删除都等待返回。// 逐个删除asyncfunctiondeleteOneByOne(filePaths:string[]):Promisenumber{letstartDate.now();for(letpathoffilePaths){awaitfs.unlink(path);}letendDate.now();returnend-start;}这个写法最直接但也是性能最差的。每次 unlink 都要发起一次系统调用并且当前异步函数要等到该次调用完成才继续。100 次就是 100 次上下文切换。批量删除则使用 Promise.all 并发执行。// 批量删除asyncfunctiondeleteBatch(filePaths:string[]):Promisenumber{letstartDate.now();letpromisesfilePaths.map(pathfs.unlink(path));awaitPromise.all(promises);letendDate.now();returnend-start;}代码变化只有一行但效果差异显著。Promise.all 会将所有 unlink 调用并发提交底层会充分利用多核能力并行处理 I/O 请求总耗时基本等于最慢的一个文件删除时间而不是所有文件删除时间之和。最后在页面中调用并打印结果。import{promptAction}fromkit.ArkUI;EntryComponentstruct PerfTestPage{StateresultOneByOne:number0;StateresultBatch:number0;build(){Column(){Text(逐个删除耗时: this.resultOneByOnems).fontSize(18).margin(20)Text(批量删除耗时: this.resultBatchms).fontSize(18).margin(20)Button(开始测试).onClick(async(){// 确保测试目录存在且无干扰文件lettestDirsandboxPath/test_perf/;try{awaitfs.rmdir(testDir,true);}catch(e){// 目录不存在属于正常情况}letfilesawaitprepareTestFiles(sandboxPath,100);this.resultOneByOneawaitdeleteOneByOne(files);// 重新准备文件避免已删除filesawaitprepareTestFiles(sandboxPath,100);this.resultBatchawaitdeleteBatch(files);})}.width(100%).height(100%).padding(20)}}大文件流式读取与避免频繁小写批量删除解决了小文件场景但大文件读写又是另一个坑。很多人直接使用 fs.read 和 fs.write每次读写固定大小的 buffer。如果文件很大且读取次数很多性能会急剧下降。推荐使用 fs.createReadStream 和 fs.createWriteStream基于流式处理。只在必要时才读写。// 流式读取大文件asyncfunctionreadLargeFile(filePath:string):Promisestring{letstreamawaitfs.createReadStream(filePath,{encoding:utf-8});letchunks:string[][];letbufnewArrayBuffer(4096);letreadLen-1;while(readLen!0){letresultawaitstream.read(buf);readLenresult.bytesRead;if(readLen0){letdecodernewutil.TextDecoder(utf-8,{ignoreBOM:true});chunks.push(decoder.decodeWithStream(buf.slice(0,readLen)));}}awaitstream.close();returnchunks.join();}流式读写的核心优势是不需要一次性将整个文件加载到内存而且 read 环节是异步的不会阻塞其他任务。频繁小写操作是另一个容易被忽视的性能陷阱。比如每收到一条小数据就调用一次 fs.write。建议的做法是累积到一定量比如 4KB 或 8KB后再写入一次。// 避免频繁小写批量写入asyncfunctionwriteBatchToFile(filePath:string,chunks:ArrayBuffer[],batchSize:number4096){letfileawaitfs.open(filePath,fs.OpenMode.CREATE|fs.OpenMode.WRITE_ONLY);letmergedBuffernewUint8Array(batchSize);letoffset0;for(letchunkofchunks){letchunkU8newUint8Array(chunk);if(offsetchunkU8.lengthbatchSize){// 先写入已累积的数据awaitfs.write(file.fd,mergedBuffer.buffer.slice(0,offset));offset0;}mergedBuffer.set(chunkU8,offset);offsetchunkU8.length;}// 写入剩余数据if(offset0){awaitfs.write(file.fd,mergedBuffer.buffer.slice(0,offset));}awaitfs.close(file.fd);}常见问题 1Promise.all 并发导致系统资源枯竭现象使用 Promise.all 一次性提交上百个文件操作时部分操作返回错误错误信息为 EMFILE打开文件过多或 EBUSY。原因虽然并发提高了吞吐量但每个异步操作都会消耗一个文件描述符。如果并发数超过系统限制通常是 1024 或更少后续操作就会失败。解决方案控制并发数。可以使用类似 p-limit 的思路手动分割批次。// 限流批量删除asyncfunctiondeleteBatchWithLimit(filePaths:string[],concurrency:number20):Promisevoid{for(leti0;ifilePaths.length;iconcurrency){letbatchfilePaths.slice(i,iconcurrency);awaitPromise.all(batch.map(pathfs.unlink(path)));}}这样既保证了并发度又不会一次性耗尽文件描述符。推荐 concurrency 设置在 20-50 之间具体可根据设备性能调整。常见问题 2单个文件操作失败影响整个批量现象批量删除时某个文件已被其他进程删除或路径不存在导致 Promise.all 整体 reject后续所有操作中断。原因Promise.all 的 fail-fast 机制。任何一个 Promise reject整体就 reject。解决方案每个异步操作加上 try-catch让失败不影响整体。// 容错批量删除asyncfunctiondeleteBatchSafe(filePaths:string[]):Promisenumber{letstartDate.now();letpromisesfilePaths.map(pathfs.unlink(path).catch(err{console.warn(删除失败,path,err.message);returnnull;}));awaitPromise.all(promises);letendDate.now();returnend-start;}这样做的好处是即使某个文件删除失败其余文件仍能正常删除且整体耗时不会因为单个失败而异常。最佳实践1. 优先使用 Promise.all 处理独立 I/O 任务为什么顺序等待会让总耗时呈线性增长而并发能大幅缩减等待时间。但要注意并发数量上限。2. 不要在主线程上发起同步 I/OHarmonyOS 的 fs 接口提供了异步版本但有些人习惯用同步 API。同步 I/O 会阻塞当前线程如果当时在主线程上会导致 ArkUI 无法刷新出现页面“假死”。3. 频繁小写操作一定要合并写入一次写入 4KB 比四次写入 1KB 快很多。合并写入能减少系统调用次数和内存搬运开销。Demo 入口以下为完整可运行页面代码基于新建的 ArkTS 应用工程直接替换Index.ets即可测试。// pages/Index.etsimport{fs}fromkit.CoreFileKit;import{common}fromkit.AbilityKit;import{promptAction}fromkit.ArkUI;letcontextgetContext()ascommon.UIAbilityContext;letsandboxPathcontext.filesDir;asyncfunctionprepareTestFiles(dirPath:string,count:number):Promisestring[]{lettestDirdirPath/test_perf/;try{awaitfs.access(testDir);}catch(err){awaitfs.mkdir(testDir,true);}letfilePaths:string[][];for(leti0;icount;i){letfilePathtestDirfile_${i}.txt;letfileawaitfs.open(filePath,fs.OpenMode.CREATE|fs.OpenMode.WRITE_ONLY);awaitfs.write(file.fd,hello);awaitfs.close(file.fd);filePaths.push(filePath);}returnfilePaths;}asyncfunctiondeleteOneByOne(filePaths:string[]):Promisenumber{letstartDate.now();for(letpathoffilePaths){awaitfs.unlink(path);}letendDate.now();returnend-start;}asyncfunctiondeleteBatch(filePaths:string[]):Promisenumber{letstartDate.now();letpromisesfilePaths.map(pathfs.unlink(path).catch(err{console.warn(批量删除失败,path,err.message);returnnull;}));awaitPromise.all(promises);letendDate.now();returnend-start;}EntryComponentstruct Index{StateresultOneByOne:number0;StateresultBatch:number0;build(){Column(){Text(逐个删除耗时: this.resultOneByOnems).fontSize(18).margin(20)Text(批量删除耗时: this.resultBatchms).fontSize(18).margin(20)Button(开始测试).onClick(async(){lettestDirsandboxPath/test_perf/;try{awaitfs.rmdir(testDir,true);}catch(e){// 目录不存在}letfilesawaitprepareTestFiles(sandboxPath,100);this.resultOneByOneawaitdeleteOneByOne(files);filesawaitprepareTestFiles(sandboxPath,100);this.resultBatchawaitdeleteBatch(files);})}.width(100%).height(100%).padding(20)}}FAQQ为什么真机上批量删除比逐个删除快很多但模拟器上差异不大A模拟器的文件 I/O 是模拟的底层可能没有真正的并发多路复用。真机的文件系统是物理硬件并行 I/O 利用多核能力的收益更明显。建议性能测试以真机为准。Q批量操作时如果其中一个文件被其他进程持锁会阻塞整个批次吗A如果文件被持锁fs.unlink 会返回错误。在容错写法下当前操作失败不影响其他操作。但如果系统文件锁机制导致 unlink 阻塞等待这种情况较少则会影响该批次。建议对于高并发关键路径使用 try-catch 并设置超时机制。Q使用流式读取时发现内存占用依然很高怎么回事A检查是否在每次 read 后保留了 buffer 引用导致无法 GC。推荐把 chunks 数组中的 buffer 及时释放或者使用 TransformStream 完成拼接后丢弃中间数据。示例代码地址项目地址