Dawn's Blogs

分享技术 记录成长

0%

栈和堆

Golang 程序会在两个地方分配内存:每一个 goroutine 的栈和全局堆。但是垃圾回收机制在栈和堆上的性能差异非常大:

  • 在栈上分配时,函数执行结束时自动回收。在栈上分配和回收内存的开销很低,只需要 2 个 CPU 指令:PUSH 和 POP。
  • 在堆上分配时,在某个时间点上利用垃圾回收机制进行回收。在堆上分配内存,一个很大的额外开销则是垃圾回收,需要经过垃圾回收算法的计算才能回收堆上的对象

逃逸分析

在函数内部声明变量时,需要知道这个变量分配在栈上还是堆上。

编译器决定内存分配位置的方式,就称之为逃逸分析。逃逸分析由编译器完成,作用于编译阶段。

发生场景

发生逃逸分析的场景:

  • 指针逃逸
  • interface{} 动态类型逃逸
  • 栈空间不足
  • 闭包
阅读全文 »

sync.Pool

sync.Pool 用于复用对象,sync.Pool 是可伸缩的,同时也是并发安全的,其大小仅受限于内存的大小。从 sync.Pool 中取出的值不一定每一次都会经过内存分配,可以复用之前已经创建过已有但是已经使用结束的对象

简单来说,就是保存和复用临时对象,减少内存分配,降低 GC 压力

sync.Pool 使用只需要知道三点即可:

  • 声明对象池时,需要指定 New 函数,对象池中没有对象时,将会调用 New 函数创建。
  • Get 用于从对象池中获取一个对象,Put 用于向对象池内放回一个对象。

如果在需要频繁创建对象的场景下,sync.Pool 有两个优点

  • 节省了对象初始化的时间,因为每一次都不一定是新的对象所以可以在一定程度上节省对象初始化时间,提升了效率。
  • 节省内存分配,对象是可重用的,不必每一次都分配内存。

sync.Once

sync.Once 保证了只执行一次函数,常常用于单例模式,在前文中也可以用于关闭管道。sync.Once 仅提供了一个方法 Do,参数 f 是对象初始化函数:

1
func (o *Once) Do(f func())

原理实现

从 sync.Once 的功能可以想到,用于实现它需要两个部分:

  • 一个标志判断是否已经执行了 Do,如果没有执行过 Do 则执行对象初始化函数。
  • 线程安全,并发的判断是否已经执行了,如果没有则执行 Do 中定义的函数,所以需要互斥锁来实现。

以下是 sync.Once 的源码实现,其中 Once 结构体包含两个字段:

  • done 字段用于标记是否已经执行过。
  • m 字段为互斥锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Once struct {
done uint32
m Mutex
}

func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}

func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
阅读全文 »

channel 的三种状态和操作结果:

操作 空值(nil) 非空已关闭 非空未关闭
关闭 panic panic 成功关闭
发送数据 永久阻塞 panic 阻塞或成功发送
接收数据 永久阻塞 永不阻塞 阻塞或者成功接收

在 select 中,如果其中的全部通道都被关闭,则一定不会执行 defaul 而是随机选择其中一个被关闭的 case 去执行。

关闭通道

通道关闭原则:

一个常用的使用 Go 通道的原则是不要在数据接收方或者在有多个发送者的情况下关闭通道。换句话说,我们只应该让一个通道唯一的发送者关闭此通道

关闭 channel 的方法有以下几种方法。

粗鲁的方法

如果关闭已经关闭的通道,则会触发 panic,可以用 recover 使得程序回复正常

这种方法违反了通道关闭原则,因为这种方法使得多个协程都可以关闭通道。

1
2
3
4
5
6
7
8
9
10
11
12
func SafeClose(ch chan T) (justClosed bool) {
defer func() {
if recover() != nil {
// 一个函数的返回结果可以在defer调用中修改。
justClosed = false
}
}()

// 假设ch != nil。
close(ch) // 如果 ch 已关闭,将 panic
return true // <=> justClosed = true; return
}

礼貌的方式

使用 sync.Once 或者互斥锁来保证 channel 只被关闭一次。

1
2
3
4
5
6
7
8
9
10
type MyChannel struct {
C chan T
once sync.Once
}

