记一次 Golang pkg 性能提升 38147125738.8 倍之旅
我正在使用 linux-open-ports 项目来获取系统上当前打开的端口列表。不过我注意到实现速度比我预期的要慢一点,所以我开始调查、分析、修改并使整个过程,使其性能提升了 38147125738.8 倍。下面我对整个过程做一个详细叙述。
1
基线
Go 提供了通过基准测试建立基线的工具,因此让我们利用它并编写一个基准测试:
package linuxopenportsimport "testing"func BenchmarkGetOpenPorts(b *testing.B) { ports, err := GetOpenPorts() if err != nil { b.Fatal(err) } if len(ports) == 0 { b.Fatal("no ports detected") }}
这在我的系统上产生的结果:
goos: linuxgoarch: amd64pkg: github.com/intevel/linux-open-portscpu: AMD Ryzen 7 PRO 7840U w/ Radeon 780M GraphicsBenchmarkGetOpenPortsBenchmarkGetOpenPorts-16 1 1570580328 ns/opPASSok github.com/intevel/linux-open-ports 1.588s
2
pkg 的概念
在基于 Linux 的系统中获取开放端口列表的方法非常容易理解:
获取所有连接及其 inode 的列表,这些存储在:
/proc/net/tcp/proc/net/tcp6/proc/net/udp/proc/net/udp6
以上所有内容均遵循相同的格式:
~ :: head -n1 < /proc/net/tcpsl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode 0: 3600007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 991 0 15761 1 0000000000000000 100 0 0 10 5
这里没有进程 ID(PID),我们必须将所有找到的进程映射到系统上的 inode 相应进程 pids
读取每个/proc//fd 目录以访问其套接字 inode(格式的文件 socket:[])
返回端口及其 inode 和进程 ID 的列表
3
深入探索
linux-open-ports 导出一个函数 GetOpenPorts 和相应的类型 OpenPort:
type OpenPort struct { Protocol string Port int PID int}func GetOpenPorts() ([]OpenPort, error) { // ...}
GetOpenPorts 调用 findPIDByInode 每个文件中的每个 inode:
func GetOpenPorts() ([]OpenPort, error) { var openPorts []OpenPort uniquePorts := make(map[string]bool) protocolFiles := map[string][]string{ "tcp": {"/proc/net/tcp", "/proc/net/tcp6"}, "udp": {"/proc/net/udp", "/proc/net/udp6"}, } for protocol, files := range protocolFiles { for _, filePath := range files { // .. scanner setup and error handling for scanner.Scan() { fields := strings.Fields(scanner.Text()) // .. field checks and assignments inode := fields[9] pid := findPIDByInode(inode) // .. } } } return openPorts, nil}
该 findPIDByInode 函数在每次调用时读取整个/proc 目录来查找 inode 所属的进程。
func findPIDByInode(inode string) int { procDirs, _ := os.ReadDir("/proc") for _, procDir := range procDirs { if !procDir.IsDir() || !isNumeric(procDir.Name()) { continue } pid := procDir.Name() fdDir := filepath.Join("/proc", pid, "fd") fdFiles, err := os.ReadDir(fdDir) if err != nil { continue } for _, fdFile := range fdFiles { fdPath := filepath.Join(fdDir, fdFile.Name()) link, err := os.Readlink(fdPath) if err == nil && strings.Contains(link, fmt.Sprintf("socket:[%s]", inode)) { pidInt, _ := strconv.Atoi(pid) return pidInt } } } return -1}func isNumeric(s string) bool { _, err := strconv.Atoi(s) return err == nil}
4
解析整数使用不当
第一个引起我注意的简单方法是 isNumeric 函数调用,它尝试解析目录名称并检查它是否为数字,以确保目录是进程的目录。使用 unicode.IsDigit 判断目录名称的第一个字节应该足够且更快:
func findPIDByInode(inode string) int { procDirs, _ := os.ReadDir("/proc") for _, procDir := range procDirs { pid := procDir.Name() if !procDir.IsDir() || !unicode.IsDigit(rune(pid[0])) { continue } fdDir := filepath.Join("/proc", pid, "fd") fdFiles, err := os.ReadDir(fdDir) if err != nil { continue } for _, fdFile := range fdFiles { fdPath := filepath.Join(fdDir, fdFile.Name()) link, err := os.Readlink(fdPath) if err == nil && strings.Contains(link, fmt.Sprintf("socket:[%s]", inode)) { pidInt, _ := strconv.Atoi(pid) return pidInt } } } return -1}
goos: linuxgoarch: amd64pkg: github.com/intevel/linux-open-portscpu: AMD Ryzen 7 PRO 7840U w/ Radeon 780M GraphicsBenchmarkGetOpenPortsBenchmarkGetOpenPorts-16 1 1108555474 ns/opPASSok github.com/intevel/linux-open-ports 1.123s
基准测试记录从 1570580328 ns/op 到 1108555474 ns/op (1.57s 到 1.11s, 快了 0.46s 或者 1.41x ).
5
让我们缓存 Map 并获得 38147125738.8 倍的加速
了解情况的读者会注意到,/proc/每次遇到新的 inode 时迭代目录并不是那么明智。相反,我们应该只迭代一次,然后在 inode 到 pid 的 map 中查找找到的 inode:
func inodePIDMap() map[string]string { m := map[string]string{} procDirs, _ := os.ReadDir("/proc") for _, procDir := range procDirs { pid := procDir.Name() if !procDir.IsDir() && !unicode.IsDigit(rune(pid[0])) { continue } fdDir := filepath.Join("/proc", pid, "fd") fdFiles, err := os.ReadDir(fdDir) if err != nil { continue } for _, fdFile := range fdFiles { path := filepath.Join(fdDir, fdFile.Name()) linkName, err := os.Readlink(path) if err != nil { continue } if strings.Contains(linkName, "socket") { // index 8:till end -1 because socket:[ is 8 bytes long and ] // is at the end inode := linkName[8 : len(linkName)-1] m[inode] = pid } } } return m}
GetOpenPorts 必须更新该功能以匹配此更改:
func GetOpenPorts() ([]OpenPort, error) { var openPorts []OpenPort uniquePorts := make(map[string]bool) protocolFiles := map[string][]string{ "tcp": {"/proc/net/tcp", "/proc/net/tcp6"}, "udp": {"/proc/net/udp", "/proc/net/udp6"}, } cachedInodePIDMap := inodePIDMap() for protocol, files := range protocolFiles { for _, filePath := range files { // .. scanner setup and error handling for scanner.Scan() { fields := strings.Fields(scanner.Text()) // .. field checks and assignments inode := fields[9] pid, ok := cachedInodePIDMap[inode] if !ok { continue } // .. } } } return openPorts, nil}
加速比是巨大的,从 1108555474 ns/op 到 0.02906 ns/op,对应于 38147125739.848587x 加速比。
旧基准
goos: linuxgoarch: amd64pkg: github.com/intevel/linux-open-portscpu: AMD Ryzen 7 PRO 7840U w/ Radeon 780M GraphicsBenchmarkGetOpenPortsBenchmarkGetOpenPorts-16 1 1108555474 ns/opPASSok github.com/intevel/linux-open-ports 1.123s
新基准
goos: linuxgoarch: amd64pkg: github.com/intevel/linux-open-portscpu: AMD Ryzen 7 PRO 7840U w/ Radeon 780M GraphicsBenchmarkGetOpenPorts-16 1000000000 0.02906 ns/opPASSok github.com/intevel/linux-open-ports 0.235s
6
总结
我会进一步研究这个问题,但上述更改已经带来了如此大的性能提升,我并没有打算就此打住。我的下一步将是删除 fmt.Sprintffor 循环主体中的。在此之前,我会启动一个 pprof 分析器并调查热点函数。
推荐
如何将 Docker 镜像的大小减少 99.82%
24 年最快的 REST API Web 服务器:Node.js、Go、Rust 和 C# (.NET) 基准测试
原创不易,随手关注或者”在看“,诚挚感谢!