async-sema tryAcquire() 用法指南:如何实现非阻塞信号量获取并避免死锁
async-sema 是一个基于 async/await 的轻量级信号量(Semaphore)库,而 tryAcquire() 是它提供的最重要的非阻塞信号量获取方法。在 Node.js 高并发场景下,如何实现非阻塞信号量获取并避免死锁,是很多开发者头疼的问题——tryAcquire() 正是解决这一痛点的利器。本文用最少的代码,带你从零掌握 async-sema tryAcquire() 的核心用法。
async-sema 是什么?先理解信号量 🚦
信号量(Semaphore)是经典的并发控制原语:它维护一个"许可"数量,任务必须先拿到许可才能执行,用完后归还。async-sema 正是基于这一思想,但专为 async/await 设计。
与社区中某些"先全部放行、最后统一同步"的实现不同,async-sema 只允许固定数量的任务同时运行,其余任务排队等待。这一设计让它非常适合接口限流、连接池管理、批量任务调度等场景。
tryAcquire() 和 acquire() 有什么区别?一张表看懂
async-sema 提供两个获取许可的方法,理解它们的区别是避免死锁的第一步:
| 对比项 | acquire() | tryAcquire() |
|---|---|---|
| 行为 | 异步等待,资源不足时自动排队 | 同步立即返回,绝不等待 |
| 返回值 | Promise,最终一定能拿到许可 | 拿到许可返回 token,否则返回 undefined |
| 资源不足时 | 进入等待队列,直到被唤醒 | 立刻返回 undefined,可快速失败 |
| 适用场景 | 必须完成的任务 | 可跳过、可重试、可降级的任务 |
| 是否触发 pauseFn | 会 | 不会 |
一句话总结:acquire() 是"排队等位",tryAcquire() 是"看看有没有空位,没有就走"。实现代码非常简洁,就藏在 src/index.ts 中——它只是从空闲许可队列里弹出一个 token 而已。
async-sema tryAcquire() 基础用法:三步上手
安装依赖后,三步即可用起来:
npm install --save async-sema
const { Sema } = require('async-sema');
// 1. 创建信号量:最多允许 2 个并发任务
const s = new Sema(2);
// 2. 尝试非阻塞获取
const token = s.tryAcquire();
if (token !== undefined) {
// 3a. 拿到许可,执行任务
try {
doWork();
} finally {
s.release(token); // 记得归还!
}
} else {
// 3b. 许可被占满,快速失败
console.log('系统繁忙,请稍后再试');
}
关键点在于:tryAcquire() 是同步方法,返回值要么是许可 token,要么是 undefined,不会产生 Promise,更不会让调用方挂起。
tryAcquire() 返回 undefined 怎么办?三种处理策略
当资源不足时,tryAcquire() 返回 undefined,你可以根据业务选择三种策略:
- 跳过任务:适合非关键操作(如日志上报、埋点统计),资源忙就放弃。
- 稍后重试:适合瞬态繁忙场景,等待一小段时间后再次尝试。
- 优雅降级:走备用路径(如缓存、降级服务),保证主流程不中断。
其中"稍后重试"是最常用的模式,配合有限次重试可以避免无限等待:
async function withRetry(task, sema, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const token = sema.tryAcquire();
if (token !== undefined) {
try {
return await task();
} finally {
sema.release(token); // 无论成功失败都归还
}
}
await sleep(100); // 让出时间片后再试
}
throw new Error('重试次数用尽,系统繁忙');
}
用 tryAcquire() 避免死锁的 4 个关键技巧 🔑
死锁的本质是"互相等待且无人释放"。使用 tryAcquire() 时有 4 个技巧能帮你彻底避开它:
- 永远在
finally中释放许可:无论任务成功还是抛异常,release()都必须执行,否则许可被永久占用,信号量最终被耗尽。 - 重试必须设上限:无限制重试等于变相死锁,设置最大重试次数并抛出明确错误。
- 区分成功与失败分支:拿到 token 与没拿到 token 的处理逻辑要彻底分离,避免在失败分支里误用 token。
- 结合
nrWaiting()观察队列:在调试时调用nrWaiting()查看排队数量,及时发现"只进不出"的异常信号。参考 test/sema.test.ts 中的用法。
特别提醒:acquire() 拿到的 token 与 tryAcquire() 拿到的 token 没有区别,归还时都要传给 release(token),这样信号量才能精确计数。
tryAcquire() 实战:快速失败限流与连接池
场景一:快速失败限流。当系统过载时,宁可拒绝新请求也不让它们堆积:
const s = new Sema(10); // 最多 10 个并发请求
app.use((req, res, next) => {
const token = s.tryAcquire();
if (token === undefined) {
return res.status(503).json({ error: '服务繁忙,请稍后重试' });
}
res.on('finish', () => s.release(token));
next();
});
场景二:管理真实资源(连接池)。async-sema 的一个巧妙特性是:许可 token 可以是真实资源。通过 initFn 初始化 token,就能用信号量直接管理数据库连接:
const pool = new Sema(3, {
initFn: () => redis.createClient() // 每个 token 就是一个连接
});
const db = pool.tryAcquire();
if (db !== undefined) {
try {
await db.get('user:1');
} finally {
pool.release(db); // 归还连接
}
}
完整示例见 examples/pooling.js,它还演示了用 drain() 在进程退出前安全关闭所有连接。
常见误区与避坑指南 ⚠️
- 把
tryAcquire()当acquire()用:tryAcquire()不会等待,若业务必须执行,请用acquire()或重试策略。 - 忘了归还许可:这是"伪死锁"的最大来源——信号量还有空位,但 token 全被泄漏了。
- 误判
undefined判断方式:判断时用token !== undefined,而不是if (token),因为自定义 token 可能是0或空字符串。测试 test/sema.test.ts 展示了正确写法。 - 过度使用
tryAcquire()轮询:高频率空转轮询会消耗 CPU,重试间隔建议不小于几十毫秒。
小结
tryAcquire() 让 async-sema 具备了"快速失败"的能力:当并发许可不足时,你可以立即感知并选择跳过、重试或降级,而不是让任务无限排队。记住三条黄金法则:同步判断、有限重试、finally 归还,就能在 Node.js 高并发场景中游刃有余地控制资源,彻底远离死锁。
想深入源码?Sema 类的完整实现见 src/index.ts,官方文档见 readme.md,更多可运行示例见 examples/basic.js 与 examples/pausing.js。动手跑一跑,你会发现信号量并发控制其实很简单!
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



