Skip to main content
今日目标:理论学得再多,不如写个工具操练一下。今天我们将利用 Go 的并发特性,编写一个多线程端口扫描器。你会亲眼看到它比 Python 单线程版本快 100 倍以上,并在此过程中掌握 “Worker Pool” (工作池) 这一工业级并发模式。

学习内容 (30 mins)

在开始写代码前,先搞懂我们要解决的核心问题和设计思路。
如何判断一个端口是否开放?
  • 探测逻辑: 我们使用 net.DialTimeout("tcp", "host:port", timeout) 尝试建立 TCP 连接。
  • 结果判断:
    • 如果不报错 (err == nil):说明三次握手成功,端口是通的。
    • 如果报错(超时或拒绝):说明端口关闭或被防火墙拦截。
  • 为什么一定要 Timeout: 默认的 TCP 连接超时时间可能长达几分钟。如果不设置超时,扫描一个不可达的 IP 会把你卡死很久。
为什么不直接启动 1024 个协程?虽然 Goroutine 很轻,但如果直接瞬间并发 1024 个网络连接:
  1. 可能会触发操作系统的 “Too many open files” 限制(文件句柄耗尽)。
  2. 可能会被防火墙判定为 DDoS 攻击 瞬间封锁你的 IP。
工业级解决方案:Worker Pool
  1. Job Queue: 创建一个 jobs 通道,里面放着要扫描的端口号(如 1-1024)。
  2. Workers: 启动固定数量(如 100 个)的 Worker 协程。
  3. 流程: 每个 Worker 抢到一个端口 -> 扫完 -> 再抢下一个。这样并发数永远稳定在 100。

代码任务 (90 mins)

我们将通过两个步骤,从基础函数到完整并发工具。
1

核心:基础连通性检查函数

这是所有网络工具的基石。先写一个独立的函数,专注于检测单个端口。
package main

import (
    "fmt"
    "net"
    "time"
)

// CheckPort 探测单个端口
// 参数: host (IP或域名), port (端口号)
// 返回: bool (true 表示开放, false 表示关闭)
func CheckPort(host string, port int) bool {
    address := fmt.Sprintf("%s:%d", host, port)
    
    // 1秒超时,这对局域网或内网足够了。公网可能需要设大一点。
    conn, err := net.DialTimeout("tcp", address, 1*time.Second)
    
    if err != nil {
        // 连接失败(超时或 Refused)
        return false
    }
    
    // 重要:一旦连接成功,必须手动关闭,否则会耗尽系统句柄
    conn.Close()
    return true
}
代码解释
  • net.DialTimeout("tcp", address, timeout):尝试建立 TCP 连接,带超时
  • err == nil:连接成功,端口开放
  • conn.Close():必须关闭连接,防止资源泄漏
  • 超时设置:1 秒超时,防止长时间等待
网络编程的设计哲学
  • 总是设置超时:网络操作可能永远不返回,必须设置超时
  • 及时释放资源:连接、文件等资源必须及时关闭
  • 错误处理:网络操作可能失败,必须处理所有错误情况
验证方法: 你可以写一个简单的 main 调用一下 CheckPort("scanme.nmap.org", 80),应该返回 true验证步骤
  1. 测试开放端口:CheckPort("scanme.nmap.org", 80) 应该返回 true
  2. 测试关闭端口:CheckPort("scanme.nmap.org", 9999) 应该返回 false
  3. 测试超时:尝试连接一个不存在的 IP,观察超时行为
2

实战:完整的并发扫描器

使用 Worker Pool 机制实现多线程扫描。这个代码可以直接作为面试作品。
package main

import (
    "fmt"
    "sort"
    "sync"
    "time"
)

// 把 CheckPort 函数复制到这里,或者放在同一个包下