func (mc *MyChannel) SafeClose() {
mc.once.Do(func() {
close(mc.C)
})
}

也可以使用 sync.Mutex 来关闭通道,维护一个布尔值来表示通道是否已经被关闭(需要用 sync.Mutex 保证互斥访问):

1
2
3
4
5
type MyChannel struct {
C chan T
closed bool
mutex sync.Mutex
}

优雅的方式

  • 情形一:M 个接收者和一个发送者,发送者通过关闭用来传输数据的通道来传递发送结束信号。
  • 情形二:一个接收者和 N 个发送者,此唯一接收者通过关闭一个额外的信号通道来通知发送者不要再发送数据了。
  • 情形三:M 个接收者和 N 个发送者,它们中的任何协程都可以让一个中间调解协程帮忙发出停止数据传送的信号。
阅读全文 »

空结构体

在 Golang 中空结构体不占据内存空间。因此可以有以下使用方法:

  • 实现集合:map[string]struct{} 可以实现集合。
  • 用作通知 channel:当 channel 只用于进程同步(通知),不用做传递消息时,可以使用 chan struct{}。
  • 仅包含方法的结构体:结构体只包含方法,不包含任何的字段时(使用 int 或者 bool 都会浪费额外的内存)。

内存对齐

作用

CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。内存对齐可以减少 CPU 的访存次数

memory alignment

Golang 中的对齐

在 Golang 中,unsafe 标准库提供了 Alignof 方法,可以返回一个类型的对齐值(对齐倍数或者对齐数)。

规定如下:

  • 对于任意类型的变量 x,unsafe.Alignof(x) 至少为 1。
  • 对于结构体变量 x,计算每一个字段 f 的对齐值,unsafe.Alignof(x) 为其中每一个字段对齐值的最大值
  • 对于数组型变量 x,unsafe.Alignof(x) 等于构成数组的元素类型的对齐值
  • 空结构体占据空间为 0。

字段顺序影响占用空间

在对内存特别敏感的结构体的设计上,可以通过调整字段的顺序,减少内存的占用

阅读全文 »

for range 性能

range 会拷贝遍历对象。

range 实现原理

slice

遍历 slice 前会先获取 slice 的长度 len_temp 作为循环次数。获取被遍历对象的拷贝,作为 for_temp

循环体中,每次循环会先获取元素值 value_temp,以及下标 index_temp 进行赋值。

注意,在循环开始前就已经确定了循环次数、并且已经将遍历对象保存了下来

1
2
3
4
5
6
7
8
9
// The loop we generate:
// for_temp := range
// len_temp := len(for_temp)
// for index_temp = 0; index_temp < len_temp; index_temp++ {
// value_temp = for_temp[index_temp]
// index = index_temp
// value = value_temp
// original body
// }

map

遍历 map 时没有指定循环次数,只是移动遍历指针 hiter,当遍历指针的 key 为 nil 时则停止循环。

map 底层使用 hash 表实现,插入数据位置是随机的,所以遍历过程中新插入的数据不能保证遍历到。

1
2
3
4
5
6
7
8
9
// The loop we generate:
// var hiter map_iteration_struct
// for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) {
// index_temp = *hiter.key
// value_temp = *hiter.val
// index = index_temp
// value = value_temp
// original body
// }

channel

channe l遍历是依次从 channel 中读取数据,读取前是不知道里面有多少个元素的。

如果 channel 中没有元素,则会阻塞等待,如果 channel 已被关闭,则会解除阻塞并退出循环。

1
2
3
4
5
6
7
8
9
// The loop we generate:
// for {
// index_temp, ok_temp = <-range
// if !ok_temp {
// break
// }
// index = index_temp
// original body
// }

性能分析

因为 range 会拷贝遍历对象,所以 for 和 range 的性能比较如下:

  • 每次迭代的元素的内存占用很低那么 for 和 range 的性能几乎是一样
  • 如果迭代的元素内存占用较高那么 for 的性能将显著地高于 range,因为 range 会拷贝迭代对象。可以考虑只迭代下标,这样和 for 的性能是一样的。
  • 如果想使用 range 同时迭代下标和值,则可以将迭代元素修改为指针
阅读全文 »

字符串拼接

常见拼接方式

