当前位置: 首页 > news >正文

【Golang】常见数据结构原理剖析

文章目录

    • 1、golang channel底层结构实现
        • channel 底层数据结构
        • 关键字段解析:
        • waitq 队列结构
        • channel 核心操作的底层逻辑
        • 缓冲 channel 与非缓冲 channel 的区别
        • 总结
      • 2、为什么等待队列需要时双向链表呢
        • 总结
      • 3、当 select 语句中某个 channel 操作就绪时,需要将其他未就绪的 channel 等待节点从队列中移除。这种情况下需要删除中间的sudog,这是为什么呢
      • 4、select是怎么实现的呢,是什么原理
        • select 的核心行为
        • select 底层实现的关键数据结构
        • select 的执行流程(核心原理)
        • 关键细节:随机性与效率
        • 总结

1、golang channel底层结构实现

在 Go 语言中,channel 是实现 goroutine 间通信的核心机制,其底层通过一个复杂的数据结构和同步逻辑实现。理解 channel 的底层结构,能帮助我们更深入地掌握其工作原理和性能特性。

channel 底层数据结构

channel 的底层实现由 Go runtime 中的 hchan 结构体(定义在 runtime/chan.go 中)表示,核心字段如下(简化版):

type hchan struct {qcount   uint           // 队列中当前元素数量dataqsiz uint           // 环形队列的容量(缓冲大小)buf      unsafe.Pointer // 指向环形队列的指针(缓冲数据存储区)elemsize uint16         // 每个元素的大小closed   uint32         // 标记 channel 是否关闭(0:未关闭,1:已关闭)elemtype *_type         // 元素类型(用于类型检查和内存分配)sendx    uint           // 发送操作的索引(环形队列的下一个发送位置)recvx    uint           // 接收操作的索引(环形队列的下一个接收位置)recvq    waitq          // 等待接收的 goroutine 队列sendq    waitq          // 等待发送的 goroutine 队列// 互斥锁,保护 hchan 的所有字段lock mutex
}
关键字段解析:

1. 缓冲相关:

  • buf:指向一个环形队列的指针,用于存储缓冲数据(仅缓冲 channel 有值,非缓冲 channel 为 nil)。
  • dataqsiz:环形队列的容量(创建 channel 时指定的 make(chan T, n) 中的 n)。
  • qcount:当前缓冲队列中的元素数量。
  • sendx/recvx:环形队列的索引,分别记录下一次发送和接收的位置(类似生产者 - 消费者模型的指针)。

2. 同步相关:

  • recvq:等待接收数据的 goroutine 队列(双向链表),当 channel 无数据时,接收方 goroutine 会阻塞并加入此队列。
  • sendq:等待发送数据的 goroutine 队列(双向链表),当 channel 缓冲满时,发送方 goroutine 会阻塞并加入此队列。
    lock:互斥锁,确保对 channel 操作的原子性(并发安全)。

3. 元信息:

  • elemtype/elemsize:记录 channel 中元素的类型和大小,用于内存分配和类型校验。
  • closed:标记 channel 是否关闭,避免对已关闭 channel 进行操作(发送会 panic,接收返回零值)。
waitq 队列结构

recvq 和 sendq 均为 waitq 类型,本质是一个双向链表,存储等待中的 goroutine:

type waitq struct {first *sudog  // 队头last  *sudog  // 队尾
}// sudog 是对 goroutine 的封装,关联等待的 channel 和数据
type sudog struct {g    *g       // 关联的 goroutinenext *sudog   // 下一个 sudogprev *sudog   // 上一个 sudogelem unsafe.Pointer // 数据指针(发送时存储待发送的值,接收时存储接收的地址)// 其他字段(如是否在 select 中等待等)
}

sudog 是连接 goroutine 和 channel 的桥梁,每个阻塞在 channel 上的 goroutine 都会被包装成 sudog 放入对应的等待队列。

channel 核心操作的底层逻辑

