MoreRSS

site iconhuizhou | 萝卜修改

Golang 分布式相关的主题,读书笔记
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

huizhou | 萝卜的 RSS 预览

Go 1.24 的 Swiss Map:兼容性、扩展哈希与遗留问题

2025-01-23 14:19:09

Go 1.24 的 Swiss Map:兼容性、扩展哈希与遗留问题

上一篇文章中,我介绍了swiss map跟 Dolthub 实现的一个Go语言版本。对于 swiss map 不太熟悉的读者,可以先去看看那篇文章。

在即将正式发布的Go1.24中swiss map 将作为现有map的替代者正式进入gosrc。它完全兼容现有API,并且在部分benchmark场景下带来了超过50%的性能提升。到目前为止,swiss map是我对于Go1.24最期待的功能了。可是它真的有期待中的那么好嘛?本文将从兼容性扩展哈希(Extendible Hashing的实现与优势,以及遗留问题三个方面,深入剖析这一新设计的核心逻辑。

兼容性:无痛迁移的底层支持

Go 的swiss map 设计目标之一是与旧版 map 兼容。通过条件编译标签和类型转换,实现在新旧版本间的无缝切换。例如,在 export_swiss_test.go 中,newTestMapType 函数直接通过类型转换将旧版 map 的元数据转换为 swiss map 的类型结构:

1
2
3
4
5
6
7
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/export_swiss_test.go#L14
func newTestMapType[K comparable, V any]() *abi.SwissMapType {
    var m map[K]V
    mTyp := abi.TypeOf(m)
    mt := (*abi.SwissMapType)(unsafe.Pointer(mTyp)) // 直接类型转换
    return mt
}

这种设计允许现有代码无需修改即可通过实验性标志启用 swiss map,同时保留了旧版哈希表的内存布局兼容性。当前gotip(go1.24-3f4164f5) 中GOEXPERIMENT=swissmap 编译选项已经默认打开,也就是默认使用的是swiss map
如果您还是想用 原来的map可以使用 GOEXPERIMENT=noswissmap

Swiss Map 的数据结构

Extendible Hashing:如何实现动态扩展?

与其他其他几个社区实现,除了在兼容性方面的改进之外,swiss map的核心创新之一是采用了Extendible Hashing(扩展哈希),以支持高效的增量扩容。传统哈希表在扩容时需要全量迁移数据,而 Extendible Hashing 通过多级目录和表拆分,将扩容开销分摊到多次操作中。

Dir与table的层级结构

map.goMap 结构体中,globalDepthdirectory 字段是关键:

1
2
3
4
5
6
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/map.go#L194
type Map struct {
    globalDepth  uint8      // dir的全局深度
    dirPtr       unsafe.Pointer // dir指针(指向多个 table)
    // ...
}

dir大小为 1 << globalDepth,每个dir项指向一个 table。当某个 table 的负载过高时,会触发拆分(Split),而非全局扩容。

拆分操作

拆分(Split)是 swiss map在单个表容量达到 maxTableCapacity(默认为 1024)时触发的动态扩容机制。其核心目标是将一个表的负载分摊到两个新表中,避免全局扩容的高延迟。以下是拆分的关键步骤与地址变化:
拆分时,原表(假设为 table A)会创建两个子表 table Lefttable Right。它们的 localDepth(本地深度)比原表大 1,表示其哈希掩码多使用了一个高位比特。

1
2
3
4
5
6
7
8
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/table.go#L1043
func (t *table) split(typ *abi.SwissMapType, m *Map) {
    localDepth := t.localDepth
    localDepth++ // 子表的 localDepth 比原表大 1
    left := newTable(typ, maxTableCapacity, -1, localDepth)
    right := newTable(typ, maxTableCapacity, -1, localDepth)
    // ...
}

leftright 是新分配的内存对象,其 groups.data 指向新分配的连续内存块(通过 newarray 函数)。

数据分配:哈希掩码与比特位判定

拆分时,根据哈希值的高位比特(由 localDepth 决定)将原表的数据分配到左表或右表。例如,若 localDepth 为 2,则使用哈希值的第 2 个高位比特(从最高位开始计数)作为分配依据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/table.go#L1052
mask := localDepthMask(localDepth) // 生成掩码,例如 0x80000000(第 32 位)
for ... {
    hash := typ.Hasher(key, m.seed)
    if hash & mask == 0 {
        left.uncheckedPutSlot(...) // 分配到左表
    } else {
        right.uncheckedPutSlot(...) // 分配到右表
    }
}
  • 掩码计算localDepthMask 根据 localDepth 生成一个掩码,例如:
    • localDepth=10x80000000(32 位系统)或 0x8000000000000000(64 位系统)。
    • 该掩码用于提取哈希值的第 localDepth 个高位比特。

3. 目录的更新与扩展

拆分完成后,需要更新全局目录(Map.directory),使原表的索引范围指向新的子表。如果原表的 localDepth 等于全局的 globalDepth,则目录需要扩展(翻倍)。

目录扩展示例

假设原表 table AlocalDepth=1globalDepth=1,目录大小为 21 << 1)。拆分后:

  1. 目录翻倍globalDepth 增加到 2,目录大小变为 41 << 2)。
  2. 索引重映射:原表的目录项(例如索引 0-1)被替换为指向 leftright 表。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// map.go
