Golang Map源码与扩容机制深度探索‌

概述

Go 语言内置的 map 是一种散列表(Hash Table)实现,提供了泛型化、并发读写安全(只要不同时写)以及近乎常数时间的查找、插入和删除性能。要深入理解 map 在底层如何工作,需要探究 Go 运行时中 hmapbmap、桶(bucket)结构、哈希函数、扩容(grow)触发条件与增量迁移机制。本文将从**map 的高层语义出发,剖析其底层数据结构**、查找插入流程扩容触发与执行,并通过代码示例ASCII 图解帮助你更容易地掌握 Go map 的实现原理。


一、Go map 的高层语义与使用示例

在 Go 语言中,map[K]V 表示从键类型 K 到值类型 V 的哈希映射。常见用法示例如下:

package main

import "fmt"

func main() {
    m := make(map[string]int)     // 创建空 map
    m["apple"] = 5                // 插入键值对
    m["banana"] = 3

    v, ok := m["apple"]           // 查找键,返回值与是否存在
    if ok {
        fmt.Println("apple=", v)  // apple= 5
    }

    delete(m, "banana")           // 删除键
    fmt.Println(m)                // map[apple:5]
}
  • make(map[K]V):在运行时创建一个空的 map,底层会分配一个空的 hmap 结构与最初的桶(bmap)数组指针。
  • m[key] = value:向 map 中插入或覆盖一个键值对。
  • v, ok := m[key]:查找键 key,返回值 v 及布尔标志 ok
  • delete(m, key):从 map 中删除键 key(如果存在)。

要探究其性能与扩容原理,需要跳转到 Go 运行时中关于 map 的实现。下面将一步步剖析 Go map 在内存中如何组织数据、如何查找、插入,以及何时、如何扩容。


二、底层关键结构与概览

在 Go 运行时包(runtime)中,map 的核心数据结构分为三个部分:

  1. hmap:表示一个散列表实例,包含散列表元数据(比如桶指针、大小、哈希参数等)
  2. bmap:单个桶(bucket)的结构,存放多个键值对,以及指向溢出桶的指针
  3. mapextra:当 map 包含大键或值时,用于存放额外内存信息

最典型的定义(简化版,细节请参考 src/runtime/map.go)如下:

// hmap:Map 句柄,保存在用户变量中
type hmap struct {
    count     int            // 当前键值对数量
    flags     uint8          // 标志位(如是否正在扩容)
    B         uint8          // 2^B 表示当前桶(buckets)的数量
    noverflow uint16         // 溢出桶总数(粗略计数)
    hash0     uint32         // 随机种子,防止哈希碰撞攻击
    buckets   unsafe.Pointer // 指向第一个 bucket 数组的指针(*bmap)
    oldbuckets unsafe.Pointer // 扩容时指向旧 bucket 的指针(*bmap)
    nevacuate uintptr        // 扩容时迁移进度索引
    extra     *mapextra      // 可选:向后链接存储大键/值的空间
}

// bmap:一个桶,存放 up to 8 个键值对(假设 64 位系统)
type bmap struct {
    tophash [bucketCnt]uint8 // 每个槽的高 8 位哈希值,0 表示空
    keys    [bucketCnt]keySlot   // 键数组,类型为 K
    values  [bucketCnt]valueSlot // 值数组,类型为 V
    overflow *bmap              // 指向下一个溢出桶
}
  • bucketCnt(在 64 位架构下为 8):一个桶最多能存放 8 条键值对(Go 运行时固定值,依据机器架构和类型大小适配)。
  • tophash[i]:存储了键 i 的哈希值的高 8 位,用于快速判断该槽是否可能匹配。
  • keys[i] / values[i]:存放键和值的内存槽,按原始类型大小对齐保存。
  • 溢出桶链(overflow 链表):当主桶已满且哈希冲突时,会将新键值对插入到溢出桶中,可能形成链表。
  • hmap.B:表示 bucket 数组的大小级别,桶数为 1 << B(即 2^B 个桶)。
  • hash0:随机哈希种子,用于结合键的哈希值,防止攻击者构造大量哈希冲突。
  • oldbucketsnevacuate:当 map 触发扩容时,用于增量迁移旧桶内容到新桶。

