一、引言
在网络管理与安全领域,TCP 端口扫描器是一款不可或缺的工具。它能够帮助我们快速探测目标主机上开放的端口,从而了解其提供的服务以及潜在的安全漏洞。Go 语言凭借其出色的并发性能,为编写高效的端口扫描器提供了有力支持。本文将详细介绍使用 Go 语言编写的三种 TCP 端口扫描器:非并发版、并发版以及基于 Goroutine 池的并发版,并深入剖析 Goroutine 池的原理及其在端口扫描器中的应用。
二、非并发版 TCP 端口扫描器
(一)代码实现
go
package main
import (
"fmt"
"net"
)
func main() {
targetHost := "127.0.0.1"
// 遍历所有可能的端口号
for port := 1; port <= 65535; port++ {
address := fmt.Sprintf("%s:%d", targetHost, port)
// 尝试建立 TCP 连接
conn, err := net.Dial("tcp", address)
if err == nil {
// 连接成功,端口开放
fmt.Printf("Port %d is open\n", port)
conn.Close()
}
}
}
(二)原理剖析
此非并发版扫描器采用了最基础的扫描策略。它按照顺序,从端口 1 开始,逐个对目标主机的端口尝试建立 TCP 连接。对于每一个端口,使用net.Dial("tcp", address)
函数进行连接操作。如果连接过程没有错误发生,即err == nil
,则表明该端口处于开放状态,此时打印出端口号,并关闭连接以释放资源。由于是逐个端口进行扫描,没有利用并发机制,所以扫描速度相对较慢,尤其在面对大量端口时,效率较低。
三、并发版 TCP 端口扫描器
(一)代码实现
go
package main
import (
"fmt"
"net"
"sync"
)
func scanPort(host string, port int, wg *sync.WaitGroup) {
defer wg.Done()
address := fmt.Sprintf("%s:%d", host, port)
conn, err := net.Dial("tcp", address)
if err == nil {
fmt.Printf("Port %d is open\n", port)
conn.Close()
}
}
func main() {
targetHost := "127.0.0.1"
var wg sync.WaitGroup
// 遍历端口号并启动 goroutine 进行扫描
for port := 1; port <= 65535; port++ {
wg.Add(1)
go scanPort(targetHost, port, &wg)
}
wg.Wait()
}
(二)原理剖析
并发版扫描器充分发挥了 Go 语言的并发特性。首先定义了scanPort
函数,该函数负责对单个端口进行扫描操作。在主函数中,通过循环遍历所有端口号,针对每个端口启动一个独立的 goroutine 来执行scanPort
函数。这里使用了sync.WaitGroup
来协调所有 goroutine 的执行。在启动每个 goroutine 之前,通过wg.Add(1)
增加等待组的计数,表示有一个任务正在进行。当每个 goroutine 完成任务后,通过defer wg.Done()
通知等待组任务已完成。最后,wg.Wait()
会阻塞主函数,直到所有的 goroutine 都完成任务。这种并发扫描方式使得多个端口的扫描可以同时进行,大大提高了扫描速度,相比于非并发版,能够在更短的时间内完成对大量端口的扫描。
四、Goroutine 池并发版 TCP 端口扫描器
(一)代码实现
go
package main
import (
"fmt"
"net"
"sync"
)
func worker(host string, ports <-chan int, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for port := range ports {
address := fmt.Sprintf("%s:%d", host, port)
conn, err := net.Dial("tcp", address)
if err == nil {
results <- fmt.Sprintf("Port %d is open", port)
conn.Close()
} else {
results <- ""
}
}
}
func main() {
targetHost := "127.0.0.1"
numWorkers := 100
// 创建端口号通道和结果通道
ports := make(chan int, 65535)
results := make(chan string, 65535)
var wg sync.WaitGroup
// 启动 worker goroutine
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(targetHost, ports, results, &wg)
}
// 向端口号通道发送端口号
for port := 1; port <= 65535; port++ {
ports <- port
}
close(ports)
go func() {
wg.Wait()
close(results)
}()
// 处理扫描结果
for result := range results {
if result!= "" {
fmt.Println(result)
}
}
}
(二)原理剖析
1. 整体架构
此版本的扫描器基于 Goroutine 池的概念构建。主要由任务队列(端口号通道ports
)、工作者(worker
函数对应的 goroutine)以及协调机制(sync.WaitGroup
和通道关闭操作)组成。
2. 任务队列
ports
通道作为任务队列,用于存储待扫描的端口号。其缓冲区大小设置为 65535,以容纳所有可能的端口号。通过循环将 1 到 65535 的端口号逐个发送到ports
通道中,完成任务的入队操作。当所有端口号都发送完毕后,关闭ports
通道,表示任务队列不再接受新的任务。
3. 工作者
worker
函数定义了工作者的行为。每个工作者从ports
通道中接收端口号,尝试建立与目标主机对应端口的 TCP 连接。如果连接成功,将包含开放端口号信息的字符串发送到results
通道;如果连接失败,则发送空字符串。工作者通过for port := range ports
循环不断从任务队列获取任务,直到任务队列关闭且所有任务都被处理完毕。
4. 协调机制
使用sync.WaitGroup
来协调所有工作者的完成状态。在启动每个工作者 goroutine 时,通过wg.Add(1)
增加计数。当工作者完成任务(即for
循环结束)后,通过defer wg.Done()
通知等待组任务完成。同时,启动一个独立的 goroutine,在其中等待所有工作者完成任务(wg.Wait()
),当所有工作者完成后,关闭results
通道,表示结果收集阶段结束。
5. 结果处理
主函数通过for result := range results
循环从results
通道中读取扫描结果。如果结果字符串不为空,则表示对应的端口开放,将其打印输出。
五、Goroutine 池原理
(一)基本概念
Goroutine 池是一种用于管理和复用 Goroutine 的机制。在处理大量并发任务时,如果无节制地创建 Goroutine,可能会导致系统资源耗尽,例如内存占用过高、调度开销过大等问题。Goroutine 池通过预先创建一定数量的 Goroutine(工作者),并让它们从任务队列中获取任务执行,从而实现对 Goroutine 的有效管理和资源的合理利用。
(二)组成部分
1. 任务队列
任务队列是 Goroutine 池的核心组件之一。它是一个通道(如上述代码中的ports
通道),用于存储待执行的任务。任务可以是任何需要并发处理的操作,在端口扫描器中就是要扫描的端口号。任务队列的缓冲区大小需要根据任务数量和系统资源进行合理设置,既要避免缓冲区过小导致任务无法及时入队,又要防止缓冲区过大占用过多内存。
2. 工作者
工作者是实际执行任务的 Goroutine。每个工作者从任务队列中获取任务,并执行相应的操作。在端口扫描器的示例中,worker
函数对应的 Goroutine 就是工作者。工作者通常会在一个循环中不断地从任务队列获取任务,直到任务队列为空且被关闭,表示所有任务都已完成。工作者的数量也需要根据系统资源和任务特性进行调整,过多的工作者可能导致资源竞争和调度开销增加,过少则无法充分利用系统的并发能力。
3. 协调机制
协调机制用于确保 Goroutine 池的正确运行。在上述代码中,sync.WaitGroup
起到了关键的协调作用。它用于跟踪所有工作者的完成状态,确保在所有任务都执行完毕后,进行后续的清理和结果处理工作。此外,通道的关闭操作也是协调机制的一部分。关闭任务通道表示不再有新的任务进入,而关闭结果通道则表示结果收集阶段结束,通知结果处理部分停止等待新的结果。
(三)工作流程
1. 初始化阶段
首先,确定工作者的数量(如numWorkers
),创建任务队列(ports
通道)和结果队列(results
通道),并为任务队列设置合适的缓冲区大小。然后,启动预先设定数量的工作者 Goroutine,每个工作者开始等待从任务队列中获取任务。
2. 任务分配阶段
将需要处理的任务逐个放入任务队列中。在端口扫描器中,就是将 1 到 65535 的端口号依次发送到ports
通道。工作者 Goroutine 会自动从任务队列中获取任务,一旦任务队列中有任务可用,工作者就会立即开始执行。
3. 任务执行阶段
工作者从任务队列中获取任务后,执行相应的任务操作。在端口扫描器中,工作者会尝试建立与目标端口的 TCP 连接,并根据连接结果将相应信息发送到结果队列。如果任务执行过程中出现错误或完成任务,工作者会继续从任务队列中获取下一个任务,直到任务队列为空且被关闭。
4. 完成阶段
当所有任务都被放入任务队列且任务队列被关闭后,工作者会继续处理剩余的任务,直到任务队列为空。此时,通过协调机制(如sync.WaitGroup
)等待所有工作者完成任务。当所有工作者完成后,关闭结果队列,然后对结果队列中的结果进行处理,如在端口扫描器中打印开放端口的信息。
六、总结
通过对三种不同版本的 Go 语言 TCP 端口扫描器的介绍以及对 Goroutine 池原理的深入剖析,我们可以看到 Go 语言在并发编程方面的强大能力。
非并发版扫描器为我们展示了端口扫描的基本逻辑,而并发版扫描器则体现了 Go 语言并发特性带来的性能提升。
基于 Goroutine 池的并发版扫描器进一步优化了资源利用和性能表现,通过合理管理 Goroutine 的数量和任务队列,避免了无节制并发带来的问题。