func (m *Map) installTableSplit(old, left, right *table) {
    if old.localDepth == m.globalDepth {
        // 目录扩展:大小翻倍
        newDir := make([]*table, m.dirLen*2)
        // 复制旧目录项并指向新表
        for i := range m.dirLen {
            newDir[2*i] = left
            newDir[2*i+1] = right
        }
        m.dirPtr = unsafe.Pointer(&newDir[0])
        m.globalDepth++
    } else {
        // 不扩展目录,仅替换部分项
        entries := 1 << (m.globalDepth - left.localDepth)
        for i := 0; i < entries; i++ {
            m.directorySet(uintptr(old.index+i), left)
            m.directorySet(uintptr(old.index+i+entries), right)
        }
    }
}
  • 地址变化
    • 原表 table AdirPtr 指向的目录项被更新为新表的地址。
    • 例如,原目录项 [A, A] 变为 [Left, Right](扩展后目录为 [Left, Right, Left, Right])。

示例:拆分前后的地址与目录变化

初始状态
  • 全局目录globalDepth=1,目录大小为 2,指向同一个表 A
    1
    2
    
    directory[0] → A (localDepth=1)
    directory[1] → A (localDepth=1)
    
触发拆分
  1. 创建子表LeftlocalDepth=2)和 RightlocalDepth=2)。
  2. 目录扩展globalDepth 增加到 2,目录大小变为 4。
  3. 更新目录项
    1
    2
    3
    4
    
    directory[0] → Left  // 哈希前缀 00
    directory[1] → Left  // 哈希前缀 01(原属于 A 的低半区)
    directory[2] → Right // 哈希前缀 10
    directory[3] → Right // 哈希前缀 11(原属于 A 的高半区)
    
  • 地址变化
    • directory[0]directory[1] 的指针从 A 变为 Left
    • directory[2]directory[3] 的指针从 A 变为 Right

拆分后的数据分布

假设原表 A 的哈希键分布如下:

  • 哈希值高位为 0001 → 分配到 Left
  • 哈希值高位为 1011 → 分配到 Right

通过掩码 localDepthMask(2)(例如 0x40000000),提取哈希值的第 2 个高位比特,决定数据归属。

关键设计优势

  1. 局部性:仅拆分负载高的表,其他表不受影响。
  2. 渐进式扩容:目录按需扩展,避免一次性全量迁移。
  3. 地址连续性:新表的 groups.data 是连续内存块,利于缓存优化。

其他优化

此外,swiss map 针对一些特定场景也有优化,比如针对于少量元素(<=8)就直接使用一个group来存储数据,尽量降低 swiss map 在少量数据的时候的性能劣势。

遗留问题与挑战

尽管 swiss map 在设计与性能上迈出了一大步,但仍存在以下待优化点:

并发支持的局限性

当前实现通过 writing 标志检测并发写入,但缺乏细粒度锁:

1
2
3
4
5
6
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/map.go#L478
func (m *Map) PutSlot(typ *abi.SwissMapType, key unsafe.Pointer) unsafe.Pointer {

    m.writing ^= 1 // 简单标志位,非原子操作
    // ...
}

这在多线程高并发场景下可能导致竞争条件。官方文档提到未来可能引入更复杂的同步机制,但目前仍需依赖外部锁。

内存碎片化

swiss mapgroup 结构(8 控制字节 + 8 键值槽)可能导致内存对齐浪费,尤其是键值类型较小时。例如,若键为 int32、值为 int8,每个槽位将浪费 3 字节。

迭代器复杂度

Iter 的实现(table.go)需处理目录扩展和表拆分,逻辑复杂:

1
2
3
4
5
6
7
8
// https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/table.go#L742
func (it *Iter) Next() {
    if it.globalDepth != it.m.globalDepth {
        // 处理目录扩展后的索引调整
        it.dirIdx <<= (it.m.globalDepth - it.globalDepth)
    }
    // ...
}

在频繁扩容的场景下,迭代器的性能可能受到影响。

此外,在最新的代码中,遗留了很多TODO

我甚至都不知道如何总结这些TODO, 目前距离 Go 1.24 正式发布还有不到一个月的时间,不知道这些TODO会不会全部解决。
在社区中关于swiss map 的讨论也还有很多,主要集中在性能优化方面,可以想象未来 还会出现很多变动。

一切都要等到2月份正式发布。

性能测试

完整的测试代码:https://github.com/hxzhouh/gomapbench
go version devel go1.24-3f4164f5 Mon Jan 20 09:25:11 2025 -0800 darwin/arm64

平均性能提升 28% 大约,在某些场景下,甚至高达 50%。不愧是Go1.24 最期待的功能。

但是,这是一份不严谨的性能测试,测试机器也仅限于我自己的笔记本电脑,有一些报告显示swiss map 在某些地方的性能甚至出现了下降。
https://x.com/valyala/status/1879988053076504761