下图用 ASCII 示意 hmap 与桶数组关系:

      ┌────────────────────────────────────────────┐
      │                  hmap                     │
      │  +---------------- B = 3 (8 桶) ----------+│
      │  | count = 12       hash0 = 0xABCDEF12     │
      │  | buckets ────────┐                      │
      │  | oldbuckets ─────┼─> [ *bmap (8 个桶) ] │
      │  | nevacuate = 4   |   ------------------ │
      │  | flags (如 growing)|   | bmap0      │ │
      │  +----------------------+   |------------│ │
      │                             | bmap1      │ │
      │                             | ...        │ │
      │                             | bmap7      │ │
      │                             ---------------- │
      │ (如果正在扩容,oldbuckets 里有旧桶,而 buckets 指向新桶)   │
      └────────────────────────────────────────────┘
  • map 初始创建时,B 最小为 0 或 1(底层会最小分配 1 << 1 = 2 个桶);随着插入增多,当触发扩容阈值时,B 会加 1,从而 buckets 数量翻倍。
  • 扩容时,oldbuckets 会指向扩容前的旧桶数组,buckets 指向新桶数组,nevacuate 表示已经迁移到新桶的下标(从 0 开始向上)。

三、哈希与索引计算

3.1 键的哈希值计算

Go 对键 K 的哈希通过内置的 runtime.maphash(或在早期版本的 runtime.fastrand)函数计算,流程大致如下:

  1. 生成随机种子hmap.hash0 在创建 map 时由运行时随机生成(64 位系统为 32 位种子),用于与键的哈希函数混淆。
  2. 对键类型进行哈希:根据键 K 类型不同(整型、字符串、接口、结构体等),运行时会调用不同的哈希程序,最终获取一个 64 位(或 32 位)哈希值 h
  3. XOR 种子h ^= hmap.hash0,使得每个 map 的哈希值都不同,避免冲突攻击。
  4. 还原为 uint32:将结果截断或混合为 32 位哈希值,供后续使用。

伪代码示例(以字符串键为例):

func hashString(h0 uint32, key string) uint32 {
    // 基于 FNV-1a 或 MurmurHash 之类算法对 key 字符串计算哈希
    h := fnv1aHash([]byte(key))
    // 与随机种子做异或
    return h ^ h0
}

3.2 桶索引计算

得到一个 32 位哈希值 h 后,需要计算出对应的桶索引(bucketIdx)与槽内位置(利用 tophash 匹配):

  1. 计算 bucketIdxbucketIdx = h & ((1 << B) - 1)

    • 由于桶数 = 1 << B,取哈希值低 B 位即可得到模运算结果,快速映射到某个桶。
  2. 计算 tophashtoph := uint8((h >> shift) & 0xFF)

    • 实际取哈希值的高 8 位作为 tophash
    • shift32 - 8 = 24(如果哈希是 32 位),将高 8 位截取。tophash 用于快速判断当前槽的哈希高位是否匹配,若不匹配无需比较完整键,能加速查找。
  3. 槽内线性探查:在一个桶中,从槽 0 到槽 bucketCnt-1(桶容量)线性扫描,比较 tophash[i] 是否与 toph 相等。若不相等,跳过;若相等,再做完整键的等值比较,确认命中。若命中则返回该槽;若找不到,则跟随 overflow 链继续。

综上,一次查找的伪流程可表示为:

h := hash(key)                  // 32 位哈希值
bucketIdx := h & ((1 << B) - 1) // 取低 B 位
toph := uint8(h >> 24)          // 取高 8 位

b := buckets[bucketIdx]         // 找到对应主桶
for ; b != nil; b = b.overflow { // 遍历主桶 + 溢出桶链
    for i := 0; i < bucketCnt; i++ {
        if b.tophash[i] != toph {
            continue
        }
        if equal(key, b.keys[i]) { // 完整键比较
            return b.values[i], true
        }
    }
}
// 未命中则返回零值
return zeroValue, false

ASCII 图解:桶内查找示意

 哈希值 h = 0xAABBCCDD
 B = 3 -> 桶数 = 8
 bucketIdx = 0xAABBCCDD & 0b00000111 = 0b101 = 5
 toph = 0xAA (高 8 位)

 buckets[5] ──► 主桶 b5
                ┌──────────────────────────────────────────┐
                │ slot0: tophash=0x10, keys[0]=...         │
                │ slot1: tophash=0xAA, keys[1]=...         │ ← compare
                │ slot2: tophash=0xFF, ...                 │
                │ ...                                      │
                │ overflow ──► 溢出桶 b5_ovf               │
                └──────────────────────────────────────────┘
  • tophash[1] == 0xAA,再做完整键比对;若相等则命中。
  • 若主桶所有槽都不命中,则顺序访问溢出桶链 b5_ovf,重复相同逻辑。