channel 的操作(发送 <-、接收 <-、关闭 close)均围绕 hchan 结构展开,核心逻辑如下:
1. 发送操作(ch <- val)

  • 加锁:先获取 hchan.lock,确保操作原子性。
  • 尝试直接发送:
    ◦ 若 recvq 不为空(有等待接收的 goroutine),则直接将数据发送给队列头部的 goroutine(通过 sudog.elem 复制数据),并唤醒该 goroutine,当前 goroutine 继续执行。
    ◦ 若 recvq 为空且缓冲未满(qcount < dataqsiz),则将数据存入 buf 环形队列,更新 sendx 和 qcount,解锁后继续执行。
  • 阻塞等待:
    ◦ 若缓冲已满(或非缓冲 channel),则将当前 goroutine 包装成 sudog,加入 sendq 队列,释放锁并阻塞(由 Go runtime 调度器挂起)。
    ◦ 当其他 goroutine 接收数据或关闭 channel 时,当前 goroutine 会被唤醒,重新尝试发送(或因 channel 关闭而 panic)。

2. 接收操作(val <- ch 或 val, ok <- ch)

  • 加锁:同样先获取 hchan.lock。
  • 尝试直接接收:
    • 若 sendq 不为空(有等待发送的 goroutine):
      ▪ 若为缓冲 channel 且缓冲未满,先将发送队列头部的 goroutine 的数据存入 buf,再从 buf 取数据返回,唤醒发送 goroutine。
      ▪ 若为非缓冲 channel 或缓冲已满,直接从发送队列头部的 goroutine 接收数据,唤醒发送 goroutine,当前 goroutine 继续执行。
      ◦ 若 sendq 为空且缓冲有数据(qcount > 0),则从 buf 环形队列取数据,更新 recvx 和 qcount,解锁后返回。
  • 阻塞等待:
    • 若缓冲为空(或非缓冲 channel),则将当前 goroutine 包装成 sudog,加入 recvq 队列,释放锁并阻塞。
    • 当其他 goroutine 发送数据或关闭 channel 时,当前 goroutine 会被唤醒,接收数据(或因 channel 关闭返回零值和 false)。

3. 关闭操作(close(ch))

  • 加锁:获取 hchan.lock。
  • 校验:若 channel 已关闭(closed == 1),则 panic(避免重复关闭)。
    • 唤醒等待队列:
    • 唤醒 recvq 中所有等待接收的 goroutine,它们会接收到零值和 false(表示 channel 已关闭)。
    • 唤醒 sendq 中所有等待发送的 goroutine,它们会因向已关闭 channel 发送数据而 panic。
    • 标记关闭:设置 closed = 1,解锁。
缓冲 channel 与非缓冲 channel 的区别
  • 非缓冲 channel(make(chan T)):dataqsiz = 0,buf = nil。发送和接收操作必须同步:发送方会阻塞直到有接收方接收,接收方会阻塞直到有发送方发送(直接通过 sudog 传递数据,不经过缓冲队列)。
  • 缓冲 channel(make(chan T, n)):dataqsiz = n,buf 指向一个大小为 n * elemsize 的环形队列。发送和接收可异步进行,当缓冲未满 / 未空时,操作无需阻塞;仅当缓冲满 / 空时,才会阻塞并加入等待队列。
总结

channel 底层通过 hchan 结构体实现,核心是 环形缓冲队列(用于缓冲数据)和 等待队列(用于阻塞的 goroutine),配合互斥锁保证并发安全。其设计兼顾了通信效率和同步灵活性,是 Go 并发模型的核心支柱。理解其底层结构,有助于写出更高效、更安全的并发代码。

2、为什么等待队列需要时双向链表呢

在 Go 语言 channel 的等待队列(recvq 和 sendq)中,使用双向链表(waitq 封装的 sudog 双向链表)而非单向链表,主要是为了高效地完成 节点的插入、删除和唤醒操作,这与 channel 的并发调度逻辑密切相关。具体原因如下:
1. 双向链表支持高效的首尾操作
等待队列的核心需求是:

  • 快速向队列中添加新的等待 goroutine(队尾插入)。
  • 快速从队列中唤醒第一个等待的 goroutine(队头删除)。
  • 在某些场景下(如 select 中 channel 被关闭或唤醒),需要快速移除中间的节点。
    双向链表通过 first(头指针)和 last(尾指针),可以在 O (1) 时间 内完成:
  • 队尾插入(last.next = 新节点,更新 last 为新节点)。
  • 队头删除(first = first.next,若新头不为空则重置其 prev 为 nil)。

如果用单向链表,队尾插入需要遍历到尾部(O (n) 时间),或额外维护尾指针(但删除中间节点仍不方便),效率远低于双向链表。