https://x.com/zigo_101/status/1882311256541102178

最终swiss map 如何,还是要看后续的演化。

总结

Go 1.24 的 swiss map 通过兼容性设计、Extendible Hashing 和优化的探测序列,显著提升了哈希表在高负载场景下的性能。然而,其并发模型和内存效率仍有改进空间。对于开发者而言,这一新特性值得在性能敏感的场景中尝试,但也需关注其当前限制。

参考资料

https://tonybai.com/2024/11/14/go-map-use-swiss-table/
https://github.com/golang/go/issues/54766
https://pub.huizhou92.com/swisstable-a-high-performance-hash-table-implementation-3e13bfe8c79b
https://www.geeksforgeeks.org/extendible-hashing-dynamic-approach-to-dbms/


  • 本文长期链接
  • 如果您觉得我的博客对你有帮助,请通过 RSS订阅我。
  • 或者在X上关注我。
  • 如果您有Medium账号,能给我个关注嘛?我的文章第一时间都会发布在Medium。

使用对冲模式降低长尾请求

2025-01-16 14:44:41

对冲请求模式出现在论文The Tail At Scale中,是Google 解决微服务长尾效应的一个办法.也是gRPC中两种重试模式之一。
source: https://grpc.io/img/basic_hedge.svg
对冲请求客户端将同一个请求发送到不同的节点,一旦收到第一个结果,客户端就会取消剩余的未处理请求。
这种模式主要作用是为了实现可以预测的延迟。假设我们的服务的一个调用链路是20个节点,每个节点的P99是1s,从概率上讲,一定有 18.2% 的请求时间大于1s。

通过对冲模式,我们每次都是从最快的节点那里得到结果,所以不会存在不可预测的长尾延迟(服务故障不在考虑范围之内) 。
在Golang中,我们可以使用context很方便的实现对冲请求,比如在下面的例子中:对于同一个后端服务,我们发起五次请求,只取最先返回的那次。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func hedgedRequest() string {
	ch := make(chan string) // chan used to abort other requests
	ctx, cancel := context.WithCancel(context.Background())

	for i := 0; i < 5; i++ {
		go func(ctx *context.Context, ch chan string, i int) {
			log.Println("in goroutine: ", i)
			if request(ctx, "http://localhost:8090", i) {
				ch <- fmt.Sprintf("finsh [from %v]", i)
				log.Println("completed goroutine: ", i)
			}
		}(&ctx, ch, i)
	}

	select {
	case s := <-ch:
		cancel()
		log.Println("cancelled all inflight requests")
		return s
	case <-time.After(5 * time.Second):
		cancel()
		return "all requests timeout after 5 secs"
	}
}

完整的代码 请访问:https://go.dev/play/p/fY9Lj_M7ZYE
这样做的好处就是,我们可以规避服务的长尾延迟,使服务的之间的延迟控制在可控的范围内。不过直接这么实现会造成额外的多倍负载。需要仔细设计。

为什么会出现长尾延迟?

出现长尾延迟的原因有很多,比如

  1. 现在混合部署已经成为主流,意味着一台物理机上有很多人跟你抢夺关键资源,所以可能会因为关键资源调度,导致长尾效应
  2. GC,这个不需要过多解释,Golang的 STW会放大长尾延迟
  3. 排队, 包括 消息队列、 网络等。
  4. ….

有什么办法可以避免对冲请求模式造成的 请求放大嘛?Go High-Performance Programming EP7: Use singleflight To Merge The Same Request 中详细介绍了如何使用 SingleFlight 来合并相同的请求。这个场景下面,使用SingleFlight 能够一定程度的缓解重复请求。

还有一种做法是只发送一个请求, 到P95的时候,如果还没有收到返回,那么就立即向第二个节点发送请求。这样做的好处就是将重复请求缩小到5%。并且大大缩短了长尾请求。

在这篇论文中,还有一些方法可以用来解决,长尾请求

  1. 服务分级 && 优先级队列(Differentiating service classes and
    higher-level queuing)
    。差异化服务类别可以用来优先调度用户正在等待的请求,而不是非交互式请求。保持低级队列较短,以便更高级别的策略更快生效。
  2. 减少队头阻塞 ,将耗费时间比较多的请求,转换成比较小的请求。Web性能优化的时候有时候也会使用这种方式。
  3. 微分区(Micro-partition) 以细粒度来调整负载便可以尽量降低负载不均导致的延迟影响。
  4. 对于性能比较差的机器,采用熔断。
  5. ……

你还有其他处理长尾请求的好方法吗?

  • 本文长期连接
  • 如果您觉得我的博客对你有帮助,请通过 RSS订阅我。
  • 或者在X上关注我。
  • 如果您有Medium账号,能给我个关注嘛?我的文章第一时间都会发布在Medium。

高效管理定时事件

2025-01-14 21:18:02

Featured image of post 高效管理定时事件

场景描述