四、插入(insert)与更新流程

插入或更新操作 m[key] = value 的核心流程与查找类似,只是会在适当位置放置新键值对,并可能触发扩容。伪代码逻辑如下:

func mapInsert(h *hmap, key K, value V) {
    if h.count >= threshold(h.B) { // 判断是否需要扩容
        growMap(h)
    }
    hkey := hash(key)
    bucketIdx := hkey & ((1 << h.B) - 1)
    toph := uint8(hkey >> 24)

    b := &buckets[bucketIdx]
    // 1. 尝试在主桶 + 溢出桶中查找是否已有该键
    for bb := b; bb != nil; bb = bb.overflow {
        for i := 0; i < bucketCnt; i++ {
            if bb.tophash[i] != toph {
                continue
            }
            if equal(key, bb.keys[i]) {
                // 找到已有键,更新值
                bb.values[i] = value
                return
            }
        }
    }
    // 2. 没有找到,插入新键
    // 2.1 找到一个空槽(tophash=0 表示空)
    for bb := b; ; bb = bb.overflow {
        for i := 0; i < bucketCnt; i++ {
            if bb.tophash[i] == 0 {
                // 放置到此空槽
                bb.tophash[i] = tophOrEmpty(toph)
                bb.keys[i] = key
                bb.values[i] = value
                h.count++
                return
            }
        }
        if bb.overflow == nil {
            // 主桶已满且无溢出桶,需创建一个新溢出桶
            bb.overflow = newBucket()
        }
    }
}

4.1 扩容触发阈值

Go map 的扩容阈值基于 负载因子(load factor),当 count+1 > bucketCount*maxLoadFactor 时触发扩容。其中 bucketCount = 1 << BmaxLoadFactor 通常取 6.5\~7(具体为常量 loadFactorNumerator / loadFactorDenominator,近似 6.5)。因此,当插入新键导致实际负载超过阈值时,就会执行 growMap,创建大小为原来两倍的新桶数组,并将旧桶里所有键值对重新哈希入新桶。

4.2 插入后计数器维护

  • 每成功插入一个新键(非更新),h.count 增加 1。
  • 删除时 h.count 减 1(会尝试在不用收缩的策略下保留当前桶大小)。

五、扩容(grow)机制与增量迁移

扩容是 Go map 最复杂的部分,因为它采用了增量迁移,让在扩容期间进行查找/插入也能正确工作,而不是一次性暂停整个 map。下面分步解析其核心原理。

5.1 扩容流程概览

  1. 创建新桶数组

    • growMap 触发时,oldbuckets = buckets
    • buckets 指向新的大小为原来两倍(1 << (B+1))的桶数组;
    • B 自增 1;
    • 标记 flags 中的 hashWritinghashGrowing,表示正在扩容。
  2. 初始化迁移进度 nevacuate = 0

    • 该字段表示旧桶数组中“已经迁移(evacuate)”到新桶的索引位置(逐个桶迁移)。
  3. 在后续查找/插入中,增量迁移

    • nevacuate 开始,每次调用 mapaccess1mapassignmapdelete 时,会优先迁移若干旧桶(根据当前操作类型迁移一到几个桶),即执行 evacuateBucket(oldbuckets[i]),将桶 i 里的所有键值对重新哈希到新桶。
    • nevacuate 增加 1,直至 nevacuate == oldBucketCount,所有旧桶迁移完成;随后清理 oldbuckets,并取消扩容标记。
  4. 在扩容期间的查找/插入

    • 查找:如果查询的桶编号 < nevacuate,说明该桶已被迁移,则直接在新桶数组中查找;如果 >= nevacuate,先在旧桶查找,并执行 evacuateBucket 迁移后再返回。
    • 插入:如果插入的桶编号 < nevacuate,则将新键值对插入到新桶;否则,先在旧桶执行迁移,将桶 i 迁移后,再将新键值对插到新桶。这样保证扩容期间的数据一致性。

