你写的批处理脚本一跑就撞限流,或者本地模型、单线程数据库连接只允许同时一个请求,这时候就需要把并发压到 1。这篇给你三种能直接抄的写法(信号量、异步锁、单消费者队列),附带判断用哪种、怎么自测、什么时候别用。
你写了个批处理脚本,一跑就撞限流;或者你手里有个本地推理进程、单连接数据库、明确声明并发上限的接口,只允许同一时刻有一个请求在跑。这时候要的不是降速,是把并发压到 1。这篇给你三种能直接抄进代码的写法,外加怎么自测它真的只跑一个、以及什么时候别这么干。
先说清楚一件事:并发为 1 限制的是「同时」在跑的任务数,不是总调用量。排队等着的一百个任务照样会一个个跑完,只是任何时刻只有一个在执行。如果你只是想压住每秒请求数,那是限速问题,别用并发锁解决,两者混在一起调试会很痛苦。判断标准很简单:本地推理进程、单连接数据库、接口文档里写死了并发上限——这三种才需要锁到 1,其余情况先想清楚是不是限速问题。想核对各家接口的并发与速率说明,可以到 中转站目录(/sites) 对应条目里看当时的描述。
先把「并发为 1」说清楚:限制的是同时在跑,不是总次数
很多人把并发 1 理解成「一次只能发一个请求、发完再发下一个」,这其实已经是串行了,但语义上要更精确:同一时刻最多一个任务处于「执行中」状态,其余任务在队列里等着被放进去。放进去的瞬间占住名额,跑完必须把名额还回来,后续任务才进得来。还名额这一步如果漏了,后面所有任务就永远卡住。
| 需求 | 该用什么 | 不该用什么 |
|---|---|---|
| 同一时刻只跑一个任务 | 信号量 / 互斥锁 / 单消费者队列 | 每秒请求数限制 |
| 每秒最多发几个请求 | 限速器(令牌桶等) | 并发锁 |
| 多实例部署仍要串行 | 外部协调组件 | 进程内锁 |
写法一:信号量与互斥锁,最直接
最通用的做法是把信号量的可用数量设成 1。任务进来先申请,拿到才执行,跑完在 finally 里释放。互斥锁本质是一回事,只是语义更强调「同一时间只有一个」,选哪个看团队习惯。关键点只有一个:释放必须放在 finally,否则一次异常抛出就永久堵死,后面全在等一个再也不回来的名额。
- 初始化名额为 1进程启动时创建一个可用数为 1 的信号量或锁对象,全局唯一,别在函数里反复新建。
- 进入前申请执行真正的工作之前先 acquire / lock,拿不到就等,等到再往下走。
- 用 try/finally 包住执行体把业务写在 try 里,finally 里 release / unlock,保证异常路径也归还名额。
- 验证只跑一个打日志记开始与结束时间戳,看是否有重叠,重叠就说明锁没生效。
有个坑值得单独提:别在锁里面做网络等待之外的慢操作。比如持锁时去读大文件、跑本地推理、等数据库返回,排队时间会被拉长,表现上就像卡死,其实是任务在排。判断自己有没有踩坑,看第一个任务耗时是不是远小于最后一个任务从入队到完成的耗时即可。
写法二:单消费者队列,任务多时更稳
任务量大、来源分散的时候,信号量写法会变成一堆任务同时挤在锁前面抢名额,不好观察也不好控。这时换成单消费者队列:生产者只管往队列里丢任务,固定只起一个消费者顺序取、顺序跑,天然就是并发 1,队列本身承担了缓冲。
- 队列一定要设上限,满了就让生产者等待或直接拒绝,否则任务堆积会把内存吃光
- 消费者的重试策略要和队列绑定,失败任务不能悄悄消失,要么重入队要么落盘
- 只起一个消费者,别为了「快一点」偷偷加第二个,那等于把并发改成了 2
- 消费者要能优雅退出,收到停止信号后把当前任务跑完再结束,避免半截任务
想对照不同类型中转站在多任务排队、限流方面的表现差异,可以看 排行与评测(/rankings) 里按场景整理的条目,具体数字以页面当时显示的为准,别照搬别人的结论。
写法三:异步场景下的锁与串行化
异步代码里最容易犯的错,是拿同步锁去保护异步任务。同步锁一拿住会阻塞事件循环,结果是并发确实变成 1 了,但整个服务所有请求都僵住,比不加锁还糟。正确做法是用语言配套的异步锁,await 着拿、await 着放,事件循环该转还转。
| 场景 | 推荐写法 | 常见错误 |
|---|---|---|
| 同步多线程 | 信号量 / 互斥锁,finally 释放 | 忘了释放导致堵死 |
| 异步单线程 | 异步锁或长度 1 的异步队列 | 用同步锁阻塞事件循环 |
| 多进程 / 多实例 | 外部协调组件,如分布式锁 | 进程内锁当跨进程用 |
如果手头的语言或框架没有现成的异步锁,可以用长度为 1 的异步队列做替代:取一个相当于申请名额,放回一个相当于释放。跨进程场景则要换思路,进程内锁只在自己进程里有意义,多实例部署时每个实例各持一把锁,并发实际还是多份,这时候得改用外部协调组件才管用。
怎么验证真的只跑一个,以及什么时候不该这么做
代码写完别只靠肉眼读逻辑。最可靠的办法是给每个任务记开始和结束时间戳,落日志之后检查有没有时间区间重叠:有重叠就是没锁住。其次是跑一批任务,看总耗时是不是近似线性累加,串行生效时通常接近单个任务耗时乘以任务数,注意这是侧面佐证,不是严格证明。
- 埋时间戳每个任务开始时记一次、结束时记一次,带上任务 ID,方便对区间。
- 检查重叠把时间区间两两对比,出现重叠说明同时跑了两个以上,回到锁的释放逻辑排查。
- 看总耗时趋势把任务数翻倍再跑一次,总耗时大致也翻倍,说明串行基本生效。
- 单一变量复现把任务数降到一个,确认单任务本身没问题,再逐步加压,别一上来就压满。
并发为 1 不是让任务变慢的理由,它只是把「同时」换成了「依次」,前提是你确实只需要先后跑。
常见问题
并发限制为 1,用信号量和用队列有什么区别?
信号量是让多个任务自己抢一个名额,适合任务量少、逻辑简单的场景;队列是把入口收敛成一个消费者,适合任务多、需要排队和失败重投的场景。前者改动小,后者更可控,看你的任务规模选。
为什么我加了锁,实际请求还是重叠的?
常见原因是锁的作用范围不对:锁对象每次调用都新建一个、或者锁只加在了部分代码路径上。另外多进程、多实例部署时进程内锁不共享,也会出现看着锁了其实没锁,需要换成外部协调。
把并发锁到 1 会不会明显变慢?
总耗时基本等于任务数乘以单次耗时,肯定比并发慢。如果任务本身很快、或者调用端允许更高并发,就没必要锁。只有在对方明确限制并发、或本地资源只能处理一个请求时,锁到 1 才是划算的。