假设有这么一个场景: 假设100w个Uber司机,司机客户端每隔10分钟上报一次数据,如果十分钟没有上报数据,服务端会将这个司机设置为离线状态,不给他派单。
我们应该如何实现这个功能?
通常的情况下,我们会使用redis的zset等第三方组件来实现这个功能,但是如果不使用第三方组件呢?只使用内存呢?大概有这么几种方案:

使用 Timer

  1. 用一个Map<uid, last_time>来记录每一个uid最近一次上报时间;
  2. 当某个用户uid存活上报时,实时更新这个Map;
  3. 启动一个timer,轮询扫描这个Map,看每个uid的last_time是否超过30s,如果超过则进行超时处理;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

var (
	userMap sync.Map
	timeout = 10 * time.Minute
)
func checkTimeout() {
	now := time.Now().Unix()
	userMap.Range(func(key, value any) bool {
		uid := key.(string)
		lastTime := value.(int64)
		if now-lastTime > int64(timeout) {
			fmt.Printf("User %s timed out. Last reported at %d\n", uid, lastTime)
			userMap.Delete(uid) 
		}
		return true
	})
}

缺点:这种方式效率不高,因为需要定期轮询整个Map,时间复杂度较高。

使用gorutine 管理

另一种方案是为每个司机分配一个Goroutine来管理其心跳超时。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
timer := time.NewTimer(timeout * time.Second)
for {
	select {
	case <-heartbeat: // 收到心跳信号
		if !timer.Stop() {
			<-timer.C
		}
		timer.Reset(timeout * time.Second)
	case <-timer.C: // 超时
		fmt.Printf("Driver %s timed out.\n", uid)
		break
	}
}
userMap.Delete(uid)

缺点:虽然不需要轮询,但每个Goroutine都有栈空间,当司机数量非常多时,内存消耗会很大。此外,Timer的创建和删除时间复杂度为O(log n),效率有待提升。
前面铺垫了这么久,终于轮到我们的主角了,时间轮。

时间轮算法

时间轮算法示意图

时间轮是一个比较有趣的算法,他最早刊登在George Varghese和Tony Lauck的论文里。 时间轮算法的核心逻辑是:

  • 使用一个固定大小的数组表示时间轮,每个槽(slot)对应一个时间间隔。
  • 每个slot中保存一个slice,用于存储当前时间段到期的任务,同时有一个Map<uid,slot> 记录uid对应的solt。
  • 使用一个定时器定期(interval)推动时间轮向前走一格(position),处理当前solt(position)的所有任务.

所以我们可以使用时间轮算法来实现功能。我们可以将interval设置成1s, 时间轮的slots为600,司机上报数据的时候,将记录插入到position-1的slot里面。
一个简单的Demo:
https://gist.github.com/hxzhouh/5e2cedc633bae0a7cf27d9f5d47bef01

优势

  • 时间轮算法添加任务只需要将任务append 到对应的slot里面,所以时间复杂度是O(1)
  • 时间轮槽位固定,内存占用可控。
  • 适合处理大量定时任务

劣势

  • 时间轮的精度受限于interval 的大小。
  • 如果任务分配不均匀的话,Delete Task 可能退化到 O(n)

所以时间轮特别适合以下场景的任务:

  • 大量任务的快速插入
  • 对时间精度要求不是特别高的场景
  • 任务分布相对均匀的情况

时间轮的优化

简单时间轮

将所有任务的换算为多少秒或毫秒(Interval)后到期,维护一个最大过期值(Interval)长度的数组。比如有10个任务,分别是1s,3s,100s 后到期,就建一个100长度的数组,数组的index就是每个任务的过期值(Interval),当前时间作为第一个元素,那么第二个元素就是1s 后到期的任务,第三个是2s 后到期的任务,依次类推。当前时间随着时钟的前进(tick),逐步发现过期的任务。

  • 开始调度一个任务(start_timer): 来一个新的调度任务时,换算任务的到期时间为到期值(Interval),直接放入相应的数组元素内即可,时间复杂度是O(1)。
  • 时钟走一格,需要做的操作(per_tick_bookkeeping):时钟走一格直接拿出这一格内的任务执行即可,时间复杂度是O(1)。

Hash有序时间轮 (Demo中使用的就是这种方式的变种)

简单时间轮虽然很完美,所有的操作时间复杂度都是O(1),但是当任务最大到期时间值非常大时,比如100w,构建这样一个数组是非常耗费内存的。可以改进一下,仍然使用时间轮,但是是用hash的方式将所有任务放到一定大小的数组内。 这个数组长度可以想象为时间轮的格子数量,轮盘大小(W)。
hash的数值仍然是每个任务的到期值(Interval),最简单的是轮盘大小(W)取值为2的幂次方,Interval哈希W后取余,余数作为轮盘数组的index,数组每个元素可能会有多个任务,把这些任务按照过期的绝对时间排序,这样就形成了一个链表,或者叫做时间轮上的一个桶。
但是Hash有序时间轮 还是有一个问题:
因为只使用了一个时间轮,处理每一格的定时任务列表的时间复杂度是 O(n),如果定时任务数量很大,分摊到每一格的定时任务列表就会很长,这样的处理性能显然是让人无法接受的。

