🧩 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 編譯器會檢查參數和返回值是否可序列化,以支持線程間傳輸。
Context、string、boolean等類型都可序列化;- 因此可直接通過
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 模式。
五、日誌任務詳細實現
以下內容詳細説明 saveLog、getLog 和 clearLog 的實現邏輯及關鍵步驟。
1. 日誌寫入邏輯 (saveLog)
日誌寫入任務通過以下步驟實現:
- 檢查日誌目錄是否存在,如果不存在則創建目錄。
- 計算日誌目錄大小,如果超過
MAX_LOG_DIR_SIZE,按修改時間排序刪除最早文件,確保目錄大小限制。 - 按當前日期生成日誌文件名(
YYYY-MM-DD.log)。 - 準備日誌內容,格式
[時間] [標籤] 日誌內容。 - 追加寫入日誌文件,如果文件不存在則創建。
- 使用 AsyncLock EXCLUSIVE 模式包裹寫入邏輯,確保同一時間僅一個任務寫日誌,防止文件衝突。
- 在子線程執行,主線程不阻塞。
核心代碼示例:
@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)
日誌獲取任務用於生成壓縮文件:
- 檢查日誌目錄是否存在。
- 檢查壓縮目錄是否存在,如果存在先刪除再重建。
- 遍歷日誌文件,按時間戳生成壓縮文件名。
- 使用
zlib.compressFile壓縮日誌目錄。 - 使用 AsyncLock EXCLUSIVE 模式,確保壓縮過程中不會與寫入或清理任務衝突。
- 返回壓縮文件路徑,如果發生異常返回空字符串。
核心代碼示例:
// 獲取日誌壓縮文件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)
日誌清理任務用於刪除日誌和壓縮目錄:
- 刪除日誌目錄及其文件。
- 刪除壓縮目錄及其文件。
- 使用 AsyncLock EXCLUSIVE 模式,確保清理操作不會與寫入或壓縮任務衝突。
- 異常通過 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 自動調度 | 不同任務可併發執行互不干擾 |
| 內存 & 線程安全 | 可序列化參數 + 非阻塞鎖 | 官方推薦方式 |
八、最佳實踐與優化建議
- 控制 TaskPool 粒度
小任務不適合併發執行,避免線程切換開銷。日誌寫入屬於中等粒度任務,適合 TaskPool。 - 鎖的作用域儘量小
lockAsync()內僅包含關鍵寫操作,避免耗時任務阻塞。 - 完善錯誤處理
使用try...catch捕獲文件系統異常,防止異步任務崩潰。 - 精簡上下文傳遞
僅傳遞必要字段(如filesDir),減少序列化負擔。
九、結語
通過 TaskPool 併發機制和 AsyncLock 異步鎖的結合,日誌系統實現了高性能、線程安全和結構清晰的日誌管理。在日誌寫入、獲取壓縮和清理任務中,EXCLUSIVE 鎖保證了臨界區操作的互斥,避免了文件衝突和數據損壞。通過子線程異步執行,主線程保持流暢響應。如下的時序圖可以看到每個任務在請求鎖、進入臨界區、執行文件操作以及釋放鎖的完整流程,直觀展示了鎖佔用和等待的場景:
文筆不好,感謝大家在百忙之中抽出寶貴的時間來閲讀本篇垃圾文章 😄。希望本文對各位理解TaskPool 、 AsyncLock或者業務需求有所幫助。如有改進點,歡迎提出~