完整流程请见下图:

  ┌──────────────────────────────────────────────────────────────┐
  │                        growMap(h)                           │
  │  1. oldbuckets = buckets                                    │
  │  2. buckets = new[numBuckets*2]                              │
  │  3. B = B + 1                                                │
  │  4. nevacuate = 0                                            │
  │  5. flags |= growing                                         │
  └──────────────────────────────────────────────────────────────┘
                   │
         后续对 h 的操作(插入/查找/删除)会调用 evacuate
                   ▼
  ┌──────────────────────────────────────────────────────────────┐
  │                 evacuateStep() (在 mapaccess 或 mapassign)   │
  │  if nevacuate < oldBucketCount {                              │
  │      evacuateBucket(oldbuckets[nevacuate])                    │
  │      nevacuate++                                              │
  │      if nevacuate == oldBucketCount {                         │
  │          // 所有桶已迁移完毕                                 │
  │          oldbuckets = nil                                     │
  │          flags &^= growing                                     │
  │      }                                                         │
  │  }                                                             │
  └──────────────────────────────────────────────────────────────┘

5.2 单个桶迁移(evacuateBucket)细节

当迁移桶 b 时,需要将 b 及其溢出桶链中的所有键值对拆出并插入到新桶数组。不同之处在于,扩容后新桶数组中一个键可能会映射到两个可能的桶,即“低位桶”与“高位桶”。原因如下:

  • 原来 B 位哈希前缀决定桶编号,新桶 B+1 位前缀会在最高位多一位。如果哈希值最高新增位为 0,则映射到老桶编号相同的低位桶;若最高新增位为 1,则映射到 “低位桶 + 原桶数”(即高位桶)。

伪代码示意 evacuateBucket

func evacuateBucket(oldb *bmap, newbuckets []*bmap, oldB int) {
    for bb := oldb; bb != nil; bb = bb.overflow {
        for i := 0; i < bucketCnt; i++ {
            if bb.tophash[i] == empty {
                continue // 空槽
            }
            k := bb.keys[i]
            v := bb.values[i]
            h := hash(k)
            // 原来 bucketIdx = h & ((1<<oldB)-1)
            // 现在 bucketIdx2 = h & ((1<<(oldB+1))-1)
            newIdx := h & ((1 << (oldB + 1)) - 1)
            // lowMask = 1 << oldB
            if (newIdx & (1 << oldB)) != 0 {
                // 高位桶
                bucketIdx := newIdx & ((1 << oldB) - 1)
                highBucket := newbuckets[bucketIdx + (1 << oldB)]
                insertToBucket(highBucket, k, v, h)
            } else {
                // 低位桶
                bucketIdx := newIdx
                lowBucket := newbuckets[bucketIdx]
                insertToBucket(lowBucket, k, v, h)
            }
        }
    }
    // 处理完后,清空 oldb 以释放内存
    oldb = nil
}
  • oldB 为扩容前的 B 值(桶数 1<<oldB)。
  • 1 << oldB 表示“旧桶数”与“增量偏移量”。
  • newIdx & (1 << oldB) 判断哈希值高位是否为 1,决定将键值对放在“高位”桶还是“低位”桶。
  • insertToBucket 逻辑与普通 mapInsert 中的“插入新键”类似,只是不会触发新的扩容。

ASCII 图解:桶迁移示意

  oldB = 2 -> 旧桶数 = 1<<2 = 4
  扩容后 newB = 3 -> 新桶数 = 8

  迁移 oldb[2] 的所有键值对:
  假设键 K 的哈希 h = 0b10110110
  oldIdx = h & 0b11 (低 2 位) = 0b10 = 2
  newIdx = h & 0b111 (低 3 位) = 0b110 = 6
  判断 (newIdx & (1<<2)) != 0  (0b110 & 0b100 = 0b100) != 0 -> 高位桶
  放到 newbuckets[ (6 & 0b011)=2 + 4 ] = newbuckets[6]

  另外若 h = 0b00101011:
  oldIdx = 0b11 = 3
  newIdx = 0b011 = 3
  (newIdx & 0b100) = 0 -> 低位桶
  放到 newbuckets[3]

5.3 扩容期间的查找与插入