层级时间轮

层级时间轮通过使用多个时间轮,并且对每个时间轮采用不同的 u,可以有效地解决简单时间轮及其变体实现的问题。
参考 Kafka 的 Purgatory 中的层级时间轮实现:

  • 每一层时间轮的大小都固定为 n,第一层时间轮的时间单位为u,那么第二层时间轮(我们称之为第一层时间轮的溢出时间轮 Overflow Wheel)的时间单位就为 n*u,以此类推。
  • 除了第一层时间轮是固定创建的,其他层的时间轮(均为溢出时间轮)都是按需创建的。
  • 原先插入到高层时间轮(溢出时间轮)的定时任务,随着时间的流逝,会被降级重新插入到低层时间轮中。

总结

总结一下几种算法的性能。

算法 添加任务复杂度 删除任务复杂度 内存开销 适用场景
Single Timer O(1)O(1) O(1)O(1) 适用于任务数量少、精度要求高的场景。
Multi Timer O(log⁡n)O(\log n) O(log⁡n)O(\log n) 适用于任务数量中等、任务间相互独立的场景,但内存开销较高。
Simple Timing Wheel O(1)O(1) O(n)O(n) 高(大数组) 任务分布均匀、到期时间精度要求较低的场景。
Hash Timing Wheel O(1)O(1) O(n)O(n) 任务数量较多、分布不均匀但对精度容忍较高的场景。
Hierarchical Timing Wheel O(1)O(1) O(log⁡n)O(\log n) 低到中 适用于大规模任务、层级管理复杂任务、需要较长生命周期的任务调度场景(如 Kafka 和 Netty)。

参考资料

  1. 真实世界中的时间轮
  2. Hashed and Hierarchical Timing Wheels

  • 本文长期连接
  • 如果您觉得我的博客对你有帮助,请通过 RSS订阅我。
  • 或者在X上关注我。
  • 如果您有Medium账号,能给我个关注嘛?我的文章第一时间都会发布在Medium。

Go 中的错误处理:新的?运算符

2025-01-11 19:35:01

背景

错误处理一直是编程中的重要组成部分, Go语言因为它独特的错误处理模式饱受争议,任何一篇写如何讨厌Go语言的博客中,一定会把“繁琐的错误处理”放在靠前的位置。这个问题在 Go 社区引发了大量讨论,探讨如何在保持清晰性和可维护性的同时减少模板代码。
在我自己的一个项目中,有 422 个 if err != nil

Proposal 详情

ianlancetaylor提出了一个新的提案#71203 ,在 Go 中引入用于错误处理的操作符?。用来简化Go的错误处理。后续Go的错误处理可能会变成这个样子:

1
2
3
4
5
6
7
// now
result, err := someFunction()
if err != nil {
    return nil, err
}
// proposal ? 
result := someFunction()?

在本例中,两种写法的结果是相等的:如果 someFunction()返回错误,就返回。
这个proposal的 核心内容就是这样了, 主要目的是减少templ代码,同时保持 Go 的显式和简洁理念。是一个语法糖,在返回多个值的函数调用(例如 (T, error))之后使用时,它会自动检查最后一个值是否为非零(表示错误)。编译器将为 这种写法生成跟以前一样的代码,保证兼容性。
在正式提案中,ianlancetaylor详细阐述了?的语法规则:

  • ?只能出现在赋值语句或表达式语句的末尾,并且表达式必须要有返回值
  • 对于表达式语句,?“吸收”的是表达式的最后一个值(通常是err)
  • 对于赋值语句,?“吸收”的是右侧表达式的最后一个值(通常是err),这样右侧值的数量会比左侧变量的数量多一个。
  • 这个被“吸收”的值称为qvalue, 必须是实现了error接口的接口类型。
  • ?后面可以跟一个代码块。如果没有代码块,当qvalue不为nil时,函数会立即返回,并将qvalue赋给最后一个返回值。如果?后面有代码块,当qvalue不为nil时,代码块会被执行。在代码块中,会隐式声明一个名为err的变量,其值和类型与qvalue相同。

基本的使用场景可能是这样子的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

r := os.Open("file.txt") ? // ? 吸收了  os.Open 的error, 如果不为空,就会返回。

func Run() error { 
	Start() ? // 如果 Start 返回非 nil 的 error,立即返回该 error 
	return nil 
}

func process() error {
	result := doSomething() ? {
		return fmt.Errorf("something failed: %v", err)  // qvalue 
	}
	anotherResult := doAnotherThing(result)?
	return nil
}

优点

这个 proposal 最重要(也是唯一的好处)好处是减少 Go 程序中的重复代码数量, 根据proposal 中的描述.

reduces the error handling boilerplate from 9 tokens to 5, 24 non-whitespace characters to 12, and 3 boilerplate lines to 2.

跟以前的错误处理提案try 等不同的是, ? 不会引入隐藏的控制流, ?的存在明确地指示了错误处理的逻辑。

