《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 完成拼接后丢弃中间数据。

示例代码地址:项目地址

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