在 Golang 中,字符串拼接有以下几种拼接方式:

  • +:使用 + 直接对两个字符串进行拼接操作。
  • fmt.Sprintf:使用 fmt.Sprintf(""%v%v", s1, s2) 进行字符串的拼接。
  • strings.Builder:使用 strings.Builder.WriteString 方法进行拼接。
  • bytes.Buffer:使用 bytes.Bufer.WriteString 方法进行拼接。
  • []byte:使用 []byte,利用 append 函数进行拼接(可以预先分配空间)。

性能比较

利用 benchmark 基准测试进行性能比较,结果如下:

  • +fmt.Sprintf效率是最低的,和其余的方式相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。
  • strings.Builderbytes.Buffer[]byte 的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是 preByteConcat,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。
1
2
3
4
5
6
BenchmarkPlusConcat-8         19      56 ms/op   530 MB/op   10026 allocs/op
BenchmarkSprintfConcat-8 10 112 ms/op 835 MB/op 37435 allocs/op
BenchmarkBuilderConcat-8 8901 0.13 ms/op 0.5 MB/op 23 allocs/op
BenchmarkBufferConcat-8 8130 0.14 ms/op 0.4 MB/op 13 allocs/op
BenchmarkByteConcat-8 8984 0.12 ms/op 0.6 MB/op 24 allocs/op
BenchmarkPreByteConcat-8 17379 0.07 ms/op 0.2 MB/op 2 allocs/op

使用建议

在进行字符串拼接时,推荐使用 strings.Builder 进行拼接字符串,并且 strings.Builder 也提供了预分配内存的方法 Grow

1
2
3
4
5
6
7
8
func builderConcat(n int, str string) string {
var builder strings.Builder
builder.Grow(n * len(str))
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
阅读全文 »

A Unified Multi-Task Learning Framework for Joint Extraction of Entities and Relations

来源:AAAI 2021

作者:Tianyang Zhao, Zhao Yan, Yunbo Cao, Zhoujun Li

机构:State Key Lab of Software Development Environment, Beihang University, Beijing, China

作者根据提取三元组的顺序将关系抽取模型分成了三类:

  • relation-last:可以被总结为先抽取出实体,然后对任意两个实体进行关系分类。这种方法的缺点就是需要枚举出所有的实体对进行关系判断,并且由非常多的负例影响了关系分类器。
  • relation-first:首先检测出句子中可能包含的关系,然后在句子中选择关系对应的 subject 和 object。这种方法首先通过预测关系,过滤掉了不相关的关系,减轻了无用关系造成的负面影响,大大避免了数据不平衡的问题。
  • relation-middle:首先提取出 subject,然后根据 subject 提取出 relation,最后提取出 object(典型的比如多轮次 QA,CasRel也属于这个方法)。在多轮次 QA 中,基于模板进行提问的,这样做的好处是:
    • 查询问题明确地提供了关于类型信息的先验知识。
    • 基于 QA 结构,增强了查询与文本之间的交互作用。
    • 它提供了一种处理重叠实体和关系的自然方法。

但是,基于 QA 的方法由以下问题

  • 严重依赖于手工设计的模板,这使得模型难以迁移。
  • 目前主流的方法无法在非实体上识别出关系,严重依赖于 NER。
  • 现有的方法无法处理非预定义的关系。

文章提出了一个实体关系联合抽取框架,将任务划分为了三个子任务:the type-attentional subject extraction,the subject-aware relation prediction(SRP),the QA-based object extraction。

  • 为了缓解模板依赖的问题,提出了 type-attention,为 subject 提取任务中提供明确的实体类型信息。
  • 论文引入了 subject-aware relation prediction 任务,利用全局和局部语义获得了给定主体的关系子集。
  • 论文提出了一种 question generation(QG)策略,以自动得到在 object 提取任务中的多种查询语句。这个子任务根据之前的类型信息和查询,在句子中选择文本跨度,而不依赖于 NER。此外,论文还提出了一种模糊问题回答方法,来解决非预定义的关系检测问题

image-20230322144531225

模型方法

为了整合任务之间的相互作用,采用多任务学习的方式来提高整体的性能。基于 relation-middle 的方式,模型由三个部分组成:

  • type-attentional subject extraction:显式的提供类型信息,来从句子中预测 subject。
  • subject-aware relation prediction:给定 subject,选择可能与 subject 相关关系的多分类问题。
  • QA-based object extraction:使用自动生成的问题从句子中选择 object。

image-20230322144550841

阅读全文 »

Timer

Timer 指定一段时间后,通过本身提供的一个 channel 通知触发一个事件,Timer 只执行一次就结束。

数据结构

Timer

Timer 有两个成员:

  • C:一个 channel,用于通知时间已经到达。
  • r:runtime 定时器,该定时器即系统管理的定时器,对上层应用不可见。
1
2
3
4
type Timer struct { // Timer代表一次定时,时间到来后仅发生一个事件。
C <-chan Time
r runtimeTimer
}

runtimeTimer

创建一个 Timer 实质上是把一个定时任务交给专门的协程进行监控,这个任务的载体便是 runtimeTimer。创建一个 Timer 就是创建一个 runtimeTimer,把它交给系统进行监控,当 runtimeTimer 到期后像 Timer.C 管道中发送一个消息。

runtimeTimer 的结构如下:

1
2
3
4
5
6
7
8
9
10
type runtimeTimer struct {
tb uintptr // 存储当前定时器的数组地址
i int // 存储当前定时器的数组下标

when int64 // 当前定时器触发时间
period int64 // 当前定时器周期触发间隔
f func(interface{}, uintptr) // 定时器触发时执行的函数
arg interface{} // 定时器触发时执行函数传递的参数一
seq uintptr // 定时器触发时执行函数传递的参数二(该参数只在网络收发场景下使用)
}

实现原理

所有 Timer 中的 runtimeTimer 由统一的底层协程进行管理,这个协程是系统协程。

系统协程把 runtimeTimer 存放在数组!!中,并按照 runtimeTimer.when 进行堆排序,定时器触发时执行预定义的函数 runtimeTimer.f,即完成了一次定时任务。

创建 Timer

创建 Timer 的过程:

  • 首先会初始化一个管道 C,用于通知上层应用。
  • 接着会创建一个 runtimeTimer,并且调用 startTimer 启动定时器(由系统协程维护)。
1
2
3
4
5
6
7
8
9
10
11
12
13
func NewTimer(d Duration) *Timer {
c := make(chan Time, 1) // 创建一个管道
t := &Timer{ // 构造Timer数据结构
C: c, // 新创建的管道
r: runtimeTimer{
when: when(d), // 触发时间
f: sendTime, // 触发后执行函数sendTime
arg: c, // 触发后执行函数sendTime时附带的参数
},
}
startTimer(&t.r) // 此处启动定时器,只是把runtimeTimer放到系统协程的堆中,由系统协程维护
return t
}

when 方法用于计算下一次定时器触发的绝对时间,即当前时间 + d。

sendTime 方法用于定时器触发时,向管道 C 中发送当前时间:

1
2
3
4
5
6
func sendTime(c interface{}, seq uintptr) {
select {
case c.(chan Time) <- Now():
default:
}
}

因为 Timer 创建时,初始化了一个缓冲区长度为 1 的管道(make(chan Time, 1)),所以 Timer 触发时向管道写入时间永远不会阻塞,sendTime 写完即退出

之所以 sendTime 使用 select 并搭配一个空的 default 分支,是因为 Ticker 也复用 sendTime。Ticker 触发时也会向管道中写入时间,但无法保证之前的数据已被取走,所以使用 select 并搭配一个空的 default 分支,确保 sendTime 不会阻塞。Ticker 触发时,如果管道中还有值,则本次不再向管道中写入时间,本次触发的事件直接丢弃。

startTimer 函数的主要作用就是将 runtimeTimer 写入到系统协程的数组中,并启动系统协程(如果系统协程还未开始运行的话)。

img

阅读全文 »

Golang 中 Context 与 WaitGroup 最大的不同在于,Context 可以控制树形结构的 goroutine,每一个 goroutine 具有相同的上下文。

由于 goroutine 派生出子 goroutine,而子 goroutine 又继续派生新的 goroutine。这种情况下使用 WaitGroup 就不太容易,因为子 goroutine 个数不容易确定,而使用 context 就可以很容易实现。

Context

Context 接口

Context 是一个接口,它定义了四个方法:

  • Deadline() (deadline time.Time, ok bool):返回一个 deadline,和一个是否已经设置 deadline 的布尔值。
  • Done() <-chan struct{}:当 Context 被关闭后,返回的是一个被关闭的 channel;而没有关闭时,返回的是 nil。
  • Err() error:当 Context 被关闭时,返回描述 Context 被关闭的原因;否则返回 nil。
  • Value(key interface{}) interface{}:根据 key 查询 key 对应的 value,用于实现 ValueContext。
1
2
3
4
5
6
7
8
9
type Context interface {
Deadline() (deadline time.Time, ok bool)

Done() <-chan struct{}

Err() error

Value(key interface{}) interface{}
}

在 context 包中,定义了一个空的 Context 用于作为其他 Context 父节点或者全局的根节点。并且还实现了四种不同类型的 Context:

  • Cancel Context:通过 WithCancel 创建。
  • Deadline Context:通过 WithDeadline 创建。
  • Timeout Context:通过 WithTimeout 创建。
  • Value Context:通过 WithValue 创建。

context 包中,有个结构体实现了 Context 接口:emptyCtx、cancelCtx、timerCtx、valueCtx。关系如下:

img

阅读全文 »

WaitGroup

数据结构

WaitGroup 的数据结构如下,包含长度为 3 的 uint32 数组。

1
2
3
type WaitGroup struct {
state1 [3]uint32
}

其中,数组包含了一个 state(由两个计数器组成)和一个信号量

  • counter: 当前还未执行结束的 goroutine 计数器。
  • waiter:等候者的数量。
  • semaphore:信号量。

img

也可以认为是 state 的类型为 uint64,而信号量的类型为 uint32。

Add Wait Done 实现

WaitGroup 实现了三个方法:

  • Add(delta int):把 delta 的值加到 counter 中。
  • Wait():waiter 的值增加 1,并阻塞等待信号量。
  • Done():counter 递减 1,当 counter 的值等于 0 时,按照 waiter 数值释放相应次数信号量。

Add

Add 函数主要的流程如下:

  • 首先把 delta 加到 counter 中,因为 delta 可能是负值,所以 counter 可能小于 0,当 counter 小于 0 时发出 panic。
  • 接着如果 counter 等于 0,则释放 waiter 个信号量
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 (wg *WaitGroup) Add(delta int) {
statep, semap := wg.state() //获取state和semaphore地址指针

state := atomic.AddUint64(statep, uint64(delta)<<32) //把delta左移32位累加到state,即累加到counter中
v := int32(state >> 32) //获取counter值
w := uint32(state) //获取waiter值

if v < 0 { //经过累加后counter值变为负值,panic
panic("sync: negative WaitGroup counter")
}

//经过累加后,此时,counter >= 0
//如果counter为正,说明不需要释放信号量,直接退出
//如果waiter为零,说明没有等待者,也不需要释放信号量,直接退出
if v > 0 || w == 0 {
return
}

//此时,counter一定等于0,而waiter一定大于0(内部维护waiter,不会出现小于0的情况),
//先把counter置为0,再释放waiter个数的信号量
*statep = 0
for ; w != 0; w-- {
runtime_Semrelease(semap, false) //释放信号量,执行一次释放一个,唤醒一个等待者
}
}

Wait

Wait 函数主要的流程如下:

  • 首先,累加 waiter 的数量
  • 接着,如果 counter 大于 0,则阻塞当前协程等待信号量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (wg *WaitGroup) Wait() {
statep, semap := wg.state() //获取state和semaphore地址指针
for {
state := atomic.LoadUint64(statep) //获取state值
v := int32(state >> 32) //获取counter值
w := uint32(state) //获取waiter值
if v == 0 { //如果counter值为0,说明所有goroutine都退出了,不需要待待,直接返回
return
}

// 使用CAS(比较交换算法)累加waiter,累加可能会失败,失败后通过for loop下次重试
if atomic.CompareAndSwapUint64(statep, state, state+1) {
runtime_Semacquire(semap) //累加成功后,等待信号量唤醒自己
return
}
}
}

这里用到了 CAS 算法,保证有多个 goroutine 同时执行 Wait() 时,也能正确累加 waiter。

Done

Done 只做了 counter 减一这一件事,这样当 counter 等于 0 时由 Add 函数唤醒等待的协程。

1
2
3
func (wg *WaitGroup) Done() {
wg.Add(-1)
}