缺点

最大的缺点就是所有的Go图书、资料需要更新,并且对于新人来说,可能需要理解这个概念,因为它跟其他语言的实现都不太一样。并且这个改动,会涉及很多代码,包括go src,所以Go Core Team 的压力也很大,因为机会只有一次。

Err 是隐式变量

?后面的代码块会隐式声明一个err变量,这可能会导致变量shadowing的问题。 proposal 中提到了一个例子,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for n = 1; !utf8.FullRune(r.buf[:n]); n++ {
	r.buf[n], err = r.readByte()
	if err != nil {
		if err == io.EOF {
			err = nil // must change outer err
			break
		}
		return
	}
}
// code that later returns err

In this example, the assignment err = nil has to change the err variable that exists outside of the for loop. Using the ? operator would introduce a new err variable shadowing the outer one.
In this example, using the ? operator would cause a compiler error because the assignment err = nil would set a variable that is never used.

在这个例子中,赋值 err = nil 必须改变存在于 for 循环之外的 err 变量。如果使用 ? 操作符,就会引入一个新的 err 变量,遮蔽外部变量。
在本例中,使用 ? 操作符还会导致编译器错误,因为赋值 err = nil 会设置一个从未使用过的变量。

写代码的心智负担会增加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func F1() error {
	err := G1()
	log.Print(err)
	G2() ?
	{
		log.Print(err)
	}
	return nil
}

func F2() error {
	err := G1()
	log.Print(err)
	G2() ? {
		log.Print(err)
	}
	return nil
}

在这个例子中,这两个函数都合法,只有G2的换行符有差异,但它们的行为却完全不同。 这个差异可不能通过fmt等方式找补回来。

不改变的合理性

尽管Go的错误处理机制经常受到批评,但它仍然是可用的。因此,社区需要权衡是否真的需要进行改变。在proposal中,ianlancetaylor反复提到: “Perhaps no change is better than this change. Perhaps no change is better than any change"。这也一定程度上反映出Go Core Team在错误处理改进方面其实并不那么坚定,感觉更多是迫于Go社区的舆论和压力。

泛型: 别Q我

总结

新的proposal可以看出Go Core Team 还是在听社区的声音。?操作符提案为Go语言的错误处理机制提供了一种新的思路。该提案通过引入简洁的语法,可以显著减少错误处理的代码量,并使代码的主流程更加清晰。尽管现在还存在一些分歧,但是总算有人在推动不是?


  • 本文长期连接
  • 如果您觉得我的博客对你有帮助,请通过 RSS订阅我。
  • 或者在X上关注我。
  • 如果您有Medium账号,能给我个关注嘛?我的文章第一时间都会发布在Medium。

[译]事实上,Go 是一种设计良好的语言

2025-01-09 18:42:34

Featured image of post [译]事实上,Go 是一种设计良好的语言

原文链接:Go is a Well-Designed Language, Actually

哈哈,没有泛型 —— 这是一句古老的程序员谚语。

从诸多方面来看,2009 年为我未来的职业生涯埋下了伏笔。那时我 13 岁,刚在一场足球赛里打进了人生中的第一粒进球。那是一次精彩的二过一配合,最后我一记大力抽射,球直入球门左上角。可惜的是,那天球探不知去向。当我还憧憬着踏入温布利球场的那一刻,Go 语言诞生了。

Go 语言很快就吸引了大批拥趸。大家钟情于它的简洁性,还有它对 Web 服务的优化,以及像 gofmt 这类实用工具。不过,凡事皆有两面,Go 语言也不例外。有人嫌弃它太过简单,抱怨它只能用来捣鼓蹩脚的 REST API,还吐槽那些过于 “热情” 的工具。

在过去的 15 年里,人们写下了大量对 Go 语言的批评,甚至是愤怒的吐槽。其中让我格外留意的,是有人认为 Go 语言设计得很糟糕。这一观点在两篇文章里体现得尤为明显 —— 《我想摆脱 Golang 先生的狂野之旅》 和 《我们告诉自己继续使用 Go 的谎言》,均出自 fastthanlime 之手。后者更是直言:

所以他们没有。他们没有设计语言。它就这么 “冒出来” 了。

设计究竟是什么?

在我看来,设计就是达成目标的计划或规范。打个比方,BBC 新闻网站的目标是向用户通报全球发生的、与他们切身相关的大事。为实现这一目标,网站会撰写新闻报道,再依据事件发生地和重要程度进行排序。毕竟,一枚朝我飞来的核弹,可比一只挂在树上的猫要紧要得多。

所以,判断一个设计好不好,要看它能在多大程度上实现既定的设计目标。

Go 语言的起源

Go 语言诞生于谷歌,Russ Cox、Rob Pike、Ken Thompson 等众多大咖都效力于谷歌。彼时,谷歌内部主要使用 Java 和 C++。Go 语言的设计者们觉得,这两门语言性能虽优,但用起来实在费劲。编译器慢吞吞的,工具还特别挑剔,而且它们的设计至少都是十年前的老黄历了。与此同时,云计算 —— 大量多核服务器协同作业,正变得日益普及。

