Stories

Detail Return Return

ArkTS 併發日誌系統實現:TaskPool + AsyncLock 實戰解析

🧩 ArkTS 併發日誌系統實現:TaskPool + AsyncLock 實戰解析

本文基於官方文檔

  • TaskPool 併發機制介紹
  • ArkTS 異步鎖 API 參考
    結合實際工程實踐,展示了在 HarmonyOS ArkTS 中構建高性能、線程安全的日誌系統的方法。

一、背景:為什麼採用併發寫日誌

日誌系統通常是高頻調用且 IO 密集的模塊。傳統實現中,日誌寫入、壓縮和舊文件清理等操作在主線程執行可能會導致:

  • 主線程阻塞,影響 UI 響應;
  • 文件寫入衝突,多線程同時操作同一文件導致數據不一致;
  • 日誌目錄膨脹,清理邏輯阻塞,降低性能。

在 API version 9 之後,ArkTS 提供了 TaskPool 併發機制和 AsyncLock 工具類,可以將耗時的日誌操作移到子線程執行,同時保證線程安全。


二、架構設計概覽

日誌系統採用四層架構:

層級 模塊 主要職責
調用入口 SaveLogHelper 對外提供統一日誌 API(save、get、clear)
抽象層 AbsSaveLog 統一日誌接口定義
實現層 SpSaveLog 使用 TaskPool + AsyncLock 實現線程安全的異步日誌
併發任務 SpSaveLogTask @Concurrent 修飾的實際任務函數(子線程執行)

整體調用鏈如下:

Logger.preLogContent()
 └── SaveLogHelper.saveLog()
      └── SpSaveLog.saveLog()
           ├── AsyncLock.lockAsync()  // 異步加鎖,保證線程安全
           └── taskpool.execute()     // 啓動併發任務 saveLog()

三、TaskPool 與 @Concurrent 的應用

在日誌系統中,三個關鍵併發任務採用 @Concurrent 修飾:

@Concurrent
export async function saveLog(ctx: Context, tag: string, message: string) { ... }

@Concurrent
export async function getLog(ctx: Context): Promise<string> { ... }

@Concurrent
export async function clearLog(ctx: Context) { ... }

@Concurrent 的作用

@Concurrent 標記函數可在子線程中執行。ArkTS 編譯器會檢查參數和返回值是否可序列化,以支持線程間傳輸。

  • Contextstringboolean 等類型都可序列化;
  • 因此可直接通過 taskpool.execute() 異步調用。

taskpool.execute() 調用

return taskpool.execute(saveLog, context, tag, message) as Promise<boolean>;

任務被序列化並派發到 TaskPool 的空閒線程異步執行,返回 Promise,避免主線程阻塞。


四、AsyncLock 異步鎖與鎖模式選擇

併發任務可能同時操作同一日誌文件或目錄,容易產生數據競爭。AsyncLock 提供異步鎖來保證線程安全:

private static lock = ArkTSUtils.locks.AsyncLock.request("SpSaveLog_lock_sp")

在日誌系統中,所有文件操作使用 EXCLUSIVE 模式鎖:

return SpSaveLog.lock.lockAsync(() => {
  const context = SaveLogManager.get().getContext()
  return taskpool.execute(saveLog, context, tag, message)
}, ArkTSUtils.locks.AsyncLockMode.EXCLUSIVE)

鎖模式選擇原因

  • EXCLUSIVE:保證同一時間只有一個任務操作日誌文件或目錄,防止寫入衝突和數據損壞;
  • SHARED:允許多個任務同時進入臨界區,適用於只讀操作;但日誌寫入/清空涉及寫操作,不適合 SHARED 模式。

因此,為了安全地執行寫操作,必須使用 EXCLUSIVE 模式。


五、日誌任務詳細實現

以下內容詳細説明 saveLoggetLogclearLog 的實現邏輯及關鍵步驟。

1. 日誌寫入邏輯 (saveLog)

日誌寫入任務通過以下步驟實現:

  1. 檢查日誌目錄是否存在,如果不存在則創建目錄。
  2. 計算日誌目錄大小,如果超過 MAX_LOG_DIR_SIZE,按修改時間排序刪除最早文件,確保目錄大小限制。
  3. 按當前日期生成日誌文件名(YYYY-MM-DD.log)。
  4. 準備日誌內容,格式 [時間] [標籤] 日誌內容
  5. 追加寫入日誌文件,如果文件不存在則創建。
  6. 使用 AsyncLock EXCLUSIVE 模式包裹寫入邏輯,確保同一時間僅一個任務寫日誌,防止文件衝突。
  7. 在子線程執行,主線程不阻塞。

核心代碼示例:

