啥时候等到Go官方支持SIMD?

单指令多数据流(SIMD,Single Instruction Multiple Data)是一种并行计算技术,允许一条指令同时处理多个数据点。SIMD在现代CPU中广泛应用,能够显著提升计算密集型任务的性能,如图像处理、机器学习、科学计算等。随着Go语言在高性能计算领域的应用逐渐增多,SIMD支持成为了开发者关注的焦点。 当前很多主流和新型的语言都有相应的simd库了,比如C++、Rust、Zig等,但Go语言的simd官方支持还一直在讨论中(issue#67520)。Go语言的设计目标是简单性和可移植性,而SIMD的实现通常需要针对不同的硬件架构进行优化,这与Go的设计目标存在一定冲突。因此,Go语言对SIMD的支持一直备受争议。 最近几周这个issue的讨论有活跃起来, 希望能快点支持。 1. Go语言与SIMD的背景 1.1 Go语言的性能追求 Go语言以其简洁的语法、高效的并发模型和快速的编译速度赢得了广泛的应用。然而,Go在性能优化方面一直面临挑战,尤其是在需要处理大量数据的场景下。SIMD作为一种高效的并行计算技术,能够显著提升计算性能,因此Go社区对SIMD的支持呼声日益高涨。 如果没有 SIMD,我们就会错过很多潜在的优化。以下是可以提高日常生活场景中性能的具体事项的非详尽列表: simdjson 通过矢量化每秒解码数十亿个整数 矢量化和性能可移植的快速排序 Hyperscan 简介 From slow to SIMD: A Go optimization story How to Use AVX512 in Golang via C Compiler 此外,它将使这些当前存在的软件包更具可移植性和可维护性: simdjson-go SHA256-SIMD MD5-SIMD 在这个月即将发布的Go 1.24版中,将会将内建的map使用Swiss Tables替换,而Swiss Tables针对AMD64的架构采用了SIMD的代码,这是不是Go官方代码库首次引进了SIMD的指令呢? 当前先前也有人实现了SIMD加速encoding/hex,被否了,当然理由也很充分:加速效果很好但请放弃吧,看起来太复杂,违背了Go简洁的初衷。 类似的还有unicode/utf8: make Valid use AVX2 on amd64 其实Go官方在2023就已经在标准库crypto/sha256中使用SIMD指令了 crypto/sha256: add sha-ni implementation。 1.2 SIMD的基本概念 SIMD通过一条指令同时处理多个数据点,通常用于向量化计算。现代CPU(如Intel的SSE/AVX、ARM的NEON)都提供了SIMD指令集,允许开发者通过特定的指令集加速计算任务。然而,直接使用SIMD指令集通常需要编写汇编代码或使用特定的编译器内置函数,这对开发者提出了较高的要求。 1.2.1 SIMD的核心思想 SIMD的核心思想是通过一条指令同时处理多个数据点。例如,传统的标量加法指令一次只能处理两个数,而SIMD加法指令可以同时处理多个数(如4个、8个甚至更多)。这种并行化处理方式能够显著提升计算密集型任务的性能。 1.2.2 SIMD指令集的组成 SIMD指令集通常包括以下几类指令: 算术运算:加法、减法、乘法、除法等。 逻辑运算:与、或、非、异或等。 数据搬移:加载、存储、重排数据。 比较操作:比较多个数据点并生成掩码。 特殊操作:如求平方根、绝对值、最大值、最小值等。 1.3 常见的指令集 1.3.1 Intel的SIMD指令集 1.3.1.1 MMX(MultiMedia eXtensions) 推出时间:1996年 寄存器宽度:64位 数据类型:整数(8位、16位、32位) 特点: 主要用于多媒体处理。 引入了8个64位寄存器(MM0-MM7)。 不支持浮点数运算。 1.3.1.2 SSE(Streaming SIMD Extensions) 推出时间:1999年 寄存器宽度:128位 数据类型:单精度浮点数(32位)、整数(8位、16位、32位、64位) 特点: 引入了8个128位寄存器(XMM0-XMM7)。 支持浮点数运算,适用于科学计算和图形处理。 后续版本(SSE2、SSE3、SSSE3、SSE4)增加了更多指令和功能。 1.3.1.3 AVX(Advanced Vector Extensions) 推出时间:2011年 寄存器宽度:256位 数据类型:单精度浮点数(32位)、双精度浮点数(64位)、整数(8位、16位、32位、64位) 特点: 引入了16个256位寄存器(YMM0-YMM15)。 支持更宽的向量操作,性能进一步提升。 后续版本(AVX2、AVX-512)支持更复杂的操作和更宽的寄存器(512位)。 1.3.1.4 AVX-512 推出时间:2016年 寄存器宽度:512位 数据类型:单精度浮点数(32位)、双精度浮点数(64位)、整数(8位、16位、32位、64位) 特点: 引入了32个512位寄存器(ZMM0-ZMM31)。 支持更复杂的操作,如掩码操作、广播操作等。 主要用于高性能计算和人工智能领域。 1.3.2 ARM的SIMD指令集 1.3.2.1 NEON 推出时间:2005年 寄存器宽度:128位 数据类型:单精度浮点数(32位)、整数(8位、16位、32位、64位) 特点: 广泛应用于移动设备和嵌入式系统。 支持16个128位寄存器(Q0-Q15)。 适用于多媒体处理、信号处理等场景。 1.3.2.2 SVE(Scalable Vector Extension) 推出时间:2016年 寄存器宽度:可变(128位至2048位) 数据类型:单精度浮点数(32位)、双精度浮点数(64位)、整数(8位、16位、32位、64位) 特点: 支持可变长度的向量操作,适应不同的硬件配置。 引入了谓词寄存器(Predicate Registers),支持条件执行。 主要用于高性能计算和机器学习。 1.4 编译器内置函数 大多数现代编译器(如GCC、Clang、MSVC)提供了SIMD指令集的内置函数,开发者可以通过这些函数调用SIMD指令,而无需编写汇编代码。 1.5 自动向量化 一些编译器支持自动向量化功能,能够自动将标量代码转换为SIMD代码。例如,使用GCC编译以下代码时,可以启用自动向量化: 1 gcc -O3 -mavx2 -o program program.c 2. Go语言中的SIMD支持现状 2.1 Go语言标准库的SIMD支持 Go语言的标准库尚未提供对SIMD的直接支持。Go语言的编译器(gc)也没有自动向量化功能,这意味着开发者无法像在C/C++中那样通过编译器自动生成SIMD代码。 在Issue #67520 中,讨论依然磨磨唧唧,讨论时常偏离到实现的具体方式上(build tag)。 2.2 第三方库与解决方案 尽管Go语言标准库缺乏对SIMD的直接支持,但社区已经开发了一些第三方库和工具,帮助开发者在Go中使用SIMD指令集。在#67520的讨论中,Clement Jean 也提供了一个概念化的实现方案:simd-go-POC 。 以下是一些第三方实现的(simd指令,不是基于simd实现的库sonic、simdjson-go等): 2.2.1 kelindar/simd kelindar/simd这个库包含一组矢量化的数学函数,它们使用 clang 编译器自动矢量化,并转换为 Go 的 PLAN9 汇编代码。对于不支持矢量化的 CPU,或此库没有为其生成代码的 CPU,也提供了通用版本。 目前它仅支持 AVX2,但生成 AVX512 和 SVE (for ARM) 的代码应该很容易。这个库中的大部分代码都是自动生成的,这有助于维护。 1 sum := simd.SumFloat32s([]float32{1, 2, 3, 4, 5}) 2.2.2 alivanz/go-simd [alivanz/go-simd](https://github.com/alivanz/go-simd)实现了 Go 语言的 SIMD(单指令多数据)操作,专门针对 ARM NEON 架构进行了优化。其目标是为特定的计算任务提供优化的并行处理能力。 下面是一个加法和乘法的例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package main import ( "log" "github.com/alivanz/go-simd/arm" "github.com/alivanz/go-simd/arm/neon" ) func main() { var a, b arm.Int8X8 var add, mul arm.Int16X8 for i := 0; i < 8; i++ { a[i] = arm.Int8(i) b[i] = arm.Int8(i * i) } log.Printf("a = %+v", b) log.Printf("b = %+v", a) neon.VaddlS8(&add, &a, &b) neon.VmullS8(&mul, &a, &b) log.Printf("add = %+v", add) log.Printf("mul = %+v", mul) } 2.2.3 pehringer/simd pehringer/simd 通过 Go 汇编提供 SIMD 支持,实现了算术运算、位运算以及最大值和最小值运算。它允许进行并行的逐元素计算,从而带来 100% 到 400% 的速度提升。目前支持 AMD64 (x86_64) 和 ARM64 处理器。 2.3 Go汇编与SIMD Go语言支持通过汇编代码直接调用CPU指令集,这为SIMD的实现提供了可能。开发者可以编写Go汇编代码,调用特定的SIMD指令集(如SSE、AVX等),从而实现高性能的向量化计算。然而,编写和维护汇编代码对开发者提出了较高的要求,且代码的可移植性较差。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 以下是一个简单的Go汇编示例,使用AVX指令集进行向量加法 TEXT ·add(SB), $0-32 MOVQ a+0(FP), DI MOVQ b+8(FP), SI MOVQ result+16(FP), DX MOVQ len+24(FP), CX TESTQ CX, CX ; 检查长度是否为0 JZ done ; 如果为0直接返回 MOVQ CX, R8 ; 保存原始长度 SHRQ $2, CX ; 除以4得到循环次数 JZ remainder ; 如果不足4个元素,跳到处理余数 XORQ R9, R9 ; 用于索引的计数器,从0开始 loop: VMOVUPD (DI)(R9*8), Y0 VMOVUPD (SI)(R9*8), Y1 VADDPD Y0, Y1, Y0 VMOVUPD Y0, (DX)(R9*8) ADDQ $4, R9 DECQ CX JNZ loop remainder: ; 处理剩余的元素 ANDQ $3, R8 ; 获取余数 JZ done ; 这里添加处理余数的代码 done: RET 当然需要a,b和 result 数组的地址是对齐的,以获得最佳性能。 结论 尽管Go语言目前对SIMD的支持尚不完善,但社区已经通过第三方库和汇编代码提供了一些解决方案。未来,随着Go编译器的改进和标准库的支持(相信Go官方最终会支持的),Go语言在高性能计算领域的潜力将进一步释放。对于开发者而言,掌握SIMD技术将有助于编写更高效的Go代码,应对日益复杂的计算任务。

2025/2/1
articleCard.readMore

DeepSeek数据库暴露?扫描一下,应该不止此一家吧!

DeepSeek出街老火了,整个AI界都在热火朝天的讨论它。 同时,安全界也没闲着,来自美国的攻击使它不得不通知中国大陆以外的手机号的注册,同时大家也对它的网站和服务安全性进行了审视,这不Wiz Research就发现它们的数据库面向公网暴露并且无需任何身份即可访问。这两个域名oauth2callback.deepseek.com:9000和dev.deepseek.com:9000。 AI的核心技术既需要这些清北的天才去研究,产品也需要专业的人才去打磨。像DeepSeek这么专业的公司都可能出现这样的漏洞,相信互联网上这么数据库无密码暴露的实例也应该不在少数(实际只找到了2个)。 基于上一篇《扫描全国的公网IP要多久》,我们改造一下代码,让它使用 tcp_syn 的方式探测clickhopuse的9000端口。 首先声明,所有的技术都是为了给大家介绍使用Go语言开发底层的网络程序所做的演示,不是为了介绍安全和攻击方面的内容,所以也不会使用已经成熟的端口和IP扫描工具如zmap、rustscan、nmap、masscan、Advanced IP Scanner、Angry IP Scanner、unicornscan等工具。 同时,也不会追求快速,我仅仅在家中的100M的网络中,使用一台10多年前的4核Linux机器进行测试,尽可能让它能出结果。我一般晚上启动它,早上吃过早餐后来查看结果。 我想把这个实验分成两部分: 寻找中国大陆暴露9000端口的公网IP 检查这些IP是否是部署clickhouse并可以无密码的访问 接下来先介绍第一部分。 寻找暴露端口9000的IP 我们需要将上一篇的代码改造,让它使用TCP进行扫描,而不是ICMP扫描,而且我们只扫描9000端口。 为了更有效的扫描,我做了以下的优化: 使用ICMP扫描出来的可用IP, 一共五千多万 使用tcp sync模拟TCP建联是的握手,这样目的服务器会回一个sync+ack的包 同时探测机自动回复一个RST, 我们也别老挂着目的服务器,怪不好意思的,及时告诉人家别等着咱了 同样的,我们也定义一个TCPScanner结构体,用来使用TCP握手来进行探测。如果你已经阅读了前一篇文章,应该对我们实现的套路有所了解。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 package fishfinding import ( "net" "os" "time" "github.com/kataras/golog" "golang.org/x/net/bpf" "golang.org/x/net/ipv4" ) type TCPScanner struct { src net.IP srcPort int dstPort int input chan string output chan string } func NewTCPScanner(srcPort, dstPort int, input chan string, output chan string) *TCPScanner { localIP := GetLocalIP() s := &TCPScanner{ input: input, output: output, src: net.ParseIP(localIP).To4(), srcPort: srcPort, dstPort: dstPort, } return s } func (s *TCPScanner) Scan() { go s.recv() go s.send(s.input) } 这里定义了一个TCPScanner结构体,它有一个Scan方法,用来启动接收和发送两个goroutine。接收goroutine用来接收目标服务器的回复(sync+ack 包),发送goroutine用来发送TCP sync包。 发送逻辑 发送goroutine首先通过net.ListenPacket创建一个原始套接字,这里使用的是ip4:tcp,然后发送TCP的包就可以了。 我并没有使用gopacket这个库来构造TCP包,而是自己构造了TCP包,因为我觉得gopacket这个库太重了,而且我只需要构造TCP包,所以自己构造一个TCP包也不是很难。 seq数我们使用了当前进程的PID,这样在接收到回包的时候,还可以使用这个seq数来判断是不是我们发送的回包。 注意这里我们要计算tcp包的checksum, 并没有利用网卡的TCP/IP Checksum Offload功能,而是自己计算checksum,原因在于我的机的网卡很古老了,没有这个功能。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 func (s *TCPScanner) send(input chan string) error { defer func() { time.Sleep(5 * time.Second) close(s.output) golog.Infof("send goroutine exit") }() // 创建原始套接字 conn, err := net.ListenPacket("ip4:tcp", s.src.To4().String()) if err != nil { golog.Fatal(err) } defer conn.Close() pconn := ipv4.NewPacketConn(conn) // 不接收数据 filter := createEmptyFilter() if assembled, err := bpf.Assemble(filter); err == nil { pconn.SetBPF(assembled) } seq := uint32(os.Getpid()) for ip := range input { dstIP := net.ParseIP(ip) if dstIP == nil { golog.Errorf("failed to resolve IP address %s", ip) continue } // 构造 TCP SYN 包 tcpHeader := &TCPHeader{ Source: uint16(s.srcPort), // 源端口 Destination: uint16(s.dstPort), // 目标端口(这里探测80端口) SeqNum: seq, AckNum: 0, Flags: 0x002, // SYN Window: 65535, Checksum: 0, Urgent: 0, } // 计算校验和 tcpHeader.Checksum = tcpChecksum(tcpHeader, s.src, dstIP) // 序列化 TCP 头 packet := tcpHeader.Marshal() // 发送 TCP SYN 包 _, err = conn.WriteTo(packet, &net.IPAddr{IP: dstIP}) if err != nil { golog.Errorf("failed to send TCP packet: %v", err) } } return nil } 接收逻辑 接收goroutine首先创建一个原始套接字,使用net.ListenIP,然后使用ipv4.NewPacketConn来创建一个ipv4.PacketConn,并设置ipv4.FlagSrc|ipv4.FlagDst|ipv4.FlagInterface,这样可以获取到源IP、目标IP和接口信息。 这里必须设置ipv4.FlagSrc|ipv4.FlagDst|ipv4.FlagInterface, 否则不能获取到目标服务器的IP。pv4.FlagDst到是不需要的。 接收到数据后,我们解析TCP头,然后判断是否是我们发送的包,如果是我们发送的包,我们就将目标IP发送到output通道。 如果是我们发送的回包,我们就判断是否是SYN+ACK包,同时判断ACK是否和我们发送的seq对应,如果是,我们就将目标IP发送到output通道。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 func (s *TCPScanner) recv() error { defer recover() // 创建原始套接字 conn, err := net.ListenIP("ip4:tcp", &net.IPAddr{IP: s.src}) if err != nil { golog.Fatal(err) } defer conn.Close() pconn := ipv4.NewPacketConn(conn) if err := pconn.SetControlMessage(ipv4.FlagSrc|ipv4.FlagDst|ipv4.FlagInterface, true); err != nil { golog.Fatalf("set control message error: %v\n", err) } seq := uint32(os.Getpid()) + 1 buf := make([]byte, 1024) for { n, peer, err := conn.ReadFrom(buf) if err != nil { golog.Errorf("failed to read: %v", err) continue } if n < tcpHeaderLength { continue } // 解析 TCP 头 tcpHeader := ParseTCPHeader(buf[:n]) if tcpHeader.Destination != uint16(s.srcPort) || tcpHeader.Source != uint16(s.dstPort) { continue } // golog.Info("peer: %s, flags: %d", peer.String(), tcpHeader.Flags) // 检查是否是 SYN+ACK, 同时检查ACK是否和发送的seq对应 if tcpHeader.Flags == 0x012 && tcpHeader.AckNum == seq { // SYN + ACK s.output <- peer.String() } } } 完整的代码在这里。 最终我把可以连接端口9000的IP保存到了一个文件中,一共有970+个IP。 检查没有身份验证clickhouse 接下来我们要检查这些IP是否是clickhouse的服务,而且没有身份验证。 使用类似的方法,我们定义一个ClickHouseChecker结构体,用来检查这些IP是否是clickhouse的服务。 它会尝试使用这些IP和9000建立和clickhouse的连接,如果连接成功,并且调用Ping()方法成功,我们就认为这个IP是clickhouse的服务。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 package fishfinding import ( "context" "fmt" "runtime" "sync" "time" "github.com/ClickHouse/clickhouse-go/v2" _ "github.com/ClickHouse/clickhouse-go/v2" "github.com/kataras/golog" ) type ClickHouseChecker struct { wg *sync.WaitGroup port int input chan string output chan string } func NewClickHouseChecker(port int, input chan string, output chan string, wg *sync.WaitGroup) *ClickHouseChecker { s := &ClickHouseChecker{ port: port, input: input, output: output, wg: wg, } return s } func (s *ClickHouseChecker) Check() { parallel := runtime.NumCPU() for i := 0; i < parallel; i++ { s.wg.Add(1) go s.check() } } func (s *ClickHouseChecker) check() { defer s.wg.Done() for ip := range s.input { if ip == "splitting" || ip == "failed" { continue } if isClickHouse(ip, s.port) { s.output <- ip } } } func isClickHouse(ip string, port int) bool { conn, err := clickhouse.Open(&clickhouse.Options{ Addr: []string{fmt.Sprintf("%s:%d", ip, port)}, // Auth: clickhouse.Auth{ // Database: "default", // Username: "default", // Password: "", // }, Settings: clickhouse.Settings{ "max_execution_time": 1, }, DialTimeout: time.Second, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetime: time.Duration(1) * time.Minute, ConnOpenStrategy: clickhouse.ConnOpenInOrder, BlockBufferSize: 10, MaxCompressionBuffer: 1024, }) if err != nil { golog.Errorf("open %s:%d failed: %v", ip, port, err) return false } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() err = conn.Ping(ctx) if err != nil { golog.Warnf("ping %s:%d failed: %v", ip, port, err) return false } return true } 实际扫描下来,几乎所有的IP的9000端口都连接超时或者不是clickhouse服务,只有4个IP是clickhouse服务,但是需要身份验证。,报错default: Authentication failed: password is incorrect, or there is no user with such name. 挺好的一件事情,至少公网暴露的clickhouse服务都是需要身份验证的。 当然也有可能是clickhouse的服务端配置了IP白名单,只允许内网访问,这样的话我们就无法访问了。也可能是clickhouse的端口改成了其他端口,我们无法访问。 有必要扫描一下全网的IP和它们的9000端口了 使用既有的程序即可。我们先拉取全网的网段信息。 1 wget -c -O- http://ftp.apnic.net/stats/apnic/delegated-apnic-latest | awk -F '|' '/ipv4/ {print $4 "/" 32-log($5)/log(2)}' | cat > ipv4.txt 先用icmp_scan扫描一下公网课访问的IP地址: 1 2 3 4 5 6 7 8 9 10 11 12 ...... [INFO] 2025/01/31 03:56 223.255.250.221 is alive [INFO] 2025/01/31 03:56 223.255.233.1 is alive [INFO] 2025/01/31 03:56 223.255.240.91 is alive [INFO] 2025/01/31 03:56 223.255.233.10 is alive [INFO] 2025/01/31 03:56 223.255.233.15 is alive [INFO] 2025/01/31 03:56 223.255.233.11 is alive [INFO] 2025/01/31 03:56 223.255.233.115 is alive [INFO] 2025/01/31 03:56 223.255.233.100 is alive [INFO] 2025/01/31 03:56 send goroutine exit [INFO] 2025/01/31 03:56 total: 884686592, alive: 15500888, time: 2h35m28.930123788s 一共8亿多个IP,可以ping的通的有1500多万个,耗时2小时扫描完。 根据网友在上一篇的留言反馈,光美国就有8亿多个IP。 我问deepseek,全球有37亿个IP,美国有9亿个,这个数量才合理,我自己扫描的8亿要远远少于这个数量。而且活跃的IP我感觉应该远远大于1500多万。 但是这些不重要了,我要做的就是能扫描到可以免密登录的clickhouse服务,看看这些IP里面有没有。 接下来我们使用tcp_scan扫描这些IP的9000端口: 1 2 3 4 5 6 7 8 9 10 11 ...... [INFO] 2025/01/31 08:47 223.197.222.126 is alive [INFO] 2025/01/31 08:47 223.197.219.60 is alive [INFO] 2025/01/31 08:47 223.220.171.218 is alive [INFO] 2025/01/31 08:47 223.221.238.176 is alive [INFO] 2025/01/31 08:47 223.197.235.26 is alive [INFO] 2025/01/31 08:47 223.197.225.240 is alive [INFO] 2025/01/31 08:47 223.197.225.208 is alive [INFO] 2025/01/31 08:47 223.197.219.139 is alive [INFO] 2025/01/31 08:47 send goroutine exit [INFO] 2025/01/31 08:47 total: 15500890, alive: 3953, time: 2m41.23585658s 在这1500多万个IP中,有3953个IP的9000端口是可以访问的,但是都需要验证能不能进行clickhouse的操作,我们需要进一步检查。 接下来我们使用clickhouse_check检查这些IP是否是clickhouse服务: 1 2 3 4 5 6 7 8 9 10 11 12 ...... [WARN] 2025/01/31 11:47 ping 223.197.222.126:9000 failed: read: read tcp 192.168.1.5:53494->223.197.222.126:9000: i/o timeout [WARN] 2025/01/31 11:47 ping 223.197.219.60:9000 failed: read: read tcp 192.168.1.5:49718->223.197.219.60:9000: i/o timeout [WARN] 2025/01/31 11:47 ping 223.221.238.176:9000 failed: read: read tcp 192.168.1.5:56662->223.221.238.176:9000: i/o timeout [WARN] 2025/01/31 11:47 ping 223.197.235.26:9000 failed: read: read tcp 192.168.1.5:47676->223.197.235.26:9000: i/o timeout [WARN] 2025/01/31 11:47 ping send:9000 failed: dial tcp: lookup send on 127.0.0.53:53: server misbehaving [WARN] 2025/01/31 11:47 ping total::9000 failed: dial tcp: address total::9000: too many colons in address [WARN] 2025/01/31 11:47 ping 223.197.225.240:9000 failed: read: read tcp 192.168.1.5:55342->223.197.225.240:9000: i/o timeout [WARN] 2025/01/31 11:47 ping 223.197.225.208:9000 failed: read: read tcp 192.168.1.5:43300->223.197.225.208:9000: i/o timeout [WARN] 2025/01/31 11:47 ping 223.197.219.139:9000 failed: read: read tcp 192.168.1.5:57552->223.197.219.139:9000: i/o timeout [INFO] 2025/01/31 11:47 total: 2, time: 4m20.744235925s 4分钟完成。最终还是真的发现有两个IP的9000端口是clickhouse服务,而且不需要密码验证。 类似的我们还可以验证Redis、Mysql等服务的安全性。

2025/1/31
articleCard.readMore

趁着假期, 快速了解 Go io/fs 包

Go 语言的 io/fs 包是 Go 1.16 版本引入的一个标准库包,它定义了文件系统的抽象接口。这个包提供了一种统一的方式来访问不同类型的文件系统,包括本地文件系统、内存文件系统、zip 文件等。 io/fs 包的主要作用 抽象文件系统: io/fs 包定义了一组接口,用于描述文件系统的基本操作,如打开文件、读取目录等。通过这些接口,我们可以编写与具体文件系统无关的代码。 统一访问方式: 无论底层文件系统是什么类型,只要实现了 io/fs 包定义的接口,就可以使用相同的代码进行访问。 提高代码可测试性: 通过使用 io/fs 包,我们可以方便地mock文件系统,从而提高代码的可测试性。 io/fs 包的核心接口 fs.FS: 表示一个文件系统,定义了打开文件的方法 Open。 fs.File: 表示一个打开的文件,定义了读取、写入、关闭等方法。 fs.FileInfo: 表示文件的元信息,包括文件名、大小、修改时间等。 fs.DirEntry 接口表示一个目录项,它可以是文件或子目录。 fs.FileInfo 接口表示文件的元信息。 fs.FileMode 类型表示文件的权限和类型,它是一个位掩码。 还有一些基于fs.FS、fs.File等接口扩展的一些接口: fs.GlobFS 接口扩展了 fs.FS 接口,增加了 Glob(pattern string) ([]string, error) 方法。该方法允许使用通配符模式匹配文件和目录。 fs.ReadDirFS 接口也扩展了 fs.FS 接口,增加了 ReadDir(name string) ([]fs.DirEntry, error) 方法。该方法用于读取指定目录下的所有文件和子目录。 fs.ReadDirFile 接口扩展了 fs.File 接口,增加了 ReadDir(n int) ([]fs.DirEntry, error) 方法。这个接口主要用于读取目录文件中的内容,返回一个 fs.DirEntry 列表。它通常用于实现了 fs.ReadDirFS 的文件系统。 fs.ReadFileFS 接口扩展了 fs.FS 接口,增加了 ReadFile(name string) ([]byte, error) 方法。这个接口允许直接读取指定文件的全部内容,返回字节切片。 它提供了一种更便捷的方式来读取文件内容,避免了先打开文件再读取的步骤。 fs.StatFS 接口也扩展了 fs.FS 接口,增加了 Stat(name string) (fs.FileInfo, error) 方法。该方法用于获取指定文件的元信息,返回一个 fs.FileInfo 对象。 fs.SubFS 接口也扩展了 fs.FS 接口,增加了 Sub(dir string) (fs.FS, error) 方法。该方法用于创建一个新的文件系统,它表示原始文件系统的一个子目录。这在需要限制访问文件系统的特定部分时非常有用。 fs.WalkDirFunc 类型定义了一个函数签名,用于 fs.WalkDir 函数的回调。 io/fs 包的应用场景 访问不同类型的文件系统: 可以使用相同的代码访问本地文件系统、内存文件系统、zip 文件等。 测试代码: 可以方便地mock文件系统,从而提高代码的可测试性。 嵌入资源: 可以将静态资源嵌入到程序中,并使用 io/fs 包进行访问。 示例代码 示例代码一:fs.FS 接口 fs.FS 接口是 io/fs 包的核心,它表示一个文件系统。最常见的实现是 os.DirFS,它表示本地文件系统的一个目录。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package main import ( "fmt" "io/fs" "log" "os" ) func main() { // 创建一个表示当前目录的文件系统 fsys := os.DirFS(".") // 打开一个文件 f, err := fsys.Open("README.md") if err != nil { log.Fatal(err) } defer f.Close() // 读取文件内容 data := make([]byte, 100) n, err := f.Read(data) if err != nil { log.Fatal(err) } fmt.Println(string(data[:n])) } 这个例子展示了如何使用 os.DirFS 创建一个文件系统,然后使用 fsys.Open 方法打开一个文件并读取其内容。 示例代码二:fs.File 接口 fs.File 接口表示一个打开的文件,它提供了读取、写入、关闭等方法。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 package main import ( "fmt" "io/fs" "log" "os" ) func main() { fsys := os.DirFS(".") f, err := fsys.Open("README.md") if err != nil { log.Fatal(err) } defer f.Close() // 获取文件信息 info, err := f.Stat() if err != nil { log.Fatal(err) } fmt.Println("File size:", info.Size()) } 这个例子展示了如何使用 f.Stat 方法获取文件的元信息。 示例代码三:fs.DirEntry 接口 fs.DirEntry 接口表示一个目录项,它可以是文件或子目录。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package main import ( "fmt" "io/fs" "log" "os" ) func main() { fsys := os.DirFS(".") entries, err := fs.ReadDir(fsys, ".") if err != nil { log.Fatal(err) } for _, entry := range entries { fmt.Println("Name:", entry.Name()) fmt.Println("Is directory:", entry.IsDir()) } } 这个例子展示了如何使用 fs.ReadDir 函数读取目录中的所有条目,并使用 entry.Name 和 entry.IsDir 方法获取条目的名称和类型。 示例代码四:fs.GlobFS 接口 fs.GlobFS 接口扩展了 fs.FS 接口,增加了 Glob 方法,允许使用通配符模式匹配文件和目录。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package main import ( "fmt" "io/fs" "log" "os" ) func main() { fsys := os.DirFS(".") if globFS, ok := fsys.(fs.GlobFS); ok { matches, err := globFS.Glob("*.go") if err != nil { log.Fatal(err) } fmt.Println("Go files:", matches) } } 这个例子展示了如何使用 fs.Glob 函数查找所有以 .go 结尾的文件。 示例代码五:fs.ReadDirFS 接口 fs.ReadDirFS 接口也扩展了 fs.FS 接口,增加了 ReadDir 方法,用于读取指定目录下的所有文件和子目录。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package main import ( "fmt" "io/fs" "log" "os" ) func main() { fsys := os.DirFS(".") if readDirFS, ok := fsys.(fs.ReadDirFS); ok { entries, err := readDirFS.ReadDir(".") if err != nil { log.Fatal(err) } fmt.Println("Directory contents:") for _, entry := range entries { fmt.Println(entry.Name()) } } } 这个例子展示了如何使用 fs.ReadDir 函数读取目录中的所有条目。 示例代码六:fs.SubFS 接口 fs.SubFS 接口也扩展了 fs.FS 接口,增加了 Sub 方法,用于创建一个新的文件系统,它表示原始文件系统的一个子目录。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package main import ( "fmt" "io/fs" "log" "os" ) func main() { fsys := os.DirFS(".") if subFS, ok := fsys.(fs.SubFS); ok { sub, err := subFS.Sub("subdir") if err != nil { log.Fatal(err) } fmt.Println("Sub directory contents:") entries, err := fs.ReadDir(sub, ".") if err != nil { log.Fatal(err) } for _, entry := range entries { fmt.Println(entry.Name()) } } } 这个例子展示了如何使用 fs.Sub 函数创建一个表示子目录的文件系统,并读取其内容。 示例代码七:fs.WalkDirFunc 接口 fs.WalkDirFunc 类型定义了一个函数签名,用于 fs.WalkDir 函数的回调。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package main import ( "fmt" "io/fs" "log" "os" ) func main() { fsys := os.DirFS(".") err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } fmt.Println("Walking:", path) return nil }) if err != nil { log.Fatal(err) } } 这个例子展示了如何使用 fs.WalkDir 函数遍历目录,并使用 fs.WalkDirFunc 函数打印每个文件和目录的路径。 那些有趣的文件系统 内存文件系统 内存文件系统是一种虚拟文件系统,它将文件存储在内存中而不是磁盘上。内存文件系统通常用于临时存储数据,或者用于测试和调试目的。 这种文件系统速度非常快,但数据在程序退出后会丢失。Go 语言的 testing/fstest 包提供了一个 MapFS 包,可以方便地创建内存文件系统。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 package main import ( "fmt" "io/fs" "log" "os" "testing/fstest" ) func main() { // 创建一个内存文件系统 fsys := fstest.MapFS{ "file1.txt": {Data: []byte("Hello, world!")}, "dir1/file2.txt": {Data: []byte("This is file2.")}, } // 打开一个文件 f, err := fsys.Open("file1.txt") if err != nil { log.Fatal(err) } defer f.Close() // 读取文件内容 data := make([]byte, 100) n, err := f.Read(data) if err != nil { log.Fatal(err) } fmt.Println(string(data[:n])) // 遍历文件系统 err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } fmt.Println("Walking:", path) return nil }) if err != nil { log.Fatal(err) } } 也有一些第三方的库实现了内存文件系统,比如psanford/memfs,这是一个它的例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package main import ( "fmt" "io/fs" "github.com/psanford/memfs" ) func main() { rootFS := memfs.New() err := rootFS.MkdirAll("dir1/dir2", 0777) if err != nil { panic(err) } err = rootFS.WriteFile("dir1/dir2/f1.txt", []byte("incinerating-unsubstantial"), 0755) if err != nil { panic(err) } err = fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, err error) error { fmt.Println(path) return nil }) if err != nil { panic(err) } content, err := fs.ReadFile(rootFS, "dir1/dir2/f1.txt") if err != nil { panic(err) } fmt.Printf("%s\n", content) } 嵌入式文件系统 嵌入式文件系统将文件嵌入到程序中,这样可以方便地将静态资源打包到程序中。Go 语言标准库提供了一个 embed 包,可以方便地创建嵌入式文件系统。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package main import ( "embed" "fmt" "io/fs" "log" ) //go:embed static var staticFiles embed.FS func main() { // 打开一个嵌入的文件 f, err := staticFiles.Open("static/file1.txt") if err != nil { log.Fatal(err) } defer f.Close() // 读取文件内容 data := make([]byte, 100) n, err := f.Read(data) if err != nil { log.Fatal(err) } fmt.Println(string(data[:n])) // 遍历嵌入式文件系统 err = fs.WalkDir(staticFiles, "static", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } fmt.Println("Walking:", path) return nil }) if err != nil { log.Fatal(err) } } 这个例子展示了如何使用embed.FS类型创建一个嵌入式文件系统,并使用staticFiles.Open` 方法打开一个嵌入的文件。 云存储文件系统 有一些第三方库提供了将 S3 存储桶挂载为本地文件系统的功能,这样我们就可以像访问本地文件一样访问 S3 文件。例如,go-cloud 库就提供了对多种云存储服务的统一访问接口,包括 S3。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 package main import ( "context" "fmt" "io/fs" "log" "gocloud.dev/blob" _ "gocloud.dev/blob/gcs" // 引入 GCS 驱动,如果使用其他云存储服务,请引入相应的驱动 ) func main() { // 设置 S3 存储桶 URL bucketURL := "gs://my-bucket" // 创建一个 blob.Bucket bucket, err := blob.OpenBucket(context.Background(), bucketURL) if err != nil { log.Fatal(err) } defer bucket.Close() // 创建一个 fs.FS fsys := blob.NewFS(bucket) // 现在可以使用 fsys 进行文件操作 err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } fmt.Println("Walking:", path) return nil }) if err != nil { log.Fatal(err) } } 这个例子展示了如何使用 gocloud.dev/blob 包将 google GCS 存储桶挂载为本地文件系统,并使用 fs.WalkDir 函数遍历存储桶中的文件。

2025/1/30
articleCard.readMore

扫描全国的公网IP需要多久?

自从加入百度负责物理网络的监控业务之后,我大部分的都是编写各种各样额度底层的网络程序。业余时间我也是编写一些有趣的网络程序,不仅仅是兴趣,也是为未来的某个业务探索一下技术方案。 而且这次,我想知道,就在我这一个10年前的小mini机器4核机器上,在家庭网络中扫描全国(中国大陆)的所有的公网IP地址需要多少时间。 利用它,我可以知道和全国各省市的运营商、云服务商的联通情况。有没有运营商的出口故障以及IP已没有被运营商或者有关部门劫持。 TL;DR: 一共扫描了3亿个地址(343142912),当前ping的通的IP 592万个(5923768),耗时1小时(1h2m57.973755197s)。 这次我重构了以前的一个扫描公网IP的程序。先前的程序使用gopacket收发包,也使用gopacket组装包。但是gopacket很讨厌的的一个地方是它依赖libpcap库,没有办法在禁用CGO的情况下。 事实上利用Go的扩展包icmp和ipv4,我们完全可以不使用gopacket实现这个功能,本文介绍具体的实现。 程序的全部代码在:https://github.com/smallnest/fishfinder 程序的主要架构 程序使用ICMP协议进行探测。 首先它启动一个goroutine解析全国的IP地址。IP地址文件每一行都是一个网段,它对每一个网段解析成一组IP地址,把这组IP地址扔进input channel。 一个发送goroutine从input通道中接收IP地址,然后组装成ICMP echo包发送给每一个IP地址,它只负责发送,发送完所有的地址就返回。 一个接收goroutine处理接收到的ICMP reply 回包,并将结果写入到output channel中。 主程序不断的从output中接收已经有回包的IP并打印到日志中,直到所有的IP都处理完就退出。 这里涉及到并发编程的问题,几个goroutine怎么协调: IP解析和任务分发goroutine和发送goroutine通过input通讯。分发goroutine处理完所有的IP后,就会关闭input通知发送goroutine。 发送goroutine得知input关闭,就知道已经处理完所有的IP,发送完最后的IP后把output关闭。 接收goroutine往output发送接收到回包的IP, 如果output关闭,再往output发送就会panic,程序中捕获了panic。不过还没到这一步主程序应该就退出了。 主程序从output读取IP, 一旦output关闭,主程序就打印统计信息后推出。 如果你对Go并发编程有疑问,可以阅读极客时间上的《Go并发编程实战课》专栏,或者图书《深入理解Go并发编程》。 如果你是Rust程序员,不就我会推出《Go并发编程实战课》姊妹专栏,专门介绍Rust并发编程。 如果你对网络编程感兴趣,今年我还想推出《深入理解网络编程》的专栏或者图书,如果你感兴趣,欢迎和我探讨。 主程序的代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 package main import ( "flag" "time" "github.com/kataras/golog" ) var ( protocol = flag.String("p", "icmp", "The protocol to use (icmp, tcp or udp)") ) // 嵌入ip.sh func main() { flag.Parse() input := make(chan []string, 1024) output := make(chan string, 1024) scanner := NewICMPScanner(input, output) var total int var alive int golog.Infof("start scanning") start := time.Now() // 将待探测的IP发送给send goroutine go func() { lines := readIPList() for _, line := range lines { ips := cidr2IPList(line) input <- ips total += len(ips) } close(input) }() // 启动 send goroutine scanner.Scan() // 接收 send goroutine 发送的结果, 直到发送之后5秒结束 for ip := range output { golog.Infof("%s is alive", ip) alive++ } golog.Infof("total: %d, alive: %d, time: %v", total, alive, time.Since(start)) } 接下来介绍三个三个主要goroutine的逻辑。 公网IP获取以及任务分发 首先你需要到互联网管理中心下载中国大陆所有的注册的IP网段,这是从亚太互联网络信息中心下载的公网IP信息,实际上可以探测全球的IP,这里以中国大陆的公网IP为例。 通过下面的代码转换成网段信息: 1 2 3 #!/bin/bash wget -c -O- http://ftp.apnic.net/stats/apnic/delegated-apnic-latest | awk -F '|' '/CN/&&/ipv4/ {print $4 "/" 32-log($5)/log(2)}' | cat > ipv4.txt ipv4.txt文件中是一行行的网段: 1 2 3 4 5 6 7 8 1.0.1.0/24 1.0.2.0/23 1.0.8.0/21 1.0.32.0/19 1.1.0.0/24 1.1.2.0/23 1.1.4.0/22 ... 数据量不大,我们全读取进来(如果太多的话我们就流式读取了)。 解析每一行的网段,转换成IP地址列表,然后发送给input通道。 等处理完就把inpout通道关闭。 1 2 3 4 5 6 7 8 9 go func() { lines := readIPList() for _, line := range lines { ips := cidr2IPList(line) input <- ips total += len(ips) } close(input) }() 发送逻辑 我使用了ICMPScanner结构体来管理发送和接收的逻辑。看名字你也可以猜测到我们将来还可以使用TCP/UDP等协议进行探测。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 type ICMPScanner struct { src net.IP input chan []string output chan string } // 调大缓存区 // sysctl net.core.rmem_max // sysctl net.core.wmem_max func NewICMPScanner(input chan []string, output chan string) *ICMPScanner { localIP := getLocalIP() s := &ICMPScanner{ input: input, output: output, src: net.ParseIP(localIP), } return s } func (s *ICMPScanner) Scan() { go s.recv() go s.send(s.input) } send方法负责发送ICMP包,recv方法负责接收ICMP包。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // send sends a single ICMP echo request packet for each ip in the input channel. func (s *ICMPScanner) send(input chan []string) error { defer func() { time.Sleep(5 * time.Second) close(s.output) golog.Infof("send goroutine exit") }() id := os.Getpid() & 0xffff // 创建 ICMP 连接 conn, err := icmp.ListenPacket("ip4:icmp", s.src.String()) if err != nil { log.Fatal(err) } defer conn.Close() // 不负责接收数据 filter := createEmptyFilter() if assembled, err := bpf.Assemble(filter); err == nil { conn.IPv4PacketConn().SetBPF(assembled) } ... // 先忽略,后面再介绍 return nil } send方法中,我们首先创建一个ICMP连接,我通过icmp包创建了一个连接,然后设置了一个BPF过滤器,过滤掉我们不关心的包。 这是一个技巧,这个连接我们不关心接收到的包,只关心发送的包,所以我们设置了一个空的过滤器。 这个设计本来是为了将来的性能扩展做准备,可以创建多个连接用来更快的发送。不过目前我们只使用一个连接,所以这个连接其实可以和接收goroutine共享,目前的设计还是发送和接收使用各自的连接。 接下来就是发送的逻辑了,也就是上面省略的部分: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 seq := uint16(0) for ips := range input { for _, ip := range ips { dst, err := net.ResolveIPAddr("ip", ip) if err != nil { golog.Fatalf("failed to resolve IP address: %v", err) } // 构造 ICMP 报文 msg := &icmp.Message{ Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{ ID: id, Seq: int(seq), Data: []byte("Hello, are you there!"), }, } msgBytes, err := msg.Marshal(nil) if err != nil { golog.Errorf("failed to marshal ICMP message: %v", err) } // 发送 ICMP 报文 _, err = conn.WriteTo(msgBytes, dst) if err != nil { golog.Errorf("failed to send ICMP message: %v", err) } seq++ } } 发送循环从input通道中读取IP地址,然后构造ICMP echo报文,发送到目标地址。 从 input channel 读取 IP 列表 对每个 IP 执行以下操作: 解析 IP 地址 构造 ICMP echo 请求报文 序列化报文 发送到目标地址 icmp报文中的ID我们设置为进程的PID,在接收的时候可以用来判断是否是我们发送的回包。 接收逻辑 接收逻辑比较简单,我们只需要接收ICMP回包,然后解析出IP地址,然后发送到output通道。 首先我们创建一个ICMP连接,然后循环接收ICMP回包,解析出IP地址,然后发送到output通道。 我们只需处理ICMPTypeEchoReply类型的回包,然后判断ID是否是我们发送的ID,如果是就把对端的IP发送到output通道。 我们通过ID判断回包针对我们的场景就足够了,不用再判断seq甚至payload信息。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 func (s *ICMPScanner) recv() error { defer recover() id := os.Getpid() & 0xffff // 创建 ICMP 连接 conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") if err != nil { log.Fatal(err) } defer conn.Close() // 接收 ICMP 报文 reply := make([]byte, 1500) for { n, peer, err := conn.ReadFrom(reply) if err != nil { log.Fatal(err) } // 解析 ICMP 报文 msg, err := icmp.ParseMessage(protocolICMP, reply[:n]) if err != nil { golog.Errorf("failed to parse ICMP message: %v", err) continue } // 打印结果 switch msg.Type { case ipv4.ICMPTypeEchoReply: echoReply, ok := msg.Body.(*icmp.Echo) if !ok { continue } if echoReply.ID == id { s.output <- peer.String() } } } } 可以看到,200行代码基本就可以我们扫描全国公网IP的程序了。你也可以尝试扫描一下全球的IP地址,看看需要多少时间。 对了,下面是我运行这个程序的输出: 1 2 3 4 5 ... [INFO] 2025/01/26 22:01 223.255.236.221 is alive [INFO] 2025/01/26 22:01 223.255.252.9 is alive [INFO] 2025/01/26 22:01 send goroutine exit [INFO] 2025/01/26 22:01 total: 343142912, alive: 5923768, time: 1h2m57.973755197s

2025/1/27
articleCard.readMore

Go中秘而不宣的数据结构: 四叉堆,不是普通的二叉堆

Go语言中Timer以及相关的Ticker、time.After、time.AfterFunc 等定时器最终是以四叉堆的数据形式存放的。 全局的 timer 堆也经历过三个阶段的重要升级。 Go 1.9 版本之前,所有的计时器由全局唯一的四叉堆维护,goroutine间竞争激烈。 Go 1.10 - 1.13,全局使用 64 个四叉堆维护全部的计时器,通过分片减少了竞争的压力,但是本质上还是没有解决 1.9 版本之前的问题 Go 1.14 版本之后,每个 P 单独维护一个四叉堆,避免了goroutine的竞争。 (后面我们再介绍 per-P 的数据结构) 常见的堆(heap)常常以二叉堆的形式实现。可是为什么Go timer使用四叉堆呢? 以最小堆为例,下图展示了二叉堆和四叉堆的区别: 二叉堆:每个节点最多有2个子节点;四叉堆:每个节点最多有4个子节点 在相同节点数下,四叉堆的高度更低,约为二叉堆的一半(log₄n vs log₂n) 对于最小堆来说, 父节点的值小于等于子节点的值。 父节点和子节点的索引计算也略有不同。二叉堆的父子索引如下: 1 2 3 parent = (i - 1) // 2 left_child = 2 * i + 1 right_child = 2 * i + 2 四叉堆的父子索引如下: 1 2 3 parent = (i - 1) // 4 first_child = 4 * i + 1 last_child = 4 * i + 4 他们的操作时间复杂度: 因为四叉树的高度相对更低,所以四叉堆适合数据量特别大,需要减少树的高度的场景, Go的timer很久以前(11年前)就使用四叉树来实现Timer的保存,当然Go开发者也是根据测试结果选择了四叉树,最早的这个提交可以查看: ## code review 13094043: time: make timers heap 4-ary (Closed) 在Go的运行时中,四叉堆的实现在 src/runtime/time.go 文件中,可以查看源码实现。timers数据结构代表Timer的集合,每个P都有一个timers实例,用于维护当前P的所有Timer。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // A timers is a per-P set of timers. type timers struct { // 互斥锁保护timers; 虽然timers是每个P的,但是调度器可以访问另一个P的timers,所以我们必须锁定。 mu mutex // heap是一组计时器,按heap[i].when排序。这就是一个四叉堆,虽然没有明确的说明。 // 必须持有锁才能访问这个堆。 heap []timerWhen // len是heap的长度的原子副本。 len atomic.Uint32 // zombies是堆中标记为删除的计时器的数量。 zombies atomic.Int32 raceCtx uintptr // minWhenHeap是最小的heap[i].when值(= heap[0].when)。 // wakeTime方法使用minWhenHeap和minWhenModified来确定下一个唤醒时间。 // 如果minWhenHeap = 0,表示堆中没有计时器。 minWhenHeap atomic.Int64 // minWhenModified是具有timerModified位设置的计时器的最小heap[i].when的下界。 // 如果minWhenModified = 0,表示堆中没有timerModified计时器。 minWhenModified atomic.Int64 } type timerWhen struct { timer *timer when int64 } func (ts *timers) lock() { lock(&ts.mu) } func (ts *timers) unlock() { ts.len.Store(uint32(len(ts.heap))) unlock(&ts.mu) } 同时Timer结构体还引用了Timers, 这叫你中有我,我中有你,这样的设计是为了方便Timer的管理,Timer的创建、删除、执行都是通过Timers来实现的。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 type timer struct { mu mutex astate atomic.Uint8 state uint8 isChan bool blocked uint32 when int64 period int64 f func(arg any, seq uintptr, delay int64) arg any seq uintptr ts *timers // 注意这里 sendLock mutex isSending atomic.Int32 } 我们来看看对这个堆操作的一些方法。 timerHeapN定义了堆是四叉堆,也就是每个节点最多有4个子节点。 1 const timerHeapN = 4 堆常用的辅助方法就是siftUp和siftDown,分别用于上浮和下沉操作。 下面是上浮的方法,我把一些跟踪检查的代码去掉了。整体看代码还是比较简单的,就是不停的上浮,直到找到合适的位置。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // siftUp将位置i的计时器在堆中合适的位置,通过将其向堆的顶部移动。 func (ts *timers) siftUp(i int) { heap := ts.heap if i >= len(heap) { badTimer() } // 注意下面两行我们保存了当前i的计时器和它的when值 tw := heap[i] when := tw.when if when <= 0 { badTimer() } for i > 0 { p := int(uint(i-1) / timerHeapN) // 父节点 (i-1)/4 if when >= heap[p].when { // 如果父节点的when <= 当前节点的when,那么就不需要再上浮了 break } heap[i] = heap[p] // 父节点下沉到当前的i i = p // i指向父节点, 继续循环上浮检查 } // 如果发生了上浮,那么最后将tw放到上浮到的合适位置 if heap[i].timer != tw.timer { heap[i] = tw } } 类似的,下面是下沉的方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 // siftDown将位置i的计时器放在堆中的正确位置,通过将其向堆的底部移动。 func (ts *timers) siftDown(i int) { heap := ts.heap n := len(heap) if i >= n { badTimer() } // 如果已经是叶子节点,不用下沉了 if i*timerHeapN+1 >= n { return } // 保存当前i的计时器和when值 tw := heap[i] when := tw.when if when <= 0 { badTimer() } // 从左子节点开始,找到最小的when值,然后将当前节点下沉到这个位置 for { leftChild := i*timerHeapN + 1 // 左子节点 if leftChild >= n { break } w := when c := -1 for j, tw := range heap[leftChild:min(leftChild+timerHeapN, n)] { // 从左子节点开始遍历子节点,找到小于当前w的最小的子节点 if tw.when < w { w = tw.when c = leftChild + j } } if c < 0 { // 如果没有找到比当前节点更小的子节点,那么就不用下沉了 break } // 将当前节点下沉到最小的子节点 heap[i] = heap[c] i = c } // 如果发生了下沉,那么最后将tw放到下沉到的合适位置 if heap[i].timer != tw.timer { heap[i] = tw } } 比上浮略微复杂,因为需要在兄弟节点中找到最小的节点,然后将当前节点下沉到这个位置。 对于一个任意的slice,我们可以把它初始化为一个四叉堆,方法如下: 1 2 3 4 5 6 7 8 9 10 func (ts *timers) initHeap() { if len(ts.heap) <= 1 { return } // 从最后一个非叶子节点开始,依次下沉 for i := int(uint(len(ts.heap)-1-1) / timerHeapN); i >= 0; i-- { ts.siftDown(i) } } 当然timers还有一些辅助timer处理的一些方法,很多和四叉堆没有关系了,我就不一一介绍了,我主要介绍几个和四叉堆相关的方法。 这里吐槽一下,这个time.go文件中代码组织很乱,timer和timers的方法都穿插在一起。理论应该是timer方法和timers方法分开,这样更清晰。或者把timers抽取到一个单独的文件中。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func (ts *timers) deleteMin() { // 得到堆顶元素 t := ts.heap[0].timer if t.ts != ts { throw("wrong timers") } t.ts = nil // 将timer的ts置为nil,自此和ts一别两宽,再无瓜葛 // 将最后一个元素设置为堆顶 last := len(ts.heap) - 1 if last > 0 { ts.heap[0] = ts.heap[last] } ts.heap[last] = timerWhen{} // 将最后一个元素置为空 ts.heap = ts.heap[:last] // 缩减slice,剔除最后的空元素 if last > 0 { // 将堆顶元素下沉 ts.siftDown(0) } ts.updateMinWhenHeap() if last == 0 { ts.minWhenModified.Store(0) } } 增加一个timer到堆中: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func (ts *timers) addHeap(t *timer) { if netpollInited.Load() == 0 { netpollGenericInit() } if t.ts != nil { throw("ts set in timer") } // 设置timer的ts为当前的timers,从此执子之手,笑傲江湖 t.ts = ts // 添加到最后 ts.heap = append(ts.heap, timerWhen{t, t.when}) ts.siftUp(len(ts.heap) - 1) // 上浮它到合适的位置 if t == ts.heap[0].timer { ts.updateMinWhenHeap() } } n叉堆 d-ary 堆或 d-heap 是一种优先队列数据结构,是二进制堆的泛化,其中节点有d个子节点而不是 2 个子节点。因此,二进制堆是2堆,而三元堆是3堆。根据 Tarjan 和 Jensen 等人的说法,d-ary堆是由 Donald B. Johnson 1975 年发明的。 此数据结构允许比二进制堆更快地执行降低优先级操作(因为深度更浅了),但代价是删除最小操作速度较慢。这种权衡导致算法的运行时间更长,其中降低优先级操作比删除最小操作更常见。此外,d-ary堆比二进制堆具有更好的内存缓存行为,尽管理论上最坏情况下的运行时间更长,但它们在实践中运行得更快。与二进制堆一样,d-ary堆是一种就地数据结构,除了在堆中存储项目数组所需的存储空间外,它不使用任何额外的存储空间。 在Go生态圈已经有相应的库实现这个数据结构,比如ahrav/go-d-ary-heap,所以如果你有类似场景的需求,或者想对比测试,你可以使用这个库。 导入库: 1 import "github.com/ahrav/go-d-ary-heap" 下面的例子是创建三叉最小堆和四叉最大堆的例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 package main import ( "fmt" "github.com/ahrav/go-d-ary-heap" ) func main() { // Create a min-heap for integers with a branching factor of 3. minHeap := heap.NewHeap[int](3, func(a, b int) bool { return a < b }) // Create a max-heap for integers with a branching factor of 4. maxHeap := heap.NewHeap[int](4, func(a, b int) bool { return a > b }) } 往堆中增加元素: 1 2 3 4 5 6 7 minHeap.Push(10) minHeap.Push(5) minHeap.Push(15) maxHeap.Push(10) maxHeap.Push(5) maxHeap.Push(15) 从堆中移除最值: 1 2 fmt.Println(minHeap.Pop()) // Outputs: 5 fmt.Println(maxHeap.Pop()) // Outputs: 15 返回但是不移除最值: 1 2 fmt.Println(minHeap.Peek()) // Assuming more elements were added, outputs the smallest fmt.Println(maxHeap.Peek()) // Assuming more elements were added, outputs the largest

2024/11/18
articleCard.readMore

HeapMap, 一个混合功能的数据结构Go语言实现

今天在准备《秘而不宣》系列下一篇文章时,思绪飘散了,突然想到使用 Heap 的功能再加 HashTable (Map) 的功能,可以构造一种新的数据结构,然后把我聚合程序中的数据聚合数据结构替换掉,总之思绪翩翩。然后在网上搜了一下,这种数据结构其实早就有了,名字叫 HeapMap。 HeapMap (也叫做 PriorityMap) 是一种结合了堆和哈希映射的数据结构,常用于需要按键排序并进行高效查找的场景。它可以在优先级队列的基础上,使用哈希映射来提供快速访问和更新。HeapMap 在实现过程中利用堆的有序性和哈希表的快速查找能力,以支持按键排序和常数时间查找。 Go 语言支付 Rob Pike 在他的 Rob Pike's 5 Rules of Programming 第 5 条就指出: Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming. 数据为王。如果你选择了合适的数据结构并进行了良好的组织,算法通常会变得显而易见。编程的核心在于数据结构,而非算法。 所以,如果在合适的场景下,针对它的特点,使用 HeapMap 会取得事半功倍的效果。 HeapMap 的主要特点 堆的特点:HeapMap 内部通过堆来维护键的顺序,可以快速获取最小或最大键。堆提供了插入和删除堆顶元素的 O(log n) 时间复杂度。 哈希映射的特点:HeapMap 同时使用哈希映射以支持快速查找。哈希映射的查找、插入、删除等操作在理想情况下时间复杂度为 O(1)。 用途:HeapMap 适合需要频繁按键排序和快速查找的场景,比如带有优先级的缓存、调度系统、任务优先队列等。 HeapMap 的基本结构 堆(Heap):用来维持按键的顺序,堆可以是最小堆或最大堆,根据具体需求决定。 哈希映射(Map):用来存储每个键值对,并支持通过键快速查找元素。 你使用一个 container/heap + map 很容易实现一个 HeapMap, 其实我们没必要自己去写一个重复的轮子了,网上其他语言比如 Rust、Java 都有现成的实现,Go 语言中也有一个很好的实现:nemars/heapmap HeapMap 的实现 nemars/heapmap 这个库是去年增加到 github 中的,我是第一个 star 它的人。我们看看它是怎么实现的。 结构体定义 1 2 3 4 5 6 7 8 9 10 11 type Entry[K comparable, V, P any] struct { Key K Value V Priority P index int } type heapmap[K comparable, V, P any] struct { h pq[K, V, P] m map[K]*Entry[K, V, P] } Entry 代表这个数据结构中的一个节点 (元素、条目) , 它包含 key、value 值,还有优先级,index 记录它在堆的实现数组中的索引。 heapmap 代表 HeapMap 的实现,它包含两个字段,第一个字段其实就是 Heap 的实现,为了方便实现泛型,它就自己实现了一个堆。第二个字段就是一个 map 对象了。 典型的方法 数据结构定义清楚了,那就就可以实现它的方法了。它实现了一些便利的方法,我们值关注几个实现就好了。 Len 方法 1 2 3 func (hm *heapmap[K, V, P]) Len() int { return len(hm.m) } 读取h字段或者m字段的长度都可以。 Peek 方法 返回root元素。 最小堆就是返回最小的元素,最大堆就是返回最大的元素。 1 2 3 4 5 6 func (hm *heapmap[K, V, P]) Peek() (Entry[K, V, P], bool) { if hm.Empty() { return Entry[K, V, P]{}, false } return *hm.h.entries[0], true } Pop 方法 弹出root元素。 1 2 3 4 5 6 7 8 func (hm *heapmap[K, V, P]) Pop() (Entry[K, V, P], bool) { if hm.Empty() { return Entry[K, V, P]{}, false } e := *heap.Pop(&hm.h).(*Entry[K, V, P]) delete(hm.m, e.Key) return e, true } 注意涉及到元素的删除操作,要同时删除 map 中的元素。 Push 方法 (Set 方法) 其实作者没有实现 Push 方法,而是使用Set 方法来实现的。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func (hm *heapmap[K, V, P]) Set(key K, value V, priority P) { if e, ok := hm.m[key]; ok { e.Value = value e.Priority = priority heap.Fix(&hm.h, e.index) return } e := &Entry[K, V, P]{ Key: key, Value: value, Priority: priority, } heap.Push(&hm.h, e) hm.m[key] = e } Set方法有两个功能。如果元素的Key已经存在,那么就是更新元素,并且根据优先级进行调整。 如果元素的Key不存在,那么就是插入元素。 Get 方法 Get 方法就是获取任意的元素。 1 2 3 4 5 6 7 func (hm *heapmap[K, V, P]) Get(key K) (Entry[K, V, P], bool) { if e, ok := hm.m[key]; ok { return *e, true } return Entry[K, V, P]{}, false } 有一点你需要注意的是,这个数据结构不是线程安全的,如果你需要线程安全的话,你可以使用 sync.Mutex/sync.RWMutex 来保护它。

2024/11/17
articleCard.readMore

Go中秘而不宣的数据结构 CacheLinePad:精细化优化

在现代多核处理器中,高效的缓存机制极大地提升了程序性能,而“伪共享”问题却常常导致缓存机制的低效。 1. 背景 cacheline 本文中有时又叫做 缓存行 在现代多核处理器中,三级缓存通常分为三级:L1、L2 和 L3,每一级缓存的大小、速度和共享方式都不同: L1 缓存:这是速度最快的缓存,通常每个 CPU 核心都有独立的 L1 缓存。L1 缓存分为两个部分:一个用于存储指令(L1I),另一个用于存储数据(L1D)。L1 缓存的容量一般较小(通常 32KB - 64KB),但是读取速度极快,以极低的延迟为 CPU 核心提供服务。 L2 缓存:L2 缓存通常比 L1 缓存大一些,容量一般在 256KB - 1MB 左右,每个 CPU 核心通常也会有独立的 L2 缓存。虽然 L2 缓存的访问速度比 L1 缓存稍慢,但它仍然显著快于主存。 L3 缓存:这是三级缓存中容量最大的,通常在 8MB - 64MB 或更大。L3 缓存往往由所有 CPU 核心共享,并且主要用于减少核心之间的数据传输延迟。L3 缓存的读取速度比 L1、L2 缓存慢,但相对主存依然较快。对于多核处理器,L3 缓存是多核心之间协作的重要纽带。 CPU缓存将数据划分成若干个 cacheline,使得 CPU 访问特定数据时,能以 cacheline 为单位加载或存储数据。cacheline 的大小通常是固定的,x86 架构中常见的 cacheline 大小是 64 字节,而 Apple M 系列等一些 ARM 架构处理器上可能达到 128 字节。 在 CPU 执行程序时,若数据在某级缓存中命中,整个 cacheline 会从该缓存加载到寄存器中;若数据不在 L1 缓存中,则会依次查找 L2、L3 缓存,并最终在主存中查找并加载到缓存。由于 cacheline 是缓存操作的基本单位,每次数据传输都是以 cacheline 为最小粒度的。 比如在 mac mini m2 机器是,我们可以查看此 CPU 的缓存行大小为 128 字节: Linux 下可以查看另外一台机器的各级别缓存行大小为 64 字节: 1.1 伪共享 (False Sharing) 伪共享 是指多个线程访问同一个 cache line 中的不同变量时,导致频繁的缓存失效(cache invalidation),从而大大降低程序性能。伪共享通常在多线程编程中发生,因为在多个线程中,如果两个或多个线程操作的变量在同一个 cache line 中,但它们并没有真正的共享关系,每个线程对其变量的写操作会导致其他线程的缓存失效。这样,CPU 核心会不断地将数据写回并重新加载,产生了不必要的资源浪费。 设有两个线程,各自操作两个独立的变量 x 和 y: 1 2 3 4 type Data struct { x int64 // 线程A更新的变量 y int64 // 线程B更新的变量 } 如果变量 x 和 y 位于同一个 cache line 中,那么线程 A 更新 x 后,线程 B 也会因为缓存失效而重新加载 y,尽管 B 实际上并未使用 x 的值。这种情况下,虽然两个变量并没有直接共享,但每次写操作都会导致另一方的缓存失效,从而形成了伪共享。 1.2 如何避免伪共享? 伪共享会对性能产生严重影响,但可以通过以下几种方法来优化: 变量对齐(Padding):将每个变量扩展至一个完整的 cacheline,以防止多个线程访问同一个 cacheline。例如,可以在变量之间添加填充数据来分隔不同的 cacheline (假定 CPU 缓存行是 64 字节): 1 2 3 4 5 type Data struct { x int64 // 线程A更新的变量 _ [7]int64 // 填充7个int64以对齐至64字节的cache line大小 y int64 // 线程B更新的变量 } 将变量分散到不同的结构体中:对于经常被多个线程更新的变量,可以考虑将它们分散到不同的结构体,避免同一结构体被多个线程同时频繁更新。 使用原子变量:在某些情况下,可以使用原子变量进行更新。虽然这不会彻底消除伪共享,但可以减少缓存一致性带来的开销。 绑定 CPU 核心(CPU Affinity):可以将线程绑定到指定的 CPU 核心上,从而减少多个线程同时访问同一块缓存的数据的几率。 1.3 单线程的缓存行污染问题 虽然单线程不会出现伪共享的问题,但是单线程程序仍然有一些缓存优化的空间: 避免缓存行污染:在单线程程序中,如果频繁访问的变量分布在不同的 cache line 上,会导致缓存频繁更替,增加缓存开销。优化时可以将频繁使用的数据集中在同一个 cache line 内,减少 CPU 从内存加载数据的频率。 数据布局优化:对于单线程程序,也可以通过调整数据的内存布局,让程序更好地利用缓存。将经常一起访问的数据放在连续的内存中,以提高缓存命中率。 比如下面一个测试, 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 package main import ( "testing" ) // NonAlignedStruct 未对齐的结构体,补充后占24个字节 type NonAlignedStruct struct { a byte // 1字节,补齐7字节 b int64 // 8字节 c byte // 1字节,补齐7字节 } // AlignedStruct 已对齐的结构体,补充后占16个字节 type AlignedStruct struct { b int64 // 8字节 a byte // 1字节 c byte // 1字节 _ [6]byte // 填充6个字节,总共16个字节 } const arraySize = 1024 * 1024 var ( nonAlignedArray [arraySize]NonAlignedStruct alignedArray [arraySize]AlignedStruct result int64 ) // 初始化数组 func init() { for i := 0; i < arraySize; i++ { nonAlignedArray[i] = NonAlignedStruct{ a: byte(i), b: int64(i), c: byte(i), } alignedArray[i] = AlignedStruct{ a: byte(i), b: int64(i), c: byte(i), } } } // BenchmarkNonAligned 测试未对齐结构体的性能 func BenchmarkNonAligned(b *testing.B) { var sum int64 b.ResetTimer() for i := 0; i < b.N; i++ { for j := 0; j < arraySize; j++ { sum += nonAlignedArray[j].b // 读取未对齐结构体的字段 } } result = sum // 防止编译器优化 } // BenchmarkAligned 测试已对齐结构体的性能 func BenchmarkAligned(b *testing.B) { var sum int64 b.ResetTimer() for i := 0; i < b.N; i++ { for j := 0; j < arraySize; j++ { sum += alignedArray[j].b // 读取已对齐结构体的字段 } } result = sum // 防止编译器优化 } 可以看到读取对齐的结构体性能要远远好于未对齐的结构体。 很多高性能的库都会采用 CacheLine 优化的数据结构,比如 Java 生态圈知名的 LMAX Disruptor。 Go 标准库中也有类似的优化,让我们一起来看看它的实现和应用场景。 2. Go 运行时中的 CacheLine 2.1 运行时中的 CacheLinePad 我们支持,Go 语言支持不同的 CPU 架构,不同的 CPU 架构的缓存行的大小也可能不同,Go 语言是如何统一的呢? 方法很简单,就是针对不同的 CPU 架构,定义不同大小的缓存行。 首先定义统一的结构和变量: 1 2 3 4 5 6 // CacheLinePad 用来填充结构体,避免伪共享 type CacheLinePad struct{ _ [CacheLinePadSize]byte } // CacheLineSize 是 CPU 的缓存行大小,不同的 CPU 架构可能不同. // 目前 Go 运行时没有检测真实的缓存行大小,所以代码实现使用每个 GOARCH 的常量 CacheLinePadSize 作为近似值。 var CacheLineSize uintptr = CacheLinePadSize 然后针对不同的 CPU 架构定义不同的缓存行大小。 比如arm64的CPU, 文件go/src/internal/cpu/cpu_arm64.go中定义了缓存行大小为128字节: go/src/internal/cpu/cpu_arm64.go 1 2 3 4 // CacheLinePadSize is used to prevent false sharing of cache lines. // We choose 128 because Apple Silicon, a.k.a. M1, has 128-byte cache line size. // It doesn't cost much and is much more future-proof. const CacheLinePadSize = 128 比如64bit的龙芯, 缓存行大小是64字节,文件go/src/internal/cpu/cpu_loong64.go中定义了缓存行大小为64字节: go/src/internal/cpu/cpu_loong64.go 1 2 3 // CacheLinePadSize is used to prevent false sharing of cache lines. // We choose 64 because Loongson 3A5000 the L1 Dcache is 4-way 256-line 64-byte-per-line. const CacheLinePadSize = 64 又比如x86和amd64的CPU, 缓存行大小是64字节,文件go/src/internal/cpu/cpu_x86.go中定义了缓存行大小为64字节: go/src/internal/cpu/cpu_x86.go 1 2 3 4 5 //go:build 386 || amd64 package cpu const CacheLinePadSize = 64 所以Go运行时是根据它支持的不同的 CPU 架构,定义不同的缓存行大小,以此来避免伪共享问题。 但是这个数据结构是定义在Go运行时internal库中,不对外暴露,那么我们怎么用的? 2.2 golang.org/x/sys/cpu 没关系,Go的扩展库golang.org/x/sys/cpu中提供了CacheLinePad的定义,我们可以直接使用。 1 type CacheLinePad struct{ _ [cacheLineSize]byte } 它的实现和Go运行时中的一样,只是把CacheLinePad暴露出来了,所以我们可以在自己的项目中直接使用。 2.3 Go运行时中的应用场景 在这个系列的上一篇文章中,我们介绍了treap, treap使用在semTable中,semTable是Go运行时中的一个数据结构,用来管理semaphore的等待队列。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type semaRoot struct { lock mutex treap *sudog // root of balanced tree of unique waiters. nwait atomic.Uint32 // Number of waiters. Read w/o the lock. } var semtable semTable // Prime to not correlate with any user patterns. const semTabSize = 251 type semTable [semTabSize]struct { root semaRoot pad [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte } 等并发读取semTable时,由于semTable中的root是一个semaRoot结构体,semaRoot中有mutex,treap等字段,这些字段可能会被不同的CPU核心同时访问,导致伪共享问题。 为了解决伪共享问题,它增加了一个Pad字段,补齐字段的大小到CacheLineSize,这样就可以避免伪共享问题。当然这里可以确定semaRoot的大小不会超过一个CacheLineSize。 mheap 结构体中展示了另外一种场景,将部分字段使用CacheLinePad隔开, 避免arenas字段和上面的字段之间的伪共享问题。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 type mheap struct { _ sys.NotInHeap lock mutex pages pageAlloc // page allocation data structure sweepgen uint32 // sweep generation, see comment in mspan; written during STW allspans []*mspan // all spans out there pagesInUse atomic.Uintptr // pages of spans in stats mSpanInUse pagesSwept atomic.Uint64 // pages swept this cycle pagesSweptBasis atomic.Uint64 // pagesSwept to use as the origin of the sweep ratio sweepHeapLiveBasis uint64 // value of gcController.heapLive to use as the origin of sweep ratio; written with lock, read without sweepPagesPerByte float64 // proportional sweep ratio; written with lock, read without reclaimIndex atomic.Uint64 reclaimCredit atomic.Uintptr _ cpu.CacheLinePad // prevents false-sharing between arenas and preceding variables arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena ... } go/src/runtime/stack.go中stackpool结构体中也使用了CacheLinePad,展示了另外一种用法: 1 2 3 4 var stackpool [_NumStackOrders]struct { item stackpoolItem _ [(cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte } 因为item的大小不确定,可能小于一个CacheLineSize,也可能大于一个CacheLineSize,所以这里对CacheLinePad求余,只需补充一个小于CacheLineSize的字节即可。 一般软件开发中,我们不需要关心这些细节,但是当我们需要优化性能时,了解这些底层的实现,可以帮助我们更好的理解和优化程序。

2024/11/17
articleCard.readMore

Go中秘而不宣的数据结构 Treap:随机化的二叉搜索树

treap 是一棵二叉树,它同时维护二叉搜索树 (BST) 和堆的属性, 所以由此得名 (tree + heap   ⇒  treap)。 从形式上讲,treap (tree + heap) 是一棵二叉树,其节点包含两个值,一个 key 和一个 priority,这样 key 保持 BST 属性,priority 是一个保持 heap 属性的随机值(至于是最大堆还是最小堆并不重要)。相对于其他的平衡二叉搜索树,treap的特点是实现简单,且能基本实现随机平衡的结构。属于弱平衡树。 treap 由 Raimund Siedel 和 Cecilia Aragon 于 1989 年提出。 treap 通常也被称为“笛卡尔树”,因为它很容易嵌入到笛卡尔平面中: 具体来说,treap 是一种在二叉树中存储键值对 (X,Y) 的数据结构,其特点是:按 X 值满足二叉搜索树的性质,同时按 Y 值满足二叉堆的性质。如果树中某个节点包含值 (X₀,Y₀),那么: 左子树中所有节点的X值都满足 X ≤ X₀ (BST 属性) 右子树中所有节点的X值都满足 X₀ ≤ X (BST 属性) 左右子树中所有节点的Y值都满足 Y ≤ Y₀ (堆属性。这里以最大堆为例) 在这种实现中,  X是键(同时也是存储在 Treap 中的值),并且  Y称为优先级。如果没有优先级,则 treap 将是一个常规的二叉搜索树。 优先级(前提是每个节点的优先级都不相同)的特殊之处在于:它们可以确定性地决定树的最终结构(不会受到插入数据顺序的影响)。这一点是可以通过相关定理来证明的。 这里有个巧妙的设计:如果我们随机分配这些优先级值,就能在平均情况下得到一棵比较平衡的树(避免树退化成链表)。这样就能保证主要操作(如查找、插入、删除等)的时间复杂度保持在 O(log N) 水平。 正是因为这种随机分配优先级的特点,这种数据结构也被称为"随机二叉搜索树"。 Treap维护堆性质的方法用到了旋转,且只需要进行两种旋转操作,因此编程复杂度较红黑树、AVL树要小一些。 红黑树的操作: 插入 以最大堆为例 给节点随机分配一个优先级,先和二叉搜索树的插入一样,先把要插入的点插入到一个叶子上,然后跟维护堆一样进行以下操作: 如果当前节点的优先级比父节点大就进行2. 或3. 的操作 如果当前节点是父节点的左子叶就右旋 如果当前节点是父节点的右子叶就左旋。 删除 因为 treap满足堆性质,所以只需要把要删除的节点旋转到叶节点上,然后直接删除就可以了。具体的方法就是每次找到优先级最大的子叶,向与其相反的方向旋转,直到那个节点被旋转到了叶节点,然后直接删除。 查找 和一般的二叉搜索树一样,但是由于 treap的随机化结构,Treap中查找的期望复杂度是 O(logn) 以上是 treap 数据结构的背景知识,如果你想了解更多而关于 treap 的知识,你可以参考 https://en.wikipedia.org/wiki/Treap https://medium.com/carpanese/a-visual-introduction-to-treap-data-structure-part-1-6196d6cc12ee https://cp-algorithms.com/data_structures/treap.html Go 运行时的 treap 和用途 在 Go 运行时 sema.go#semaRoot 中,定义了一个数据结构 semaRoot: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 type semaRoot struct { lock mutex treap *sudog // 不重复的等待者(goroutine)的平衡树(treap)的根节点 nwait atomic.Uint32 // 等待者(goroutine)的数量 } type sudog struct { g *g next *sudog prev *sudog elem unsafe.Pointer // data element (may point to stack) acquiretime int64 releasetime int64 ticket uint32 isSelect bool success bool waiters uint16 parent *sudog // semaRoot binary tree waitlink *sudog // g.waiting list or semaRoot waittail *sudog // semaRoot c *hchan // channel } 这是Go语言互斥锁(Mutex)底层实现中的关键数据结构,用于管理等待获取互斥锁的goroutine队列。我们已经知道,在获取 sync.Mutex 时,如果锁已经被其它 goroutine 获取,那么当前请求锁的 goroutine 会被 block 住,就会被放入到这样一个数据结构中 (所以你也知道这个数据结构中的 goroutine 都是唯一的,不重复)。 semaRoot 保存了一个平衡树,树中的 sudog 节点都有不同的地址 (s.elem) ,每个 sudog 可能通过 s.waitlink 指向一个链表,该链表包含等待相同地址的其他 sudog。对具有相同地址的 sudog 内部链表的操作时间复杂度都是O(1).。扫描顶层semaRoot列表的时间复杂度是 O(log n),其中 n 是具有被阻塞goroutine的不同地址的数量(这些地址会散列到给定的semaRoot)。 semaRoot 的 treap *sudog 其实就是一个 treap, 我们来看看它的实现。 增加一个元素(入队) 增加一个等待的goroutine(sudog)到 semaRoot 的 treap 中,如果 lifo 为 true,则将 s 替换到 t 的位置,否则将 s 添加到 t 的等待列表的末尾。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool) { // 设置这个要加入的节点 s.g = getg() s.elem = unsafe.Pointer(addr) s.next = nil s.prev = nil s.waiters = 0 var last *sudog pt := &root.treap // 从根节点开始 for t := *pt; t != nil; t = *pt { // ① // 如果地址已经在列表中,则加入到这个地址的链表中 if t.elem == unsafe.Pointer(addr) { // 如果地址已经在列表中,并且指定了先入后出flag,这是一个替换操作 if lifo { // 替换操作 *pt = s s.ticket = t.ticket ... // 把t的各种信息复制给s } else { // 增加到到等待列表的末尾 if t.waittail == nil { t.waitlink = s } else { t.waittail.waitlink = s } t.waittail = s s.waitlink = nil if t.waiters+1 != 0 { t.waiters++ } } return } last = t // 二叉搜索树查找 if uintptr(unsafe.Pointer(addr)) < uintptr(t.elem) { // ② pt = &t.prev } else { pt = &t.next } } // 为新节点设置ticket.这个ticket是一个随机值,作为随机堆的优先级,用于保持treap的平衡。 s.ticket = cheaprand() | 1 // ③ s.parent = last *pt = s // 根据优先级(ticket)旋转以保持treap的平衡 for s.parent != nil && s.parent.ticket > s.ticket { // ④ if s.parent.prev == s { root.rotateRight(s.parent) // ⑤ } else { if s.parent.next != s { panic("semaRoot queue") } root.rotateLeft(s.parent) // ⑥ } } } ① 是遍历 treap 的过程,当然它是通过搜索二叉树的方式实现。 addr就是我们一开始讲的treap的key,也就是 s.elem。 首先检查 addr 已经在 treap 中,如果存在,那么就把 s 加入到 addr 对应的 sudog 链表中,或者替换掉 addr 对应的 sudog。 这个addr, 如果对于sync.Mutex来说,就是 Mutex.sema字段的地址。 1 2 3 4 type Mutex struct { state int32 sema uint32 } 所以对于阻塞在同一个sync.Mutex上的goroutine,他们的addr是相同的,所以他们会被加入到同一个sudog链表中。 如果是不同的sync.Mutex锁,他们的addr是不同的,那么他们会被加入到这个treap不同的节点。 进而,你可以知道,这个rootSema是维护多个sync.Mutex的等待队列的,可以快速找到不同的sync.Mutex的等待队列,也可以维护同一个sync.Mutex的等待队列。 这给了我们启发,如果你有类似的需求,可以参考这个实现。 ③就是设置这个节点的优先级,它是一个随机值,用于保持treap的平衡。这里有个技巧就是总是把优先级最低位设置为1,这样保证优先级不为0.因为优先级经常和0做比较,我们将最低位设置为1,就表明优先级已经设置。 ④ 就是将这个新加入的节点旋转到合适的位置,以保持treap的平衡。这里的旋转操作就是上面提到的左旋和右旋。稍后看。 移除一个元素(出队) 对应的,还有出对的操作。这个操作就是从treap中移除一个节点,这个节点就是一个等待的goroutine(sudog)。 dequeue 搜索并找到在semaRoot中第一个因addr而阻塞的goroutine。 比如需要唤醒一个goroutine, 让它继续执行(比如直接将锁交给它,或者唤醒它去争抢锁)。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 func (root *semaRoot) dequeue(addr *uint32) (found *sudog, now, tailtime int64) { ps := &root.treap s := *ps for ; s != nil; s = *ps { // ①, 二叉搜索树查找 if s.elem == unsafe.Pointer(addr) { // ② goto Found } if uintptr(unsafe.Pointer(addr)) < uintptr(s.elem) { ps = &s.prev } else { ps = &s.next } } return nil, 0, 0 Found: // ③ ... if t := s.waitlink; t != nil { // ④ *ps = t ... } else { // ⑤ // 旋转s到叶节点,以便删除 for s.next != nil || s.prev != nil { if s.next == nil || s.prev != nil && s.prev.ticket < s.next.ticket { root.rotateRight(s) } else { root.rotateLeft(s) } } // ⑤ 删除s if s.parent != nil { if s.parent.prev == s { s.parent.prev = nil } else { s.parent.next = nil } } else { root.treap = nil } tailtime = s.acquiretime } ... // 清理s的不需要的信息 return s, now, tailtime } ① 是遍历 treap 的过程,当然它是通过搜索二叉树的方式实现。 addr就是我们一开始讲的treap的key,也就是 s.elem。如果找到了,就跳到 Found 标签。如果没有找到,就返回 nil。 ④是检查这个地址上是不是有多个等待的goroutine,如果有,就把这个节点替换成链表中的下一个节点。把这个节点从treap中移除并返回。 如果就一个goroutine,那么把这个移除掉后,需要旋转treap,直到这个节点被旋转到叶节点,然后删除这个节点。 这里的旋转操作就是上面提到的左旋和右旋。 左旋 rotateLeft rotateLeft 函数将以 x 为根的子树左旋,使其变为 y 为根的子树。 左旋之前的结构为 (x a (y b c)),旋转后变为 (y (x a b) c)。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 func (root *semaRoot) rotateLeft(x *sudog) { // p -> (x a (y b c)) p := x.parent y := x.next b := y.prev y.prev = x // ① x.parent = y // ② x.next = b // ③ if b != nil { b.parent = x // ④ } y.parent = p // ⑤ if p == nil { root.treap = y // ⑥ } else if p.prev == x { // ⑦ p.prev = y } else { if p.next != x { throw("semaRoot rotateLeft") } p.next = y } } 具体步骤: 将 y 设为 x 的父节点(②),x 设为 y 的左子节点(①)。 将 b 设为 x 的右子节点(③),并更新其父节点为 x(④)。 更新 y 的父节点为 p(⑤),即 x 的原父节点。如果 p 为 nil,则 y 成为新的树根(⑥)。 根据 y 是 p 的左子节点还是右子节点,更新对应的指针(⑦)。 左旋为 右旋 rotateRight rotateRight 旋转以节点 y 为根的树。 将 (y (x a b) c) 变为 (x a (y b c))。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 func (root *semaRoot) rotateRight(y *sudog) { // p -> (y (x a b) c) p := y.parent x := y.prev b := x.next x.next = y // ① y.parent = x // ② y.prev = b // ③ if b != nil { b.parent = y // ④ } x.parent = p // ⑤ if p == nil { root.treap = x // ⑥ } else if p.prev == y { // ⑦ p.prev = x } else { if p.next != y { throw("semaRoot rotateRight") } p.next = x } } 具体步骤: 将 y 设为 x 的右子节点(①), x 设为 y 的父节点(②) 将 b 设为 y 的左子节点(③),并更新其父节点为 y(④) 更新 x 的父节点为 p(⑤),即 y 的原父节点。如果 p 为 nil,则 x 成为新的树根(⑥) 根据 x 是 p 的左子节点还是右子节点,更新对应的指针(⑦) 右旋为 理解了左旋和右旋,你就理解了出队代码中这一段为什么把当前节点旋转到叶结点中了: 1 2 3 4 5 6 7 8 // 旋转s到叶节点,以便删除 for s.next != nil || s.prev != nil { if s.next == nil || s.prev != nil && s.prev.ticket < s.next.ticket { root.rotateRight(s) } else { root.rotateLeft(s) } } 整体上看,treap这个数据结构确实简单可维护。左旋和右旋的代码量很少,结合图看起来也容易理解。 出入队的代码也很简单,只是简单的二叉搜索树的操作,加上旋转操作。 这是我介绍的Go秘而不宣的数据结构第三篇,希望你喜欢。你还希望看到Go运行时和标准库中的哪些数据结构呢,欢迎留言。 我会不定期的从关注者列表并点赞文章的同学中选出一位,送出版商和出版社的老师赠送的书,欢迎参与。

2024/11/17
articleCard.readMore

Go中秘而不宣的数据结构 BitVec, 资源优化方法之位向量

位图(bitmap)是一种优雅而高效的数据结构,它巧妙地利用了计算机最底层的位运算能力。你可以把它想象成一个巨大的开关阵列,每个开关只有打开和关闭两种状态 —— 这就是位图的本质。每一位都可以独立控制,却又可以通过位运算实现群体操作。 在实际应用中,位图的威力令人惊叹。设想你需要在海量数据中查找重复的数字,传统的哈希表或数组都会占用大量内存。而位图却能巧妙地用一个比特位标记一个数字的出现情况,极大地压缩了存储空间。在处理10亿个不重复的整数时,位图仅需要125MB内存,相比其他数据结构动辄需要几个GB,效率提升显著。 位图的运用也体现在我们日常使用的数据库系统中。数据库会用位图索引来加速查询,尤其是对于性别、状态这样的枚举字段,一个位图就能快速定位满足条件的记录。比如在电商系统中,快速筛选出"在售且有库存"的商品,位图索引可以通过简单的位与运算瞬间得出结果。 在大规模系统的权限控制中,位图也显示出其独特魅力。用户的各项权限可以编码到不同的位上,判断权限时只需一条位运算指令,既高效又直观。比如一个CMS系统,可以用一个32位的整数表示用户的全部权限状态,包括读、写、管理等多个维度。 布隆过滤器更是位图思想的精妙应用。它用多个哈希函数在位图上标记数据,能够以极小的内存代价判断一个元素是否可能存在。这在网页爬虫、垃圾邮件过滤等场景下广泛应用。虽然可能有小概率的误判,但在实际应用中往往是可以接受的权衡。 正是由于以上特点,位图在处理海量数据、状态标记、数据压缩、快速统计等场景中表现出色。它用最简单的方式解决了最复杂的问题,这正是计算机科学之美的体现。 BitVec 和 BitMap 类似,只是关注点有些不同。BitVec更像是位操作的抽象数据类型,它强调的是向量化的位运算操作。比如在Rust语言中, bitvec 提供了一系列方便的接口来进行位操作。而Bitmap则更强调其作为"图"的特性,通常用固定大小的位数组来表示集合中元素的存在性。 BitVec 具有以下的优势: 空间效率高 - 每个比特位只占用1位(bit)空间,可以表示0或1两种状态 快速的位运算 - 支持AND、OR、XOR等位运算操作,性能很高,甚至可以利用 SIMD 加速 随机访问快 - 可以O(1)时间定位到任意位置的比特位 紧凑存储 - 一个字节(byte)可以存储8个比特位的信息 内存占用小 - 对于数据量大但状态简单的场景很节省内存 Go 内部实现的 BitVec 在 Go 运行时的内部, cmd/compile/internal/bitvec 实现了一个位向量数据结构 BitVec,在 ssa 活跃性分析中使用(bvecSet 封装了 BitVec)。在 runtime/stack.go 中实现了 bitvector 并在内存管理中使用。 我们重点看 BitVec, 它的方法比较全。 BitVec 的结构体定义如下: 1 2 3 4 5 6 7 8 9 type BitVec struct { N int32 // 这个向量中包含的bit数 B []uint32 // 保存这些bit所需的数组 } func New(n int32) BitVec { nword := (n + wordBits - 1) / wordBits // 计算保存这些bit所需的最少的数组 return BitVec{n, make([]uint32, nword)} } 然后定义了一批位操作的方法: func (dst BitVec) And(src1, src2 BitVec) :对两个位向量进行与操作,结果放入到 dst 位向量中 func (dst BitVec) AndNot(src1, src2 BitVec) func (bv BitVec) Clear() func (dst BitVec) Copy(src BitVec) func (bv BitVec) Count() int func (bv1 BitVec) Eq(bv2 BitVec) bool func (bv BitVec) Get(i int32) bool func (bv BitVec) IsEmpty() bool func (bv BitVec) Next(i int32) int32 func (bv BitVec) Not() func (dst BitVec) Or(src1, src2 BitVec) func (bv BitVec) Set(i int32) func (bv BitVec) String() string func (bv BitVec) Unset(i int32) 这里可以看到 Go 内部实现也有一些"不规范"的方法,这些 Receiver 的名字不一致,叫做了 dst、bv、bv 1 三种名称,看起来是有深意的。dst 代表操作最后存储的位向量。不过 bv 1 就有点说不过去了,虽然也能理解,为了和参数中的 bv 2 保持一致。 我们可以挑几个方法看它的实现。 比如 And 方法: 1 2 3 4 5 6 7 8 9 10 func (dst BitVec) And(src1, src2 BitVec) { if len(src1.B) == 0 { return } _, _ = dst.B[len(src1.B)-1], src2.B[len(src1.B)-1] // hoist bounds checks out of the loop for i, x := range src1.B { dst.B[i] = x & src2.B[i] } } 就是求两个位向量的交集,这里用到了位运算 &。逐个元素进行位与操作,然后存储到 dst 中。 可以看到如果使用SIMD指令,这里的性能会有很大的提升。 再比如Not方法: 1 2 3 4 5 6 7 8 func (bv BitVec) Not() { for i, x := range bv.B { bv.B[i] = ^x } if bv.N%wordBits != 0 { bv.B[len(bv.B)-1] &= 1<<uint(bv.N%wordBits) - 1 // clear bits past N in the last word } } 这里是对位向量取反,用到了位运算 ^。然后对最后一个元素进行了特殊处理,清除了多余的位。 这里这一句bv.B[len(bv.B)-1] &= 1<<uint(bv.N%wordBits) - 1可能难以理解,其实是为了清除最后一个元素中多余的位,这里的 1<<uint(bv.N%wordBits) - 1 就是一个掩码,用来清除多余的位。 再比如Count方法: 1 2 3 4 5 6 7 func (bv BitVec) Count() int { n := 0 for _, x := range bv.B { n += bits.OnesCount32(x) } return n } 这里是统计位向量中 1 的个数,用到了 bits.OnesCount32 方法,这个方法是一个快速计算Uint32中bit为1的个数的方法。 这里的实现都是比较简单的,但是在实际应用中,位向量的操作是非常高效的,可以用来解决很多问题。 如果你的项目中有这种需求,比如你要实现一个布隆过滤器/布谷鸟过滤器,或者你要实现一个高效的权限控制系统,那么位向量是一个非常好的选择。

2024/11/17
articleCard.readMore

Go中秘而不宣的数据结构 runq, 难怪运行时调度那么好

首先,让我们先来回顾 Go 运行时的 GPM 模型。这方面的介绍网上的资料都非常非常多了,但是我们也不妨回顾一下: GPM模型中的G代表goroutine。每个goroutine只占用几KB的内存,可以轻松创建成千上万个。G包含了goroutine的栈、指令指针和其他信息,如阻塞channel的等待队列等。 P代表processor,可以理解为一个抽象的CPU核心。P的数量默认等于实际的CPU核心数,但可以通过环境变量进行调整。P维护了一个本地的goroutine队列,还负责执行goroutine并管理与之关联的上下文信息。 M代表machine,是操作系统线程。一个M必须绑定一个P才能执行goroutine。当一个M阻塞时,运行时会创建一个新的M或者复用一个空闲的M来保证P的数量总是等于GOMAXPROCS的值,从而充分利用CPU资源。 在这个模型中,P扮演了承上启下的角色。它连接了G和M,实现了用户层级的goroutine到操作系统线程的映射。这种设计允许Go在用户空间进行调度,避免了频繁的系统调用,大大提高了并发效率。 调度过程中,当一个goroutine被创建时,它会被放到P的本地队列或全局队列中。如果P的本地队列已满,一些goroutine会被放到全局队列。当P执行完当前的goroutine后,会优先从本地队列获取新的goroutine来执行。如果本地队列为空,P会尝试从全局队列或其他P的队列中偷取goroutine。 这种工作窃取(work-stealing)算法确保了负载的动态平衡。当某个P的本地队列为空时,它可以从其他P的队列中窃取一半的goroutine,这有效地平衡了各个P之间的工作负载。 Go 运行时这么做,主要还是减少 P 之间对获取 goroutine 之间的竞争。本地队列 runq 主要由持有它的 P 进行读写,只有在"被偷"的情况下,才可能有"数据竞争"的问题,而这种情况发生概率较少,所以它设计了一个高效的 runq 数据结构来应对这么场景。实际看起来和上面介绍的 PoolDequeue 有异曲同工之妙。 本文还会介绍 global queue 等数据结构,但不是本文的重点。 runq 在运行时中 P 是一个复杂的数据结构,下面列出了本文关注的它的几个字段: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 // 一个goroutine的指针 type guintptr uintptr //go:nosplit func (gp guintptr) ptr() *g { return (*g)(unsafe.Pointer(gp)) } //go:nosplit func (gp *guintptr) set(g *g) { *gp = guintptr(unsafe.Pointer(g)) } //go:nosplit func (gp *guintptr) cas(old, new guintptr) bool { return atomic.Casuintptr((*uintptr)(unsafe.Pointer(gp)), uintptr(old), uintptr(new)) } type p struct { id int32 status uint32 // one of pidle/prunning/... link puintptr schedtick uint32 // incremented on every scheduler call syscalltick uint32 // incremented on every system call sysmontick sysmontick // last tick observed by sysmon m muintptr // back-link to associated m (nil if idle) mcache *mcache pcache pageCache raceprocctx uintptr deferpool []*_defer // pool of available defer structs (see panic.go) deferpoolbuf [32]*_defer // Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen. goidcache uint64 goidcacheend uint64 // 本地运行的无锁循环队列 runqhead uint32 runqtail uint32 runq [256]guintptr // 如果非nil,是一个可优先运行的G runnext guintptr ... } runq 是一个无锁循环队列,由数组实现,它的长度是 256,这个长度是固定的,不会动态调整。runqhead 和 runqtail 分别是队列的头和尾,runqhead 指向队列的头部,runqtail 指向队列的尾部。 runq 数组的每个元素是一个 guintptr 类型,它是一个 uintptr 类型的别名,用来存储 g 的指针。 runq 的操作主要是 runqput、runqputslow、runqputbatch、runqget、runqdrain、runqgrab、runqsteal等方法。 接下来我们捡重点的方法看一下它是怎么实现高效额度并发读写的. runqput runqput 方法是向 runq 中添加一个 g 的方法,它是一个无锁的操作,不会阻塞。它的实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // runqput 尝试将 g 放到本地可运行队列上。 // 如果 next 为 false,runqput 将 g 添加到可运行队列的尾部。 // 如果 next 为 true,runqput 将 g 放在 pp.runnext 位置。 // 如果可运行队列已满,runnext 将 g 放到全局队列上。 // 只能由拥有 P 的所有者执行。 func runqput(pp *p, gp *g, next bool) { if !haveSysmon && next { // 如果没有 sysmon,我们必须完全避免 runnext,否则会导致饥饿。 next = false } if randomizeScheduler && next && randn(2) == 0 { // 如果随机调度器打开,我们有一半的机会避免运行 runnext next = false } // 如果 next 为 true,优先处理 runnext // 将当前的goroutine放到 runnext 中, 如果原来runnext中有goroutine, 则将其放到runq中 if next { retryNext: oldnext := pp.runnext if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) { goto retryNext } if oldnext == 0 { return } // Kick the old runnext out to the regular run queue. gp = oldnext.ptr() } // 重点来了,将goroutine放入runq中 retry: h := atomic.LoadAcq(&pp.runqhead) // ① t := pp.runqtail if t-h < uint32(len(pp.runq)) { // ② 如果队列未满 pp.runq[t%uint32(len(pp.runq))].set(gp) // ③ 将goroutine放入队列 atomic.StoreRel(&pp.runqtail, t+1) // ④ 更新队尾 return } if runqputslow(pp, gp, h, t) { // ⑤ 如果队列满了,调用runqputslow 尝试将goroutine放入全局队列 return } // 如果队列未满,上面的操作应该已经成功返回,否则重试 goto retry } runqput 方法的实现非常简单,它首先判断是否需要优先处理 runnext,如果需要,就将 g 放到 runnext 中,然后再将 g 放到 runq 中。 runq 的操作是无锁的,它通过 atomic 包提供的原子操作来实现。 这里使用的内部的更精细化的原子操作,这个也是我后面专门有一篇文章来讲解的。你现在大概把①、④ 理解为Load、Store操作即可。 ②、⑤ 分别处理本地队列未满和队列已满的情况,如果队列未满,就将 g 放到队列中,然后更新队尾;如果队列已满,就调用 runqputslow 方法,将 g 放到全局队列中。 ③ 处直接将 g 放到队列中,这是因为只有当前的 P 才能操作 runq,所以不会有并发问题。 同时我们也可以看到,我们总是往尾部插入, t总是一直增加的, 取余操作保证了循环队列的特性。 runqputslow 会把本地队列中的一半的 g 放到全局队列中,包括当前要放入的 g。一旦涉及到全局队列,就会有一定的竞争,Go运行时使用了一把锁来控制并发,所以 runqputslow 方法是一个慢路径,是性能的瓶颈点。 runqputbatch func runqputbatch(pp *p, q *gQueue, qsize int) 是批量往本地队列中放入 g 的方法,比如它从其它 P 那里偷来一批 g ,需要放到本地队列中,就会调用这个方法。它的实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // runqputbatch 尝试将 q 上的所有 G 放到本地可运行队列上。 // 如果队列已满,它们将被放到全局队列上;在这种情况下,这将暂时获取调度器锁。 // 只能由拥有 P 的所有者执行。 func runqputbatch(pp *p, q *gQueue, qsize int) { h := atomic.LoadAcq(&pp.runqhead) // ① t := pp.runqtail n := uint32(0) for !q.empty() && t-h < uint32(len(pp.runq)) { // ② 放入的批量goroutine非空, 并且本地队列还足以放入 gp := q.pop() pp.runq[t%uint32(len(pp.runq))].set(gp) t++ n++ } qsize -= int(n) if randomizeScheduler { // ③ 随机调度器, 随机打乱 off := func(o uint32) uint32 { return (pp.runqtail + o) % uint32(len(pp.runq)) } for i := uint32(1); i < n; i++ { j := cheaprandn(i + 1) pp.runq[off(i)], pp.runq[off(j)] = pp.runq[off(j)], pp.runq[off(i)] } } atomic.StoreRel(&pp.runqtail, t) // ④ 更新队尾 if !q.empty() { lock(&sched.lock) globrunqputbatch(q, int32(qsize)) unlock(&sched.lock) } } ①获取队列头,使用原子操作获取队头。 它下面一行是获取队尾的值,你可以思考下为什么不需要使用atomic.LoadAcq。 ② 逐个的将 g 放到队列中,直到放完或者放满。 如果是随机调度器,则使用混淆算法将队列中的 g 随机打乱。 最后如果队列还有剩余的 g,则调用 globrunqputbatch 方法,将剩余的 g 放到全局队列中。 runqget runqget 方法是从 runq 中获取一个 g 的方法,它是一个无锁的操作,不会阻塞。它的实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // runqget 从本地可运行队列中获取一个 G。 // 如果 inheritTime 为 true,gp 应该继承当前时间片的剩余时间。 // 否则,它应该开始一个新的时间片。 // 只能由拥有 P 的所有者执行。 func runqget(pp *p) (gp *g, inheritTime bool) { next := pp.runnext // 如果有 runnext,优先处理 runnext if next != 0 && pp.runnext.cas(next, 0) { // ① return next.ptr(), true } for { h := atomic.LoadAcq(&pp.runqhead) // ② 获取队头 t := pp.runqtail if t == h { // ③ 队列为空 return nil, false } gp := pp.runq[h%uint32(len(pp.runq))].ptr() // ④ 获取队头的goroutine if atomic.CasRel(&pp.runqhead, h, h+1) { // ⑤ 更新队头 return gp, false } } } ① 如果有 runnext,则优先处理 runnext,将 runnext 中的 g 取出来。 ② 获取队列头。 如果 ③ 队列为空,直接返回。 ④ 获取队头的 g,这就是要读取的 g。 ⑤ 更新队头,这里使用的是 atomic.CasRel 方法,它是一个原子的 Compare-And-Swap 操作,用来更新队头。 可以看到这里只使用到了队列头runqhead。 runqdrain runqdrain 方法是从 runq 中获取所有的 g 的方法,它是一个无锁的操作,不会阻塞。它的实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // runqdrain 从 pp 的本地可运行队列中获取所有的 G 并返回。 // 只能由拥有 P 的所有者执行。 func runqdrain(pp *p) (drainQ gQueue, n uint32) { oldNext := pp.runnext if oldNext != 0 && pp.runnext.cas(oldNext, 0) { drainQ.pushBack(oldNext.ptr()) // ① 将 runnext 中的goroutine放入队列 n++ } retry: h := atomic.LoadAcq(&pp.runqhead) // ② 获取队头 t := pp.runqtail qn := t - h if qn == 0 { return } if qn > uint32(len(pp.runq)) { // ③ 居然超出队列的长度了? goto retry } if !atomic.CasRel(&pp.runqhead, h, h+qn) { // ④ 更新队头 goto retry } // ⑤ 将队列中的goroutine放入队列drainQ中 for i := uint32(0); i < qn; i++ { gp := pp.runq[(h+i)%uint32(len(pp.runq))].ptr() drainQ.pushBack(gp) n++ } return } runqgrab runqgrab 方法是从 runq 中获取一半的 g 的方法,它是一个无锁的操作,不会阻塞。它的实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // runqgrab 从 pp 的本地可运行队列中获取一半的 G 并返回。 // Batch 是一个环形缓冲区,从 batchHead 开始。 // 返回获取的 goroutine 数量。 // 可以由任何 P 执行。 func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 { for { h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer n := t - h n = n - n/2 // ① 取一半的goroutine if n == 0 { if stealRunNextG { // ② 如果要偷取runnext中的goroutine if next := pp.runnext; next != 0 { if pp.status == _Prunning { // ② 如果要偷取runnext中的goroutine,这里会sleep一会 if !osHasLowResTimer { usleep(3) } else { osyield() } } if !pp.runnext.cas(next, 0) { continue } batch[batchHead%uint32(len(batch))] = next return 1 } } return 0 } if n > uint32(len(pp.runq)/2) { // ③ 如果要偷取的goroutine数量超过一半, 重试 continue } // ④ 将队列中至多一半的goroutine放入batch中 for i := uint32(0); i < n; i++ { g := pp.runq[(h+i)%uint32(len(pp.runq))] batch[(batchHead+i)%uint32(len(batch))] = g } if atomic.CasRel(&pp.runqhead, h, h+n) { // ⑤ 更新队头 return n } } } ① 取一半的 g,这里是一个简单的算法,取一半的 g。 ② 如果要偷取 runnext 中的 g,则会尝试偷取 runnext 中的 g。 ③ 如果要偷取的 g 数量超过一半,则重试。 ④ 将队列中至多一半的 g 放入 batch 中。 ⑤ 更新队头,这里使用的是 atomic.CasRel 方法,它是一个原子的 Compare-And-Swap 操作,用来更新队头。 runqsteal runqsteal 方法是从其它 P 的 runq 中偷取 g 的方法,它是一个无锁的操作,不会阻塞。它的实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // runqsteal 从 p2 的本地可运行队列中偷取一半的 G 并返回。 // 如果 stealRunNextG 为 true,它还会尝试偷取 runnext 中的 G。 func runqsteal(pp, p2 *p, stealRunNextG bool) *g { t := pp.runqtail n := runqgrab(p2, &pp.runq, t, stealRunNextG) // ① 从p2中偷取一半的goroutine if n == 0 { return nil } n-- gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr() // ② 获取偷取的一个goroutine if n == 0 { return gp } h := atomic.LoadAcq(&pp.runqhead) // ③ 获取队头 if t-h+n >= uint32(len(pp.runq)) { // ④ 如果队列满了,重置队列 throw("runqsteal: runq overflow") } atomic.StoreRel(&pp.runqtail, t+n) // ⑤ 更新队尾 return gp } 它实际使用了 runqgrab 方法来偷取 g,然后再从 runq 中取出一个 g。 以上就是runq的主要操作,它针对Go调度器的特点,设计了一套特定的队列操作的函数,这些函数都是无锁的,不会阻塞,保证了高效的并发读写。 gQueue 和 gList gQueue 和 gList 是 Go 运行时中的两个队列,它们都是用来存储 g 的,但是它们的实现方式不同。 gQueue是一个G的双端队列,可以从首尾增加gp, 通过g.schedlink链接。一个G只能在一个gQueue或gList上。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 type gQueue struct { head guintptr tail guintptr } func (q *gQueue) empty() bool { return q.head == 0 } // push 将gp添加到q的头部。 func (q *gQueue) push(gp *g) { gp.schedlink = q.head q.head.set(gp) if q.tail == 0 { q.tail.set(gp) } } // pushBack 增加gp到q的尾部。 func (q *gQueue) pushBack(gp *g) { gp.schedlink = 0 if q.tail != 0 { q.tail.ptr().schedlink.set(gp) } else { q.head.set(gp) } q.tail.set(gp) } // q2的所有G添加到q的尾部。之后不能再使用q2。 func (q *gQueue) pushBackAll(q2 gQueue) { if q2.tail == 0 { return } q2.tail.ptr().schedlink = 0 if q.tail != 0 { q.tail.ptr().schedlink = q2.head } else { q.head = q2.head } q.tail = q2.tail } // pop 移除并返回队列q的头部。如果q为空,则返回nil。 func (q *gQueue) pop() *g { gp := q.head.ptr() if gp != nil { q.head = gp.schedlink if q.head == 0 { q.tail = 0 } } return gp } // popList 将所有的元素从队列q中取出并返回一个gList。 func (q *gQueue) popList() gList { stack := gList{q.head} *q = gQueue{} return stack } 而gList是一个G的链表,通过g.schedlink链接。一个G只能在一个gQueue或gList上。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 type gList struct { head guintptr } func (l *gList) empty() bool { return l.head == 0 } // push 将gp添加到l的头部。 func (l *gList) push(gp *g) { gp.schedlink = l.head l.head.set(gp) } // pushAll 将q中的所有G添加到l的头部。 func (l *gList) pushAll(q gQueue) { if !q.empty() { q.tail.ptr().schedlink = l.head l.head = q.head } } // pop 移除并返回l的头部。如果l为空,则返回nil。 func (l *gList) pop() *g { gp := l.head.ptr() if gp != nil { l.head = gp.schedlink } return gp } 这是常规的数据结构中链表的实现,你可以和教科书中的介绍和实现做对比,看看书本中的内容如何应用到显示的工程中的。 global runq 一个全局的runq用来处理太多的goroutine, 在本地runq中的goroutine太少的情况下,从全局队列中偷取goroutine。 主要用来处理P中goroutine不均的情况。 因为它直接使用一把锁(sched.lock),而不是lock-free的数据结构,所以代码阅读和理解起来会相对简单一些。这里就不详细介绍了 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 var ( sched schedt ) type schedt struct { ... // Global runnable queue. runq gQueue runqsize int32 ... } func globrunqput(gp *g) { assertLockHeld(&sched.lock) // 保证锁被持有 sched.runq.pushBack(gp) sched.runqsize++ } func globrunqputhead(gp *g) { assertLockHeld(&sched.lock) // 保证锁被持有 sched.runq.push(gp) sched.runqsize++ } func globrunqputbatch(batch *gQueue, n int32) { assertLockHeld(&sched.lock) // 保证锁被持有 sched.runq.pushBackAll(*batch) sched.runqsize += n *batch = gQueue{} } func globrunqget(pp *p, max int32) *g { assertLockHeld(&sched.lock) // 保证锁被持有 if sched.runqsize == 0 { // 如果全局队列为空 return nil } n := sched.runqsize/gomaxprocs + 1 // 从全局队列中获取goroutine的数量 if n > sched.runqsize { n = sched.runqsize } if max > 0 && n > max { // 如果max大于0,取最小值 n = max } if n > int32(len(pp.runq))/2 { // 如果要获取的goroutine数量超过一半,只取一半,不贪婪 n = int32(len(pp.runq)) / 2 } sched.runqsize -= n gp := sched.runq.pop() // 从全局队列中获取一个goroutine n-- for ; n > 0; n-- { // 从全局队列中获取n-1个goroutine gp1 := sched.runq.pop() runqput(pp, gp1, false) // 将goroutine放入本地队列 } return gp // 返回获取的goroutine }

2024/10/20
articleCard.readMore

Go中秘而不宣的数据结构 spmc, 10倍性能于 channel

Go 标准库和运行中中,有一些专门针对特定场景优化的数据结构,这些数据结构并没有暴露出来,这个系列就是逐一介绍这些数据结构。 这一次给大家介绍的就是一个 lock-free、高性能的单生产者多消费者的队列:PoolDequeue 和 PoolChain。 到底是一个还是两个呢? 主要是 PoolDequeue, 它是一个固定尺寸,使用 ringbuffer (环形队列) 方式实现的队列。 PoolChain 是在它的基础上上,实现的一个动态尺寸的队列。 生产者消费者模式是常见的一种并发模式,根据生产者的数量和消费者的数量,可以分为四种情况: 单生产者-单消费者模式: spsc 单生产者-多消费者模式: spmc 多生产者-单消费者模式: mpsc 多生产者-多消费者模式: mpmc Channel 基本上可以看做是一种多生产者多消费者模式的队列。可以同时允许多个生产者发送数据,有可以允许多个消费者消费数据,它也可以应用在其他模式的场景,比如 rpc 包中的 oneshot 模式、通知情况下的的单生产者多消费者模式、rpc 和服务端单连接通讯时的消息处理,就是多生产者单消费者模式。 但是 Go 标准库的 sync 包下,有一个针对单生产者多消费者的数据结构,它是一个 lock-free 的数据结构,针对这个场景做了优化,被使用在 sync.Pool 中。 sync.Pool 采用了一种类似 Go 运行时调度的机制,针对每个 p 有一个 private 的数据,同时还有一个 shared 的数据,如果在本地 private、shared 中没有数据,就去其他 P 对应的 shared 去偷取。难么同时可能有多个 P 偷取同一个 shared, 这是多消费者。 同时对 shared 的写只有它隶属的 p 执行 Put 的时候才会发生: 1 2 3 4 5 6 7 l, _ := p.pin() if l.private == nil { l.private = x } else { l.shared.pushHead(x) } runtime_procUnpin() 这有属于单生产者模式。sync.Pool 使用了 PoolDequeue 和 PoolChain 来做优化。 首先我们先来了解 poolDequeue。 poolDequeue poolDequeue 是一个 lock-free 的数据结构,必然会使用 atomic, 同时它要求必须使用单生产者,否则会有并发问题。消费者可以是并发多个,当然你用一个也没问题。 其中,生产者可以使用下面的方法: pushHead: 在队列头部新增加一个数据。如果队列满了,增加失败 popHead: 在队列头部弹出一个数据。生产者总是弹出新增加的数据,除非队列为空 消费者可以使用下面的一个方法: popTail: 从队尾处弹出一个数据,除非队列为空。所以消费者总是消费最老的数据,这也正好符合大部分的场景 接下来就是分析代码了,有点枯燥,你可以跳过。 代码分析 首先我们看这个struct的定义: 1 2 3 4 type poolDequeue struct { headTail atomic.Uint64 vals []eface } 这里有两个重要的字段: headTail: 一个 atomic.Uint64 类型的字段,它的高 32 位是 head,低 32 位是 tail。head 是下一个要填充的位置,tail 是最老的数据的位置。 vals: 一个 eface 类型的切片,它是一个环形队列,大小必须是 2 的幂次方。 生产者增加数据的逻辑如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func (d *poolDequeue) pushHead(val any) bool { ptrs := d.headTail.Load() head, tail := d.unpack(ptrs) if (tail+uint32(len(d.vals)))&(1<<dequeueBits-1) == head { // 队列满 return false } slot := &d.vals[head&uint32(len(d.vals)-1)] // 检查 head slot 是否被 popTail 释放 typ := atomic.LoadPointer(&slot.typ) if typ != nil { // 另一个 goroutine 正在清理 tail,所以队列还是满的 return false } // 如果值为空,那么设置一个特殊值 if val == nil { val = dequeueNil(nil) } // 队列头是空的,将数据写入 slot *(*any)(unsafe.Pointer(slot)) = val // ① // 增加 head,这样 popTail 就可以消费这个 slot 了 // 同时也是一个 store barrier,保证了 slot 的写入 d.headTail.Add(1 << dequeueBits) return true } ① 处会有并发问题吗?万一有两个 goroutine 同时执行到这里,会不会有问题?这里没有问题,因为要求只有一个生产者,不会有另外一个goroutine同时写这个槽位。 注意它还实现了pack和unpack方法,用于将 head 和 tail 打包到一个 uint64 中,或者从 uint64 中解包出 head 和 tail。 消费者消费数据的逻辑如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 func (d *poolDequeue) popTail() (any, bool) { var slot *eface for { // ② ptrs := d.headTail.Load() head, tail := d.unpack(ptrs) if tail == head { // 队列为空 return nil, false } // 确认头部和尾部(用于我们之前的推测性检查),并递增尾部。如果成功,那么我们就拥有了尾部的插槽。 ptrs2 := d.pack(head, tail+1) if d.headTail.CompareAndSwap(ptrs, ptrs2) { // 成功读取了一个 slot slot = &d.vals[tail&uint32(len(d.vals)-1)] break } } // 剩下来就是读取槽位的值 val := *(*any)(unsafe.Pointer(slot)) if val == dequeueNil(nil) { // 如果本身就存储的nil val = nil } // 释放 slot,这样 pushHead 就可以继续写入这个 slot 了 slot.val = nil // ③ atomic.StorePointer(&slot.typ, nil) // ④ return val, true } ② 处是一个 for 循环,这是一个自旋的过程,直到成功读取到一个 slot 为止。在有大量的goroutine的时候,这里可能会是一个瓶颈点,但是少量的消费者应该还不算大问题。 ③ 和 ④ 处是释放 slot 的过程,这样生产者就可以继续写入这个 slot 了。 生产者还可以调用popHead方法,用来弹出刚刚压入还没有消费的数据: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func (d *poolDequeue) popHead() (any, bool) { var slot *eface for { ptrs := d.headTail.Load() head, tail := d.unpack(ptrs) if tail == head { // 队列为空 return nil, false } // 确认头部和尾部(用于我们之前的推测性检查),并递减头部。如果成功,那么我们就拥有了头部的插槽。 head-- ptrs2 := d.pack(head, tail) if d.headTail.CompareAndSwap(ptrs, ptrs2) { // 成功取回了一个 slot slot = &d.vals[head&uint32(len(d.vals)-1)] break } } val := *(*any)(unsafe.Pointer(slot)) if val == dequeueNil(nil) { val = nil } // 释放 slot,这样 pushHead 就可以继续写入这个 slot 了 *slot = eface{} return val, true } 这是一个固定大小的队列,如果队列满了,生产者就会失败。这个队列的大小是 2 的幂次方,这样可以用 & 来取模,而不用 %,这样可以提高性能。 PoolChain PoolChain 是在 PoolDequeue 的基础上实现的一个动态尺寸的队列,它的实现和 PoolDequeue 类似,只是增加了一个 headTail 的链表,用于存储多个 PoolDequeue。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type poolChain struct { // head 是生产者用来push的 poolDequeue。只有生产者访问,所以不需要同步 head *poolChainElt // tail 是消费者用来pop的 poolDequeue。消费者访问,所以需要原子操作 tail atomic.Pointer[poolChainElt] } type poolChainElt struct { poolDequeue // next由生产者原子写入,消费者原子读取。它只能从nil转换为非nil。 // prev由消费者原子写入,生产者原子读取。它只能从非nil转换为nil。 next, prev atomic.Pointer[poolChainElt] } 考虑到文章中代码过多,大家就会感觉很枯燥了,我就不具体展示代码了,你可以在 https://github.com/golang/go/blob/master/src/sync/poolqueue.go#L220-L302 查看具体的实现。 整体的思想就是将多个poolDequeue串联起来,生产者在head处增加数据,消费者在tail处消费数据,当tail的poolDequeue为空时,就从head处获取一个poolDequeue。 当head满了的时候,就增加一个新的poolDequeue。 这样就实现了动态尺寸的队列。 sync.Pool中就是使用的PoolChain来实现的,它是一个单生产者多消费者的队列,可以同时有多个消费者消费数据,但是只有一个生产者生产数据。 为了能将这个数据结构暴露出来使用,我把相关的代码复制到 https://github.com/smallnest/exp/blob/master/gods/poolqueue.go , 增加了单元测试和性能测试的代码。 你可以学到这个方法,使用类似的技术,创建一个 look-free 无线长度的 byte buffer。在一些 Go 的网络优化库中就使用这种方法,避免频繁的 grow 和 copy 既有数据。 与channel的性能比较 我们来看一下poolDequeue、PoolChain和channel的性能对比。 我们使用一个goroutine进行写入,10个goroutine进行读取: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 package gods import ( "sync" "testing" ) func BenchmarkPoolDequeue(b *testing.B) { const size = 1024 pd := NewPoolDequeue(size) var wg sync.WaitGroup // Producer go func() { for i := 0; i < b.N; i++ { pd.PushHead(i) } wg.Done() }() // Consumers numConsumers := 10 wg.Add(numConsumers + 1) for i := 0; i < numConsumers; i++ { go func() { for { if _, ok := pd.PopTail(); !ok { break } } wg.Done() }() } wg.Wait() } func BenchmarkPoolChain(b *testing.B) { pc := NewPoolChain() var wg sync.WaitGroup // Producer go func() { for i := 0; i < b.N; i++ { pc.PushHead(i) } wg.Done() }() // Consumers numConsumers := 10 wg.Add(numConsumers + 1) for i := 0; i < numConsumers; i++ { go func() { for { if _, ok := pc.PopTail(); !ok { break } } wg.Done() }() } wg.Wait() } func BenchmarkChannel(b *testing.B) { ch := make(chan interface{}, 1024) var wg sync.WaitGroup // Producer go func() { for i := 0; i < b.N; i++ { ch <- i } close(ch) wg.Done() }() // Consumers numConsumers := 10 wg.Add(numConsumers + 1) for i := 0; i < numConsumers; i++ { go func() { for range ch { } wg.Done() }() } wg.Wait() } 运行这个benchmark,我们可以看到poolDequeue和PoolChain的性能要比channel高很多,大约是channel的10倍。 poolDequeue 比 PoolChain 要好一些,性能是后者的两倍。

2024/10/20
articleCard.readMore

在 Rust 中同时支持异步和同步代码

来,过路人,请坐到我身边来,听老衲讲一讲我对 Rust 过分要求的故事。 介绍 想象一下,你打算用Rust创建一个新库。这个库的唯一功能就是封装一个你需要的公共API, 比如 Spotify API或者 ArangoDB 之类的数据库。这并不是造火箭,你也不是在发明什么新东西或者处理复杂的算法,所以你认为这应该相对简单直接。 你决定用异步方式实现这个库。你的库中大部分工作都涉及执行HTTP请求,主要是I/O操作,所以使用异步是有道理的(而且,这也是Rust圈里现在的潮流)。你开始编码,几天后就准备好了v0.1.0版本。当 cargo publish 成功完成并将你的作品上传到 crates.io 时,你暗自得意地想: "不错嘛"。 几天过去了,你在GitHub上收到了一个新通知。有人提了一个问题: 我如何同步使用这个库? 我的项目不使用异步,因为对我的需求来说太复杂了。我想尝试你的新库,但不确定怎么轻松地使用它。我不想在代码中到处使用 block_on(endpoint())。。我见过像 reqwest 这样的 crate导出一个 blocking模块,提供完全相同的功能,你能不能也这么做? 从底层来看,这听起来是个很复杂的任务。为异步代码(需要像 tokio 这样的运行时、awaiting future、pinning等)和普通的同步代码提供一个通用接口?好吧,既然他们提出请求的态度很好,也许我们可以试试。毕竟,代码中唯一的区别就是 async 和 await 关键字的出现,因为你没有做什么花哨的事情。 好吧,这或多或少就是crate 发生的事情 rspotify ,我曾经和它的创建者 Ramsay 一起维护它。对于那些不知道的人来说,它是 Spotify Web API 的一个包装器。对不了解的人来说,这是一个Spotify Web API的封装。说明一下,我最终确实实现了这个功能,尽管不如我希望的那么干净利落;我会在Rspotify系列的这篇新文章中试图解释这个情况。 第一种方法 为了提供更多背景信息,Rspotify 的客户端大致如下: 1 2 3 4 5 6 7 8 9 10 struct Spotify { /* ... */ } impl Spotify { async fn some_endpoint(&self, param: String) -> SpotifyResult<String> { let mut params = HashMap::new(); params.insert("param", param); self.http.get("/some-endpoint", params).await } } 本质上,我们需要让 some_endpoint 同时支持异步和阻塞两种使用方式。这里的关键问题是,当你有几十个端点时,你该如何实现这一点?而且,你怎样才能让用户在异步和同步之间轻松切换呢? 老掉牙的复制粘贴大法 这是最初实现的方法。它相当简单,而且确实能用。你只需要把常规的客户端代码复制到 Rspotify 的一个新的 blocking模块里。reqwest(我们用的 HTTP 客户端)和 reqwest::blocking 共用一个接口,所以我们可以在新模块里手动删掉 async 或 .await 这样的关键字,然后把 reqwest 的导入改成 reqwest::blocking。 这样一来,Rspotify 的用户只需要用 rspotify::blocking::Client 替代 rspotify::Client,瞧!他们的代码就变成阻塞式的了。这会让只用异步的用户的二进制文件变大,所以我们可以把它放在一个叫 blocking 的特性开关后面,大功告成。 不过,问题后来就变得明显了。整个 crate 的一半代码都被复制了一遍。添加或修改一个端点就意味着要写两遍或删两遍所有东西。 除非你把所有东西都测试一遍,否则没法确保两种实现是等效的。这主意倒也不坏,但说不定你连测试都复制粘贴错了呢!那可怎么办?可怜的代码审查员得把同样的代码读两遍,确保两边都没问题 —— 这听起来简直就是人为错误的温床。 根据我们的经验,这确实大大拖慢了 Rspotify 的开发进度,尤其是对于不习惯这种折腾的新贡献者来说。作为 Rspotify 的一个新晋且热情的维护者,我开始研究其他可能的解决方案。 召唤 block_on 第二种方法是把所有东西都在异步那边实现。然后,你只需为阻塞接口做个包装,在内部调用 block_on。block_on 会运行 future 直到完成,本质上就是把它变成同步的。你仍然需要复制方法的定义,但实现只需写一次: 1 2 3 4 5 6 7 8 9 10 11 mod blocking { struct Spotify(super::Spotify); impl Spotify { fn endpoint(&self, param: String) -> SpotifyResult<String> { runtime.block_on(async move { self.0.endpoint(param).await }) } } } 请注意,为了调用block_on,您首先必须在端点方法中创建某种运行时。例如,使用tokio : 1 2 3 4 5 let mut runtime = tokio::runtime::Builder::new() .basic_scheduler() .enable_all() .build() .unwrap(); 这就引出了一个问题:我们是应该在每次调用端点时都初始化运行时,还是有办法共享它呢?我们可以把它保存为一个全局变量(呃,真恶心),或者更好的方法是,我们可以把运行时保存在 Spotify 结构体中。但是由于它需要对运行时的可变引用,你就得用 Arc<Mutex<T>> 把它包起来,这样一来就完全扼杀了客户端的并发性。正确的做法是使用 Tokio 的 Handle,大概是这样的: 1 2 3 4 5 6 7 8 9 10 11 use tokio::runtime::Runtime; lazy_static! { // You can also use `once_cell` static ref RT: Runtime = Runtime::new().unwrap(); } fn endpoint(&self, param: String) -> SpotifyResult<String> { RT.handle().block_on(async move { self.0.endpoint(param).await }) } 虽然使用 handle 确实让我们的阻塞客户端更快了^1,但还有一种性能更高的方法。如果你感兴趣的话,这正是 reqwest 自己采用的方法。简单来说,它会生成一个线程,这个线程调用 block_on 来等待一个装有任务的通道 [^2] (https://nullderef.com/blog/rust-async-sync/#block-on-channels) [^3] (https://nullderef.com/blog/rust-async-sync/#block-on-reqwest)。 不幸的是,这个解决方案仍然有相当大的开销。你需要引入像 futures 或 tokio 这样的大型依赖,并将它们包含在你的二进制文件中。所有这些,就是为了...最后还是写出阻塞代码。所以这不仅在运行时有成本,在编译时也是如此。这在我看来就是不对劲。 而且你仍然有不少重复代码,即使只是定义,积少成多也是个问题。reqwest 是一个巨大的项目,可能负担得起他们的 blocking 模块的开销。但对于像 rspotify 这样不那么流行的 crate 来说,这就难以实现了。 复制 crate 另一种可能的解决方法是,正如 features 文档所建议的那样,创建独立的 crate。我们可以有 rspotify-sync 和 rspotify-async,用户可以根据需要选择其中一个作为依赖,甚至如果需要的话可以两个都用。问题是 —— 又来了 —— 我们究竟该如何生成这两个版本的 crate 呢?即使使用 Cargo 的一些技巧,比如为每个 crate 准备一个 Cargo.toml 文件(这种方法本身就很不方便),我也无法在不复制粘贴整个 crate 的情况下做到这一点。 采用这种方法,我们甚至无法使用过程宏,因为你不能在宏中凭空创建一个新的 crate。我们可以定义一种文件格式来编写 Rust 代码的模板,以便替换代码中的某些部分,比如 async/.await。但这听起来完全超出了我们的范畴。 最终版是:maybe_async crate 第三次尝试基于一个名为 maybe_async 的 crate。我记得当初发现它时,天真地以为这就是完美的解决方案。 总之,这个 crate 的思路是,你可以用一个过程宏自动移除代码中的 async 和 .await,本质上就是把复制粘贴的方法自动化了。举个例子: 1 2 #[maybe_async::maybe_async] async fn endpoint() { /* stuff */ } 生成以下代码: 1 2 3 4 5 #[cfg(not(feature = "is_sync"))] async fn endpoint() { /* stuff */ } #[cfg(feature = "is_sync")] fn endpoint() { /* stuff with `.await` removed */ } 你可以通过在编译 crate 时切换 maybe_async/is_sync 特性来配置是要异步还是阻塞代码。这个宏适用于函数、trait 和 impl 块。如果某个转换不像简单地移除 async 和 .await 那么容易,你可以用 async_impl 和 sync_impl 过程宏来指定自定义实现。它处理得非常好,我们在 Rspotify 中已经使用它一段时间了。 事实上,它效果如此之好,以至于我让 Rspotify 变成了HTTP 客户端无关的,这比异步/同步无关更加灵活。这使我们能够支持多种 HTTP 客户端,比如 reqwest 和 ureq ,而不用管客户端是异步的还是同步的。 如果你有 maybe_async,实现HTTP 客户端无关并不是很难。你只需要为 HTTP 客户端定义一个 trait,然后为你想支持的每个客户端实现它: 一段代码胜过千言万语。(你可以在这里找到 Rspotify 的 reqwest客户端的完整源代码, ureq 也可以在这里找到 ) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #[maybe_async] trait HttpClient { async fn get(&self) -> String; } #[sync_impl] impl HttpClient for UreqClient { fn get(&self) -> String { ureq::get(/* ... */) } } #[async_impl] impl HttpClient for ReqwestClient { async fn get(&self) -> String { reqwest::get(/* ... */).await } } struct SpotifyClient<Http: HttpClient> { http: Http } #[maybe_async] impl<Http: HttpClient> SpotifyClient<Http> { async fn endpoint(&self) { self.http.get(/* ... */) } } 然后,我们可以进一步扩展,让用户通过在他们的 Cargo.toml 中设置特性标志来选择他们想要使用的客户端。比如,如果启用了 client-ureq,由于 ureq 是同步的,它就会启用 maybe_async/is_sync。这样一来,就会移除 async/.await 和 #[async_impl] 块,Rspotify 客户端内部就会使用 ureq 的实现。 这个解决方案避免了我之前提到的所有缺点: 完全没有代码重复 无论是在运行时还是编译时都没有额外开销。如果用户想要一个阻塞客户端,他们可以使用 ureq,这样就不会引入 tokio 及其相关依赖 对用户来说很容易理解;只需在 Cargo.toml 中配置一个标志 不过,先停下来想几分钟,试试看你能不能找出为什么不应该这么做。实际上,我给你9个月时间,这就是我花了多长时间才意识到问题所在... 问题 嗯,问题在于 Rust 中的特性必须是叠加的:"启用一个特性不应该禁用功能,而且通常应该可以安全地启用任意组合的特性"。当依赖树中出现重复的 crate 时,Cargo 可能会合并该 crate 的特性,以避免多次编译同一个 crate。如果您想了解更多详细信息,参考资料对此进行了很好的解释。 这种优化意味着互斥的特性可能会破坏依赖树。在我们的情况下,maybe_async/is_sync 是一个由 client-ureq 启用的 切换特性。所以如果你试图同时启用 client-reqwest 来编译,它就会失败,因为 maybe_async 将被配置为生成同步函数签名。不可能有一个 crate 直接或间接地同时依赖于同步和异步的 Rspotify,而且根据 Cargo 参考文档,maybe_async 的整个概念目前是错误的。 新特性解析器 v2 一个常见的误解是,这个问题可以通过"特性解析器v2"来修复,参考文档也对此进行了很好的解释。从2021版本开始,这个新版本已经默认启用了,但你也可以在之前的版本的 Cargo.toml 中指定使用它。这个新版本除了其他改进,还在一些特殊情况下避免了特性的统一,但不包括我们的情况: 对于当前未在构建的目标,启用在平台特定依赖项上的特性会被忽略。 构建依赖和过程宏不会与普通依赖共享特性。 除非构建需要它们的目标(如测试或示例),否则开发依赖不会激活特性。 为了以防万一,我自己尝试复现了这个问题,结果确实如我所料。这个代码库是一个特性冲突的例子,在任何特性解析器下都会出错。 其他失败 有一些 crate也存在这个问题: arangors 和 aragog :ArangoDB 的包装器。两者都用于 maybe_async 在异步和同步之间切换(arangors 事实上,的作者是同一个人)^5 [^6] (https://nullderef.com/blog/rust-async-sync/#aragog-error)。 inkwell :LLVM 的包装器。它支持多个版本的 LLVM,但彼此之间不兼容[7]。 k8s-openapi :Kubernetes 的包装器,与 inkwell ^8存在同样的问题。 修复 maybe_async 随着这个 crate 开始变得流行起来,有人在 maybe_async 中提出了这个问题,解释了情况并展示了一个修复方案: async 和 sync 在同一程序中 fMeow/maybe-async-rs #6 maybe_async 现在会有两个特性标志:is_sync 和 is_async。这个 crate 会以同样的方式生成函数,但会在标识符后面添加 _sync 或 _async 后缀,这样就不会冲突了。例如: 1 2 #[maybe_async::maybe_async] async fn endpoint() { /* stuff */ } 现在将生成以下代码: 1 2 3 4 5 #[cfg(feature = "is_async")] async fn endpoint_async() { /* stuff */ } #[cfg(feature = "is_sync")] fn endpoint_sync() { /* stuff with `.await` removed */ } 然而,这些后缀会引入噪音,所以我在想是否有可能以更符合人体工程学的方式来实现。我fork了maybe_async并尝试了一下,你可以在这一系列评论中读到更多相关内容。总的来说,这太复杂了,我最终放弃了。 修复这个边缘情况的唯一方法就是让Rspotify对所有人的可用性变差。但我认为,同时依赖异步和同步版本的人可能很少;实际上我们还没有收到任何人的抱怨。与reqwest不同,rspotify是一个"高级"库,所以很难想象它会在一个依赖树中出现多次。 也许我们可以向Cargo的开发者寻求帮助? 官方支持 虽然不是官方的,但 Rust 中可以进一步探索的另一种有趣方法是“Sans I/O”。这是一个 Python 协议,它抽象了网络协议(如 HTTP)的使用,从而最大限度地提高了可重用性。Rust 中现有的一个示例是 tame-oidc。 Rspotify 远不是第一个遇到这个问题的项目,所以阅读之前的相关讨论可能会很有趣: 这个现已关闭的 Rust 编译器 RFC 添加 oneof 配置谓词(类似 #[cfg(any(…))])来支持互斥特性。这只是让在别无选择的情况下拥有冲突特性变得更容易,但特性仍应该是严格叠加的。 前一个 RFC 在 Cargo 本身允许互斥特性的背景下引发了一些讨论,尽管有一些有趣的信息,但并没有取得太大进展。 Cargo 中的这个问题 解释了 Windows API 的类似情况。讨论包括更多示例和解决方案想法,但还没有被 Cargo 采纳。 Cargo 中的另一个问题 要求提供一种方法来轻松测试和构建不同标志组合。如果特性是严格叠加的,那么 cargo test --all-features 将涵盖所有情况。但如果不是,用户就必须用多个特性标志组合运行命令,这相当麻烦。非官方的 cargo-hack 已经可以实现这一点。 一种完全不同的方法 基于关键字泛型倡议。这似乎是解决这个问题的最新尝试,但仍处于"探索"阶段, 截至目前还没有可用的 RFC。 根据这条旧评论,这不是 Rust 团队已经否决的东西;它仍在讨论中。 虽然是非官方的,但另一个可以在 Rust 中进一步探索的另一种有趣方法是 “Sans I/O”。这是一种 Python 协议,它在我们的案例中抽象了 HTTP 等网络协议的使用,从而最大化了可重用性。Rust 中现有的一个例子是 tame-oidc。 结论 我们目前面临以下选择: 忽视 Cargo 参考。我们可以假设没有人会同时使用 Rspotify 的同步和异步版本。 修复 maybe_async 并为我们库中的每个端点添加 _async 和 _sync 后缀。 放弃支持异步和同步代码。这已经变成了一团糟,我们没有足够的人力来处理,而且它影响了 Rspotify 的其他部分。问题是一些依赖 rspotify 的 crate,如 ncspot 或 spotifyd 是阻塞的,而其他如 spotify-tui 使用异步,所以我不确定他们会怎么想。 我知道这是我给自己强加的问题。我们可以直接说"不。我们只支持异步"或"不。我们只支持同步"。虽然有用户对能够使用两者感兴趣,但有时你就是得说不。如果这样一个特性变得如此复杂,以至于你的整个代码库变成一团糟,而你没有足够的工程能力来维护它,那这就是你唯一的选择。如果有人真的很在意,他们可以直接 fork 这个 crate 并将其转换为同步版本供自己使用。 毕竟,大多数 API 封装库等只支持异步或阻塞代码中的一种。例如,serenity (Discord API)、sqlx (SQL 工具包)和 teloxide (Telegram API)是仅异步的,而且它们非常流行。。 尽管有时候很沮丧,但我并不后悔花了这么多时间兜圈子试图让异步和同步都能工作。我最初为 Rspotify 做贡献就是为了_学习。我没有截止日期,也没有压力,我只是想在空闲时间尝试改进 Rust 中的一个库。而且我确实学到了_很多;希望在读完这篇文章后,你也是如此。 也许今天的教训是,我们应该记住 Rust 毕竟是一种低级语言,有些事情如果不引入大量复杂性是不可能实现的。无论如何,我期待 Rust 团队将来如何解决这个问题。 那么你怎么看?如果你是 Rspotify 的维护者,你会怎么做?如果你愿意,可以在下面留言。

2024/8/28
articleCard.readMore

四种字符串和bytes互相转换方式的性能比较

昨天公司群中同事提到 Go 1.22 中 string 和 bytes 的互转不需要再用 unsafe 那个包了,直接转就可以。我翻看了 Go 1.22 的 release notes 没找到相应的介绍,但是大家提到了 kubernetes 的 issue 中有这个说法: As of go 1.22, for string to bytes conversion, we can replace the usage of unsafe.Slice(unsafe.StringData(s), len(s)) with type casting []bytes(str), without the worry of losing performance. As of go 1.22, string to bytes conversion []bytes(str) is faster than using the unsafe package. Both methods have 0 memory allocation now. 自 Go 1.22 起,对于 string 到 bytes 的转换,我们可以用类型转换 []bytes(str) 来替换 unsafe.Slice(unsafe.StringData(s), len(s)) 的用法,而不用担心性能损失。 自 Go 1.22 起,string 到 bytes 的转换 []bytes(str) 比使用 unsafe 包更快。现在两种方法都不会有内存分配。 这个说法让我很好奇,但是我还是想验证一下这个说法。 注意,这个说法只谈到了 string 到 bytes 的转换,并没有提到 bytes 到 string 的转换,这篇文章也会关注这两者的互转。 首先,让我们看看几种 string 和 bytes 的转换方式,然后我们再写 benchmark 比较它们之间的性能。 一、强转 字符串和 bytes 之间可以强制转换,编译器会内部处理。代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 func toRawBytes(s string) []byte { if len(s) == 0 { return nil } return []byte(s) } func toRawString(b []byte) string { if len(b) == 0 { return "" } return string(b) } 这里我们做了一点点优化,处理空 string或者 bytes 的情况。 二、传统 unsafe 方式 reflect 包中定义了 SliceHeader 和 StringHeader, 分别对应 slice 和 string 的数据结构 1 2 3 4 5 6 7 8 9 type SliceHeader struct { Data uintptr Len int Cap int } type StringHeader struct { Data uintptr Len int } 我们按照这种数据结构,可以实现 string 和 bytes 的互转。我们暂且把它叫做 reflect 方式吧,虽然下面的代码没有用到 reflect 包,但是实际我们是按照 reflect 包中的这两个数据结构进行转换的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func toReflectBytes(s string) []byte { if len(s) == 0 { return nil } x := (*[2]uintptr)(unsafe.Pointer(&s)) h := [3]uintptr{x[0], x[1], x[1]} return *(*[]byte)(unsafe.Pointer(&h)) } func toReflectString(b []byte) string { if len(b) == 0 { return "" } return *(*string)(unsafe.Pointer(&b)) } 三、新型 unsafe 方式 我在两年前的文章与日俱进,在 Go 1.20 中这种高效转换的方式又变了介绍了新的 unsafe 方式,reflect 包中的 SliceHeader 和 StringHeader 准备废弃了。让我们看看这种新的转换方式: 1 2 3 4 5 6 7 8 9 10 11 12 13 func toBytes(s string) []byte { if len(s) == 0 { return nil } return unsafe.Slice(unsafe.StringData(s), len(s)) } func toString(b []byte) string { if len(b) == 0 { return "" } return unsafe.String(unsafe.SliceData(b), len(b)) } 利用 unsafe.Slice 、unsafe.String、unsafe.StringData 和 unsafe.SliceData 完成 Slice 和 String 的转换以及底层数据的指针的获取。 四、kubernetes 的实现 在 k8s 中,使用的是下面方式的优化的转换: 1 2 3 4 5 6 7 func toK8sBytes(s string) []byte { return *(*[]byte)(unsafe.Pointer(&s)) } func toK8sString(b []byte) string { return *(*string)(unsafe.Pointer(&b)) } 可以看到,相对于传统 unsafe 方式,k8s 的实现更简洁,并没有为toBytes临时构造3元素的数组,而是直接将 string 和 bytes 的指针进行转换。 string不是只包含两个字段么?slice不是包含三个字段么?toK8sBytes返回的[]byte的cap是怎么确定的呢? 最后我们再分析这个问题,现在先把这几个实现的性能搞清楚。 性能比较 我们分别对这几种实现进行 benchmark,看看它们之间的性能差异。 使用一个简单的字符串和它对应的bytes, 分别进行 string 到 bytes 、 bytes 到 string 的转换。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 var s = "hello, world" var bts = []byte("hello, world") func BenchmarkStringToBytes(b *testing.B) { var fns = map[string]func(string) []byte{ "强制转换": toRawBytes, "传统转换": toReflectBytes, "新型转换": toBytes, "k8s转换": toK8sBytes, } for name, fn := range fns { b.Run(name, func(b *testing.B) { for i := 0; i < b.N; i++ { bts = fn(s) } }) } } func BenchmarkBytesToString(b *testing.B) { var fns = map[string]func([]byte) string{ "强制转换": toRawString, "传统转换": toReflectString, "新型转换": toString, "k8s转换": toK8sString, } for name, fn := range fns { b.Run(name, func(b *testing.B) { for i := 0; i < b.N; i++ { s = fn(bts) } }) } } 在Mac mini M2上运行,go1.22.6 darwin/arm64,结果如下: 1 2 3 4 5 6 7 8 goos: darwin goarch: arm64 pkg: github.com/smallnest/study/str2bytes BenchmarkStringToBytes/强制转换-8 78813638 14.73 ns/op 16 B/op 1 allocs/op BenchmarkStringToBytes/传统转换-8 599346962 2.010 ns/op 0 B/op 0 allocs/op BenchmarkStringToBytes/新型转换-8 624976126 1.929 ns/op 0 B/op 0 allocs/op BenchmarkStringToBytes/k8s转换-8 887370499 1.211 ns/op 0 B/op 0 allocs/op string 转 bytes性能最好的是k8s方案, 新型转换和传统转换性能差不多,新型方案略好,强制转换性能最差。 1 2 3 4 5 BenchmarkBytesToString/强制转换-8 92011309 12.68 ns/op 16 B/op 1 allocs/op BenchmarkBytesToString/传统转换-8 815922964 1.471 ns/op 0 B/op 0 allocs/op BenchmarkBytesToString/新型转换-8 624965414 1.922 ns/op 0 B/op 0 allocs/op BenchmarkBytesToString/k8s转换-8 1000000000 1.194 ns/op 0 B/op 0 allocs/op 而对于 bytes 转 string,k8s方案性能最好,传统转换次之,新型转换性能再次之,强制转换性能非常不好。 在Linux amd64上运行,go1.22.0 linux/amd64,结果如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 goos: linux goarch: amd64 pkg: test cpu: Intel(R) Xeon(R) Platinum BenchmarkStringToBytes/强制转换-2 30606319 42.02 ns/op 16 B/op 1 allocs/op BenchmarkStringToBytes/传统转换-2 315913948 3.779 ns/op 0 B/op 0 allocs/op BenchmarkStringToBytes/新型转换-2 411972518 2.753 ns/op 0 B/op 0 allocs/op BenchmarkStringToBytes/k8s转换-2 449640819 2.770 ns/op 0 B/op 0 allocs/op BenchmarkBytesToString/强制转换-2 38716465 29.18 ns/op 16 B/op 1 allocs/op BenchmarkBytesToString/传统转换-2 458832459 2.593 ns/op 0 B/op 0 allocs/op BenchmarkBytesToString/新型转换-2 439537762 2.762 ns/op 0 B/op 0 allocs/op BenchmarkBytesToString/k8s转换-2 478885546 2.375 ns/op 0 B/op 0 allocs/op 整体上看,k8s方案、传统转换、新型转换性能都挺好,强制转换性能最差。k8s在bytes转string上性能最好。 性能分析 等等,kubernates的讨论中,不是说Go1.22中string到bytes的转换可以直接用[]byte(str)了么?为什么这里的性能测试中,强制转换为什么性能那么差呢? 同时你也可以看到,强制转换每个op都会有一次内存分配:1 allocs/op,这严重影响了它的性能。 如果我们编写两个benchmark测试函数, 如下: 1 2 3 4 5 6 7 8 9 10 11 func BenchmarkStringToBytesRaw(b *testing.B) { for i := 0; i < b.N; i++ { _ = toRawBytes(s) } } func BenchmarkBytesToStringRaw(b *testing.B) { for i := 0; i < b.N; i++ { _ = toRawString(bts) } } 执行: 1 2 3 4 5 goos: darwin goarch: arm64 pkg: github.com/smallnest/study/str2bytes BenchmarkStringToBytesRaw-8 1000000000 0.2921 ns/op 0 B/op 0 allocs/op BenchmarkBytesToStringRaw-8 506502222 2.363 ns/op 0 B/op 0 allocs/op 你会发现一个令人诧异的事情,强制转换的性能非常好,没有额外的内存分配(零拷贝),设置字符串转换为bytes好太多。 这是咋回事呢? 当然聪明的你就会想到这个肯定是编译器做了优化,通过内联,把toRawBytes的函数调用展开了,这个好处是发现s 1 2 3 4 5 6 7 8 # go test -gcflags="-m=2" -bench Raw -benchmem ... ./convert_test.go:48:6: can inline toRawBytes with cost 10 as: func(string) []byte { if len(s) == 0 { return nil }; return ([]byte)(s) } ./convert_test.go:55:6: can inline toRawString with cost 10 as: func([]byte) string { if len(b) == 0 { return "" }; return string(b) } ... ./convert_test.go:101:17: ([]byte)(s) does not escape ./convert_test.go:101:17: zero-copy string->[]byte conversion ... 通过-gcflags="-m=2", 我们可以观察内联和逃逸分析的结果,可以看到编译器优化了强制转换的函数,将string转换为bytes的操作优化为零拷贝。 而上一节我们的benchmark中,bts = toRawBytes(s)这个操作,会导致([]byte)(s)逃逸到堆上,这样就会有一次内存分配,并且性能底下。 所以你现在情况了,Go1.22确实对强制转换做了优化,但是这个优化是通过编译器的内联和逃逸分析来实现的,并不是所有的场景都能够优化到零拷贝。 谁能在编写代码的时候注意到这个优化呢,甚至准确的判断能否避免逃逸?所以可能在现阶段,我们还是会通过其他三种方式进行优化。 貌似Go 1.23会进一步优化,参考这个CL: cmd/compile: restore zero-copy string->[]byte optimization k8s实现的问题 一开始,我们留了一个问题:toK8sBytes返回的[]byte的cap是多少? 1 2 3 func toK8sBytes(s string) []byte { return *(*[]byte)(unsafe.Pointer(&s)) } len是明确的,字段对应字符串的len字段,但是cap是多少呢?字符串可是没有cap字段的。 我们可以通过下面的代码来验证: 1 2 3 4 5 6 7 func Test_toK8sBytes(t *testing.T) { a := *(*[3]int64)(unsafe.Pointer(&s)) fmt.Printf("%d, %d, %d\n", a[0], a[1], a[2]) b := *(*[]byte)(unsafe.Pointer(&s)) fmt.Printf("%d, %d, %d\n", unsafe.SliceData(b), len(b), cap(b)) } 首先我们强制获取三个字段,第一个字段应该是字符串底层数据的指针。第二个字段是字符串的长度,第三个字段是什么呢? 同样我进行强制转换成slice of byte, 然后打印slice的底层数据指针,长度和容量。 输出结果如下(每次运行可能会得到不同的结果): 1 2 4375580047, 12, 4375914624 4375580047, 12, 4375914624 可以看到两者的结果是一致的,第一个值就是底层数据指针,第二个值是长度12,第三个啥也不是,就取得的内存中的值,随机的,并不是容量12。 所以通过这种方式转换的slice,其容量是不确定的,这个是一个问题,可能会导致一些问题,比如slice的append操作。 1、如果得到的slice的容量那么大,我们是不是尽情的append数据呢? 1 2 3 4 b := *(*[]byte)(unsafe.Pointer(&s)) fmt.Printf("%d, %d, %d\n", unsafe.SliceData(b), len(b), cap(b)) b = append(b, '!') 运行上面的测试会导致panic: 1 2 3 unexpected fault address 0x105020dfb fatal error: fault [signal SIGBUS: bus error code=0x1 addr=0x105020dfb pc=0x10501ee98] 2、如果修改返回的bytes, 共享底层数据的原始string是不是也会发生变化? 1 2 3 b := *(*[]byte)(unsafe.Pointer(&s)) fmt.Printf("%d, %d, %d\n", unsafe.SliceData(b), len(b), cap(b)) b[0] = 'H' 运行上面的测试,会导致string的值s发生变化吗? 答案是不会,运行这段代码依然会导致panic" 1 2 3 unexpected fault address 0x104f1cdcf fatal error: fault [signal SIGBUS: bus error code=0x1 addr=0x104f1cdcf pc=0x104f1ae74] 3、如果修改原始的bytes, 返回的string是不是也会发生变化? 我们知道,字符串是不可变的,所以这个问题的答案是? 测试代码如下: 1 2 3 4 c := *(*string)(unsafe.Pointer(&bts)) fmt.Printf("%s\n", c) bts[0] = 'H' fmt.Printf("%s\n", c) 原始的bytes bts发生变化,返回的string c会发生变化吗?上面的代码打印出修改前后同一个字符串的值: 1 2 hello, world Hello, world 哈,字符串也变成了"可变"的了。 总结 Go 1.22中,string和bytes的互转在部分场景(未逃逸的情况)下做了优化,实现了零拷贝,性能优秀,但是并不是所有的场景都能优化到零拷贝,所以我们、可以再等等,再等几个版本优化完全后再替换传统的互转方式。 在字符串和bytes互转的情况下,我们要确定bytes是不是可变的,这样会避免意外的情况发生,否则不妨采用强制转换的方式,安全第一。

2024/8/13
articleCard.readMore

没有什么不可能:修改 Go 结构体的私有字段

在Go语言中,结构体(struct)中的字段如果是私有的,只能在定义该结构体的同一个包内访问。这是为了实现数据的封装和信息隐藏,提高代码的健壮性和安全性。 但是在某些情况下,我们可能需要在外部包中访问或修改结构体的私有字段。这时,我们可以使用 Go 语言提供的反射(reflect)机制来实现这一功能。 即使我们能够实现访问,这些字段你没有办法修改,如果尝试通过反射设置这些私有字段的值,会 panic。 甚至有时,我们通过反射设置一些变量或者字段的值的时候,会 panic, 报错 panic: reflect: reflect.Value.Set using unaddressable value。 在本文中,你将了解到: 如何通过 hack 的方式访问外部结构体的私有字段 如何通过 hack 的方式设置外部结构体的私有字段 如何通过 hack 的方式设置 unaddressable 的值 首先我先介绍通过反射设置值遇到的 unaddressable 的困境。 通过反射设置一个变量的值 如果你使用过反射设置值的变量,你可能熟悉下面的代码,而且这个代码工作正常: 1 2 3 4 5 var x = 47 v := reflect.ValueOf(&x).Elem() fmt.Printf("原始值: %d, CanSet: %v\n", v.Int(), v.CanSet()) // 47, false v.Set(reflect.ValueOf(50)) 注意这里传入给 reflect.ValueOf 的是 x 的指针 &x, 所以这个 Value 值是 addresable 的,我们可以进行赋值。 如果把 &x 替换成 x, 我们再尝试运行: 1 2 3 4 5 var x = 47 v := reflect.ValueOf(x) fmt.Printf("Original value: %d, CanSet: %v\n", v.Int(), v.CanSet()) // 47, false v.Set(reflect.ValueOf(50)) 可以看到panic: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Original value: 47, CanSet: false panic: reflect: reflect.Value.Set using unaddressable value goroutine 1 [running]: reflect.flag.mustBeAssignableSlow(0x1400012c410?) /usr/local/go/src/reflect/value.go:272 +0x74 reflect.flag.mustBeAssignable(...) /usr/local/go/src/reflect/value.go:259 reflect.Value.Set({0x104e13e40?, 0x104e965b8?, 0x104dec7e6?}, {0x104e13e40?, 0x104e0ada0?, 0x2?}) /usr/local/go/src/reflect/value.go:2319 +0x58 main.setUnaddressableValue() /Users/smallnest/workspace/study/private/main.go:27 +0x1c0 main.main() /Users/smallnest/workspace/study/private/main.go:18 +0x1c exit status 2 文章最后我会介绍如何通过 hack 的方式解决这个问题。 接下来我再介绍访问私有字段的问题。 访问外部包的结构体的私有字段 我们先准备一个 model 包,在它之下定义了两个结构体: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package model type Person struct { Name string age int } func NewPerson(name string, age int) Person { return Person{ Name: name, age: age, // unexported field } } type Teacher struct { Name string Age int // exported field } func NewTeacher(name string, age int) Teacher { return Teacher{ Name: name, Age: age, } } 注意Person的age字段是私有的,Teacher的Age字段是公开的。 在我们的main函数中,你不能访问Person的age字段: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package main; import ( "fmt" "reflect" "unsafe" "github.com/smallnest/private/model" ) func main() { p := model.NewPerson("Alice", 30) fmt.Printf("Person: %+v\n", p) // fmt.Println(p.age) // error: p.age undefined (cannot refer to unexported field or method age) t := model.NewTeacher("smallnest", 18) fmt.Printf("Teacher: %+v\n", t) // Teacher: {Name:Alice Age:30} } 那么真的就无法访问了吗?也不一定,我们可以通过反射的方式访问私有字段: 1 2 3 4 p := model.NewPerson("Alice", 30) age := reflect.ValueOf(p).FieldByName("age") fmt.Printf("原始值: %d, CanSet: %v\n", age.Int(), age.CanSet()) // 30, false 运行这个程序,可以看到我们获得了这个私有字段age的值: 1 原始值: 30, CanSet: false 这样我们就绕过了Go语言的访问限制,访问了私有字段。 设置结构体的私有字段 但是如果我们尝试修改这个私有字段的值,会 panic: 1 age.SetInt(50) 或者 1 age.Set(reflect.ValueOf(50)) 报错信息: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 原始值: 30, CanSet: false panic: reflect: reflect.Value.SetInt using value obtained using unexported field goroutine 1 [running]: reflect.flag.mustBeAssignableSlow(0x2?) /usr/local/go/src/reflect/value.go:269 +0xb4 reflect.flag.mustBeAssignable(...) /usr/local/go/src/reflect/value.go:259 reflect.Value.SetInt({0x1050ac0c0?, 0x14000118f20?, 0x1050830a8?}, 0x32) /usr/local/go/src/reflect/value.go:2398 +0x44 main.setUnexportedField() /Users/smallnest/workspace/study/private/main.go:37 +0x1a0 main.main() /Users/smallnest/workspace/study/private/main.go:18 +0x1c exit status 2 实际上,reflect.Value的Set方法会做一系列的检查,包括检查是否是addressable的,以及是否是exported的字段: 1 2 3 4 5 func (v Value) Set(x Value) { v.mustBeAssignable() x.mustBeExported() // do not let unexported x leak ... } v.mustBeAssignable()检查是否是addressable的,而且是exported的字段: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func (f flag) mustBeAssignable() { if f&flagRO != 0 || f&flagAddr == 0 { f.mustBeAssignableSlow() } } func (f flag) mustBeAssignableSlow() { if f == 0 { panic(&ValueError{valueMethodName(), Invalid}) } // Assignable if addressable and not read-only. if f&flagRO != 0 { panic("reflect: " + valueMethodName() + " using value obtained using unexported field") } if f&flagAddr == 0 { panic("reflect: " + valueMethodName() + " using unaddressable value") } } f&flagRO == 0 代表是可写的(exported),f&flagAddr != 0 代表是addressable的,当这两个条件任意一个不满足时,就会报错。 既然我们明白了它检查的原理,我们就可以通过 hack 的方式绕过这个检查,设置私有字段的值。我们还是要使用unsafe代码。 这里我们以标准库的sync.Mutex结构体为例, sync.Mutex包含两个字段,这两个字段都是私有的: 1 2 3 4 type Mutex struct { state int32 sema uint32 } 正常情况下你只能通过Mutex.Lock和Mutex.Unlock来间接的修改这两个字段。 现在我们演示通过 hack 的方式修改Mutex的state字段的值: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 func setPrivateField() { var mu sync.Mutex mu.Lock() field := reflect.ValueOf(&mu).Elem().FieldByName("state") state := field.Interface().(*int32) fmt.Println(*state) // ❶ flagField := reflect.ValueOf(&field).Elem().FieldByName("flag") flagPtr := (*uintptr)(unsafe.Pointer(flagField.UnsafeAddr())) // 修改flag字段的值 *flagPtr &= ^uintptr(flagRO) // ❷ field.Set(reflect.ValueOf(int32(0))) mu.Lock() // ❸ fmt.Println(*state) } type flag uintptr const ( flagKindWidth = 5 // there are 27 kinds flagKindMask flag = 1<<flagKindWidth - 1 flagStickyRO flag = 1 << 5 flagEmbedRO flag = 1 << 6 flagIndir flag = 1 << 7 flagAddr flag = 1 << 8 flagMethod flag = 1 << 9 flagMethodShift = 10 flagRO flag = flagStickyRO | flagEmbedRO ) ❶ 处我们已经介绍过了,访问私有字段的值,这里会打印出1 ❶ 处我们清除了flag字段的flagRO标志位,这样就不会报reflect: reflect.Value.SetInt using value obtained using unexported field错误了 ❸ 处不会导致二次加锁带来的死锁,因为state字段的值已经被修改为0了,所以不会阻塞。最后打印结果还是1 这样我们就可以实现了修改私有字段的值了。 使用unexported字段的Value设置公开字段 看reflect.Value.Set的源码,我们可以看到它会检查参数的值是否unexported,如果是,就会报错,下面就是一个例子: 1 2 3 4 5 6 7 8 9 10 11 func setUnexportedField2() { alice := model.NewPerson("Alice", 30) bob := model.NewTeacher("Bob", 40) bobAgent := reflect.ValueOf(&bob).Elem().FieldByName("Age") aliceAge := reflect.ValueOf(&alice).Elem().FieldByName("age") bobAgent.Set(aliceAge) // ❹ } 注意❹处,我们尝试把alice的私有字段age的值赋值给bob的公开字段Age,这里会报错: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 panic: reflect: reflect.Value.Set using value obtained using unexported field goroutine 1 [running]: reflect.flag.mustBeExportedSlow(0x1400012a000?) /usr/local/go/src/reflect/value.go:250 +0x70 reflect.flag.mustBeExported(...) /usr/local/go/src/reflect/value.go:241 reflect.Value.Set({0x102773a60?, 0x1400012a028?, 0x60?}, {0x102773a60?, 0x1400012a010?, 0x1027002b8?}) /usr/local/go/src/reflect/value.go:2320 +0x88 main.setUnexportedField2() /Users/smallnest/workspace/study/private/main.go:50 +0x168 main.main() /Users/smallnest/workspace/study/private/main.go:18 +0x1c exit status 2 原因alice的age值被识别为私有字段,它是不能用来赋值给公开字段的。 有了上一节的经验,我们同样可以绕过这个检查,实现这个赋值: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func setUnexportedField2() { alice := model.NewPerson("Alice", 30) bob := model.NewTeacher("Bob", 40) bobAgent := reflect.ValueOf(&bob).Elem().FieldByName("Age") aliceAge := reflect.ValueOf(&alice).Elem().FieldByName("age") // 修改flag字段的值 flagField := reflect.ValueOf(&aliceAge).Elem().FieldByName("flag") flagPtr := (*uintptr)(unsafe.Pointer(flagField.UnsafeAddr())) *flagPtr &= ^uintptr(flagRO) // ❺ bobAgent.Set(reflect.ValueOf(50)) bobAgent.Set(aliceAge) // ❻ } ❺ 处我们修改了aliceAge的flag字段,去掉了flagRO标志位,这样就不会报错了,❻处我们成功的把alice的私有字段age的值赋值给bob的公开字段Age。 这样我们就可以实现了使用私有字段的值给其他Value值进行赋值了。 给unaddressable的值设置值 回到最初的问题,我们尝试给一个unaddressable的值设置值,会报错。 结合上面的hack手段,我们也可以绕过限制,给unaddressable的值设置值: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func setUnaddressableValue() { var x = 47 v := reflect.ValueOf(x) fmt.Printf("原始值: %d, CanSet: %v\n", v.Int(), v.CanSet()) // 47, false // v.Set(reflect.ValueOf(50)) flagField := reflect.ValueOf(&v).Elem().FieldByName("flag") flagPtr := (*uintptr)(unsafe.Pointer(flagField.UnsafeAddr())) // 修改flag字段的值 *flagPtr |= uintptr(flagAddr) // 设置可寻址标志位 fmt.Printf("CanSet: %v\n", v.CanSet()) // true v.SetInt(50) fmt.Printf("修改后的值: %d\n", v.Int()) // 50 } 运行这个程序,不会报错,可以看到我们成功的给unaddressable的值设置了新的值。 回顾 我们通过修改Value值的flag标志位,可以绕过reflect的检查,实现了访问私有字段、设置私有字段的值、用私有字段设置值,以及给unaddressable的值设置值。 这些都是unsafe的方式,一般情况下不鼓励进行这样的hack操作,但是这种技术也不是完全没有用户,如果你正在写一个debugger,用户在断点出可能想修改某些值,或者你在写深拷贝的库,或者编写某种ORM库,或者你就像突破限制,访问第三方不愿意公开的字段,你有可能会采用这种非常规的技术。 我是鸟窝,一位老程序员,在百度写代码。如果你感觉这篇文章给你带来了帮助,请点击下方点赞按钮或者评论区进行评论。

2024/8/8
articleCard.readMore

使用eBPF编写系统调用跟踪器

先决条件 系统调用、eBPF、C语言、底层编程基础。 简介 eBPF(扩展的伯克利数据包过滤器)是一项允许用户在内核中运行自定义程序的技术。BPF或cBPF(经典BPF)是eBPF的前身,它提供了一种简单高效的方法来基于预定义规则过滤数据包。与内核模块相比,eBPF程序提供了更高的安全性、可移植性和可维护性。现有多种高级方法可用于处理eBPF程序,如Cilium的Go语言库、bpftrace、libbpf等。 注意: 本文要求读者对eBPF有基本了解。如果你不熟悉它,ebpf.io上的这篇文章是很好的参考资料。 目标 你应该已经熟悉著名的工具 strace。我们将使用eBPF开发类似的工具。例如: 1 ./beetrace /bin/ls 以下是该文本的地道中文翻译: 概念 在开始编写我们的工具之前,我们需要熟悉一些关键概念。 跟踪点(Tracepoints):这些是放置在 Linux 内核代码各个部分的检测点。它们提供了一种方法,可以在不修改内核源代码的情况下,钩入内核中的特定事件或代码路径。可用于跟踪的事件可以在 /sys/kernel/debug/tracing/events 中找到。 SEC 宏:它在目标 ELF 文件中创建一个新的段,段名与跟踪点的名称相同。例如,SEC(tracepoint/raw_syscalls/sys_enter) 创建了一个具有这个名称的新段。可以使用 readelf 命令查看这些段。 1 readelf -s --wide somefile.o 映射(Maps):这些是可以从 eBPF 程序和用户空间运行的应用程序中访问的共享数据结构。 编写 eBPF 程序 由于 Linux 内核中存在大量的系统调用,我们不会编写一个全面的工具来跟踪所有系统调用。相反,我们将专注于跟踪几个常见的系统调用。为了实现这一目标,我们将编写两类程序:eBPF 程序和加载器(用于将 BPF 对象加载到内核并将其附加进来)。 让我们首先创建一些数据结构来进行初始设置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 // controller.h // SYS_ENTER : for retrieving system call arguments // SYS_EXIT : for retrieving the return values of syscalls typedef enum { SYS_ENTER, SYS_EXIT } event_mode; struct inner_syscall_info { union { struct { // For SYS_ENTER mode char name[32]; int num_args; long syscall_nr; void *args[MAX_ARGS]; }; long retval; // For SYS_EXIT mode }; event_mode mode; }; struct default_syscall_info{ char name[32]; int num_args; }; // Array for storing the name and argument count of system calls const struct default_syscall_info syscalls[MAX_SYSCALL_NR] = { [SYS_fork] = {"fork", 0}, [SYS_alarm] = {"alarm", 1}, [SYS_brk] = {"brk", 1}, [SYS_close] = {"close", 1}, [SYS_exit] = {"exit", 1}, [SYS_exit_group] = {"exit_group", 1}, [SYS_set_tid_address] = {"set_tid_address", 1}, [SYS_set_robust_list] = {"set_robust_list", 1}, [SYS_access] = {"access", 2}, [SYS_arch_prctl] = {"arch_prctl", 2}, [SYS_kill] = {"kill", 2}, [SYS_listen] = {"listen", 2}, [SYS_munmap] = {"sys_munmap", 2}, [SYS_open] = {"open", 2}, [SYS_stat] = {"stat", 2}, [SYS_fstat] = {"fstat", 2}, [SYS_lstat] = {"lstat", 2}, [SYS_accept] = {"accept", 3}, [SYS_connect] = {"connect", 3}, [SYS_execve] = {"execve", 3}, [SYS_ioctl] = {"ioctl", 3}, [SYS_getrandom] = {"getrandom", 3}, [SYS_lseek] = {"lseek", 3}, [SYS_poll] = {"poll", 3}, [SYS_read] = {"read", 3}, [SYS_write] = {"write", 3}, [SYS_mprotect] = {"mprotect", 3}, [SYS_openat] = {"openat", 3}, [SYS_socket] = {"socket", 3}, [SYS_newfstatat] = {"newfstatat", 4}, [SYS_pread64] = {"pread64", 4}, [SYS_prlimit64] = {"prlimit64", 4}, [SYS_rseq] = {"rseq", 4}, [SYS_sendfile] = {"sendfile", 4}, [SYS_socketpair] = {"socketpair", 4}, [SYS_mmap] = {"mmap", 6}, [SYS_recvfrom] = {"recvfrom", 6}, [SYS_sendto] = {"sendto", 6}, }; 加载器将读取用户通过命令行参数提供的待追踪 ELF 文件的路径。然后,加载器会创建一个子进程,并使用 execve 来运行命令行参数中指定的程序。 父进程将处理加载和附加 eBPF 程序所需的所有设置。它还执行一项关键任务:通过 BPF 哈希映射将子进程的 ID 发送给 eBPF 程序。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // loader.c int main(int argc, char **argv) { if (argc < 2) { fatal_error("Usage: ./beetrace <path_to_program>"); } const char *file_path = argv[1]; pid_t pid = fork(); if (pid == 0) { // Child process int fd = open("/dev/null", O_WRONLY); if(fd==-1){ // error } dup2(fd, 1); // disable stdout for the child process sleep(2); // wait for the parent process to do the required setup for tracing execve(file_path, NULL, NULL); } else{ // Parent process } } 要追踪系统调用,我们需要编写由 tracepoint/raw_syscalls/sys_enter 和 tracepoint/raw_syscalls/sys_exit 跟踪点触发的 eBPF 程序。这些跟踪点提供了对系统调用号和参数的访问。对于给定的系统调用,tracepoint/raw_syscalls/sys_enter 跟踪点总是在 tracepoint/raw_syscalls/sys_exit 跟踪点之前触发。我们可以使用前者获取系统调用参数,使用后者获取返回值。 此外,我们将使用 eBPF 映射在用户空间程序和我们的 eBPF 程序之间共享信息。具体来说,我们将使用两种类型的 eBPF 映射:哈希映射和环形缓冲区。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // controller.c // Hashmap struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(key_size, 10); __uint(value_size, 4); __uint(max_entries, 256 * 1024); } pid_hashmap SEC(".maps"); // Ring buffer struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } syscall_info_buffer SEC(".maps"); 确定了映射关系之后,我们就可以动手写代码了。首先,让我们来编写针对追踪点 tracepoint/raw_syscalls/sys_enter 的程序代码。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 // loader.c SEC("tracepoint/raw_syscalls/sys_enter") int detect_syscall_enter(struct trace_event_raw_sys_enter *ctx) { // Retrieve the system call number long syscall_nr = ctx->id; const char *key = "child_pid"; int target_pid; // Reading the process id of the child process in userland void *value = bpf_map_lookup_elem(&pid_hashmap, key); void *args[MAX_ARGS]; if (value) { target_pid = *(int *)value; // PID of the process that executed the current system call pid_t pid = bpf_get_current_pid_tgid() & 0xffffffff; if (pid == target_pid && syscall_nr >= 0 && syscall_nr < MAX_SYSCALL_NR) { int idx = syscall_nr; // Reserve space in the ring buffer struct inner_syscall_info *info = bpf_ringbuf_reserve(&syscall_info_buffer, sizeof(struct inner_syscall_info), 0); if (!info) { bpf_printk("bpf_ringbuf_reserve failed"); return 1; } // Copy the syscall name into info->name bpf_probe_read_kernel_str(info->name, sizeof(syscalls[syscall_nr].name), syscalls[syscall_nr].name); for (int i = 0; i < MAX_ARGS; i++) { info->args[i] = (void *)BPF_CORE_READ(ctx, args[i]); } info->num_args = syscalls[syscall_nr].num_args; info->syscall_nr = syscall_nr; info->mode = SYS_ENTER; // Insert into ring buffer bpf_ringbuf_submit(info, 0); } } return 0; } 同理,我们也能编写用于读取返回值并将其传递给用户态空间的程序代码。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // controller.c SEC("tracepoint/raw_syscalls/sys_exit") int detect_syscall_exit(struct trace_event_raw_sys_exit *ctx) { const char *key = "child_pid"; void *value = bpf_map_lookup_elem(&pid_hashmap, key); pid_t pid, target_pid; if (value) { pid = bpf_get_current_pid_tgid() & 0xffffffff; target_pid = *(pid_t *)value; if (pid == target_pid) { struct inner_syscall_info *info = bpf_ringbuf_reserve(&syscall_info_buffer, sizeof(struct inner_syscall_info), 0); if (!info) { bpf_printk("bpf_ringbuf_reserve failed"); return 1; } info->mode = SYS_EXIT; info->retval = ctx->ret; bpf_ringbuf_submit(info, 0); } } return 0; } 现在,让我们来完善加载器程序中父进程的功能部分。但在进行之前,我们需要理解几个关键函数的工作原理。 1、bpf_object__open: 通过打开由传递路径指向的 BPF ELF 对象文件并在内存中加载它,创建一个 bpf_object 结构体实例。 1 LIBBPF_API struct bpf_object *bpf_object__open(const char *path); 2、bpf_object__load: 将 BPF 对象加载到内核中。 1 LIBBPF_API int bpf_object__load(struct bpf_object *obj); 3、bpf_object__find_program_by_name: 返回指向有效 BPF 程序的指针。 1 LIBBPF_API struct bpf_program *bpf_object__find_program_by_name(const struct bpf_object *obj, const char *name); 4、bpf_program__attach: 根据自动检测的程序类型、附加类型和适用的额外参数,将 BPF 程序附加到内核。 1 LIBBPF_API struct bpf_link *bpf_program__attach(const struct bpf_program *prog); 5、bpf_map__update_elem: 允许在与提供的键对应的 BPF 映射中插入或更新值。 1 LIBBPF_API int bpf_map__update_elem(const struct bpf_map *map, const void *key, size_t key_sz, const void *value, size_t value_sz, __u64 flags); 6、bpf_object__find_map_fd_by_name: 给定一个 BPF 映射名称,返回该映射的文件描述符。 1 LIBBPF_API int bpf_object__find_map_fd_by_name(const struct bpf_object *obj, const char *name); 7、ring_buffer__new: 返回指向环形缓冲区的指针。 1 LIBBPF_API struct ring_buffer *ring_buffer__new(int map_fd, ring_buffer_sample_fn sample_cb, void *ctx, const struct ring_buffer_opts *opts); 第二个参数必须是一个可用于处理从环形缓冲区接收的数据的回调函数。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 bool initialized = false; static int syscall_logger(void *ctx, void *data, size_t len) { struct inner_syscall_info *info = (struct inner_syscall_info *)data; if (!info) { return -1; } if (info->mode == SYS_ENTER) { initialized = true; printf("%s(", info->name); for (int i = 0; i < info->num_args; i++) { printf("%p,", info->args[i]); } printf("\b) = "); } else if (info->mode == SYS_EXIT) { if (initialized) { printf("0x%lx\n", info->retval); } } return 0; } 它会打印系统调用的名称和参数。 8、ring_buffer__consume: 此函数处理环形缓冲区中可用的事件。 1 LIBBPF_API int ring_buffer__consume(struct ring_buffer *rb); 现在我们有了编写加载器所需的一切要素。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 // loader.c #include <bpf/libbpf.h> #include "controller.h" #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> void fatal_error(const char *message) { puts(message); exit(1); } bool initialized = false; static int syscall_logger(void *ctx, void *data, size_t len) { struct inner_syscall_info *info = (struct inner_syscall_info *)data; if (!info) { return -1; } if (info->mode == SYS_ENTER) { initialized = true; printf("%s(", info->name); for (int i = 0; i < info->num_args; i++) { printf("%p,", info->args[i]); } printf("\b) = "); } else if (info->mode == SYS_EXIT) { if (initialized) { printf("0x%lx\n", info->retval); } } return 0; } int main(int argc, char **argv) { int status; struct bpf_object *obj; struct bpf_program *enter_prog, *exit_prog; struct bpf_map *syscall_map; const char *obj_name = "controller.o"; const char *map_name = "pid_hashmap"; const char *enter_prog_name = "detect_syscall_enter"; const char *exit_prog_name = "detect_syscall_exit"; const char *syscall_info_bufname = "syscall_info_buffer"; if (argc < 2) { fatal_error("Usage: ./beetrace <path_to_program>"); } const char *file_path = argv[1]; pid_t pid = fork(); if (pid == 0) { int fd = open("/dev/null", O_WRONLY); if(fd==-1){ fatal_error("failed to open /dev/null"); } dup2(fd, 1); sleep(2); execve(file_path, NULL, NULL); } else { printf("Spawned child process with a PID of %d\n", pid); obj = bpf_object__open(obj_name); if (!obj) { fatal_error("failed to open the BPF object"); } if (bpf_object__load(obj)) { fatal_error("failed to load the BPF object into kernel"); } enter_prog = bpf_object__find_program_by_name(obj, enter_prog_name); exit_prog = bpf_object__find_program_by_name(obj, exit_prog_name); if (!enter_prog || !exit_prog) { fatal_error("failed to find the BPF program"); } if (!bpf_program__attach(enter_prog) || !bpf_program__attach(exit_prog)) { fatal_error("failed to attach the BPF program"); } syscall_map = bpf_object__find_map_by_name(obj, map_name); if (!syscall_map) { fatal_error("failed to find the BPF map"); } const char *key = "child_pid"; int err = bpf_map__update_elem(syscall_map, key, 10, (void *)&pid, sizeof(pid_t), 0); if (err) { printf("%d", err); fatal_error("failed to insert child pid into the ring buffer"); } int rbFd = bpf_object__find_map_fd_by_name(obj, syscall_info_bufname); struct ring_buffer *rbuffer = ring_buffer__new(rbFd, syscall_logger, NULL, NULL); if (!rbuffer) { fatal_error("failed to allocate ring buffer"); } if (wait(&status) == -1) { fatal_error("failed to wait for the child process"); } while (1) { int e = ring_buffer__consume(rbuffer); if (!e) { break; } sleep(1); } } return 0; } 以下便是 eBPF 程序的部分。所有的 C 语言源码最终会被编译整合成单一的对象文件。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 // controller.c #include "vmlinux.h" #include <bpf/bpf_helpers.h> #include <bpf/bpf_core_read.h> #include <sys/syscall.h> #include "controller.h" struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(key_size, 10); __uint(value_size, 4); __uint(max_entries, 256 * 1024); } pid_hashmap SEC(".maps"); struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } syscall_info_buffer SEC(".maps"); SEC("tracepoint/raw_syscalls/sys_enter") int detect_syscall_enter(struct trace_event_raw_sys_enter *ctx) { // Retrieve the system call number long syscall_nr = ctx->id; const char *key = "child_pid"; int target_pid; // Reading the process id of the child process in userland void *value = bpf_map_lookup_elem(&pid_hashmap, key); void *args[MAX_ARGS]; if (value) { target_pid = *(int *)value; // PID of the process that executed the current system call pid_t pid = bpf_get_current_pid_tgid() & 0xffffffff; if (pid == target_pid && syscall_nr >= 0 && syscall_nr < MAX_SYSCALL_NR) { int idx = syscall_nr; // Reserve space in the ring buffer struct inner_syscall_info *info = bpf_ringbuf_reserve(&syscall_info_buffer, sizeof(struct inner_syscall_info), 0); if (!info) { bpf_printk("bpf_ringbuf_reserve failed"); return 1; } // Copy the syscall name into info->name bpf_probe_read_kernel_str(info->name, sizeof(syscalls[syscall_nr].name), syscalls[syscall_nr].name); for (int i = 0; i < MAX_ARGS; i++) { info->args[i] = (void *)BPF_CORE_READ(ctx, args[i]); } info->num_args = syscalls[syscall_nr].num_args; info->syscall_nr = syscall_nr; info->mode = SYS_ENTER; // Insert into ring buffer bpf_ringbuf_submit(info, 0); } } return 0; } SEC("tracepoint/raw_syscalls/sys_exit") int detect_syscall_exit(struct trace_event_raw_sys_exit *ctx) { const char *key = "child_pid"; void *value = bpf_map_lookup_elem(&pid_hashmap, key); pid_t pid, target_pid; if (value) { pid = bpf_get_current_pid_tgid() & 0xffffffff; target_pid = *(pid_t *)value; if (pid == target_pid) { struct inner_syscall_info *info = bpf_ringbuf_reserve(&syscall_info_buffer, sizeof(struct inner_syscall_info), 0); if (!info) { bpf_printk("bpf_ringbuf_reserve failed"); return 1; } info->mode = SYS_EXIT; info->retval = ctx->ret; bpf_ringbuf_submit(info, 0); } } return 0; } char LICENSE[] SEC("license") = "GPL"; 编译之前,我们不妨先构建一个测试程序,以便后续使用我们的工具对其进行追踪分析。 1 2 3 4 5 #include<stdio.h> int main(){ puts("tracer in action"); return 0; } 可以利用下面提供的 Makefile 来完成所有相关组件的编译工作。 1 2 3 compile: clang -O2 -g -Wall -I/usr/include -I/usr/include/bpf -o beetrace loader.c -lbpf clang -O2 -g -target bpf -c controller.c -o controller.o 整个代码可以在以下的GitHub仓库中找到: https://github.com/0xSh4dy/bee_tracer 参考链接: https://ebpf.io/ https://github.com/libbpf/libbpf

2024/8/4
articleCard.readMore

Russ Cox 引退以及他的新项目 Oscar

Go 第一代技术领导人 Rob Pike, 近两年已经隐居澳大利亚。 Go 第二代技术领导人 Russ Cox 2024 年 8 月 2 日宣布卸任,转战 AI 项目,聚焦 Oscar 项目。 Go 第三代技术领导人 Austin Clements, 同样和 Russ Cox 一样毕业于美国的一个计算机技术比较出名的一个学院,算是 Russ Cox 的师弟,Austin是Go语言运行时系统和垃圾收集器的主要贡献者之一,在运行时和内存管理等底层系统方面有深入的专长。 Go 第一代技术领导人 Rob Pike, 近两年已经隐居澳大利亚。 Go 第二代技术领导人 Russ Cox 2024 年 8 月 2 日宣布卸任,转战 AI 项目,聚焦 Oscar 项目。 Go 第三代技术领导人 Austin Clements, 同样和 Russ Cox 一样毕业于美国的一个计算机技术比较出名的一个学院,算是 Russ Cox 的师弟,Austin是Go语言运行时系统和垃圾收集器的主要贡献者之一,在运行时和内存管理等底层系统方面有深入的专长。 Russ Cox 他在网上的 ID 是 rsc,他是麻省理工学院 MIT 2008 届的博士毕业生,他本科和研究生都是在哈佛大学就读的,Go team 里的又一个学神。他所在的项目组是隶属于 MIT 计算机科学与人工智能实验室的并行与分布式操作系统组,据网上的资料 Austin Clements 也是在这个实验室这个组。 Russ 在哈佛大学就读期间就在 Bell Labs 贝尔实验室里实习(Russ 出生成长的家就在贝尔实验室附近,所以他从高中时期就一直在贝尔实验室的计算机科学部门泡着,所以大佬们是不是关注一下学区房,自己做不到也要为下一代考虑下 :)),那时候他和 Rob Pike 一起开发贝尔实验室的分布式操作系统 Plan 9 (上世纪 80 年代末由贝尔实验室的 Ken Thompson 和 Rob Pike 等人发起并领导的项目),后来他去 MIT 攻读博士学位期间顺便去了 Google 实习,就在他博士快毕业的时候,Rob Pike 和 Ken Thompson 一起和他介绍了他们正在设计的一门新语言 Go,并大概是这么对他说的:“嘿,我们正试图把我们以前在 Plan 9 开发软件时非常喜欢的所有东西用在那些我们想在 Google 里写的软件里,你想过来帮忙一起搞吗?”,然后 Russ 就这样被这两位传奇程序员拉拢进来,事实上 Russ 一直都认为他能在博士毕业以后直接加入 Go 团队是发生在他人生中最幸运的事之一,他说仿佛自己过去十年所学的一切东西就是为了这一刻而准备的。 他加入团队之后就接手了编译器和 Runtime 这两大核心模块,并协助一起开发了标准库,之后依靠这些先前的经验,他和其他人一起完成了标准库后续的所有重构和优化,这就是为什么当你去看 Go 语言的源码的时候会发现 Russ 的名字几乎无处不在,到处都是他的 commits。 在整个 Go 代码仓库中,Russ Cox 提交的代码量是最多的。(请注意,下图中的人物都是大佬,三代领导人都在里面,包括两巨头和 Ian) 考虑到Russ Cox在Go项目中的资历和地位以及大学的经历,他可能在某些方面扮演了Austin Clements的导师角色,但这只是推测。我同样推测 Russ Cox 和 Rob Pike 之间也有类似的师承关系。只不过国外可能没有咱们中国这种拜师的礼仪,没有磕头敬过酒。 从 Russ Cox 的信中,可以看到实际他领导 Go 项目已经 12 年,从曾经充满理想、热情澎湃的有志青年,已经进入到经历沧桑的中年,他也在思考自己的职业规划。 尤其最近几位知名 Gopher 大佬对他的批评,不知道是否导致他引退的导火索呢?或许大佬内心受伤了,心灰意冷了。 作为一个绝顶聪明,学历和资历都是金字塔的技术大牛,正处于正当打的年纪,你猜 Ross Cox 下一步会做些什么? 当然是结合当前炙热的 AI 技术,再结合 Go 的经验,做一点有意义的事情,说不定又会发展为一个明星的项目。 这个项目叫 Oscar (奥斯卡),一个开源的贡献者 Agent架构。事实上前几天 Russ Cox 已经透露了它的第一个原型:gabyhelp Oscar 旨在通过创建用于开源维护的 自动化帮助 或 Agent 来改进开源软件开发。我们相信有很多机会可以减少维护大型和小型开源项目所涉及的辛苦。 这句话已经完全说明这个项目的远景了。 大型语言模型(LLMs)能够对自然语言(如问题报告或维护者指令)进行语义分析,并在自然语言指令和程序代码之间进行转换,这为代理与人更顺畅地交互创造了新的机会。LLMs可能最终只是整个图景中小小的(但关键的!)一部分; Agent的大部分行为将是执行标准的、确定性的代码。 Oscar与许多以开发为中心的LLMs使用方式不同,它完全不试图增强或取代编码过程。毕竟,编写代码是开发软件中最有趣的部分。相反,这个想法是专注于那些不那么有趣的部分,比如处理新提交的问题、将问题与现有文档匹配等。 奥斯卡在很大程度上是一个实验。其实 Russ Cox 目前也还不知道它最终会去哪里。即便如此,他们的第一个原型,即 @gabyhelp 机器人,已经在 Go 问题跟踪器中进行了许多成功的交互。这也许是让 Russ Cox 兴奋准备大干一场的动力吧。 目前,Oscar 是在 Go 项目的主持下开发的。在未来的某个时候,它可能会(也可能不会)被分拆成一个单独的项目。 Oscar项目的具体目标是: 减少维护人员解决问题的工作量 [请注意,解决并不总是意味着修复] 减少维护人员解决更改列表 (CL) 或拉取请求 (PR) 的工作量 [请注意,解决并不总是意味着提交/合并] 减少维护者解决论坛问题的工作量 让更多人成为高效的维护者 和 Copilot 等工具不同,自动化编码不是 Oscar目标。相反,我们专注于自动化维护人员的工作。 维护者的辛苦并不是 Go 项目所独有的,因此 Oscar的目标是构建一个任何软件项目都可以重用和扩展的架构,构建他们自己的 Agent,根据项目的需求进行定制。因此 Oscar 是:开源贡献者代理架构。等他们完成额差不多,国内的头部互联网也就会推出类似的产品了。 到目前为止, Russ Cox 已经确定了三项能力,它们将成为奥斯卡的重要组成部分: 在贡献者交互期间索引和显示相关的项目上下文。 使用自然语言来控制确定性工具。 分析问题报告和 CL/PR,以帮助在提交期间或提交后不久实时改进它们,并适当地标记和路由它们 具体的方法 Russ Cox 也在下面这篇文档中描述清楚了,大家可以进一步的了解。 Oscar,一个开源的贡献者代理架构 参考资料: https://mp.weixin.qq.com/s/YF6WGHpY3LYOamG6KmasFg https://golang.design/history/cn.html https://strikefreedom.top/archives/my-trip-to-san-diego-for-go-contributor-summit-2023 https://groups.google.com/g/golang-dev/c/0OqBkS2RzWw https://go.googlesource.com/oscar/+/refs/heads/master/README.md

2024/8/2
articleCard.readMore

128位整数的原子操作

我们已经知道,标准库中的 atomic 针对 int32/uint32、int64/uint64 提供了原子操作的方法和函数,但是如果针对 128 bit 的整数呢? 当然使用128 bit 整数的原子操作的场景可能比较少,也不会有太多人有这个需求,但是如果我们需要对几个 32 bit、64 bit 变量进行原子操作吗, atomic128 可能就很有用。 tmthrgd/atomic128 在几年前提供了 atomic 128 的实验性功能,最后放弃了,但是他提供了一个思路,可以使用 CMPXCHG16B 指令为 AMD 64 架构的CPU 提供 atomic 128 功能。 CAFxX/atomic128 fork 了上面的项目,继续维护,还是使用 CMPXCHG16B 指令,只为 AMD 64 架构提供原子操作。 首先我们看看它的功能然后再看一看它的实现,最后我们思路发散一下,看看使用 AVX 为 128 bit 甚至更多 bit 的整数提供原子操作是否可行。 atomic128 的方法 Package atomic128 实现了对 128 bit值的原子操作。在可能的情况下(例如,在支持 CMPXCHG16B 的 amd64 处理器上),它会自动使用 CPU 的原生特性来实现这些操作;否则,它会回退到基于互斥锁(mutexes)的方法。 Go 的基本整数中不包含 int128/uint128,所以这个库先定义了一个 Int128 的类型: 1 2 3 4 type Uint128 struct { d [3]uint64 m sync.Mutex } 然后类似标准库 atomic 中对各种整数的操作,它也提供了类似的方法: 1 2 3 4 5 6 7 8 9 func AddUint128(ptr *Uint128, incr [2]uint64) [2]uint64 func CompareAndSwapUint128(ptr *Uint128, old, new [2]uint64) bool func LoadUint128(ptr *Uint128) [2]uint64 func StoreUint128(ptr *Uint128, new [2]uint64) func SwapUint128(ptr *Uint128, new [2]uint64) [2]uint64 func OrUint128(ptr *Uint128, op [2]uint64) [2]uint64 func AndUint128(ptr *Uint128, op [2]uint64) [2]uint64 func XorUint128(ptr *Uint128, op [2]uint64) [2]uint64 可以看到,除了正常的 Add、CAS、Load、Store、Swap 函数,还贴心的提供了 Or、And、Xor 三个位操作的函数。 下面是一个简单的例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 n := &atomic128.Uint128{} v := atomic128.LoadUint128(n) // [2]uint64{0, 0} atomic128.StoreUint128(n, [2]uint64{1, ^uint64(0)}) v = atomic128.LoadUint128(n) // [2]uint64{1, ^uint64(0)} v = AddUint128(n, [2]uint64{2, 40}) v = atomic128.LoadUint128(n) // [2]uint64{3, 40} v = atomic128.SwapUint128(n, [2]uint64{4, 50}) v = atomic128.LoadUint128(n) // [2]uint64{4, 50} v = atomic128.CompareAndSwapUint128(n, [2]uint64{4, 50}, [2]uint64{5, 60}) v = atomic128.LoadUint128(n) // [2]uint64{5, 60} v = atomic128.OrUint128(n, [2]uint64{0, 0}) v = atomic128.LoadUint128(n) // [2]uint64{5, 60} atomic128 的实现 聪明的你也许看到Uint128的定义的时候就会感觉有一点不对劲,为啥128bit的整数要用3个64bit的整数来表示呢? 2个Uint64不就够了吗? 这是为了保证128位对齐,类似的技术在Go 1.20之前的WaitGroup中也有使用。进一步了解可以查看: https://go101.org/article/memory-layout.html https://pkg.go.dev/sync/atomic#pkg-note-BUG 通过包含三个Uint64元素的数组,我们总能通过下面的方法得到128位对齐的地址: 1 2 3 4 5 6 func addr(ptr *Uint128) *[2]uint64 { if (uintptr)((unsafe.Pointer)(&ptr.d[0]))%16 == 0 { // 指针已经128位对齐 return (*[2]uint64)((unsafe.Pointer)(&ptr.d[0])) } return (*[2]uint64)((unsafe.Pointer)(&ptr.d[1])) // 必然ptr.d[1]是128位对齐的 (AMD64架构) } 通过变量useNativeAmd64判断CPU是否支持CMPXCHG16B指令: 1 2 3 func init() { useNativeAmd64 = cpuid.CPU.Supports(cpuid.CX16) } 如果不支持,回退到使用Mutex实现一个低效的atomic 128bit原子操作: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func CompareAndSwapUint128(ptr *Uint128, old, new [2]uint64) bool { if runtime.GOARCH == "amd64" && useNativeAmd64 { return compareAndSwapUint128amd64(addr(ptr), old, new) } // 不支持CMPXCHG16B指令,使用Mutex ptr.m.Lock() v := load(ptr) if v != old { ptr.m.Unlock() return false } store(ptr, new) ptr.m.Unlock() return true } 如果支持CMPXCHG16B指令,直接调用compareAndSwapUint128amd64函数: 1 2 3 4 5 6 7 8 9 10 TEXT ·compareAndSwapUint128amd64(SB),NOSPLIT,$0 MOVQ addr+0(FP), BP MOVQ old+8(FP), AX MOVQ old+16(FP), DX MOVQ new+24(FP), BX MOVQ new+32(FP), CX LOCK CMPXCHG16B (BP) SETEQ swapped+40(FP) RET 主要依赖CMPXCHG16B实现。 CMPXCHG16B是一条X86体系结构中的指令,全称为"Compare and Exchange 16 Bytes"。它用于原子地比较和交换16个字节(128位)的内存区域。 这条指令的作用是: 将要比较的16个字节的内存值加载到一个寄存器中。 将要写入的16个字节的值加载到另一个寄存器中。 比较内存中的值和第一个寄存器中的值是否相等。 如果相等,则用第二个寄存器中的值覆盖内存中的值。 根据比较结果,设置相应的标志位。 思路发散 当前很多号称性能优化的库,可能会使用SIMD指令集来提高性能,比如AVX、SSE等。那么,我们是否可以使用AVX指令集来实现对128位整数甚至256、512位整数的原子操作呢? 有一篇很好的文章介绍了这方面的探索:Aligned AVX loads and stores are atomic。 各家处理器手册中并没有为AVX指令集提供原子性的担保。The AMD64 Architecture Programmer’s Manual只是保证了内存操作最大8个字节,CMPXCHG16B是原子的。The Intel® 64 and IA-32 Architectures Software Developer’s Manual也做了类似的保证。此外,Intel手册明确指出AVX指令没有任何原子性保证。 这篇文章的作者做了实验,得出下面的结论: 尽管看起来对齐的 128 位操作室原子的,但是 CPU 提供商没有提供担保,我们还是使用 CMPXCHG16B 指令保险。

2024/6/16
articleCard.readMore

Go 朝着错误的方向发展

这是 Aliaksandr Valialkin 昨天刚写的一篇文章, 心有戚戚焉,所以特意翻译成中文,个人感觉,自从Rob Pike退休后,Go在大方向迷失了,正如老貘(Go101)所说,目前Go的开发就像完成KPI一样,也许, 大师不会再回来了。 Aliaksandr Valialkin是fasthttp的作者,也是VictoriaMetrics开发者,一位资深的Go程序员。 以下是译文。 以下是对原文的地道中文翻译: Go编程语言以易于使用而闻名。得益于经过深思熟虑的语法、特性和工具,Go允许编写任意复杂度的易读易维护的程序(参见GitHub上的这个列表)。 有些软件工程师称Go为"无聊"和"过时",因为它缺乏其他编程语言的高级特性,如单子、Option类型、LINQ、借用检查器、零开销抽象、面向方面编程、继承、函数和运算符重载等。虽然这些特性在特定领域可能可以简化编码,但它们除了好处之外还有非零的成本。这些特性通常对锻炼大脑有好处。但是在处理生产代码时,我们不需要额外的精神负担,因为我们已经很忙于解决业务任务了。所有这些特性的主要成本是增加了结果代码的复杂性: 仅仅通过阅读代码就变得更难理解正在发生的事情; 调试此类代码变得更加困难,因为您需要跳过数十个非平凡的抽象才能到达业务逻辑; 由于这些特性施加的限制,为此类代码添加新功能变得更加困难。 这可能会显著减慢甚至阻碍代码开发的进度。这就是Go一开始就没有这些特性的主要原因。 不幸的是,一些这样的特性开始出现在最新的Go版本中: 泛型已在Go1.18中添加。许多软件工程师希望Go有泛型,因为他们认为这将大大提高Go的生产力。Go1.18发布已经两年了,但没有迹象表明生产力有所提高。Go中泛型的整体采用率仍然很低。为什么?因为大多数实际的Go代码根本不需要泛型。另一方面,泛型显著增加了Go语言本身的复杂性。例如,尝试理解泛型添加后Go类型推断的所有细节。它的复杂性看起来已经非常接近于C++的类型推断了:)另一个问题是Go中的泛型缺乏C++模板中存在的基本特性。例如,Go泛型不支持泛型类型的泛型方法。它们也不支持模板特化和模板模板参数,以及许多其他需要充分利用泛型编程的特性。让我们将这些缺失的特性添加到Go中吧!等等,那我们就得到另一个过于复杂的C++克隆了。那么,为什么要一开始就将半生不熟的泛型添加到Go中呢?🤦 根据这个提交,Range over functions 又名迭代器、生成器或协程将在 Go 1.23 中添加。让我们仔细看看这个"特性"。 Go1.23 中的迭代器 如果你不太熟悉Go中的迭代器,请阅读这篇出色的介绍文章。本质上,这是一种语法糖,允许在具有特殊签名的函数上使用for...range循环。这使得可以编写遍历自定义集合和类型的自定义迭代器。听起来像是一个很棒的功能,不是吗?让我们试着弄清楚这一功能解决了哪些实际问题。这在这里有概述: Go语言没有标准的方式来遍历一系列值。由于缺乏约定,我们最终使用了各种各样的方法。每种实现都是根据当时的上下文做出最合理的决定,但是孤立地做出的决策导致了用户的困惑。 仅在标准库中,我们就有archive/tar.Reader.Next、bufio.Reader.ReadByte、bufio.Scanner.Scan、container/ring.Ring.Do、database/sql.Rows、expvar.Do、flag.Visit、go/token.FileSet.Iterate、path/filepath.Walk、go/token.FileSet.Iterate、runtime.Frames.Next和sync.Map.Range,几乎没有任何一个在迭代的确切细节上达成一致。即使函数签名相同,语义也不总是一致。例如,大多数返回(T, bool)的迭代函数都遵循Go的惯例,即bool表示T是否有效。相反,runtime.Frames.Next返回的bool则表示下一次调用是否会返回有效的内容。 当你想要遍历某些内容时,你首先必须了解你调用的特定代码是如何处理迭代的。这种不统一阻碍了Go追求的在大型代码库中方便移动的目标。人们常常将Go代码看起来都大致相同作为一个优势,但对于包含自定义迭代的代码而言,这显然是不真实的。 再说一次,拥有在Go中遍历各种类型的统一方式听起来是合理的。但是对于作为Go主要优势之一的向后兼容性又如何呢?根据Go的兼容性规则,上面提到的标准库中所有现有的自定义迭代器将永远保留在标准库中。因此,所有新的Go版本在标准库中都将至少提供两种不同的方式来遍历各种类型 —— 旧的方式和新的方式。这增加了Go编程的复杂性,因为: 您需要了解遍历各种类型的两种方式,而不是单一方式。 您需要能够阅读和维护使用旧迭代器的旧代码,以及可能使用旧迭代器、新迭代器或同时使用两种迭代器类型的新代码。 在编写新代码时,您需要选择适当的迭代器类型。 Go1.23 中迭代器的其他问题 以下是对原文的地道中文翻译: 在Go 1.23之前,for...range循环只能应用于内置类型:整数(从Go1.22开始)、字符串、切片、映射和通道。这些循环的语义很清晰,易于理解(遍历通道的循环语义更加复杂,但如果你处理并发编程,那你应该很容易理解)。 从Go 1.23开始,for...range循环可以应用于具有特殊签名的函数(又称拉取和推送函数)。这使得单凭阅读代码就无法理解给定的看似无辜的for...range循环到底会在底层做什么。它可以做任何事情,就像任何函数调用一样。不同之处在于,Go中的函数调用一直都是显式的,比如f(args),而for...range循环隐藏了实际的函数调用。另外,它还对循环体应用了一些不太明显的转换: 它隐式地将循环体包裹在一个匿名函数中,并隐式地将这个函数传递给推送迭代器函数。 它隐式地调用匿名的拉取函数,并将返回的结果传递给循环体。 它隐式地将return、continue、break、goto和defer语句转换为另一个不太明显的语句,存在于传递给推送迭代器函数的匿名函数中。 另外,在一般情况下,在循环迭代之后使用迭代器函数返回的参数是不安全的,因为迭代器函数可能会在下一次循环迭代时重用它们。 Go 曾因易于阅读和理解的显式代码执行路径而闻名。这一特性在 Go1.23 中不可逆转地被破坏了:(我们用什么来交换?另一种遍历类型的方式,它具有一些隐式的语义,而且在某些情况下行为与广告描述的不同。当遍历可能在迭代过程中返回错误的类型时(例如database/sql.Rows、path/filepath.Walk 或任何其他在迭代过程中进行 IO 操作的类型),这种新方式就无法按预期工作,因为你需要手动检查迭代错误,无论是在循环内部还是在循环之后,这与使用旧方法的做法是一样的。 即使你使用不会返回错误的迭代器,生成的 for ... range 循环也看起来比使用显式回调的旧方法更加不清晰。哪种代码更容易理解和调试? 1 2 3 tree.walk(func(k, v string) { println(k, v) }) 1 2 3 for k, v := range tree.walk { println(k, v) } 请记住,后一个循环会被隐式地转换为前一个带有显式回调调用的代码。现在让我们从循环中返回一些东西: 1 2 3 4 5 for k, v := range tree.walk { if k == "foo" { return v } } 它被隐式转换为难以跟踪的代码,类似于以下代码: 1 2 3 4 5 6 7 8 9 10 11 12 var vOuter string needOuterReturn := false tree.walk(func(k, v string) bool { if k == "foo" { needOuterReturn = true vOuter = v return false } }) if needOuterReturn { return vOuter } 看起来很容易调试:) 如果tree.walk通过从字节切片进行不安全转换将v传递给回调函数,那么这段代码可能会崩溃,因为v的内容在下一次循环迭代时可能会发生变化。因此,隐式生成的防弹代码必须使用strings.Clone()函数,这可能导致不必要的内存分配和复制: 1 2 3 4 5 6 7 8 9 10 11 12 var vOuter string needOuterReturn := false tree.walk(func(k, v string) bool { if k == "foo" { needOuterReturn = true vOuter = strings.Clone(v) return false } }) if needOuterReturn { return vOuter } range over func这一特性对函数签名施加了限制。这些限制不适用于所有需要遍历集合元素的场景。这迫使软件工程师在使用for...range循环时进行丑陋的hack,以及编写理想情况下适合给定任务的显式代码之间做出艰难选择。 结论 令人遗憾的是,Go开始朝着增加复杂性和隐式代码执行的方向发展。也许我们需要停止添加增加Go复杂性的新功能,而是专注于Go的核心特性 - 简单性、高效性和性能。例如,最近Rust开始在对性能要求苛刻的领域取代Go的份额。我相信如果Go核心团队专注于优化热循环,比如循环展开和SIMD使用,这种趋势是可以扭转的。这不应该太过影响编译和链接速度,因为只有少量编译后的Go代码需要优化。没有必要试图优化所有简单代码的变体 - 这些代码即使优化了热循环也仍然会很慢。只需针对那些由注重代码性能的软件工程师故意编写的特定模式进行优化就足够了。 Go比Rust容易使用得多。为什么要在性能竞赛中输给Rust呢? Go可以获得的另一个有用特性的例子是,在不增加语言本身和使用这些特性的Go代码复杂性的情况下,进行类似于小的改善代码质量的改进。 我是谁? 我是一名专门编写简单、面向性能的Go代码的软件工程师,如VictoriaMetrics、quicktemplate、fastjson、fasthttp、fastcache、easyproto等。多亏了Go,我一直试图遵循KISS(Keep It Simple,Stupid)设计原则。

2024/6/11
articleCard.readMore

Rob Pike 语录

1. 计算机领域里,没有什么问题是加一层间接寻址解决不了的。 There's nothing in computing that can't be broken by another level of indirection. 这是 Rob Pike 的修改版。 经常 level of insriection 误引用为 abstraction layer。 原始版本出自 Butler Lampson All problems in computer science can be solved by another level of indirection 但是 David Wheeler 完成了下半句: All problems in computer science can be solved by another level of indirection, except for the problem of too many layers of indirection. 还有 Kevlin Henney 的下半句: ll problems in computer science can be solved by another level of indirection, except for the problem of too many layers of indirection." From Beautiful Code: Another Level of Indirection 这句话幽默地指出,在计算机编程中,通过引入额外的抽象层或中间层,几乎可以解决任何复杂的问题。这种思路在软件设计和架构中很常见。 2. 数据为王。如果你选择了正确的数据结构,并且组织得当,算法几乎总是不言自明的。编程的核心在于数据结构,而不是算法。 Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming. "数据为王"意味着在软件开发中,数据的组织和表示方式比实现算法的具体细节更加重要。 这个观点强调了良好的数据结构设计对于编程效率和代码质量的重要性。它鼓励程序员把更多精力放在思考如何组织和表示数据上,而不是过分关注算法的技巧性。 3. 面向对象设计就是计算机界的罗马数字。 Object-oriented design is the roman numerals of computing. 这是一句颇具争议性和挑衅性的话。罗马数字在数学史上曾经很重要,但现代计算中已被阿拉伯数字体系取代,因为后者更简单、更高效。这句话暗示面向对象设计(OOD)也是如此。 Pike认为,就像罗马数字对现代数学来说过于繁琐和低效一样,面向对象编程(OOP)的某些方面(如过度的类层次结构和封装)可能导致代码复杂、难以理解和维护。 这个比喻引发了编程圈内的热议。支持者认为它点出了OOP的一些问题,如过度设计和不必要的复杂性。反对者则认为OOP仍然是一个强大和有用的范式。 值得注意的是,Pike是Go语言的创始人之一,Go语言采用了一种不同于传统OOP的设计哲学,更注重简单性和数据结构。 4. 最后,我意识到光想是没什么出路的,该动手实践了。 Eventually, I decided that thinking was not getting me very far and it was time to try building. 这句话体现了一种在技术和创新领域常见的实用主义态度。 "光想没出路"是一种常见的中文表达,意思是仅靠思考而不付诸行动是难以取得进展的。 "动手实践"强调了在实践中学习和创新的重要性。在编程、创业等领域,这种"边做边学"的方法被广泛推崇。 但这并不是完全否定思考的价值。恰当的思考和规划仍然重要,只是强调不要陷入"分析瘫痪"(analysis paralysis),即过度分析导致迟迟不能行动的状态。 5. 见识短浅,想象力就会受限。 Narrowness of experience leads to narrowness of imagination. 更为甚者, 见识短浅, 反而将大师的作品视为平庸。一个讽刺性的文章: The Evolution of a Go Programmer 6. 这就是现代计算:简单的东西变得过于复杂,因为随意捣鼓太容易;复杂的东西依旧复杂,因为修复太难。 Such is modern computing: everything simple is made too complicated because it's easy to fiddle with; everything complicated stays complicated because it's hard to fix. Rob Pike 作为一位推崇简洁设计的大师,他对当前计算机行业的复杂性提出了犀利的批评。 第一部分讽刺了一些程序员或设计师的倾向:明明有简单的解决方案,却偏爱使用复杂的技术,导致本来简单的问题变得难以理解和维护。 第二部分点出了一个现实问题:一旦系统变得复杂,就很难简化。原因可能包括兼容性问题、团队惯性、或者是简单地因为理解和重构复杂系统需要大量时间和资源。 它呼应了UNIX哲学中的一个核心原则:做好一件事。也就是说,程序应该简单、模块化,只专注于完成一个任务。 7. 过程名应该反映它做什么;函数名应该反映它返回什么。 Procedure names should reflect what they do; function names should reflect what they return 这是编程领域的一条重要命名规范,有助于提高代码的可读性和可维护性。 在中文编程圈,"过程"(procedure)和"函数"(function)的区别经常被讨论。简单来说: "过程"执行一系列操作,通常不返回值,重点在于"做"。 "函数"计算并返回一个值,重点在于"得到"什么。 8. 花哨的算法在 N 小的时候很慢,而 N 通常都很小。 Fancy algorithms are slow when N is small, and N is usually small. "N" 在算法分析中代表输入规模,如要排序的元素个数、要搜索的数据量等。"大 O 表示法"(如 O(n)、O(n²))就是用 N 来描述算法在最坏情况下的时间复杂度。 这句话的启示: 不要过早优化。在数据量小的情况下,简单直白的算法可能更快、更易理解。 理解实际问题的规模。过度设计(用复杂算法解决小问题)可能适得其反。 = 在选择算法时,要考虑具体场景,不能只看理论复杂度。 俗语"大炮打蚊子",就是类似的道理。有时候,简单的方法反而更有效。 9. UNIX不仅已经死了,臭的都快熏死人了。 Not only is UNIX dead, it's starting to smell really bad. 在1990年代,微软的Windows和IBM的OS/2等图形用户界面(GUI)操作系统开始流行,而基于命令行的UNIX看起来过时了。一些人认为,用户友好的GUI是未来,UNIX这样的系统已经落伍,注定会消亡。 讽刺的是,这个预言并没有完全实现: Linux(一个UNIX类操作系统)在服务器领域占据主导地位。 macOS基于BSD(另一个UNIX变种)。 甚至Windows 10也加入了Linux子系统。 这句话现在常被用来嘲笑那些过早宣布某项技术"死亡"的人。 10. 想要杜绝傻瓜行为的编程语言,往往自己也变得傻不拉几。 Languages that try to disallow idiocy become themselves idiotic. 过度限制程序员可能弊大于利: 好的程序员应该被信任和赋能,而不是被当成"傻瓜"对待。 有时,所谓的"傻瓜行为"其实是创新和效率的源泉。 语言应该提供工具和指导,而不是强制规定唯一的"正确"方式。 这句话也反映了一个更广的设计哲学:过度设计来防止错误,可能带来更多问题。无论是编程语言、产品设计还是管理,给予用户或团队合理的自由和信任,往往比试图规避一切风险更有效。 11、缓存不是架构,只是个优化手段而已。 Caches aren't architecture, they're just optimization. Pike的观点是: 不要因为缓存效果好,就把它当成架构的一部分。 如果没有缓存系统就崩溃,那可能是架构有问题。 缓存应该是"锦上添花",而不是"救命稻草"。 但这并不意味着缓存不重要。实际上: 合理使用缓存可以极大提升性能。 在某些场景(如高并发网站),缓存几乎是必需的。 关键是平衡: 先有好的架构和算法。 在合适的地方加缓存,但不要让系统对缓存产生依赖。 缓存失效或穿透时,系统应该能够"优雅降级"。 用中文的一句话概括就是:"先治本,再治标"。缓存是"标"(优化性能),好的架构才是"本"。 12、没有类型层次,就不用费劲去管理类型层次了。 When there is no type hierarchy you don't have to manage the type hierarchy. 它直指面向对象编程(OOP)中一个常见的复杂性来源。"类型层次"主要指面向对象语言中的类继承结构: "没有类型层次"并非完全否定OOP,而是指一种不同的设计风格: 组合优于继承:用组合(has-a)而不是继承(is-a)来复用代码。 接口而非基类:定义行为协议,而不是强制继承关系。 简单类型:类的职责单一,减少复杂的层次结构。 这种思想在Go语言中很明显: Go没有类和继承,但有结构体和接口。 结构体可以嵌入其他结构体来复用字段和方法,但不是继承。 接口是隐式的:只要一个类型实现了接口的所有方法,它就"是"那个接口。 13、按工程管理的规则,生产力最重要;可在工程师眼里,乐趣才是第一位。生产力源于乐趣。 Productivity is most important by engineering management rules, but enjoyment is most important for engineers. One stems from the other. 这里的"乐趣"不只是表面的快乐,更指: 解决有趣问题的满足感。 创造优雅代码的成就感。 与团队协作的归属感。 学习新技术的好奇心。 优秀的工程师往往被这些内在动机驱动,而不仅仅是外部压力。 Pike指出,高生产力实际上源于工程师的乐趣。 当工程师享受工作时,他们会: 自发加班,因为问题太有趣了。 主动优化代码,因为看到丑陋的代码会不舒服。 积极学习,因为新技术太酷了。 这些行为自然而然地提高了生产力。 这一观点在软件行业有广泛共识: Google的20%时间:员工可以花20%工作时间做自己感兴趣的项目。许多重要产品(如Gmail)就是这样诞生的。虽然说现在Google已经去掉了20%工作时间的政策。 开源社区:大多数贡献者是因为热情而不是报酬。这种模式创造了Linux、Python等。 创业文化:舒适的办公环境、弹性工作制,都是为了让员工更快乐,从而更有创造力。 对管理者的启示: 不要只盯着KPI。创造让工程师愉悦的环境,生产力自然会提高。 理解并尊重工程师的动机。有时,让他们"玩"反而能得到更好的结果。 对工程师的启示: 追求技术乐趣并不自私。它能让你更高效,也让产品更优秀。 但也要有度。纯粹追求个人兴趣而忽视团队目标,同样问题。 14、第一法则:你猜不准程序会在哪里耗时。性能瓶颈总在意想不到的地方冒出来,所以别想当然去优化,除非你证实了那里就是瓶颈所在。 Rule 1. You can't tell where a program is going to spend its time. Bottlenecks occur in surprising places, so don't try to second guess and put in a speed hack until you've proven that's where the bottleneck is "猜不准程序会在哪里耗时": 程序性能不是直观的。即使是经验丰富的程序员,也常常错误预测哪部分代码最慢。 现代系统复杂(多线程、缓存、编译器优化等),让性能特征更难预测。 "性能瓶颈总在意想不到的地方冒出来": "性能瓶颈"是限制整体性能的最慢部分。就像木桶,最短的那块板决定了水位。 "意想不到"暗示即使是看似简单的代码,也可能因为被频繁调用或数据量大而成为瓶颈。 "别想当然去优化": "想当然"在中文里就是不经证实就认定。这在性能优化中很危险。 过早优化(premature optimization)是编程界臭名昭著的反模式。它可能导致: 代码复杂化,难以理解和维护。 浪费时间在实际上不慢的部分。 引入新的bug或性能问题。 "除非你证实了那里就是瓶颈所在": "证实"是关键。不是猜测,而是通过性能分析工具(profiler)确定。 现代profiler可以精确定位耗时函数、内存分配等,让优化有的放矢。 这一法则的实践: 先让它跑起来(Make it work) 然后让它对(Make it right) 最后才是让它快(Make it fast) 每一步都用数据(profile)来指导,不猜测。 14、扩展 第14条事实上来源自Robe Pike的编程五原则, 包括上面的多条名言: 第一法则: 你猜不准程序会在哪儿磨蹭。性能瓶颈总冒出在意想不到的地方,所以别瞎猜着去优化,除非你摸清楚了瓶颈的准确位置。 第二法则: 先量化。没测量之前别瞎调速度,就算测出来了,也得是哪块代码严重拖后腿了才优化。 第三法则: 花里胡哨的算法在数据小时龟速,而且数据往往就不大。花哨算法有大常数。除非你明摆着要处理大数据,别整那些花活。(就算真遇到大数据,也先用第二法则。) 第四法则: 花哨的算法比简单的更容易藏bug,而且实现起来费劲得很。算法要简单,数据结构也要简单。 第五法则: 数据为王。如果你选对了数据结构,组织得当,算法几乎都是呼之即来。编程的核心是数据结构,不是算法。 Pike的第一、二法则重申了Tony Hoare的名言:"过早优化是万恶之源"。 在中文里,"万恶之源"是个很重的词,用在这儿既有警示,也带点调侃。 Ken Thompson把Pike 的第三、四法则总结为:"疑难杂症,暴力破解"。 "暴力破解"在中文程序员圈很常见,指不优雅但直接有效的解法。 第三、四法则体现了KISS(Keep It Simple, Stupid)设计哲学。中文常说"简单就是美",或者IT圈的"能用短裤的地方,别穿西装"。 第五法则早先出现在Fred Brooks的《人月神话》中。程序员们常把它简化为"写傻瓜代码,用聪明对象"。 这句话在中文圈也很流行,强调代码逻辑要直白,复杂性应该封装在良好设计的数据结构里。 15、如果POSIX线程算好东西,我都不敢想它比什么还好。 If POSIX threads are a good thing, perhaps I don't want to know what they're better than. 这是一句典型的程序员式嘲讽,直指他认为设计糟糕的一个技术标准。相当于说"它已经够糟了,居然还有比它更糟的?" Pike为什么这么说? 复杂性:pthreads API被认为过于复杂和底层,容易出错。 死锁风险:错误使用pthreads很容易导致死锁、竞态条件等并发问题。 可移植性问题:尽管POSIX旨在提高可移植性,但不同系统的pthreads实现仍有差异。 16、缓存的bug,哪个不是妖魔鬼怪。 There's no such thing as a simple cache bug. "缓存"在计算机中无处不在:CPU缓存(L1, L2, L3)、内存缓存(如Redis)、浏览器缓存、数据库查询缓存、CDN(内容分发网络)、DNS缓存等等。 Pike之所以这么说,是因为缓存bug的特点: 诡异性:缓存问题常常表现得不一致、间歇性,像"鬼打墙"。 隐蔽性:问题可能潜伏很久才暴露,像"潜伏的妖怪"。 牵连性:一个小小的缓存问题可能导致系统范围的故障,像"妖风四起"。 难调试:因为缓存常常是分布式的,跟踪问题如同"捉鬼"。 死灰复燃:以为修好了,问题却在高负载时死灰复燃,像"怪物再生"。 17、用 Unix 就跟只听大卫·卡西迪的歌似的,纯属乐坛井底之蛙。 Using Unix is the computing equivalent of listening only to music by David Cassidy. Rob Pike在一次采访中说了这句话rob pike responds。 Rob Pike可是Unix的大佬级人物,和Ken Thompson、Dennis Ritchie一起在贝尔实验室创造了Unix。他怎么可能真的觉得Unix就像肤浅的流行乐? 现在的macOS(Mach内核)、Linux,甚至Windows(WSL)都有Unix的影子。 Pike仿佛预言般地讽刺:"看看,大家最后都来听'大卫·卡西迪'了。" Rob Pike这句话是典型的技术人的自嘲式炫耀。表面上自贬,实际上是在用幽默的方式表达:"对,我们就是主流,因为我们简单好用。学院派笑话我们Low,但最后还不是得用我们的东西?"这种自黑中带着骄傲的调调。 18、"智能"终端可不是"自作聪明"的终端,而是你能调教的好帮手。 A smart terminal is not a smartass terminal, but rather a terminal you can educate. Rob Pike这句话用带点烟火气的方式,点出了技术设计的一个关键:真正的"智能"不是自作聪明,而是在交互中学习和成长。就像中国老话说的,"学而不舍,才能化茧成蝶"。无论是终端还是人,都是这个道理。 19、Socket 是 IO 接口的 X Window。 Sockets are the X windows of IO interfaces. 表面上是在夸 Socket,就像当年吹捧 X Window 一样,实际上是 Pike 式的反话,意思是:"Socket 复杂、难用,就跟 X Window 一样令人头疼。" 为什么 Pike 这么黑 Socket? 复杂性:Socket API 有很多参数、选项和状态,容易用错。 底层性:直接操作网络协议,程序员要处理字节流、缓冲区等底层细节。 错误处理难:网络环境复杂,Socket 编程中的错误情况多,很难全面处理。 跨平台坑多:不同操作系统的 Socket 实现有微妙差异,写出跨平台代码很烦。 20、搞个理论上不那么刺激的编程语言干嘛?因为好用啊,能用才是王道。 Why would you have a language that is not theoretically exciting? Because it's very useful. 这话当然还是出自Rob Pike。作为Unix和Go语言之父,他一贯秉持"实用主义者"的态度。 学院派追求"道"(理论和原则)。Pike强调"术"(实用技巧和方法)。 21、并发不是并行 Concurrency is not parallelism 22、 Go语言 箴言 Go Proverbs

2024/6/10
articleCard.readMore

Rust tips #81 ~ #90

<a id="more"></a>

2024/6/9
articleCard.readMore