于是,他们决定打造一门属于自己的语言,优先考虑让它能在大规模的计算任务以及人力协作方面游刃有余。Rob Pike 在 Go at Google 一文中解释道:

硬件规模庞大,软件亦是如此。软件动辄数百万行代码,服务器端大多用 C++ 编写,其余部分则大量采用 Java 和 Python。数千名工程师投身于代码编写工作。

在别的场合,Rob Pike 一如既往地以谦逊、含蓄的口吻谈及他所面向的那数千名工程师:

关键在于,我们的程序员是谷歌员工,而非科研人员。高深精妙的语言,他们可玩不转。

重要提示:要是你正在搞设计,千万要避免贬低、居高临下地对待你的设计受众。

尽管有这么一段引发争议的言论,不过我们还是能看出一个相当合理的设计目标:这门语言得让编写和维护大型并发服务器代码变得轻松容易,哪怕使用者是数千名技能水平参差不齐的开发人员

针对 Go 语言的批评

咱们来瞧瞧人们对 Go 语言的一些怨言,再依据它的设计目标来评判一番。

文件系统 API

Go 语言的文件系统 API 常常遭人诟病,原因是它太偏向 Unix 系统了。Windows 系统不像 Unix 那样有文件权限一说,所以 Go 语言只能返回一些形同虚设的权限。而且,Go 对路径的处理相当简单粗暴。操作系统有自己的路径分隔符,而路径在 Go 里就是 string 类型 —— 仅仅是一串字节,没有任何实质性的检查或限制(译者注:在 go 1.24 版本中使用 os.ROOT 会改善不少)。

其他语言在这方面就严谨得多。比如 在 Rust 里获取文件修改时间的方法,有可能返回 None。Zig 语言里 文件的元数据会因操作系统而异

不过从设计目标的角度来看,这倒也情有可原。Go 语言本就是为谷歌量身打造的,和大多数服务器一样,谷歌的服务器 清一色用的是 Linux。要是你设计一门主打服务器应用的语言,以 Unix 为核心来打造文件系统 API,不失为一个明智之举。

无运算符或函数重载

在 Go 语言里,和 Java 不同,函数和方法只有单一的定义(一旦指定了构建标签和目标)。与 C++ 迥异的是,运算符是在编译器里预先实现好的,无法重载。在 time 包里,要是想把 Duration 类型的值加到 Time 类型上,得用 Add 方法。要是你想增加两天,可不能像这样调用 Add(0 /*years*/, 0 /*months*/, 2 /*days*/),而得用 AddDate 方法。

在有些人眼里,这显得不够优雅,但它胜在简洁明了。在 Go 代码里看到函数调用,你心里清楚只需查看一处定义就行。要是瞅见一个运算符,你也明白它是针对内置类型的,干的肯定是靠谱的事儿,绝不会是 铸造 NFT 这种奇葩操作

费力的错误处理

公允地讲,当下编程语言的潮流是追求简洁。也难怪程序员们都反感 Go 语言里那种 if err!= nil 的错误处理风格。

然而,这也是深思熟虑后的抉择:

虽说相比之下,Go 语言检查错误的写法更啰嗦,但这种显式设计让控制流程一目了然 —— 就是字面意义上的清晰。

清晰明了的控制流程让代码的可读性更强。虽说支持异常处理的语言写起代码来可能更快,但生成的代码没那么简洁,而且控制流程藏得很深。

Go 语言常常因避开异常处理这类特性而饱受批评,有人觉得这简直是开倒车。曾经有人质问设计者:“为啥你们对 20 世纪 70 年代以来有关类型系统的研究成果一概无视?”。类似的论调 在别处也屡见不鲜

首先,Rob Pike 可瞧不上这种傲慢,也压根儿不 care:

Go 旨在解决谷歌在软件开发过程中遭遇的难题,这就使得这门语言虽说算不上开创性的科研语言,但用来搞大型软件项目,那绝对是把好手。

其次,将错误设计成明确的值,已然成为一种(再度)引领潮流的做法。Go、Rust 和 Zig 都选用了这种方式。Swift 语言即便支持异常,也要求你在函数签名里标明哪些函数可能会出错。

可怜的 FFI 能力

译者注:FFI,中文名叫语言交互接口 (Foreign Function Interface),指的是能在某种计算机语言里调用其他语言的接口。

Go 语言与其他语言的兼容性欠佳。要是你想调用 C 函数,比如使用 SQLite,那就得通过 CGO。要知道,CGO 可不是纯正的 Go,还存在性能损耗。由于 goroutine(拥有由 Go 运行时设定的专属堆栈)是执行单元,Go 就得按照 C 语言的期望来做一些操作以获取堆栈,这成本可不低。

Go 语言的 FFI 表现不佳,还因为它有自己的编译器、链接器和调试器。Go 生态系统里的好多东西都是定制化的。

不过,考虑到设计目标的话,这也说得通。服务器软件必须支持并发,所以采用了 goroutine。这必然会让调用 C 代码变得复杂些,但这种权衡利弊,至少适配 Go 用于服务器间通信而非进程间通信的并发系统。

