我用Go和Rust写了一个TCP端口扫描器
Go版本:
package main
import (
"bufio"
"flag"
"fmt"
"net"
"os"
"sort"
"sync"
"time"
)
// Config 存储命令行参数
type Config struct {
Target string // 目标 IP 地址
Concurrency int // 并发数
StartPort int // 起始端口
EndPort int // 结束端口
Timeout time.Duration // 超时时间
}
func parseFlags() Config {
var config Config
// 定义命令行参数
flag.StringVar(&config.Target, "ip", "127.0.0.1", "目标 IP 地址")
flag.IntVar(&config.Concurrency, "con", 100, "并发数")
flag.IntVar(&config.StartPort, "begin", 1, "起始端口")
flag.IntVar(&config.EndPort, "end", 65535, "结束端口")
timeout := flag.Int("to", 1, "超时时间(秒)")
// 参数生效
flag.Parse()
// 将超时秒数转换为 Duration
config.Timeout = time.Duration(*timeout) * time.Second
return config
}
func measureTime(fn func()) {
start := time.Now()
fn()
elapsed := time.Since(start)
fmt.Printf("Execution Time: %v\n", elapsed)
}
var config = parseFlags()
func worker(ports <-chan int, results chan<- int) {
var wg sync.WaitGroup
// 创建指定数量的 worker
for range config.Concurrency {
wg.Go(func() {
for port := range ports {
addr := net.JoinHostPort(config.Target, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, config.Timeout)
if err != nil {
continue
}
conn.Close()
results <- port
}
})
}
wg.Wait()
close(results)
}
func tcpScan() {
ports := make(chan int, config.Concurrency)
results := make(chan int, config.Concurrency)
// 发送端口到 ports channel
go func() {
for i := config.StartPort; i <= config.EndPort; i++ {
ports <- i
}
close(ports)
}()
// 启动 worker
go worker(ports, results)
fmt.Printf("Scanning %s\n", config.Target)
// 收集结果
opened := []int{}
for port := range results {
opened = append(opened, port)
}
// 排序
sort.Ints(opened)
// 打印结果
fmt.Printf("Open ports (%d found):\n", len(opened))
for _, port := range opened {
fmt.Printf(" %d\n", port)
}
}
func main() {
measureTime(tcpScan)
waitEnter()
}
// 按回车键退出程序
func waitEnter() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
break
}
}
}
Rust版本:
use clap::Parser;
use futures::StreamExt;
use std::time::{Duration, Instant};
use tokio::net::TcpStream;
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
struct Config {
/// 目标 IP 地址
#[arg(short = 'i', long = "ip", default_value = "127.0.0.1")]
ip: String,
/// 起始端口
#[arg(short = 's', long = "start", default_value_t = 1)]
start_port: u16,
/// 结束端口
#[arg(short = 'e', long = "end", default_value_t = 65535)]
end_port: u16,
/// 并发数
#[arg(short = 'c', long = "concurrency", default_value_t = 25000)]
concurrency: usize,
/// 超时时间(毫秒)
#[arg(short = 't', long = "timeout", default_value_t = 200)]
timeout: u64,
}
#[tokio::main]
async fn main() {
let args = Config::parse();
let start = Instant::now();
// 使用缓冲流控制并发
let mut result: Vec<_> = futures::stream::iter(args.start_port..=args.end_port)
.map(|port| {
let host_port = format!("{}:{}", args.ip, port);
async move {
if let Ok(Ok(_)) = tokio::time::timeout(
Duration::from_millis(args.timeout),
TcpStream::connect(host_port),
)
.await
{
Some(port)
} else {
None
}
}
})
.buffer_unordered(args.concurrency) // 最大并发数
.filter_map(|port| async move { port })
.collect()
.await;
result.sort();
let count = result.len();
print!("Open ports ({count} found):\n");
result.into_iter().for_each(|port| print!(" {port}\n"));
println!("Execution Time: {:?}", start.elapsed());
}
我都是用命令行参数的默认值,没有手动额外设定参数值。
我的台式机采用英特尔12代i3处理器,Windows操作系统。
Go版本耗时:小于1秒
Rust版本耗时:大于2秒
我知道测试用例不是完全等价的,比如并发数的设定:因为Rust版本并发设为100速度更慢,约14秒)。我想知道Rust具体慢的原因。
我已经询问了 DeepSeek、GLM、豆包、千问 等大语言模型,但它们都没有给出合理的解释。故来此询问,请大佬们指点迷津。
顺便一提,我想在Rust中尝试将Go代码尽可能1:1迁移过来,但发现tokio似乎只有oneshot和mpsc这两种;而mpsc似乎无法满足代码迁移的需求。(因为Receiver无法被clone)
实在是水平有限,借助AI都没能实现Rust的通道版本,所以没办法做1:1的测试,这里也希望大佬们支支招。
还有一件事情:Rust版本调整并发数到65535,会扫不到较高的端口号,比如4万以上的端口号:
49664
49665
49666
49667
49670
49682
Go语言如果直接给每个tcp链接起一个go程而不使用worker模式,也扫不到这些端口。Rust用tokio来spawn 65535个异步tcp连接,也扫不到这些端口。这又是怎么回事呢?
注:我尝试调整了Rust版本的并发数、超时时间,似乎达到某些阈值会扫不到这些端口。
评论区
写评论futures::StreamExt::buffer_unordered和tokio在高并发时都有点性能问题,另外mpmc可以找第三方库来用,这里用的tokio-mpmc,不过貌似这个库也有点性能问题有那个系统资源限制(异常被代码忽略了)和windows的默认重试策略存在,之前的测试确实可能掺入了虚假的数据。所以以下的测试都尽量排除了这些因素。
测试准备
在 Linux 上用以下指令增大程序开启文件上限(linux默认端口可复用,可以不管),防止资源溢出("Too many open files"):
在 Winodws 上,避免并发量超过
15000来防止端口资源溢出("由于系统缓冲区空间不足或队列已满,不能执行套接字上的操作。"),重试这个就得自己创建RawSocket了,甚至可能需要FFI才能取消重试,太麻烦了为了监测有响应的TCP连接在异步运行时内的消耗,定义一个时间监测的装饰器:
TimeSpan只测试正常完成的任务(取消的不会输出),在调度器内的future本身调度完成的时间。更精细的时间数据,可以让start由外部统一传入一个"原点",可以测量初始偏移和最终偏移。测试示例
这里只展示在最低配的阿里云1核主机上测试比较(Ubuntu x86_64 2.5GHz),以下的数据都来自这台主机:
注意:
cargo r -r -- [options]运行,Go使用go build test.go+./test [options]测试结果表明,
tokio调度器性能存在一个任务数量界限,在同时处理任务数量达到数千个时(可能与机子性能相关),调度器的额外消耗开始明显增加,并且随着数量继续扩大,开销增加趋势也在迅速变大,到最后可能还不如低任务数量的效率。futures::StreamExt::buffer_unordered接口做并发是单线程的,在低任务时开销不明显,但是并发量高起来后,副作用就相对明显了tokio-mpmc+tokio::spawn改造后,满载情况下耗时大概能从3.x(原始代码) 降到2.x秒-c 65535一次性全扫描,Rust代码的耗时也在 2.x 左右波动,而设置-c 10240,Rust代码耗时也是在 1.6~2.x 左右波动类似的,Go版(go version go1.25.5 linux/amd64)代码应该也是有一个性能衰减边界,不过没有
tokio这么明显:-con 65535时,耗时大概 5.x~6.x 秒(CPU 90+%)-con 10240时,耗时大概 7.x 秒(此时发现CPU大概只有60+%)上面测试结果已经发现了,在排除其他非语言因素后,Go版扫描比Rust版明显更慢,debug模式亦如此,而在设置
-c 5120和-con 5120后对比更明显:Rust
-c 5120CPU 90+%Golang
-con 5120CPU 25+%测试发现,Go大概需要在15k并发度的时候,才能CPU 90+%,而Rust在4k并发度就能CPU 90+%
进一步对比,一次性全扫描本机
127.0.0.1,Rust依然维持 1.x2.x,而Go提升到 2.x3.x对于两者的耗时差异,由于我上次接触Go已经是快两年前了,现在对Go底层机制不甚了解,也不清楚为什么Go在并发度低的时候CPU都跑不满。
所以只能盲猜可能是因为垃圾回收+有栈协程,垃圾回收会导致频繁停顿,而有栈协程在切换任务时,比无栈协程需要保存和恢复更多的临时变量。
当然,也有可能是因为这台测试机的性能太差了
因为之前发现了一些比较奇怪的现象,后面好奇研究了一下:
其中比较重要的是,不同操作系统上,对进程的资源数量,或多或少都会有限制。比如:
所以,在并发量较高的时候,某时刻资源耗尽,因为旧资源未能及时释放,会导致后续新的socket创建失败,直接提前返回err,导致快速的虚假连接失败。
关于golang标准实现里用的api优化,在微软官方学习资料里SIO_TCP_INITIAL_RTO控制代码和TCP_INITIAL_RTO_PARAMETERS 结构有,里面说明了如何配置重传次数
我觉得这个主要还是系统环境导致的差异,加上Rust只使用了默认的系统调用,而Go对windows的行为进行了优化,所以Go版能在网络协议完整的情况更快返回,而Rust完全取决于系统实现。对于这一点,我也使用Python进行了测试,发现表现上跟Rust是一致的,这里就不再展示。
下面是简单的抓包分析
windows 示例
在网络协议层面上,TCP的连接速度应该是与语言无关的,所以我针对前面出现的情况,作为示例在windows上抓取了网络包来分析,为了简化数据这里只放关键数据。
本机端口扫描(
127.0.0.1或者本机内网Ip),这通常会进入本地回环(Loopback),网络包不会真的发送到系统外部,在内核中就直接转发了,所以默认防火墙不会拦截。如果是使用开放了网络防火墙的外网Ip,也是一样的。这里每次都是连接请求等待2秒后才提示目标计算机积极拒绝,无法连接
外网端口扫描(外网Ip,数据包经过外部网络传递),这通常会经过网关、防火墙等设备,可能被拦截。
这里每次都是连接请求等待21秒后才提示连接的主机没有响应,连接失败
2秒和21秒也算是windows上很经典的连接等待时间了,从上面抓包记录分析,实际分为两类:
SYN数据包是能正确响应RST网络协议的,系统是能立即知道目标主机拒绝连接,但是在windows上依然会重复尝试4次,每次间隔0.5秒,然后再返回失败。SYN数据包被拦截丢弃的(无响应,因为中途可能经过网关和防火墙),就会触发TCP层的丢包重传机制(1->2->4->8->..),但是在windows上,第21秒才被系统暂停,共4次重传,然后返回失败。在 Linux 系统上的差异
在 Linux 系统(我用的是ubuntu)上,基础的网络层过程跟 Windows 基本是一样,但是操作系统默认处理的行为不一样:
RST的(没有被防火墙拦截),Linux 系统不会重试,而是会立即返回失败。总结
总结就是,端口扫描效率受到目标主机的网关、防火墙等拦截规则影响很大,并且对于连接请求的响应与否,不同系统实现的默认处理方式也有所不同。对于明确拒绝连接的端口,windows依然会尝试重连4次,而linux会直接返回失败;对于无响应的连接,linux则是比windows默认等待的时间更久。
上面已经有亮出Go对Windows连接速度的优化处理,而rust我在
crate.io简单搜了一下,也不清楚应该用什么关键字,貌似还没有相关优化过windows连接速度的库。怎么感觉像window的问题,我在mac(arm)上跑go和rust差不多都是1.8
Go版本我在我的服务器上跑平均1,8秒
Rust的标准库+OS线程,平均600毫秒
阿里云服务规格ecs.e-c1m2.xlarge, 4C8G
省流就是:windows和linux逻辑不一样
大概方向:
大量线程+阻塞 TcpStream::connect_timeout 在 Windows 上比 Linux 更昂贵,Windows 对大量并发短连接的上下文切换/套接字管理开销更高
Linux 对本地回环连接(尤其返回 ECONNREFUSED)通常更快,Windows 在某些情况下会有更高的系统调用/防火墙处理延迟。
线程数太多导致调度/资源争用,200 线程在 Windows 上可能比在 Linux 上代价更高
--
👇
hzqelf: --
👇
artiga033:
我复制粘贴了这段代码覆盖上去,耗时27秒。另外:
这份代码在playground确实很快,但在我的电脑上跑了一分钟也没有出结果。这真的很奇怪!
我的Windows11家庭版版本号为:24H2,版本是:26100.7623
--
👇
artiga033:
我复制粘贴了这段代码覆盖上去,耗时27秒。另外:
这份代码在playground确实很快,但在我的电脑上跑了一分钟也没有出结果。这真的很奇怪!
我的Windows11家庭版版本号为:24H2,版本是:26100.7623
好像和这里有关:
你这样用一个大 future 只做并发、不做并行的话就相当于在一个线程上叫 6 万多次的 connect 系统调用,好比你 Go 那边设置了 GOMAXPROCS=1,即使 socket 设置了非阻塞,肯定也要比 Go runtime 帮你把任务调度到不同系统线程上要慢。如果只是传一直递增的端口号的话可以不需要通道,直接用一个 AtomicU32,然后 worker 那边一直用 fetch_add 拿一个,直到扫描范围末尾就好。
首先你的这个版本在我的Windows上,golang要2s,Rust要14s,如果rust改成100并发那我不知道要多久,因为等了一分钟还没跑完。
Linux下比较正常 go版本300ms,Rust1400ms。
我不知道为什么你那里没问题,可能你的Windows版本和配置和我不一样,不过我试了我的两个Windows设备都有这个问题。
先说为什么,对于随便一个没有开放的端口,以下代码:
在Windows上,go版本只要几百微秒,rust版本要两秒,我猜是tokio用的系统调用不一样,像是等了一次重传。所以造成的结果就是,Rust版本实际上不管对于是真关闭的端口,还有开放但是不回包的端口,都会一直等到min(2s,timeout_duration)。找了一圈没找到怎么通过改windows配置解决,要么只能直接手搓socket。所以我下面的内容都是基于linux测的。
至少在建立连接这块,异步一般没有同步快,所以你这还不如直接用标准库OS线程,
首先就是光是把你那个tokio timeout直接换成标准库,就能快不少:
这个版本是 700ms,正好比tokio快一倍,比go慢一倍。这里你就已经可以把配置改成和golang一样是100并发1s超时了,我测试没明显差别。
如果把 spawn_blocking 去掉,会快非常多,到400ms左右(不过这是因为我这里是wsl,开放的端口一共就两个),但是非常不建议,至于为什么看过tokio文档应该就知道了。
所以直接用标准库+OS线程其实更快... 这个case甚至在playground上都能跑进一秒,在我本地是72ms。
至于mpmc channel,可以用crossbeam或者flume,应该能优化到更高。
当然我的结果其实没什么参考价值,如前所述,我这里开放的端口极少,而你的程序逻辑就非常依赖超时判断,端口数量不同差距应该会很大。
最后,
计算机网络基本问题,唯一标识一个TCP连接的四元组是(源IP,源端口,目标IP,目标端口),那你这里源IP和目标IP是一样的(127.0.0.1),又是同一台机器端口也是共用的,你把全部端口都要完了那新的任务再向OS申请端口的时候直接就失败了,你把错误打印出来而不要忽略就知道了,这个和语言没关系...Go的话可以用net.Dialer里的LocalAddr字段手动选一个别的IP,比如127.0.0.2,但其实这样你也不能要65535,因为1024以下的端口你也是申请不到的,除非是root,而且作为dialer的话可能可用的端口更少,总之建议补一下计网基础。Rust标准库和tokio都没有设置源IP的选项,只能自己创建raw socket。
你贴出来的rust就没用tokio::spawn啊, 贴你最新的代码