scan4all 并发控制基石:SizedWaitGroup 限制 Goroutine 并发数的原理与实战
【免费下载链接】scan4allOfficial repository vuls Scan: 15000+PoCs; 23 kinds of application password crack; 7000+Web fingerprints; 146 protocols and 90000+ rules Port scanning; Fuzz, HW, awesome BugBounty( ͡° ͜ʖ ͡°)...项目地址: https://gitcode.com/GitHub_Trending/sca/scan4all
导读
SizedWaitGroup 是一个基于 Go 标准库sync.WaitGroup设计的并发原语,在保留"等待所有任务结束"能力的同时,为同时启动的 Goroutine 数量加上硬性上限,从而在追求吞吐与保护下游资源(数据库、目标主机等)之间取得平衡。本文以 scan4all 项目所依赖的 sizedwaitgroup 官方 README 为主线,结合其 源码实现 与 pkg/httpx 和 pkg/naabu 中的真实用法,完整讲解 API、底层原理与工程实践。读完本文,你将能独立用 SizedWaitGroup 构建"并发受限且可统一收尾"的 Go 任务池,并理解 scan4all 各扫描模块并发度参数背后的机制。
SizedWaitGroup 是什么:带并发上限的 WaitGroup
Go 标准库的sync.WaitGroup解决了"主协程等待一组子协程全部结束"的问题,但它不限制同时运行的协程数量。当需要快速启动大量任务时(例如批量探测数千个目标、并发查询数据库),若不做节流,瞬间创建的协程数量可能打爆内存或压垮下游服务。
SizedWaitGroup 正是为此而生。按其官方 README 的定义:
SizedWaitGrouphas the same role and API assync.WaitGroupbut it adds a limit of the amount of goroutines started concurrently.
即:角色与 API 完全对齐sync.WaitGroup,但额外限制同时启动的协程数量上限。一个典型场景是并发查询数据库:任务很多、希望尽快完成,但又不希望同时发出的查询过多导致数据库过载。这本质上是一个"信号量(semaphore)+ 等待组"的组合语义,适合所有"大量任务 + 有界并发 + 等待全部完成"的场景,包括:
- 批量 HTTP 探测 / Web 指纹识别;
- 端口扫描、子域名爆破;
- 多线程爬虫、日志分析、批量文件处理;
- 任何对并发数有硬约束的流水线任务。
该依赖在项目中的引入版本记录于 go.mod(github.com/remeh/sizedwaitgroup v1.0.0),源码以 vendor 方式存放于 vendor/github.com/remeh/sizedwaitgroup,包含sizedwaitgroup.go、README.md与LICENSE三个文件。
核心 API 一览:与 sync.WaitGroup 对齐的四件套
SizedWaitGroup 对外暴露的 API 与标准库高度相似,共四个方法与一个构造函数:
| 方法 | 功能 | 与 sync.WaitGroup 的差异 |
|---|---|---|
New(limit int) SizedWaitGroup | 构造实例,limit为最大并发协程数 | 标准库没有对应构造,sync.WaitGroup{}零值即用 |
Add() | 登记一个任务;可能阻塞,当并发数达到上限时会等待,直到有Done()释放名额 | sync.WaitGroup.Add()从不阻塞 |
AddWithContext(ctx) error | 同Add(),但可在等待名额时响应context取消,返回ctx.Err() | 标准库无此变体 |
Done() | 标记一个任务结束,释放一个并发名额 | 语义相同 |
Wait() | 阻塞直到所有任务完成 | 语义相同 |
Add()的阻塞特性是本库区别于标准库的关键:标准库的Add只做计数器累加,而 SizedWaitGroup 的Add相当于"先抢名额再登记",名额不够就原地等待。下面通过 README 的完整示例看它如何被使用。
从 README 示例出发:50 个任务、最多 8 个并发
以下是 sizedwaitgroup README 提供的完整示例:50 个模拟数据库查询任务,只允许 8 个协程同时运行。
package main import ( "fmt" "math/rand" "time" "github.com/remeh/sizedwaitgroup" ) func main() { rand.Seed(time.Now().UnixNano()) // Typical use-case: // 50 queries must be executed as quick as possible // but without overloading the database, so only // 8 routines should be started concurrently. swg := sizedwaitgroup.New(8) for i := 0; i < 50; i++ { swg.Add() go func(i int) { defer swg.Done() query(i) }(i) } swg.Wait() } func query(i int) { fmt.Println(i) ms := i + 500 + rand.Intn(500) time.Sleep(time.Duration(ms) * time.Millisecond) }拆解这段代码的执行流:
swg := sizedwaitgroup.New(8):创建并发上限为 8 的实例;- 循环 50 次:先
swg.Add()抢占名额(前 8 次立即通过,第 9 次起在名额被释放前阻塞),随后启动协程执行query(i); query内通过defer swg.Done()保证任务无论正常还是异常退出都会释放名额,并让内部计数器减一;- 主协程调用
swg.Wait()阻塞,直到全部 50 个任务结束。
可以直观理解为:消费者(协程)排队领取"并发许可证",只有拿到许可证的任务才会真正运行,因此任意时刻实际运行的任务数不会超过New指定的上限。
两点工程提示:
- 代码中的
rand.Seed是旧版 Go 的写法,Go 1.20 起全局随机源已自动初始化,该调用已废弃(仅为展示语义,不影响主逻辑); swg.Add()在主循环里是同步阻塞的,因此任务提交本身也被限速,不会出现"先瞬间启动 50 个协程再靠 Channel 限流"的失控状态。
实现原理:缓冲 Channel 令牌 + 内部 WaitGroup
README 只描述了行为,真正的机制在 sizedwaitgroup.go 中,核心只有约 80 行。结构体定义如下:
type SizedWaitGroup struct { Size int current chan struct{} wg sync.WaitGroup }它由两个部分组成:
current chan struct{}:容量等于并发上限的缓冲 Channel,充当"并发令牌池/信号量"。struct{}零内存占用,只传递"有无"信号;wg sync.WaitGroup:内部委托的标准库等待组,负责"等待全部任务结束"的语义。
构造函数New负责设定上限:
func New(limit int) SizedWaitGroup { size := math.MaxInt32 // 2^32 - 1 if limit > 0 { size = limit } return SizedWaitGroup{ Size: size, current: make(chan struct{}, size), wg: sync.WaitGroup{}, } }注意两点:当传入的limit <= 0时,会回退到math.MaxInt32(约 21 亿),即"实际上不设限";这保证了误传 0 或负数时不会因 Channel 容量为 0 而全线死锁。从源码结构看,这是一个刻意设计的容错分支,使用时仍应显式传入期望的并发数。
Add与Done则完成令牌的抢占与归还:
func (s *SizedWaitGroup) Add() { s.AddWithContext(context.Background()) } func (s *SizedWaitGroup) AddWithContext(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() case s.current <- struct{}{}: break } s.wg.Add(1) return nil } func (s *SizedWaitGroup) Done() { <-s.current s.wg.Done() }机制可以概括为:
Add()向current这个容量为N的缓冲 Channel发送一个空结构体——发送成功即代表抢到一个并发名额;当 Channel 已满(已有 N 个任务在跑)时,发送操作会阻塞,直到某个Done()从 Channel 中取走一个元素腾出位置;- 抢到名额后再调用
s.wg.Add(1)登记任务,保证"名额与计数"严格配对; Done()先从 Channel 中取走一个元素(释放令牌),再执行s.wg.Done()(递减计数器);Wait()直接委托s.wg.Wait(),在计数器归零前一直阻塞。
由于 Channel 发送/接收的原子性,这套机制天然是并发安全的,无需额外的互斥锁;Size字段只用于记录上限(New时写入,之后不再使用)。整个库无任何第三方依赖,仅引用context、math、sync三个标准库包。
AddWithContext:可取消的并发控制扩展
AddWithContext是本库超出标准库语义的一个增强点:当并发名额已满、Add陷入阻塞时,调用方可以通过context.Context主动中止等待。
select { case <-ctx.Done(): return ctx.Err() case s.current <- struct{}{}: break }- 若在抢到名额之前
ctx被取消,select命中<-ctx.Done()分支,立即返回ctx.Err()(如context.Canceled或context.DeadlineExceeded),不登记任务; - 若抢到名额,则正常继续并返回
nil。
Add()本身只是AddWithContext(context.Background())的简化形式,即"永不取消的默认上下文"。这个扩展让库可以直接用于超时控制、优雅停机、任务批量取消等场景:例如扫描任务被用户中断时,可以让等待名额的协程不再继续排队,而是快速退出并向上传递错误。
在 scan4all 中的真实应用
SizedWaitGroup 并非只存在于 vendor 目录中的"理论依赖",它正是 scan4all 多个扫描模块并发调度的实际基础设施。以下两处可以直接在源码中印证。
httpx:单协程输出 + 按线程数限流的扫描池
在 pkg/httpx/runner/runner.go 中,输出写入环节被限制为单并发:
// output routine wgoutput := sizedwaitgroup.New(1) wgoutput.Add() output := make(chan Result, 200) go func(output chan Result) { defer wgoutput.Done() // ... 过滤、格式化为 JSON/CSV、写入文件 }(output)New(1)意味着输出协程池最多只有 1 个并发——所有扫描结果统一由一个协程串行消费,避免多协程并发写文件或乱序输出。这是"用 SizedWaitGroup 限定并发数为 1"来实现串行化的典型手法。
而真正的扫描工作池则由 pkg/httpx/runner/runner.go#L632 建立:
wg := sizedwaitgroup.New(r.options.Threads)随后在 process 函数 中按Add → go 任务 → Done的模式逐个派发探测任务。也就是说,用户通过-threads之类的选项设置并发数时,最终生效的正是这个 SizedWaitGroup 的上限:它确保同时进行 HTTP 探测的协程数不会超过用户设定值,从而避免对目标站点造成瞬时流量冲击。
naabu:按速率参数限流的主机扫描
在端口扫描模块 pkg/naabu/v2/pkg/runner/runner.go 中:
// Scan workers r.wgscan = sizedwaitgroup.New(r.options.Rate) r.limiter = ratelimit.New(r.options.Rate)扫描工作池的并发上限直接由Rate选项决定,并与令牌桶限速器ratelimit.New(r.options.Rate)配合:limiter.Take()负责控制发送速率,wgscan负责控制同时活跃的扫描协程数。在 pkg/naabu/v2/pkg/runner/targets.go#L252 中还能看到另一处sizedwaitgroup.New(r.options.Threads),用于按线程数约束目标解析与任务派发。
从这些调用关系可以归纳出 scan4all 的一个并发设计模式:输出串行化(New(1))+ 扫描并发受限(New(Threads/Rate))+ 速率令牌桶限流(ratelimit)三层配合,既保证了高吞吐,又让每个阶段的下游(磁盘、网络、目标主机)都处于可控负载之下。
使用注意事项与最佳实践
综合 README 语义与源码实现,在实际项目中用好 SizedWaitGroup 需要注意以下几点:
Add()会阻塞:它必须放在"提交任务的循环"里(而不是协程内部),才能起到限流作用;若把它挪进 goroutine,限流将失去意义,所有协程仍会被瞬间创建。Done()必须用defer保证执行:否则任务 panic 或提前 return 会导致名额永久占用、计数无法归零,Wait()将永远阻塞(与sync.WaitGroup的经典陷阱一致)。limit <= 0等于不设限:源码会回退到math.MaxInt32,因此若期望"上限为 0 即禁止执行",需要自行校验入参,不能依赖本库。- 禁止复制使用中的实例:与
sync.WaitGroup相同,使用中的SizedWaitGroup不应被复制(拷贝会使内部 Channel 与 WaitGroup 的状态分裂),应始终通过指针传递。 - 配合 context 使用:需要支持超时或取消的任务优先使用
AddWithContext,避免在名额耗尽时无限期排队。 - 限流语义是"并发数",不是"速率":如果需要严格的时间维度速率控制(如每秒 N 个请求),应像 naabu 那样叠加独立的限速器(pkg/naabu/v2/pkg/runner/runner.go#L248),两者互补而非互替。
- 上限的选择是权衡:上限越大吞吐越高,但对下游(数据库、目标 Web 服务、文件系统)的压力也越大,应根据实际承载能力设置,这正是 README 中"不过载数据库"的初衷。
许可证与版权
本库遵循 MIT 许可证,版权归 Rémy Mathieu © 2016(详见 vendor/github.com/remeh/sizedwaitgroup/LICENSE)。MIT 许可允许自由使用、修改与再分发,这也是 scan4all 将其作为 vendor 依赖直接纳入项目的许可基础。
【免费下载链接】scan4allOfficial repository vuls Scan: 15000+PoCs; 23 kinds of application password crack; 7000+Web fingerprints; 146 protocols and 90000+ rules Port scanning; Fuzz, HW, awesome BugBounty( ͡° ͜ʖ ͡°)...项目地址: https://gitcode.com/GitHub_Trending/sca/scan4all
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考