这些决策也让 Go 语言在工具方面占尽优势。编译器是专为 Go 打造的,这意味着它能一门心思地快速编译 Go 代码。调试器能够理解 goroutine 以及 Go 的所有内置类型。

那么 Go 语言很棒吗?

这就见仁见智了。就我个人而言,我挺喜欢它的。我经手过的 Go 代码,读起来、理解起来通常都不费劲。它没有那些花里胡哨的东西,逼着我一门心思写实在的代码,而不是构建些华而不实的抽象概念。我还成功地向一大帮刚从大学毕业的新人传授过 Go 语言。

但这并不意味着我对它的缺点视而不见。有一回,我跟一位客户通电话,他碰上一个错误,就因为没检查错误,我们费了好大劲才追踪到问题所在。要是开着 Linter,这事儿本可轻松避免,可要是没开,那就麻烦了。Go 语言长久以来都不支持泛型,编写泛型数据结构的时候可费劲了。每次收到一份关于 Windows 系统的错误报告,我都得停下来琢磨琢磨,是不是 Go 语言让我产生了一种错误的安全感?

说到底,这些问题都是设计过程中有意权衡取舍的结果。你可以说不喜欢 Go 语言,或者它不适合某个应用场景,又或者它满足不了你的需求。甚至,你大可以直言讨厌它。但千万别断言它设计得糟糕。


  • 本文长期连接
  • 如果您觉得我的博客对你有帮助,请通过 RSS订阅我。
  • 或者在X上关注我。
  • 如果您有Medium账号,能给我个关注嘛?我的文章第一时间都会发布在Medium。

IT 巨头正在杀死他们的客户

2025-01-09 16:01:05

Featured image of post IT 巨头正在杀死他们的客户

近期,Dennis Schubert发布了一则帖子,称 “diaspora*” 项目的网络基础设施因为访问流量过大而陷入了性能瓶颈。令人震惊的是,他发现70% 的请求来自 IT 巨头公司的 LLM(大语言模型)爬虫。这些爬虫无视 robots.txt 文件,贪婪地抓取网站的所有可用数据,甚至是一些无关紧要的内容。

Dennis 感到无比愤怒,因为 ChatGPTAmazon 的爬虫竟然爬取了 Wiki 的全部编辑历史,每一页的每次编辑都被记录下来。他质问:

“他们到底要做什么?是想研究文本如何随时间变化吗?”

这种对数据的无底线掠夺,导致服务器负载极高,用户访问体验显著下降。Dennis 尝试了一些反制措施:

  1. 更新 robots.txt:无效,爬虫无视规则。
  2. 限制访问速率:失败,爬虫会快速更换 IP。
  3. 屏蔽 User Agent:没用,爬虫伪装成普通用户。

最终 Dennis 感慨,这种行为已经接近于对整个互联网的DDoS 攻击

为什么 IT 巨头需要爬我们的数据?

答案是:AI 数据饥荒
随着大模型的普及,用于训练 AI 的高质量语料已经见底。正如 OpenAI 工程师 James Betker 所言:

“模型优劣的关键在于数据集的质量。它们正在以惊人的精度复刻数据集。”

为了在 AI 竞赛中领先,巨头们不惜一切代价获取更多数据。个人网站、自建 Wiki,这些原本属于小众的内容,正成为巨头们争相攫取的目标。

我们能够应对吗?

IT 巨头拥有顶尖的爬虫和反爬虫技术团队,能够在抓取与用户体验之间找到平衡。但对于个人网站和小型项目来说,这无疑是一场不对等的战争。

Dennis 提出了以下两种反制策略:

  1. Tarpit 技术:生成无意义的随机文本,诱导爬虫抓取无关内容。
  2. JavaScript 陷阱:让 AI 爬虫加载 JavaScript 才能获得数据,而这些脚本可能暗含挖矿代码。

尽管这些方法可能有效,但实现起来成本不菲。

没有链接的互联网

巨头公司的终极目标是什么?
是将用户牢牢锁定在他们的生态系统中。通过 AI 提供“最优内容”,用户无需访问其他网站,甚至看不到其他链接。一切内容直接呈现,广告作为附加品,而创作者只能沦为巨头的数据供应商。

这种趋势正在瓦解互联网的开放性。
无论你如何优化 SEO 或产出优质内容,巨头的 AI 会优先抓取并整合,用户永远不会直接访问你的网站。最终,个人创作者将失去流量与收入,整个互联网变成巨头的“金矿”。

总结

IT 巨头正在用技术手段,掠夺数据,榨取价值,逐步摧毁互联网的多样性与开放性。对于个人网站而言,我们几乎无力抗争,而这场改变已经不可逆。

引用资源:


  • 本文长期连接
  • 如果您觉得我的博客对你有帮助,请通过 RSS订阅我。
  • 或者在X上关注我。
  • 如果您有Medium账号,能给我个关注嘛?我的文章第一时间都会发布在Medium。