在扩容尚未完成、oldbuckets != nilnevacuate < oldBucketCount 期间,所有查找/插入都需要兼容旧桶与新桶。规则如下:

  1. 查找(mapaccess1

    • 计算 bucketIdx
    • 如果 bucketIdx < nevacuate,表示该桶已被迁移,将直接在新桶数组中查找。
    • 否则,先在对应的旧桶链中查找;如果没找到,再在新桶中查找。
    • 在查找前或后,执行一次 evacuateBucket(oldbuckets[nevacuate]),以推进扩容进度。
  2. 插入(mapassign

    • 同样计算 bucketIdx
    • 如果 bucketIdx < nevacuate,将新键值对插入新桶;否则先迁移该桶(调用 evacuateBucket),然后插入到新桶。

通过这种“按需迁移 + 查找优先”策略,能保证扩容时不需要一次性将所有旧桶全部迁移完毕即可进行操作,同时均摊了扩容开销,避免出现扩容期间大幅停顿。


六、完整数据流 ASCII 图解

下面用一张整合图示意从普通插入到扩容、以及扩容期间查找的过程。

              ┌─────────────────────────────────────────┐
              │                mapInsert               │
              │   if count+1 > bucketCount*loadFactor  │
              │       ➔ growMap(h)                    │
              │   计算 h := hash(key)                 │
              │   bucketIdx := h & ((1<<B)-1)         │
              └──────┬──────────────────────────────────┘
                     │
           ┌─────────▼─────────┐
           │  buckets? oldbuckets?  │
           │                         │
           │  oldbuckets == nil  │  oldbuckets != nil && bucketIdx < nevacuate  │
           │    │  插入到新桶       │           │ 查找→若无则插入到新桶                       │
           │    │                  │           │   先执行 evacuateBucket()                   │
           │    │                  │           │                                           │
           │    │--查找/插入流程--→ │           │                                           │
           │                         │           │                                           │
           └─────────────────────────┘   older   │
                       expand阶段                │
             ┌───────────────────────────────┐   │
             │ oldbuckets[nevacuate]         │   │
             │   evacuateBucket(oldbuckets[i])  │  │
             │   nevacuate++                   │  │
             │   if nevacuate == oldBucketCount │ │
             │       oldbuckets = nil           │ │
             └───────────────────────────────┘   │
                     ▲                          │
                     │ 当任意 mapInsert/mapAccess 调用时,触发此动作  │
                     └────────────────────────────────────────────────┘

七、代码示例:触发扩容并观察性能

下面用一段示例程序直观触发扩容,并观察 map 在不同阶段的行为与性能。程序将在插入一定数量键值对后,打印出扩容后 h.B 的变化以及桶总数 1<<B 的变化。

package main

import (
    "fmt"
    "runtime"
)

func main() {
    m := make(map[string]int)

    // 记录首次 B 的值
    prevB := getMapB(m)
    fmt.Printf("初始 B = %d, 桶数 = %d\n", prevB, 1<<prevB)

    total := 50000
    for i := 0; i < total; i++ {
        key := fmt.Sprintf("key_%d", i)
        m[key] = i

        // 每 5000 次检查一次 B 的值
        if i%5000 == 0 {
            B := getMapB(m)
            if B != prevB {
                fmt.Printf("插入到 %d 时触发扩容: B 从 %d 变为 %d, 桶数 从 %d 变为 %d\n",
                    i, prevB, B, 1<<prevB, 1<<B)
                prevB = B
            }
        }
    }
    fmt.Println("最终 map 大小:", len(m))
}

// go:linkname 获取 map 中 hmap 结构的 B 字段
// 注意:linkname 用法仅供演示,生产代码不可滥用
import _ "unsafe"

// 运行时内部函数声明(linkname)
func getmapB(m map[string]int) uint8

func getMapB(m map[string]int) uint8 {
    return getmapB(m)
}

说明

  • getmapB 利用 //go:linkname 链接到运行时私有符号 runtime.mapB(未在此示例中写出完整 linkname 指令,仅作示意),可省去通过反射或不安全转换来获取 hmap.B
  • 执行时可观察到 B 值如何随插入数量增长而依次增加,例如:

    初始 B = 1, 桶数 = 2
    插入到 0 时触发扩容: B 从 1 变为 2, 桶数 从 2 变为 4
    插入到 5000 时触发扩容: B 从 2 变为 3, 桶数 从 4 变为 8
    插入到 10000 时触发扩容: B 从 3 变为 4, 桶数 从 8 变为 16
    ...
    最终 map 大小: 50000

通过该示例,你可以直观感受到 map 在插入超过负载阈值时会不断翻倍桶数,并触发增量迁移。


八、注意事项与性能建议

  1. 避免键类型过大

    • 如果键 K 是大结构体或大字符串,每次哈希与复制键都需要大量内存拷贝,影响性能。常见优化包括将大型结构体替换为字符串 ID 或指针。
  2. 尽量避免高冲突场景

    • 如果大量键的哈希值碰撞到同一个桶,会导致溢出桶链变长,查找/插入需要遍历多个桶,性能下降。
    • 可以使用自定义哈希函数(例如键对象的方法中实现更均匀的哈希)来降低冲突概率。
  3. 合理设置初始容量

    • 使用 make(map[K]V, hint) 手动预设 hint(预估需要插入的键数量),可以减少扩容次数。
    • 例如 make(map[string]int, 10000) 会预分配大小足够放置约 10000 个键的桶数,避免插入过程多次扩容。
  4. 监控 map 大小与 GC

    • map 中的键值对存储在堆上,且扩容会分配新桶数组并迁移旧桶,其间会产生大量垃圾对象,需要等待 GC 回收旧桶,可能造成短暂的 GC 压力。
    • 在高并发场景使用大量短生命周期 map 时,应关注垃圾回收指标,必要时手动调用 runtime.GC() 或降低负载因子(目前 Go 不支持动态调节此参数)。

九、小结

本文从以下几个方面对 Go 语言 map 的源代码与扩容机制做了深度探索:

  1. 高层语义与使用示例:快速回顾 map 常见用法。
  2. 底层关键结构 hmapbmap:介绍了 hmap 中的字段意义与 bmap 桶结构、tophash、溢出桶链。
  3. 哈希与桶索引计算:讲解如何计算桶索引与 tophash,以定位键值对。
  4. 插入(insert)与更新:伪代码说明新键插入与已有键覆盖流程,并阐释扩容阈值触发逻辑。
  5. 扩容(grow)机制与增量迁移:重点剖析扩容时如何创建新桶、增量迁移旧桶、处理扩容期间查找和插入的一致性。
  6. 完整数据流 ASCII 图解:通过综合图示演示扩容涉及的各个步骤与状态转换。
  7. 代码示例:触发扩容并观察性能:演示如何在实际运行中监测 map 扩容。
  8. 注意事项与性能建议:提出键类型、哈希冲突、预估容量和 GC 影响等实战建议。

通过对上述内容的学习,你应当能够更深入地理解 Go map 在内存中的组织、查找与扩容原理,并在性能调优、避免冲突、正确使用扩容预置等方面做出更合理的设计。

评论已关闭

推荐阅读

DDPG 模型解析,附Pytorch完整代码
2024年11月24日
DQN 模型解析,附Pytorch完整代码
2024年11月24日
AIGC实战——Transformer模型
2024年12月01日
Socket TCP 和 UDP 编程基础(Python)
2024年11月30日
python , tcp , udp
如何使用 ChatGPT 进行学术润色?你需要这些指令
2024年12月01日
AI
最新 Python 调用 OpenAi 详细教程实现问答、图像合成、图像理解、语音合成、语音识别(详细教程)
2024年11月24日
ChatGPT 和 DALL·E 2 配合生成故事绘本
2024年12月01日
omegaconf,一个超强的 Python 库!
2024年11月24日
【视觉AIGC识别】误差特征、人脸伪造检测、其他类型假图检测
2024年12月01日
[超级详细]如何在深度学习训练模型过程中使用 GPU 加速
2024年11月29日
Python 物理引擎pymunk最完整教程
2024年11月27日
MediaPipe 人体姿态与手指关键点检测教程
2024年11月27日
深入了解 Taipy:Python 打造 Web 应用的全面教程
2024年11月26日
基于Transformer的时间序列预测模型
2024年11月25日
Python在金融大数据分析中的AI应用(股价分析、量化交易)实战
2024年11月25日
AIGC Gradio系列学习教程之Components
2024年12月01日
Python3 `asyncio` — 异步 I/O,事件循环和并发工具
2024年11月30日
llama-factory SFT系列教程:大模型在自定义数据集 LoRA 训练与部署
2024年12月01日
Python 多线程和多进程用法
2024年11月24日
Python socket详解,全网最全教程
2024年11月27日