@Concurrent
export async function saveLog(ctx: Context, tag: string, message: string) {
  if (!ctx) {
    return
  }
  const funcName = 'saveLog'
  const logDir = ctx.filesDir + LOG_DIR // 統一日誌目錄
  // 檢查並創建日誌目錄
  try {
    const exist = await fs.access(logDir)
    if (!exist) {
      await fs.mkdir(logDir)
    }
  } catch (e) {
    LogUtil.errorForce(funcName, 'mkdir file error:' + e)
  }
  // 計算文件夾大小並清理舊文件
  const getFileSize = (path: string): number => {
    let total = 0
    try {
      const files = fs.listFileSync(path)
      for (const file of files) {
        const stat = fs.statSync(path + '/' + file)
        if (stat.isFile()) {
          total += stat.size
        }
      }
    } catch (e) {
      LogUtil.errorForce(funcName, 'getFileSize error', e)
    }
    return total
  }
  const cleanOldLogsIfNeeded = (): void => {
    let total = getFileSize(logDir)
    if (total <= MAX_LOG_DIR_SIZE) {
      return
    }
    let files: string[] = []
    try {
      files = fs.listFileSync(logDir)
    } catch (e) {
      LogUtil.errorForce(funcName, 'get files error', e)
    }
    // 按修改時間排序(最早的在前)
    files.sort((a, b) => {
      let mtimeA = 0, mtimeB = 0
      try {
        const statA = fs.statSync(logDir + '/' + a)
        mtimeA = statA?.mtime ?? 0
      } catch (e) {
        mtimeA = 0
        LogUtil.errorForce(funcName, `sort failed: get statA.mtime error: ${e}`)
      }
      try {
        const statB = fs.statSync(logDir + '/' + b)
        mtimeB = statB?.mtime ?? 0
      } catch (e) {
        mtimeB = 0
        LogUtil.errorForce(funcName, `sort failed: get statA.mtimeB error: ${e}`)
      }
      return mtimeA - mtimeB
    })
    // 依次刪除最早的文件直到小於 20MB
    for (const f of files) {
      if (total <= MAX_LOG_DIR_SIZE) {
        break
      }
      try {
        const fpath = logDir + '/' + f
        const stat = fs.statSync(fpath)
        fs.unlinkSync(fpath)
        total -= stat.size
      } catch (e) {
        LogUtil.errorForce(funcName, 'remove file error:' + e)
      }
    }
  }
  // 調用清理邏輯
  cleanOldLogsIfNeeded()
  // 當前日期對應的日誌文件
  const date = new Date()?.toISOString?.()?.split?.('T')?.[0] ?? "1970-01-01"
  const filePath = `${logDir}/${date}.log`
  // 準備日誌內容
  const time = new Date()?.toLocaleString?.('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) ?? "1970-01-01"
  const logLine = `[${time}] [${tag}] ${message}\n`
  // 追加寫入日誌
  let file: fs.File | undefined
  try {
    file = await fs.open(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
    const stat = await fs.stat(filePath)
    await fs.write(file.fd, logLine, { offset: stat.size })
  } finally {
    fs.close(file?.fd)
  }
}

2. 日誌獲取邏輯 (getLog)

日誌獲取任務用於生成壓縮文件:

  1. 檢查日誌目錄是否存在。
  2. 檢查壓縮目錄是否存在,如果存在先刪除再重建。
  3. 遍歷日誌文件,按時間戳生成壓縮文件名。
  4. 使用 zlib.compressFile 壓縮日誌目錄。
  5. 使用 AsyncLock EXCLUSIVE 模式,確保壓縮過程中不會與寫入或清理任務衝突。
  6. 返回壓縮文件路徑,如果發生異常返回空字符串。

核心代碼示例:

// 獲取日誌壓縮文件uri
@Concurrent
export async function getLog(ctx: Context): Promise<string> {
  const funcName = 'getLog'
  const result: string = ''
  if (!ctx) {
    return result
  }
  const logDir = ctx.filesDir + LOG_DIR
  const zipDir = ctx.filesDir + LOG_ZIP_DIR
  try {
    const exist = await fs.access(logDir)
    if (!exist) {
      return result
    }
  } catch (e) {
    LogUtil.errorForce(funcName, 'logDir does not exist: ' + e)
    return result
  }
  try {
    const exist = await fs.access(zipDir)
    if (exist) {
      await fs.rmdir(zipDir)
      await fs.mkdir(zipDir)
    } else {
      await fs.mkdir(zipDir)
    }
  } catch (e) {
    LogUtil.errorForce(funcName, 'zipDir operate error: ' + e)
    return result
  }
  const getTimestampForFilename = (): string => {
    try {
      const now = new Date()
      const yyyy = now.getFullYear()
      const MM = String(now.getMonth() + 1).padStart(2, '0')
      const dd = String(now.getDate()).padStart(2, '0')
      const hh = String(now.getHours()).padStart(2, '0')
      const mm = String(now.getMinutes()).padStart(2, '0')
      const ss = String(now.getSeconds()).padStart(2, '0')
      const ms = String(now.getMilliseconds()).padStart(3, '0')
      return `${yyyy}${MM}${dd}_${hh}${mm}${ss}_${ms}`
    } catch (e) {
      LogUtil.errorForce(funcName, 'getTimestampForFilename failed: ' + e)
      return '1970-01-01'
    }
  }
  try {
    // 獲取文件列表
    let files: string[] = []
    try {
      files = fs.listFileSync(logDir)
    } catch (e) {
      LogUtil.errorForce(funcName, 'listFileSync failed: ' + e)
    }
    if (!files || files.length === 0) { //目錄下沒有文件不能壓縮,否則解壓的時候會報錯
      return result
    }
    const zipFilePath = zipDir + `/${getTimestampForFilename()}.zip`
    try {
      await zlib.compressFile(logDir, zipFilePath, {})
      return zipFilePath
    } catch (e) {
      LogUtil.errorForce(funcName, 'compressFile failed: ' + e)
      return result
    }
  } catch (e) {
    const err = e as BusinessError
    LogUtil.errorForce(
      funcName,
      'get log zip failed with error message: ' + err.message + ', error code: ' + err.code
    )
    return result
  }
}

3. 日誌清理邏輯 (clearLog)

日誌清理任務用於刪除日誌和壓縮目錄:

  1. 刪除日誌目錄及其文件。
  2. 刪除壓縮目錄及其文件。
  3. 使用 AsyncLock EXCLUSIVE 模式,確保清理操作不會與寫入或壓縮任務衝突。
  4. 異常通過 try/catch 捕獲,保證任務安全和系統穩定。

核心代碼示例:

// 清空日誌(直接刪除日誌文件夾)
@Concurrent
export async function clearLog(ctx: Context) {
  if (!ctx) {
    return
  }
  const funcName = 'clearLog'
  const logPath = ctx.filesDir + LOG_DIR
  const zipPath = ctx.filesDir + LOG_ZIP_DIR
  try {
    await fs.rmdir(logPath)
  } catch (e) {
    const err = e as BusinessError
    LogUtil.errorForce(funcName,
      'unlink log dir failed with error message: ' + err?.message + ', error code: ' + err?.code)
  }
  try {
    await fs.rmdir(zipPath)
  } catch (e) {
    const err = e as BusinessError
    LogUtil.errorForce(funcName,
      'unlink log zip dir failed with error message: ' + err?.message + ', error code: ' + err?.code)
  }
}

所有這些操作都在子線程執行,主線程保持流暢。


六、Logger 與 SaveLog 的結合

業務日誌入口使用 Logger.preLogContent()

if (forceLog && tag != "EventReport") {
  SaveLogHelper.saveLog(tag, JSON.stringify(content))
}

當日志滿足保存條件時,通過 SaveLogHelper 和 SpSaveLog 調用 TaskPool 異步執行日誌操作。


七、整體性能與併發策略總結

目標 解決方案 實現
避免主線程阻塞 TaskPool + @Concurrent 日誌寫入、壓縮、清理都在子線程完成
防止文件衝突 AsyncLock + EXCLUSIVE 異步鎖保證同一時間僅一個任務寫日誌
多任務同時寫日誌 TaskPool 自動調度 不同任務可併發執行互不干擾
內存 & 線程安全 可序列化參數 + 非阻塞鎖 官方推薦方式

八、最佳實踐與優化建議

  1. 控制 TaskPool 粒度
    小任務不適合併發執行,避免線程切換開銷。日誌寫入屬於中等粒度任務,適合 TaskPool。
  2. 鎖的作用域儘量小
    lockAsync() 內僅包含關鍵寫操作,避免耗時任務阻塞。
  3. 完善錯誤處理
    使用 try...catch 捕獲文件系統異常,防止異步任務崩潰。
  4. 精簡上下文傳遞
    僅傳遞必要字段(如 filesDir),減少序列化負擔。

九、結語

通過 TaskPool 併發機制和 AsyncLock 異步鎖的結合,日誌系統實現了高性能、線程安全和結構清晰的日誌管理。在日誌寫入、獲取壓縮和清理任務中,EXCLUSIVE 鎖保證了臨界區操作的互斥,避免了文件衝突和數據損壞。通過子線程異步執行,主線程保持流暢響應。如下的時序圖可以看到每個任務在請求鎖、進入臨界區、執行文件操作以及釋放鎖的完整流程,直觀展示了鎖佔用和等待的場景:

文筆不好,感謝大家在百忙之中抽出寶貴的時間來閲讀本篇垃圾文章 😄。希望本文對各位理解TaskPool 、 AsyncLock或者業務需求有所幫助。如有改進點,歡迎提出~

user avatar
0 users favorite the story!

Post Comments

Some HTML is okay.