func main() {
    host := "scanme.nmap.org" // Nmap 官方提供的测试靶机
    fmt.Printf("Scanning %s ...\n", host)
    start := time.Now()

    // 1. 初始化通道
    // jobs: 存放要扫描的端口号
    // results: 存放扫描结果(开放的端口号)
    jobs := make(chan int, 100)
    results := make(chan int, 100)
    
    var wg sync.WaitGroup

    // 2. 启动 50 个并发 Worker
    // 这些 Worker 启动后会立刻阻塞在 range jobs 上,等待任务
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 不断取任务,直到 jobs 通道被关闭
            for port := range jobs {
                if CheckPort(host, port) {
                    results <- port // 发现开放端口,发送给结果通道
                } else {
                    results <- 0    // 未开放,发送 0 表示完成
                }
            }
        }()
    }

    // 3. 生产者:分发任务 (1-1024 端口)
    // 另起一个协程去发任务,避免阻塞主线程接收结果
    go func() {
        for i := 1; i <= 1024; i++ {
            jobs <- i
        }
        close(jobs) // 发完了,记得关掉,通知 Worker 准备收工
    }()

    // 4. 收集结果
    var openPorts []int
    
    // 既然我们知道发了 1024 个任务,就肯定会收到 1024 个结果
    // 这里可以直接循环 1024 次,或者用另外的 WaitGroup 策略
    for i := 1; i <= 1024; i++ {
        port := <-results
        if port != 0 {
            openPorts = append(openPorts, port)
            fmt.Printf("✨ Port %d is open\n", port)
        }
    }

    // 5. 打印最终报告
    close(results)
    sort.Ints(openPorts) // 排序让结果更好看
    
    fmt.Printf("\n--- Scan Finished in %v ---\n", time.Since(start))
    fmt.Printf("Total %d ports open: %v\n", len(openPorts), openPorts)
}
代码解释
  • Worker Pool 模式:固定数量的 Worker,通过 Channel 分发任务
  • make(chan int, 100):带缓冲的通道,可以暂存 100 个任务
  • for port := range jobs:Worker 从通道中取任务,直到通道关闭
  • close(jobs):关闭通道,通知 Worker 没有更多任务
Worker Pool 的设计哲学
  • 控制并发数:避免创建过多 Goroutine,防止资源耗尽
  • 任务队列:通过 Channel 实现任务队列,解耦生产者和消费者
  • 优雅关闭:通过关闭 Channel 通知 Worker 停止工作
验证步骤
  1. 运行程序:go run 25_scanner.go
  2. 你应该能看到程序在几秒钟内完成扫描,并列出 80, 22 等开放端口
  3. 观察 Worker 的并发执行,理解 Worker Pool 的工作方式
  4. 尝试修改 Worker 数量,观察性能变化
常见错误
  • 句柄泄漏:很多新手在 net.Dial 成功后忘了写 conn.Close()。当并发量大时,你的程序会因为 Socket 没释放而崩溃。
  • 死锁:如果你在主线程里塞任务 jobs <- i,但是没有启动 Worker 或者 Worker 处理不过来导致 jobs 满了,主线程就会卡死。建议把任务生成也放到 go func() 里。

拓展任务 (30 mins)

命令行参数化

任务:使用 flag 包,让用户可以指定 IP 和并发数。提示
  • host := flag.String("host", "127.0.0.1", "Target IP")
  • threads := flag.Int("t", 50, "Number of threads")
  • 运行:./scanner -host 8.8.8.8 -t 100

更详细的探测

任务:只知道端口开放还不够。挑战:对于开放的端口,尝试读取它返回的前 1KB 数据(Banner Grabbing),看看能不能识别出它是 SSH 还是 HTTP 服务器。

今日产出物

  • ~/projects/learn-go/25_scanner.go - 高性能端口扫描器

参考代码

查看参考代码

在 GitHub 查看完整的示例代码

在线运行

使用 Replit 运行 (网络可能受限)

实际应用场景

性能大比拼

  • Python (单线程): 扫描 1000 个端口约需 5 分钟 (300秒)。
  • Go (50 并发 Worker): 扫描 1000 个端口约需 3 秒
  • 这就是 100 倍 的性能差距,也是为什么 Go 是开发网络工具的首选。

安全合规提示

  • 不要扫公网 IP: 除非是你自己的服务器。扫描他人 IP 在很多国家是违法的。
  • 内网巡检: 这个工具非常适合用于企业内网巡检,快速发现有哪些机器违规开放了高危端口(如 3389, 23)。
与 Day 26 的关联:今天我们解决了 网络 IO 并发。明天我们将挑战 本地磁盘 IO 并发,编写一个能瞬间分析 GB 级日志的统计工具。

回到目录

查看完整进度

下一天: 工具开发 II

Day 26 | Go 实战工具开发 (二)