2. 支持从中间节点快速删除
在某些场景下,需要从等待队列中 移除中间的 sudog 节点(例如:

  • 当 select 语句中某个 channel 操作就绪时,需要将其他未就绪的 channel 等待节点从队列中移除。
  • 当 goroutine 被唤醒时,需要将其从等待队列中删除(可能处于队列中间,而非头部)。
    双向链表的每个节点(sudog)都有 prev 和 next 指针,删除中间节点时:
  • 只需修改该节点前一个节点的 next 指针,以及后一个节点的 prev 指针(O (1) 时间)。

而单向链表删除中间节点需要先遍历找到前驱节点(O (n) 时间),在高频并发的 channel 操作中,这种性能损耗是不可接受的。

3. 适配 Go runtime 的调度逻辑
Go 的调度器在唤醒 goroutine 时,需要快速定位并操作等待队列中的节点。例如:

  • 当 channel 有数据写入时,需要唤醒 recvq 的第一个等待者(队头)。
  • 当 channel 被关闭时,需要唤醒队列中 所有等待者(遍历链表,从 first 到 last)。

双向链表的遍历可以轻松从头部开始(first → next → … → last),也可以从尾部反向遍历(虽然 channel 场景中较少用到,但结构上支持),灵活性更高。

总结

channel 的等待队列本质是一个 FIFO(先进先出)的阻塞队列,但需要支持高频的插入(队尾)、删除(队头或中间)操作。双向链表通过 prev 和 next 指针,在这些操作中提供了 O (1) 级别的效率,完美适配了 channel 并发调度的性能需求。这也是 Go runtime 选择双向链表作为等待队列底层结构的核心原因。

接收者在尝试接收操作时候,当缓冲channel未满的时候,为什么需要先将发送队列头部的groutine数据存入到buf里面,然后再从buf里取数据返回。

在缓冲 channel 的接收操作中,当缓冲未满时,若发送队列(sendq)中有等待的 goroutine,会先将发送队列头部的 goroutine 数据存入缓冲(buf),再从缓冲中取数据返回。这种设计的核心目的是 维持缓冲队列的 FIFO 特性 并 优化并发调度效率,具体原因如下:

1. 保证缓冲队列的 FIFO 语义
缓冲 channel 的核心是一个 环形队列(FIFO),其设计初衷是让数据的发送和接收遵循 “先进先出” 的顺序。当缓冲未满但发送队列有等待的 goroutine 时,这些等待的发送者本质上是 “更早到达” 的生产者,它们的数据理应优先进入缓冲队列。
例如:

  • 缓冲 channel 容量为 2,当前缓冲中有 1 个数据(A),此时发送队列 sendq 中有等待发送 B 和 C 的 goroutine。
  • 当接收者尝试接收时,若直接从发送队列取 B 给接收者,缓冲中会剩下 A,后续的发送 / 接收会打破 FIFO 顺序(比如下一次接收本应取 A,却可能取到新的发送数据)。而先将 B 存入缓冲(此时缓冲变为 [A, B]),再从缓冲头部取 A 返回给接收者,既保证了缓冲队列的 FIFO 顺序,也让等待的发送者 B 顺利入队,符合 channel 设计的语义一致性。

2. 避免缓冲空间浪费,平衡生产者 - 消费者效率
缓冲 channel 的缓冲空间是一种有限资源,其价值在于允许发送和接收异步进行(无需立即阻塞)。当缓冲未满时,若直接将发送队列的 goroutine 数据传递给接收者(跳过缓冲),会导致缓冲空间被浪费,同时可能让后续的发送操作继续阻塞(明明缓冲有空间却不用)。
例如:

  • 缓冲容量为 2,当前缓冲为空,发送队列有一个等待发送 X 的 goroutine,此时有一个接收者到来。
  • 若直接将 X 传递给接收者,缓冲仍为空,发送队列的 X 被消耗,但缓冲的空间未被利用。若此时又有一个发送者发送 Y,本可以直接存入缓冲(无需阻塞),但如果没有上述逻辑,可能因设计疏漏导致不必要的调度开销。

通过先将发送队列的 X 存入缓冲,再从缓冲取 X 给接收者,缓冲虽短暂被占用后释放,但保证了缓冲空间的 “充分利用” 逻辑 —— 只要缓冲有空间,等待的发送者应优先填充缓冲,而非直接 “插队” 传递给接收者。

3. 简化并发调度逻辑,避免复杂的特殊判断
Go runtime 的调度逻辑需要尽可能简洁以保证效率。若针对 “缓冲未满且发送队列非空” 的场景设计特殊逻辑(比如直接将发送者数据传递给接收者),会引入额外的条件判断和分支处理,增加代码复杂度。
而采用 “先入缓冲,再出缓冲” 的统一逻辑,能让缓冲 channel 的所有操作(发送、接收)都遵循同一套 FIFO 规则:

  • 发送操作:优先入缓冲,缓冲满则入发送队列。
  • 接收操作:优先从缓冲取,缓冲空则从发送队列取(或入接收队列)。

这种一致性设计减少了特殊情况的处理,让 channel 的底层逻辑更易维护,也降低了并发场景下的逻辑冲突风险(比如多个接收者同时唤醒时的数据竞争)。

总结
缓冲 channel 中 “接收时先将发送队列数据入缓冲,再从缓冲取数据” 的逻辑,本质是为了 维持 FIFO 语义、充分利用缓冲空间 并 简化调度逻辑。这一设计确保了 channel 操作的一致性和可预测性,同时优化了并发场景下的资源利用效率。

3、当 select 语句中某个 channel 操作就绪时,需要将其他未就绪的 channel 等待节点从队列中移除。这种情况下需要删除中间的sudog,这是为什么呢

在 select 语句中,当某个 channel 操作就绪时,需要将其他未就绪 channel 的等待节点(sudog)从队列中移除,核心原因是 避免这些未就绪的等待节点被错误唤醒或阻塞,保证 select 的语义正确性和 goroutine 的正常调度。具体可从以下角度理解:

1. select 的核心语义:仅执行一个就绪的分支
select 语句的行为是:同时监听多个 channel 的操作(发送或接收),当其中任意一个 channel 操作就绪时(可执行而不阻塞),就执行该分支;若多个同时就绪,则随机选择一个执行;若所有分支都未就绪,则阻塞等待,直到至少一个就绪。
这意味着,当 select 中某个 channel 操作就绪并开始执行后,其他未就绪的 channel 操作必须 “放弃等待”—— 因为 select 已经确定了唯一要执行的分支,其他分支的等待状态必须被取消。

2. 未移除等待节点会导致的问题
当 select 中的某个 goroutine 为了等待多个 channel 操作而将自己的 sudog 加入多个 channel 的等待队列(recvq 或 sendq)后:

  • 若其中一个 channel 就绪并执行了操作,此时其他 channel 的等待队列中仍保留着该 goroutine 的 sudog。
  • 若不及时移除这些 sudog,当后续其他 channel 就绪时(例如有数据发送或被关闭),runtime 会错误地唤醒这个已经 “不需要等待” 的 goroutine,导致:
  • 重复唤醒:该 goroutine 已经因 select 的某个分支被唤醒并执行,却可能被其他 channel 再次唤醒,引发逻辑混乱(例如重复处理数据)。
  • 资源泄漏:sudog 关联的 goroutine 已经继续执行,但 sudog 仍留在等待队列中,会占用内存资源,甚至可能导致其他操作错误(例如关闭 channel 时唤醒不存在的等待者)。

3. 双向链表支持高效移除中间节点
select 中的 goroutine 会为每个待监听的 channel 创建一个 sudog,并将这些 sudog 分别加入对应 channel 的等待队列(可能处于队列的中间位置,而非头部)。
当 select 确定执行某个分支后,需要从其他所有 channel 的等待队列中 删除当前 goroutine 对应的 sudog。由于等待队列是双向链表,每个 sudog 有 prev 和 next 指针,因此可以在 O (1) 时间 内完成中间节点的删除(只需调整前后节点的指针),避免了遍历整个队列的性能开销。
这种高效的删除能力,是 select 能够正确取消未就绪分支等待的关键保障。
示例说明
假设一个 select 同时监听 channel ch1 和 ch2 的接收操作:

select {
case <-ch1:// 分支1
case <-ch2:// 分支2
}
  • 当 goroutine 执行到该 select 时,会创建两个 sudog,分别加入 ch1.recvq 和 ch2.recvq,然后阻塞等待。
  • 若此时 ch1 有数据发送(就绪),则该 goroutine 会被从 ch1.recvq 中唤醒,执行分支 1。
  • 此时必须将该 goroutine 在 ch2.recvq 中的 sudog 移除 —— 否则,若后续 ch2 有数据发送,runtime 会错误地认为该 goroutine 仍在等待 ch2,从而尝试唤醒它,导致不可预期的行为。

总结
select 语句中移除未就绪 channel 的等待节点(sudog),是为了 保证仅执行一个就绪分支的语义,避免 goroutine 被错误唤醒或资源泄漏。而双向链表的结构支持高效删除中间节点,恰好满足了这一需求,是 select 与 channel 协作的底层基础。

4、select是怎么实现的呢,是什么原理

	Go 语言的 select 语句是实现多路复用(同时监听多个 channel 操作)的核心机制,允许在多个 channel 的发送 / 接收操作中选择一个就绪的执行。其底层实现依赖于 Go runtime 的调度逻辑和 sudog、scase 等数据结构,核心原理可概括为:同时注册多个 channel 的等待事件,通过 runtime 检测就绪状态,选择一个执行并取消其他等待。
select 的核心行为

select 的行为规则:

  1. 同时检查所有 case 中的 channel 操作(发送 / 接收)。
  2. 若有多个 case 就绪(可执行而不阻塞),随机选择一个 执行。
  3. 若只有一个 case 就绪,执行该 case。
  4. 若所有 case 都未就绪,且有 default 分支,则执行 default。
  5. 若所有 case 都未就绪且无 default,则当前 goroutine 阻塞,直到某个 case 就绪。
select 底层实现的关键数据结构

select 的实现依赖以下核心结构(定义在 runtime/select.go 中):

  1. scase:每个 case 的元信息
    select 中的每个 case(包括 default)会被封装为 scase 结构体,存储该 case 关联的 channel、操作类型(发送 / 接收)、数据指针等:
type scase struct {c    *hchan         // 关联的 channel(nil 表示 default)elem unsafe.Pointer // 数据指针(发送时为待发送值的地址,接收时为存储结果的地址)kind uint16         // 操作类型:caseRecv(接收)、caseSend(发送)、caseDefault(默认)// 其他辅助字段(如是否在等待队列中)
}
  1. sudog:关联 goroutine 与等待队列
    如前所述,sudog 是 goroutine 的封装,用于将当前 goroutine 加入多个 channel 的等待队列(recvq/sendq),并记录其在 select 中的上下文。
select 的执行流程(核心原理)

select 的执行可分为 “检测就绪” 和 “阻塞等待” 两个阶段,具体流程如下:
阶段 1:快速检测就绪的 case(非阻塞)

  1. 遍历所有 case:runtime 首先遍历 select 中的所有 scase(跳过 default),检查每个 channel 的操作是否就绪:
    ◦ 对于 接收操作(case <-ch):检查 channel 是否有数据(缓冲非空)或有等待发送的 goroutine(sendq 非空)。
    ◦ 对于 发送操作(case ch <- val):检查 channel 是否有缓冲空间(缓冲未满)或有等待接收的 goroutine(recvq 非空)。
  2. 收集就绪的 case:若发现就绪的 scase,将其加入一个临时列表。
  3. 处理就绪的 case:
    ◦ 若列表非空,随机选择一个 执行(通过 fastrand 保证随机性),执行完成后退出 select。
    ◦ 若列表为空且存在 default 分支,则执行 default 并退出。
    阶段 2:阻塞等待(无就绪 case 且无 default)
    若所有 case 都未就绪且无 default,当前 goroutine 会进入阻塞状态,流程如下:
  4. 创建 sudog 并注册到等待队列:
    ◦ 为当前 goroutine 创建一个 sudog(每个 scase 对应一个 sudog,但共享同一个 goroutine 指针)。
    ◦ 将这些 sudog 分别加入对应 channel 的等待队列(recvq 或 sendq),标记 goroutine 正在等待这些 channel。
  5. 阻塞当前 goroutine:
    ◦ 释放当前 goroutine 的 CPU 控制权,由 Go 调度器将其挂起(状态改为 Gwaiting)。
  6. 被唤醒后重新检测:
    ◦ 当某个 channel 就绪(有数据发送 / 接收,或被关闭),runtime 会唤醒等待队列中的 goroutine,并将其从所有其他 channel 的等待队列中 移除未就绪的 sudog(避免被错误唤醒)。
    ◦ 唤醒后,goroutine 重新进入阶段 1,再次检测所有 case,选择一个就绪的执行。
关键细节:随机性与效率
  1. 随机性选择:当多个 case 同时就绪时,select 会通过 fastrand 生成随机数选择一个 case,避免固定顺序导致的调度倾斜(例如某个 case 长期被优先执行)。
  2. 高效取消等待:当 select 中的一个 case 就绪并唤醒 goroutine 时,runtime 必须将该 goroutine 在其他 channel 等待队列中的 sudog 移除(通过双向链表的 prev/next 指针快速删除),否则可能导致 goroutine 被多次唤醒,引发逻辑错误。
  3. 避免 “活锁”:随机性选择确保了在高并发场景下,多个 select 同时竞争多个 channel 时,不会出现某个 case 永远无法被选中的情况。
    五、示例:select 执行的底层逻辑
ch1 := make(chan int)
ch2 := make(chan int)go func() { ch1 <- 1 }()
go func() { ch2 <- 2 }()select {
case x := <-ch1:fmt.Println("ch1:", x)
case y := <-ch2:fmt.Println("ch2:", y)
}
  • 执行 select 时,首先检测 ch1 和 ch2 是否就绪(此时两个 channel 都有数据发送,均就绪)。
  • runtime 生成随机数,随机选择一个 case 执行(例如 ch1),输出 ch1: 1。
  • 若两个 goroutine 发送数据的时机较晚,select 会先将当前 goroutine 加入 ch1.recvq 和 ch2.recvq,阻塞等待;当其中一个 channel 有数据时,唤醒 goroutine 并执行对应分支,同时移除另一个 channel 中的 sudog。
总结

select 的底层原理是通过 scase 封装每个 channel 操作,先快速检测就绪状态,若未就绪则将 goroutine 注册到多个 channel 的等待队列中阻塞等待;当某个 channel 就绪时,唤醒 goroutine 并执行对应分支,同时取消其他 channel 的等待。这一机制实现了高效的多路复用,是 Go 并发模型中连接多个 channel 的核心工具。

http://www.dtcms.com/a/585840.html

相关文章:

  • 做百度推广得用网站是吗做小说网站做国外域名还是国内的好处
  • Ubuntu 复制王者:rsync -av 终极指南 —— 进度可视化 + 无损同步,效率甩 cp 几条街!
  • ubuntu磁盘管理、磁盘扩容
  • 专业设计网站排名百达翡丽手表网站
  • 广度优先搜索
  • 高端网站建设公司名称动物自己做的网站
  • 编译OpenCV
  • jQuery Mobile 事件详解
  • 网站换模板影响国家域名注册中心
  • 佛山的网站建设公司凡科建站微信小程序
  • 建设部网站网上大厅长沙景点免费
  • 不练不熟,不写就忘 之 compose 之 动画之 animateSizeAsState动画练习
  • 函数模板和类模板
  • 从 0 到 1:我的 C++ 游戏开发全记录
  • 手机屏幕表面缺陷检测分割系统1:数据集说明(含下载链接)
  • 【MyBatis】 吃透 MyBatis:多表查询、SQL 注入防护(#{} vs ${})与连接池优化
  • 智能体AI的六大核心设计模式
  • 基于SLERP(Spherical Linear Interpolation) 进行旋转滤波
  • 站长工具seo查询5g5g成都市四方建设工程监理有限公司网站
  • 网站建设百科深圳网站建设公司fantodo
  • 接口自动化详细介绍
  • 深入解析多态:面向对象编程灵魂
  • 基于开源链动2+1模式AI智能名片S2B2C商城小程序的赛道力构建与品牌发展研究
  • 怎么做网站地图的样式wordpress网站后缀
  • 【报错解决】java:无效的目标发行版:17;源发行版17需要目标发行版17
  • C/C++输入输出初级(一) (算法竞赛)
  • java list<string> to string[] 怎么转换
  • 【Javaweb学习|黑马笔记|Day4】Web后端基础
  • 做智能网站系统重庆企业
  • Vue 项目实战《尚医通》,首页静态搭建 banner,笔记07