《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';// 获取应用沙箱路径letcontext=getContext()ascommon.UIAbilityContext;letsandboxPath=context.filesDir;// 创建测试目录asyncfunctionprepareTestFiles(dirPath:string,count:number):Promise<string[]>{lettestDir=dirPath+'/test_perf/';try{awaitfs.access(testDir);}catch(err){awaitfs.mkdir(testDir,true);}letfilePaths:string[]=[];for(leti=0;i<count;i++){letfilePath=testDir+`file_${i}.txt`;letfile=awaitfs.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[]):Promise<number>{letstart=Date.now();for(letpathoffilePaths){awaitfs.unlink(path);}letend=Date.now();returnend-start;}这个写法最直接,但也是性能最差的。每次 unlink 都要发起一次系统调用,并且当前异步函数要等到该次调用完成才继续。100 次就是 100 次上下文切换。
批量删除则使用 Promise.all 并发执行。
// 批量删除asyncfunctiondeleteBatch(filePaths:string[]):Promise<number>{letstart=Date.now();letpromises=filePaths.map(path=>fs.unlink(path));awaitPromise.all(promises);letend=Date.now();returnend-start;}代码变化只有一行,但效果差异显著。Promise.all 会将所有 unlink 调用并发提交,底层会充分利用多核能力并行处理 I/O 请求,总耗时基本等于最慢的一个文件删除时间,而不是所有文件删除时间之和。
最后在页面中调用并打印结果。
import{promptAction}from'@kit.ArkUI';@Entry@Componentstruct PerfTestPage{@StateresultOneByOne:number=0;@StateresultBatch:number=0;build(){Column(){Text('逐个删除耗时: '+this.resultOneByOne+'ms').fontSize(18).margin(20)Text('批量删除耗时: '+this.resultBatch+'ms').fontSize(18).margin(20)Button('开始测试').onClick(async()=>{// 确保测试目录存在且无干扰文件lettestDir=sandboxPath+'/test_perf/';try{awaitfs.rmdir(testDir,true);}catch(e){// 目录不存在属于正常情况}letfiles=awaitprepareTestFiles(sandboxPath,100);this.resultOneByOne=awaitdeleteOneByOne(files);// 重新准备文件,避免已删除files=awaitprepareTestFiles(sandboxPath,100);this.resultBatch=awaitdeleteBatch(files);})}.width('100%').height('100%').padding(20)}}大文件流式读取与避免频繁小写
批量删除解决了小文件场景,但大文件读写又是另一个坑。很多人直接使用 fs.read 和 fs.write,每次读写固定大小的 buffer。如果文件很大且读取次数很多,性能会急剧下降。
推荐使用 fs.createReadStream 和 fs.createWriteStream,基于流式处理。只在必要时才读写。
// 流式读取大文件asyncfunctionreadLargeFile(filePath:string):Promise<string>{letstream=awaitfs.createReadStream(filePath,{encoding:'utf-8'});letchunks:string[]=[];letbuf=newArrayBuffer(4096);letreadLen=-1;while(readLen!==0){letresult=awaitstream.read(buf);readLen=result.bytesRead;if(readLen>0){letdecoder=newutil.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:number=4096){letfile=awaitfs.open(filePath,fs.OpenMode.CREATE|fs.OpenMode.WRITE_ONLY);letmergedBuffer=newUint8Array(batchSize);letoffset=0;for(letchunkofchunks){letchunkU8=newUint8Array(chunk);if(offset+chunkU8.length>batchSize){// 先写入已累积的数据awaitfs.write(file.fd,mergedBuffer.buffer.slice(0,offset));offset=0;}mergedBuffer.set(chunkU8,offset);offset+=chunkU8.length;}// 写入剩余数据if(offset>0){awaitfs.write(file.fd,mergedBuffer.buffer.slice(0,offset));}awaitfs.close(file.fd);}常见问题 1:Promise.all 并发导致系统资源枯竭
现象:使用 Promise.all 一次性提交上百个文件操作时,部分操作返回错误,错误信息为 EMFILE(打开文件过多)或 EBUSY。
原因:虽然并发提高了吞吐量,但每个异步操作都会消耗一个文件描述符。如果并发数超过系统限制(通常是 1024 或更少),后续操作就会失败。
解决方案:控制并发数。可以使用类似 p-limit 的思路,手动分割批次。
// 限流批量删除asyncfunctiondeleteBatchWithLimit(filePaths:string[],concurrency:number=20):Promise<void>{for(leti=0;i<filePaths.length;i+=concurrency){letbatch=filePaths.slice(i,i+concurrency);awaitPromise.all(batch.map(path=>fs.unlink(path)));}}这样既保证了并发度,又不会一次性耗尽文件描述符。推荐 concurrency 设置在 20-50 之间,具体可根据设备性能调整。
常见问题 2:单个文件操作失败影响整个批量
现象:批量删除时,某个文件已被其他进程删除或路径不存在,导致 Promise.all 整体 reject,后续所有操作中断。
原因:Promise.all 的 fail-fast 机制。任何一个 Promise reject,整体就 reject。
解决方案:每个异步操作加上 try-catch,让失败不影响整体。
// 容错批量删除asyncfunctiondeleteBatchSafe(filePaths:string[]):Promise<number>{letstart=Date.now();letpromises=filePaths.map(path=>fs.unlink(path).catch(err=>{console.warn('删除失败',path,err.message);returnnull;}));awaitPromise.all(promises);letend=Date.now();returnend-start;}这样做的好处是:即使某个文件删除失败,其余文件仍能正常删除,且整体耗时不会因为单个失败而异常。
最佳实践
1. 优先使用 Promise.all 处理独立 I/O 任务
为什么:顺序等待会让总耗时呈线性增长,而并发能大幅缩减等待时间。但要注意并发数量上限。
2. 不要在主线程上发起同步 I/O
HarmonyOS 的 fs 接口提供了异步版本,但有些人习惯用同步 API。同步 I/O 会阻塞当前线程,如果当时在主线程上,会导致 ArkUI 无法刷新,出现页面“假死”。
3. 频繁小写操作一定要合并写入
一次写入 4KB 比四次写入 1KB 快很多。合并写入能减少系统调用次数和内存搬运开销。
Demo 入口
以下为完整可运行页面代码,基于新建的 ArkTS 应用工程,直接替换Index.ets即可测试。
// pages/Index.etsimport{fs}from'@kit.CoreFileKit';import{common}from'@kit.AbilityKit';import{promptAction}from'@kit.ArkUI';letcontext=getContext()ascommon.UIAbilityContext;letsandboxPath=context.filesDir;asyncfunctionprepareTestFiles(dirPath:string,count:number):Promise<string[]>{lettestDir=dirPath+'/test_perf/';try{awaitfs.access(testDir);}catch(err){awaitfs.mkdir(testDir,true);}letfilePaths:string[]=[];for(leti=0;i<count;i++){letfilePath=testDir+`file_${i}.txt`;letfile=awaitfs.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[]):Promise<number>{letstart=Date.now();for(letpathoffilePaths){awaitfs.unlink(path);}letend=Date.now();returnend-start;}asyncfunctiondeleteBatch(filePaths:string[]):Promise<number>{letstart=Date.now();letpromises=filePaths.map(path=>fs.unlink(path).catch(err=>{console.warn('批量删除失败:',path,err.message);returnnull;}));awaitPromise.all(promises);letend=Date.now();returnend-start;}@Entry@Componentstruct Index{@StateresultOneByOne:number=0;@StateresultBatch:number=0;build(){Column(){Text('逐个删除耗时: '+this.resultOneByOne+'ms').fontSize(18).margin(20)Text('批量删除耗时: '+this.resultBatch+'ms').fontSize(18).margin(20)Button('开始测试').onClick(async()=>{lettestDir=sandboxPath+'/test_perf/';try{awaitfs.rmdir(testDir,true);}catch(e){// 目录不存在}letfiles=awaitprepareTestFiles(sandboxPath,100);this.resultOneByOne=awaitdeleteOneByOne(files);files=awaitprepareTestFiles(sandboxPath,100);this.resultBatch=awaitdeleteBatch(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 完成拼接后丢弃中间数据。
示例代码地址:项目地址
