var ids []*intfor i := 0; i < 10; i++ {ids = append(ids, &i)}for _, item := range ids {println(*item)}
可以试着在 playgound 里面运行下:go.dev/play/p/O8MVGtueGAf
答案是:打印出来的全是 10。
这个结果实在离谱。原因是因为在目前 Go 的设计中,for 中循环变量的定义是 per loop 而非 per iteration。也就是整个 for 循环期间,变量 i
只会有一个。以上代码等价于:
var ids []*intvar i intfor i = 0; i < 10; i++ {ids = append(ids, &i)}
同样的问题在闭包使用循环变量时也存在,代码如下:
var prints []func()for _, v := range []int{1, 2, 3} { prints = append(prints, func() { fmt.Println(v) })}for _, print := range prints { print()}
根据上面的经验,闭包 func 中 fmt.Println(v)
,捕获到的 v
都是同一个变量。因此打印出来的都是 3。
在目前的 go 版本中,正常来说我们会这么解决:
var ids []*intfor i := 0; i < 10; i++ {i := i // 局部变量ids = append(ids, &i)}
定义一个新的局部变量, 这样无论闭包还是指针,每次迭代时所引用的内存都不一样了。
这个问题其实在 C++ 中也同样存在: wandbox.org/permlink/Se5WaeDb6quA8FCC。
但真的太容易搞错了,几乎每个 Go 程序员都踩过一遍,而且也非常容易忘记。即使这次记住了,下次很容易又会踩一遍。
甚至知名证书颁发机构 Let’s Encrypt 就踩过一样的坑 bug#1619047。代码如下:
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a// protobuf authorizations mapfunc authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) {resp := &sapb.Authorizations{}for k, v := range m {// Make a copy of k because it will be reassigned with each loop.kCopy := k// 坑在这里authzPB, err := modelToAuthzPB(&v)if err != nil {return nil, err}resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})}return resp, nil}
在这个代码中,开发人员显然是很清楚这个 for 循环变量问题的,为此专门写了一段 kCopy := k
。但是没想到紧接着下一行就不小心用了 &v
。
因为这个 bug,Let’s Encrypt 为此召回了 300 万份有问题的证书。
Go 团队目前的负责人 Russ Cox 在 2022 年 10 月份的这个讨论 discussions/56010 里面,提到要修改 for 循环变量的语义,几乎是一呼百应。今年五月份,正式发出了这个提案proposal#60078。
在今年 8 月份发布的 Go 1.21 中已经带上了这个修改。只要开启 GOEXPERIMENT=loopvar
这个环境变量,for 循环变量的生命周期将变成每个迭代定义一次。
但毫无疑问,这是个 break change。如果代码中依赖了这个 for 循环变量是 per loop 的特性,那升级之后就会遇到问题。例如以下代码:
func sum(list []int) int {m := make(map[*int]int)for _, x := range list {// 每次 & x 都是一样,因此一直追加写同一个元素m[&x] += x}// 这个 for 循环只会执行一次,因为 m 的长度一定是 1for _, sum := range m {return sum}return 0}
另外,对于程序性能也会有轻微影响, 毕竟新的方案里面将重复分配 N 次变量。对于性能极其敏感的场景,用户可以自行把循环变量提到外面。
同样的改变在 C# 也发生过,并没有出现大问题。
这个方案预计最早在 Go 1.22 就会正式开启了。按照 Go 每年发两个版本的惯例,在 2024 年 2 月份,我们就可以正式用上这个特性,彻底抛弃 x := x
的写法 ~
]]>本文主要内容汇总自 go/wiki/LoopvarExperiment 和 proposal#60078
本文属于 《Golang源码剖析系列》
Bigcache是用Golang实现的本地内存缓存的开源库,主打的就是可缓存数据量大,查询速度快。 在其官方的介绍文章《Writing a very fast cache service with millions of entries in Go》一文中,明确提出了bigcache的设计目标:
目前有许多开源的cache库,大部分都是基于map实现的,例如go-cache,ttl-cache等。bigcache明确指出,当数据量巨大时,直接基于map实现的cache库将出现严重的性能问题,这也是他们设计了一个全新的cache库的原因。
本文将通过分析bigcache v3.1.0的源码,揭秘bigcache如何解决现有map库的性能缺陷,以极致的性能优化,实现超高性能的缓存库。
当map里面数据量非常大时,会出现性能瓶颈。这是因为在Golang进行GC时,会扫描map中的每个元素。当map足够大时,GC时间过长,会对程序的性能造成巨大影响。
根据bigcache介绍文章的测试,在缓存数据达到数百万条时,接口的99th百分位延迟超过了一秒。监测指标显示堆中超过4,000万个对象,GC的标记和扫描阶段耗时超过了四秒。这样的延迟对于bigcache来说是完全无法接受的。
这个问题在Go 1.5版本中有一项专门的优化(issue-9477):如果map的key和value中使用没有指针,那么GC时将无需遍历map。例如map[int]int
、map[int]bool
。这是当时的pull request: go-review.googlesource.com/c/go/+/3288。里面提到:
Currently scanning of a map[int]int with 2e8 entries (~8GB heap)
takes ~8 seconds. With this change scanning takes negligible time.
对2e8个元素的map[int]int上进行了测试,GC扫描时间从8秒减少到0。
为什么当map的key和value不包含指针时,可以省去对元素的遍历扫描呢?这是因为map中的int、bool这种不可能会和外部变量有引用关系:
map[int]int
为例,外部没有办法取到这个key和value的指针,那也就无从引用了。这个优化听起来非常强大好用,但是在Golang中指针无处不见,结构体指针、切片甚至字符串的底层实现都包含指针。一旦在map中使用它们(例如map[int][]byte、map[string]int),同样会触发垃圾回收器的遍历扫描。
bigcache整体设计的出发点都是基于上文提到的Golang对Map GC优化,整个设计思路包含几个方面:
这是一个非常常见的数据存储优化手段。表面上bigcache中所有的数据是存在一个大cache里面,但实际上底层数据分成了N个不互重合的部分,每一个部分称为一个shard。
在Set或者Get数据时,先对key计算hash值,根据hash值取余得到目标shard,之后所有的读写操作都是在各自的shard上进行。
以Set方法为例:
func (c *BigCache) Set(key string, entry []byte) error {hashedKey := c.hash.Sum64(key)shard := c.getShard(hashedKey)return shard.set(key, hashedKey, entry)}
这么做的优势是可以减少锁冲突,提升并发量:当一个shard被加上Lock的时候,其他shard的读写不受影响。
在bigcache的设计中,对于shard有如下要求:
%
快很多(根据不权威的benchmark,计算速度大概会有2倍左右的差距)。func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) { // shardMask: uint64(config.Shards - 1)return c.shards[hashedKey&c.shardMask]}
前文提到,map的key和value一旦涉及指针相关的类型,GC时就会触发遍历扫描。
因此在bigcache的设计中,shard中的map直接定义为了map[uint64]uint32
,避免了存储任何指针。shard的结构体定义如下:
type cacheShard struct {...hashmap map[uint64]uint32entries queue.BytesQueue...}
其中:hashmap
的key是cache key的hash值,而value仅仅是个uint32。这显然不是我们Set的时候value的原始byte数组。
那value的原始值存在了哪里?答案是cacheShard中的另外一个属性entries queue.BytesQueue
。queue.BytesQueue
是一个ring buffer的内存结构,本质上就是个超大的[]byte
数组,里面存放了所有的原始数据。每个原始数据就存放在这个大[]byte数组中的其中一段。
hashmap中uint32的value值存放的就是value的原始值在BytesQueue
中的数组下标。(其实并不只是原始的value值,里面也包含了key、插入时间戳等信息)
之所以用一个大的[]byte
数组和ring buffer结构,除了方便管理和复用内存之外,一个更重要的原因是:对于[]byte数组, GC时只用看做一个变量扫描,无需再遍历全部数组。这样又避免了海量数据对GC造成的负担。
bigcache在内存结构设计上完全遵循FIFO原则:
BytesQueue
中。基本不直接对内存进行修改和删除等。这样一整套设计约定下来,bigcache的逻辑变成非常简洁明了,但这样同时造成了bigcache的局限性。
cache.Set("my-unique-key", []byte("value"))
前面讲述了bigcache的设计思想之后,Set的整个逻辑也就很清晰了:
这里需要注意的是,在bigcache的设计里面,Set时value一定得是个[]byte
类型。
前文讲到,bigcache中所有的原始数据都会被塞到一个大的[]byte数组里。因此对于bigcache来说最理想的肯定是直接给到[]byte
最为方便,否则还需要考虑序列化等问题。
BytesQueue是一个ring buffer设计,本文不再细究其实现了,和其他ring buffer的结构大同小异。
除了正常的set逻辑外,还有一些额外的情况需要考虑在内:
情况1:如果key之前设置过,Set的时候会如何处理?
在其他cache库的实现中,这种情况一般是找到旧值、删除,然后把新值设置到旧值的位置。
但在bigcache中并不是这样,前文提到,bigcache的内存结构设计是FIFO式的,哪怕是有旧值的情况下,新值也不会复用其内存,依旧是push新的value到队列中。
那旧值将如何处理的呢?我们看下代码:
if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 {if previousEntry, err := s.entries.Get(int(previousIndex)); err == nil {resetHashFromEntry(previousEntry)//remove hashkeydelete(s.hashmap, hashedKey)}}
最核心的一句就是:delete(s.hashmap, hashedKey)
简单来说:之前的旧值并未从内存中移除,仅仅只是将其偏移量从s.hashmap中移除了,使得外部读不到。
那旧值什么时候会被淘汰呢?会有两种情况:
CleanWindow
,且旧值刚好过时,会被清理的定时器自动淘汰MaxEntrySize
或者HardMaxCacheSize
,当内存满时,也会触发最旧数据的淘汰。在此之前,旧值的数据一直都会保留在内存中。
另外还有resetHashFromEntry
,这个逻辑主要是把entry中的hash部分的数值置为0。这么做只是打上一个已处理的标记,保证数据在淘汰的时候不再去调用OnRemove的callback而已。
其实这里还有个场景:当s.hashmap[hashedKey]
存在value时,并不一定是设置过这个key,也有可能发生了hash碰撞。
按照上述逻辑,bigcache并未对hash碰撞做特殊处理,统一都把之前相同hash的旧key删除。 毕竟这只是缓存的场景,并不保证之前Set进去的数据一直会存在。
问题2:当ring buffer满时,无法继续push数据,bigcache会如何处理?
情况分成两种:
entries queue.BytesQueue
未达到设定的HardMaxCacheSize(最大内存上限),或者无HardMaxCacheSize要求,则直接扩容queue.BytesQueue
直到达到上限。不过扩容的时候,是创建了一个新的空[]byte
数组,把原有数据copy过去。BytesQueue
中。如果这个时候新数据非常大,可能会为此淘汰掉许多旧数据。entry, _ := cache.Get("my-unique-key")fmt.Println(string(entry))
Get基本上是Set的逆过程,整个过程更简单一些,没有太多额外的知识可讲。不过在使用时,需要注意的是:
跟删除有关的核心逻辑只有这两行,整个逻辑和Set过程中清除旧值的一样:
...delete(s.hashmap, hashedKey)...resetHashFromEntry(wrappedEntry)...
不过在调用bigcache.Delete
接口时需要注意的是,如果key不存在时,会返回一个ErrEntryNotFound
上面讲到删除逻辑和set时清除旧值时,都只是简单的把key从map中删除,不让外部读取到而已。那原始值什么时候删呢?答案就是过期淘汰。
bigcache有个设计上的优势:bigcache没有开放单个元素的可过期时间,所有元素的cache时长都是一样的,这就意味着所有元素的过期时间在队列中天然有序。
这就使得淘汰逻辑非常简单,代码如下:
func (s *cacheShard) cleanUp(currentTimestamp uint64) {s.lock.Lock()for {if oldestEntry, err := s.entries.Peek(); err != nil {break} else if evicted := s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry); !evicted {break}}s.lock.Unlock()}
其实就是从头到尾遍历数组,直至元素不过期就跳出。
另外,即使淘汰过期数据时,数据也并未被真实的删除,仅仅对应于ring buffer中head和tail下标的移动。
这样整个删除过程非常轻量级,好处不仅在于逻辑更简单,更重要的是,淘汰时需要对整个shard加写锁,这种对有序数组的遍历删除,加锁的时间会非常短(当然也取决于这个时刻过期的数据条数)。
当然,这也意味着bigcache的局限性:数据过期模式非常简单,这种FIFO式的数据淘汰相比于LRU、LFU来说,缓存命中率会低不少。
此外从这里可以得知,哪怕是经过了淘汰,bigcache的内存也不会主动降下去,除非外部调用了Reset方法。因此在实际实践中,我们最好是控制好HardMaxCacheSize,以免OOM。
bigcache的主要逻辑已经基本讲完了,作为一个以性能为卖点的cache库,bigcache在细节上也有大量的性能优化:
varint的使用: 在最开始讲bigcache中每个entry结构的设计时,图中有一个blocksize,代表数据entry的大小,用于bigcache确定数据边界。这里blocksize用到了varint来表示,可以一定程度上减小数据量。具体varint的介绍可以参考我的另外一篇文章《解读 Golang 标准库里的 varint 实现》。
buffer内存复用:在每次set数据的时候,上面varint和整个entry都需要动态地分配内存,bigcache这里在每个shard中内置了两个全局的buffer: headerBuffer
和entrybuffer
,避免了每次的内存分配。
自己实现fnv Hash: bigcache自己实现了一套fnv hash,并没有用go官方标准库的,这也是基于性能的考虑。在Go官方的实现中 hash/fnv/fnv.go,创建Fnv对象的时候,有这么一段逻辑:
func New32a() hash.Hash32 {var s sum32a = offset32return &s}
根据Golang的逃逸分析,s这个变量在结束的时候会被外部用到,这样Go编译器会将其分配到堆上(逃逸到堆上)。
我们知道,直接在栈上操作内存比堆上更快速,因此bigcache实现了一个基于栈内存的fnv hash版本。
bigcache的介绍文章中也提到,JSON序列化问题成为了一个性能问题:
While profiling our application, we found that the program spent a huge amount of time on JSON deserialization. Memory profiler also reported that a huge amount of data was processed by
json.Marshal
.
他们换成了ffjson来替换go标准库中的json操作,性能得到了不少的提升。
不过这样给我们提了个醒,如果不是海量数据,尚未达到map的gc瓶颈,倒是没有必要直接就上bigcache, 毕竟序列化所带来的开销也不算低。
bigcache.Config
中有很多配置参数,这里大概列一下:
// Config for BigCachetype Config struct {// Number of cache shards, value must be a power of two// shard个数。必须2的平方数。Shards int// Time after which entry can be evicted // 最小粒度是秒,当CleanWindow设置的时候,一定要设置这个值LifeWindow time.Duration// Interval between removing expired entries (clean up).// If set to <= 0 then no action is performed. Setting to < 1 second is counterproductive — bigcache has a one second resolution.// 如果没有设置,数据将不会被定时清理。最好大于1秒,因为bigcache的最小时间粒度就是秒CleanWindow time.Duration// Max number of entries in life window. Used only to calculate initial size for cache shards.// When proper value is set then additional memory allocation does not occur.MaxEntriesInWindow int// Max size of entry in bytes. Used only to calculate initial size for cache shards. // 单条数据最大的size,并不会做强制约束,只是用来初始化cache大小用,这个是仅包含用户自己设置的key和value的大小。MaxEntrySize int// StatsEnabled if true calculate the number of times a cached resource was requested.// 是否对每条数据都开启hit次数统计的功能StatsEnabled bool// Verbose mode prints information about new memory allocationVerbose bool// Hasher used to map between string keys and unsigned 64bit integers, by default fnv64 hashing is used. // hash函数,默认是bigcache自己实现的fnvHasher Hasher// HardMaxCacheSize is a limit for BytesQueue size in MB.// It can protect application from consuming all available memory on machine, therefore from running OOM Killer.// Default value is 0 which means unlimited size. When the limit is higher than 0 and reached then// the oldest entries are overridden for the new ones. The max memory consumption will be bigger than// HardMaxCacheSize due to Shards' s additional memory. Every Shard consumes additional memory for map of keys// and statistics (map[uint64]uint32) the size of this map is equal to number of entries in// cache ~ 2×(64+32)×n bits + overhead or map itself. // 最大内存数限制。HardMaxCacheSize int// OnRemove is a callback fired when the oldest entry is removed because of its expiration time or no space left// for the new entry, or because delete was called.// Default value is nil which means no callback and it prevents from unwrapping the oldest entry.// ignored if OnRemoveWithMetadata is specified.OnRemove func(key string, entry []byte)// OnRemoveWithMetadata is a callback fired when the oldest entry is removed because of its expiration time or no space left// for the new entry, or because delete was called. A structure representing details about that specific entry.// Default value is nil which means no callback and it prevents from unwrapping the oldest entry.OnRemoveWithMetadata func(key string, entry []byte, keyMetadata Metadata)// OnRemoveWithReason is a callback fired when the oldest entry is removed because of its expiration time or no space left// for the new entry, or because delete was called. A constant representing the reason will be passed through.// Default value is nil which means no callback and it prevents from unwrapping the oldest entry.// Ignored if OnRemove is specified.OnRemoveWithReason func(key string, entry []byte, reason RemoveReason)// Logger is a logging interface and used in combination with `Verbose`// Defaults to `DefaultLogger()`Logger Logger}
]]>本文属于《Golang 源码剖析系列》。
最近发现 Golang 标准库竟然自带了 varint 的实现,代码位置在 encoding/binary/varint.go,这个跟protobuf里面的varint实现基本是一致的。刚好借助 golang 标准库的 varint 源码,我们来系统地学习和梳理下 varint。
熟悉 protobuf 的人肯定对 varint 不陌生,protobuf 里面除了带 fix (如 fixed32、fixed64) 之外的整数类型, 都是 varint 编码。
varint 的出现主要是为了解决两个问题:
本文将通过分析 Golang 标准库自带的 varint 源码实现,介绍 varint 的设计原理以及Golang标准库是如何解决 varint 在编码负数时遇到的问题。
varint 的设计原理非常简单:
例如:对于一个整数 300,它的二进制表示是 100101100
。我们可以将它分为两组,即 10
和 0101100
。然后在每组前面添加标志位,得到两个字节 00000010
和 10101100
,这两个字节就是 300 的 varint 编码。相比于用 uint32 的 4 字节表示,少了 50% 的存储空间。
在 Golang 标准库中有两套 varint 的函数: 分别用于无符号整数的 PutUvarint 和 Uvarint,以及用于有符号整数的 Varint 和 PutVarint。
我们先看下无符号整数的 varint 实现,代码如下:
func PutUvarint(buf []byte, x uint64) int {i := 0for x >= 0x80 {buf[i] = byte(x) | 0x80x >>= 7i++}buf[i] = byte(x)return i + 1}
代码里有个非常重要的常量:0x80,对应于二进制编码就是 1000 0000
。这个常量对接下来的逻辑非常重要:
x >= 0x80
。这意味着 x 的二进制表示形式至少有 8 位,我们刚才讲到 7 个 bit 位为一组,那 x 就需要被拆分了。byte(x) | 0x80
。将 x 的最低 8 位与 1000 0000
进行按位或操作,然后将结果存储在 buf[i] 中。这样 既可以将最高位设置为 1,同时也提取出了 x 的最低 7 位。x >>= 7
. 将 x 右移 7 位,处理下一个分组。buf[i] = byte(x)
。当 for 循环结束时,即意味着 x 的二进制表示形式最高位必然是 0。此时就不用做额外的补零操作了。Uvarint
是 PutUvarint
的逆过程,本文就不再详解了。
需要注意的是,varint 将整数划分为 7 位一组。这意味着,对于大整数 varint 将会出现负向优化。例如对于 uint64 的最大值来说,本来只需要 8 个 byte 来表示,但在 varint 中却需要 10 个字节来表示了。(64/7 ≈ 10
)
看似 varint 编码已经完美无缺了,但以上忽略了一点:负数的存在。
我们知道,在计算机中数字是用补码来表示的,而负数的补码则是将其绝对值按位取反再加 1。这就意味着一个很小的负数,它的二进制形式对应于一个非常大的整数。例如:对于一个 32 位的整数 -5
来说,其绝对值 5 的二进制形式是 101
。 但 -5
的二进制形式却是 11111111111111111111111111111011
,如果使用 varint 对其编码, 需要 5 个字节才能表示。
Golang标准库引入了 zigzag 编码来解决这个问题,zigzag 的原理非常简单:
2n
。例如整数 2,经过 zigzag 编码之后变成了 4。-n
来说,会将其映射为 2n-1
。例如负数 -3
,经过 zigzag 编码之后变成了 5。这样负数和正数在数值上完全不会冲突,正整数和负整数交错排列,这也是为什么叫做 zigzag 编码 (锯齿形编码)的原因。
同时,负数被转换成正数之后,二进制编码也精简了许多。
例如: 对 -5
进行 zigzag 编码后,变成了 9,对应于二进制为 00000000000000000000000000001001
,使用 1 个字节即可表示 varint。
我们看下 Golang 标准库的实现,代码如下:
func PutVarint(buf []byte, x int64) int {// zigzag 编码ux := uint64(x) << 1if x < 0 {ux = ^ux}return PutUvarint(buf, ux)}
从代码可以看出,对于有符号整数的varint实现,golang标准库这里分成了两步:
我们详细讲下 zigzag 编码的实现部分:
ux := uint64(x) << 1
。这个位运算左移一位,相当于 ux*2
。对于正数,符合 ZigZag 编码。ux := uint64(x) << 1; ux = ^ux
。负数这里就有些难以理解了,为什么这么转换之后就等于2n - 1
了?我们可以大概推导下整个过程,假设我们有个整数 -n
:
2*(~(-n))+1
负数的补码=绝对值按位取反+1
,那如何根据补码再推导出绝对值?这里有个公式是:|A| = ~A+1
2*(n-1) + 1 = 2n - 1
。这就完美对应上了负数的 ZigZag 编码。在 Golang 标准库里面,调用 PutUvarint 时只会使用 varint 编码,调用 PutVarint 会先进行 zigzag 编码,再进行 varint 编码。
而在 protobuf 中,如果类型是 int32、int64、uint32、uint64,只会使用 varint 编码。使用 sint32、sint64 将先进行 zigzag 编码,再进行 varint 编码
虽然 varint 编码设计非常精妙,但并不适用于所有的场景:
2^63
,那使用 varint 会用到 10 字节。相比于传统的八字节整数,反而多用了 25% 的空间本文属于《Golang 源码剖析系列》
sync.Pool 是 Golang 内置的对象池技术,可用于缓存临时对象,避免因频繁建立临时对象所带来的消耗以及对 GC 造成的压力。
在许多知名的开源库中,都可以看到 sync.Pool 的大量使用。例如,HTTP 框架 Gin 用 sync.Pool 来复用每个请求都会创建的 gin.Context
对象。 在 grpc-Go、kubernates 等也都可以看到对 sync.Pool 的身影。
但需要注意的是,sync.Pool 缓存的对象随时可能被无通知的清除,因此不能将 sync.Pool 用于存储持久对象的场景。
sync.Pool 作为 goroutine 内置的官方库,其设计非常精妙。sync.Pool 不仅是并发安全的,而且实现了 lock free,里面有许多值得学习的知识点。
本文将基于 go-1.16 的源码 对 sync.Pool 的底层实现一探究竟。
在正式讲 sync.Pool 底层之前,我们先看下 sync.Pool 的基本用法。其示例代码如下:
type Test struct {A int}func main() {pool := sync.Pool{New: func() interface{} {return &Test{A: 1,}},}testObject := pool.Get().(*Test)println(testObject.A) // print 1pool.Put(testObject)}
sync.Pool 在初始化的时候,需要用户提供一个对象的构造函数 New
。用户使用 Get
来从对象池中获取对象,使用 Put
将对象归还给对象池。整个用法还是比较简单的。
接下来,让我们详细看下 sync.Pool 是如何实现的。
在讲 sync.Pool 之前,我们先聊下 Golang 的 GMP 调度。在 GMP 调度模型中,M 代表了系统线程,而同一时间一个 M 上只能同时运行一个 P。那么也就意味着,从线程维度来看,在 P 上的逻辑都是单线程执行的。
sync.Pool 就是充分利用了 GMP 这一特点。对于同一个 sync.Pool ,它在每个 P 上都分配了一个本地对象池 poolLocal
。如下图所示。
sync.Pool 的 代码定义如下 sync/pool.go#L44:
type Pool struct {noCopy noCopylocal unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocallocalSize uintptr // size of the local arrayvictim unsafe.Pointer // local from previous cyclevictimSize uintptr // size of victims arrayNew func() interface{}}
其中,我们需要着重关注以下三个字段:
local
是个数组,长度为 P 的个数。其元素类型是 poolLocal
。这里面存储着各个 P 对应的本地对象池。可以近似的看做 [P]poolLocal
。localSize
。代表 local 数组的长度。因为 P 可以在运行时通过调用 runtime.GOMAXPROCS 进行修改, 因此我们还是得通过 localSize
来对应 local
数组的长度。New
就是用户提供的创建对象的函数。这个选项也不是必需。当不填的时候,Get 有可能返回 nil。其他几个字段我们暂时不用太过关心,这里先简单介绍下:
victim
和 victimSize
。这一对变量代表了上一轮清理前的对象池,其内容语义 local 和 localSize 一致。victim 的作用还会在下面详细介绍到。noCopy
是 Golang 源码中禁止拷贝的检测方法。可以通过 go vet
命令检测出 sync.Pool 的拷贝。这个在另外一篇文章 Golang WaitGroup 原理深度剖析 中也有讲到,这里不再展开讨论了。由于每个 P 都有自己的一个本地对象池 poolLocal,Get 和 Put 操作都会优先存取本地对象池。由于 P 的特性,操作本地对象池的时候整个并发问题就简化了很多,可以尽量避免并发冲突。
我们再看下本地对象池 poolLocal
的定义,如下:
// 每个 P 都会有一个 poolLocal 的本地type poolLocal struct {poolLocalInternalpad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte}type poolLocalInternal struct {private interface{}shared poolChain}
pad
变量的作用在下文会讲到,这里暂时不展开讨论。我们可以直接看 poolLocalInternal 的定义,其中每个本地对象池,都会包含两项:
private
私有变量。Get 和 Put 操作都会优先存取 private 变量,如果 private 变量可以满足情况,则不再深入进行其他的复杂操作。shared
。其类型为 poolChain,从名字不难看出这个是链表结构,这个就是 P 的本地对象池了。poolChain 的整个存储结构如下图所示:
从名字大概就可以猜出,poolChain 是个链表结构,其链表头 HEAD 指向最新分配的元素项。链表中的每一项是一个 poolDequeue 对象。poolDequeue 本质上是一个 ring buffer 结构。其对应的代码定义如下:
type poolChain struct {head *poolChainElttail *poolChainElt}type poolChainElt struct {poolDequeuenext, prev *poolChainElt}type poolDequeue struct {headTail uint64vals []eface}
为什么 poolChain 是这么一个链表 + ring buffer 的复杂结构呢?简单的每个链表项为单一元素不行吗?
使用 ring buffer 是因为它有以下优点:
ring buffer 的这两个特性,非常适合于 sync.Pool的应用场景。
我们再注意看一个细节,poolDequeue 作为一个 ring buffer,自然需要记录下其 head 和 tail 的值。但在 poolDequeue 的定义中,head 和 tail 并不是独立的两个变量,只有一个 uint64 的 headTail 变量。
这是因为 headTail 变量将 head 和 tail 打包在了一起:其中高 32 位是 head 变量,低 32 位是 tail 变量。如下图所示:
为什么会有这个复杂的打包操作呢?这个其实是个非常常见的 lock free 优化手段。我们在 《Golang WaitGroup 原理深度剖析》 一文中也讨论过这种方法。
对于一个 poolDequeue 来说,可能会被多个 P 同时访问(具体原因见下文 Get 函数中的对象窃取逻辑),这个时候就会带来并发问题。
例如:当 ring buffer 空间仅剩一个的时候,即 head - tail = 1
。 如果多个 P 同时访问 ring buffer,在没有任何并发措施的情况下,两个 P 都可能会拿到对象,这肯定是不符合预期的。
在不引入 Mutex 锁的前提下,sync.Pool 是怎么实现的呢?sync.Pool 利用了 atomic 包中的 CAS 操作。两个 P 都可能会拿到对象,但在最终设置 headTail 的时候,只会有一个 P 调用 CAS 成功,另外一个 CAS 失败。
atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2)
在更新 head 和 tail 的时候,也是通过原子变量 + 位运算进行操作的。例如,当实现 head++
的时候,需要通过以下代码实现:
const dequeueBits = 32atomic.AddUint64(&d.headTail, 1<<dequeueBits)
我们看下 Put 函数的实现。通过 Put 函数我们可以把不用的对象放回或者提前放到 sync.Pool 中。Put 函数的代码逻辑如下:
func (p *Pool) Put(x interface{}) {if x == nil {return}l, _ := p.pin()if l.private == nil {l.private = xx = nil}if x != nil {l.shared.pushHead(x)}runtime_procUnpin()}
从以上代码可以看到,在 Put 函数中首先调用了 pin()
。pin
函数非常重要,它有三个作用:
runtime.GOMAXPROCS
不一致时,将触发重新创建 local 数组,以和 P 的个数保持一致。func indexLocal(l unsafe.Pointer, i int) *poolLocal {lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))return (*poolLocal)(lp)}
接着,Put 函数会优先设置当前 poolLocal 私有变量 private
。如果设置私有变量成功,那么将不会往 shared 缓存池写了。这样操作效率会更高效。
如果私有变量之前已经设置过了,那就只能往当前 P 的本地缓存池 poolChain 里面写了。我们接下来看下,sync.Pool 的每个 P 的内部缓存池 poolChain 是怎么实现的。
在 Put 的时候,会去直接取 poolChain 的链表头元素 HEAD:
Put 的过程比较简单,整个过程不需要和其他 P 的 poolLocal 进行交互。
在了解 Put 是如何实现后,我们接着看 Get 的实现。通过 Get 操作,可以从 sync.Pool 中获取一个对象。
相比于 Put 函数,Get 的实现更为复杂。不仅涉及到对当前 P 本地对象池的操作,还涉及对其他 P 的本地对象池的对象窃取。其代码逻辑如下:
func (p *Pool) Get() interface{} {l, pid := p.pin()x := l.privatel.private = nilif x == nil {x, _ = l.shared.popHead()if x == nil {x = p.getSlow(pid)}}runtime_procUnpin()if x == nil && p.New != nil {x = p.New()}return x}
其中 pin()
的作用和 private
对象的作用,和 PUT 操作中的一致,这里就不再赘述了。我们着重看一下其他方面的逻辑:
首先,Get 函数会尝试从当前 P 的 本地对象池 poolChain 中获取对象。从当前 P 的 poolChain 中取数据时,是从链表头部开始取数据。 具体来说,先取位于链表头的 poolDequeue,然后从 poolDequeue 的头部开始取数据。
如果从当前 P 的 poolChain 取不到数据,意味着当前 P 的缓存池为空,那么将尝试从其他 P 的缓存池中 窃取对象。这也对应 getSlow 函数的内部实现。
在 getSlow 函数,会将当前 P 的索引值不断递增,逐个尝试从其他 P 的 poolChain 中取数据。注意,当尝试从其他 P 的 poolChain 中取数据时,是从链表尾部开始取的。
for i := 0; i <int(size); i++ {l := indexLocal(locals, (pid+i+1)%int(size))if x, _ := l.shared.popTail(); x != nil {return x}}
在对其他 P 的 poolChain 调用 popTail,会先取位于链表尾部的 poolDequeue,然后从 poolDequeue 的尾部开始取数据。如果从这个 poolDequeue 中取不到数据,则意味着该 poolDequeue 为空,则直接从该 poolDequeue 从 poolChain 中移除,同时尝试下一个 poolDequeue。
如果从其他 P 的本地对象池,也拿不到数据。接下来会尝试从 victim 中取数据。上文讲到 victim 是上一轮被清理的对象池, 从 victim 取对象也是 popTail 的方式。
最后,如果所有的缓存池都都没有数据了,这个时候会调用用户设置的 New
函数,创建一个新的对象。
sync.Pool 在设计的时候,当操作本地的 poolChain 时,无论是 push 还是 pop,都是从头部开始。而当从其他 P 的 poolChain 获取数据,只能从尾部 popTail 取。这样可以尽量减少并发冲突。
sync.Pool 没有对外开放对象清理策略和清理接口。我们上面讲到,当窃取其他 P 的对象时,会逐步淘汰已经为空的 poolDequeue。但除此之外,sync.Pool 一定也还有其他的对象清理机制,否则对象池将可能会无限制的膨胀下去,造成内存泄漏。
Golang 对 sync.Pool 的清理逻辑非常简单粗暴。首先每个被使用的 sync.Pool,都会在初始化阶段被添加到全局变量 allPools []*Pool
对象中。Golang 的 runtime 将会在 每轮 GC 前,触发调用 poolCleanup 函数,清理 allPools。代码逻辑如下:
func poolCleanup() {for _, p := range oldPools {p.victim = nilp.victimSize = 0}for _, p := range allPools {p.victim = p.localp.victimSize = p.localSizep.local = nilp.localSize = 0}oldPools, allPools = allPools, nil}
这里需要正式介绍下 sync.Pool 的 victim(牺牲者) 机制,我们在 Get 函数的对象窃取逻辑中也有提到 victim。
在每轮 sync.Pool 的清理中,暂时不会完全清理对象池,而是将其放在 victim 中。等到下一轮清理,才完全清理掉 victim。也就是说,每轮 GC 后 sync.Pool 的对象池都会转移到 victim 中,同时将上一轮的 victim 清空掉。
为什么这么做呢?
这是因为 Golang 为了防止 GC 之后 sync.Pool 被突然清空,对程序性能造成影响。因此先利用 victim 作为过渡,如果在本轮的对象池中实在取不到数据,也可以从 victim 中取,这样程序性能会更加平滑。
victim 机制最早用在 CPU Cache 中,详细可以阅读这篇 wiki: Victim_cache。
type poolLocal struct {poolLocalInternal// Prevents false sharing on widespread platforms with// 128 mod (cache line size) = 0 .pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte}
我们在上面讲到 poolLocal 时,会发现这么一个奇怪的结构:poolLocal 有一个 pad 属性,从这个属性的定义方式来看,明显是为了凑齐了 128 Byte 的整数倍。为什么会这么做呢?
这里是为了避免 CPU Cache 的 false sharing 问题:CPU Cache Line 通常是以 64 byte 或 128 byte 为单位。在我们的场景中,各个 P 的 poolLocal 是以数组形式存在一起。假设 CPU Cache Line 为 128 byte,而 poolLocal 不足 128 byte 时,那 cacheline 将会带上其他 P 的 poolLocal 的内存数据,以凑齐一整个 Cache Line。如果这时,两个相邻的 P 同时在两个不同的 CPU 核上运行,将会同时去覆盖刷新 CacheLine,造成 Cacheline 的反复失效,那 CPU Cache 将失去了作用。
CPU Cache 是距离 CPU 最近的 cache,如果能将其运用好,会极大提升程序性能。Golang 这里为了防止出现 false sharing 问题,主动使用 pad 的方式凑齐 128 个 byte 的整数倍,这样就不会和其他 P 的 poolLocal 共享一套 CacheLine。
回顾下 sync.Pool 的实现细节,总结来说,sync.Pool 利用以下手段将程序性能做到了极致:
pin
锁定当前 P,防止 goroutine 被抢占,造成程序混乱。os.Chmod("test.txt", 777)
,希望把该文件的读写及执行权限对所有用户开放。$ ls -l test.txt-r----x--x 1 cyhone 1085706827 0 Jun 20 13:27 test.txt
结果出乎意料,不仅文件权限没有按预期的变成 rwxrwxrwx
。反而执行完后,当前用户就只剩可读权限了,其他用户就只有可执行权限同时无读写权限。
因为这实在是一个简单又愚蠢的错误,所以先直接给出结论:
rwxrwxrwx
,需要写成 0777
,而非 777
。chmod
命令的表示形式,用八进制表示更方便和准确点。0777
和 777
都可以。这个问题虽然非常简单,但尴尬的是我还踩了坑,所以把这个问题及原因分享出来。
为什么 rwxrwxrwx
对应的是八进制的 0777
,而不是 777
呢?。
原因是,底层在将数字翻译成对应权限时,实际上用的该数字对应的二进制位,并将后 9 位逐位翻译。
例如,对应八进制 0777
来说,其二进制的表示如下:
从上图来看,0777
就代表了 rwxrwxrwx
。
而对于十进制的 777
,其二进制的表示形式如下:
从其按位翻译来看,恰好 777
的后 9 位,就代表了 r----x--x
, 和我们的运行结果一致。
那么话说回来,根据这个理论,如果非要用十进制表示 rwxrwxrwx
,那么应该是 511
。
我们可以用代码实验下:
fileMode := os.FileMode(511)fmt.Println(fileMode.String()) // -rwxrwxrwx
从结果看的确是符合预期的。
有时我们需要把缓存逻辑放在 Server 内部,而非网关侧如 Nginx 等,是因为这样我们可以根据需要便捷地清除缓存,或者可以使用 Redis 等其他存储介质作为缓存后端。
这样的缓存场景无非是有缓存时从缓存取,无缓存时从下游服务取,并将数据放入缓存中。这其实是个非常通用的逻辑,应该可以将其抽象出来。从而缓存逻辑无需侵入进业务代码。
我常用的 HTTP 框架是 golang 的 gin。gin 官方就有一个 cache 组件:github.com/gin-contrib/cache,但这个 cache 组件无论在性能还是接口设计上,都有一些不足之处。
因此,我重新设计了一套 cache 中间件: gin-cache。 从压测结果来看,其性能相比于 gin-contrib/cache
明显提升。
gin-contrib/cache
是 gin 官方提供的一个 cache 组件,但这个组件在性能还是接口设计上,都并不令人满意。如下:
gin-contrib/cache
对外提供的使用方式是 wrap handler 的方式,而非更加优雅和通用的 middleware。cache.CachePage(store, time.Minute, func(c *gin.Context) {c.String(200, "pong"+fmt.Sprint(time.Now().Unix()))})
gin-contrib/cache
只提供了 CachePage
、CachePageWithoutQuery
等函数,用户可以根据 url 作为缓存的 key。但该组件并不支持自定义 cache key。对于一些特殊场景,将无法满足需求。ResponseWriter
的 Write
函数。每次在 gin 中调用 Write 函数时,都会触发一次缓存的 get 和 append 操作。这种边写边 cache 的过程,其性能显然是比较糟糕的。CachePageAtomic
接口,加了一把互斥锁来保证缓存不会写冲突, 代码如下。这把互斥锁会使得在并发越大的情况下,反而接口性能会越差。func CachePageAtomic(store persistence.CacheStore, expire time.Duration, handle gin.HandlerFunc) gin.HandlerFunc {var m sync.Mutexp := CachePage(store, expire, handle)return func(c *gin.Context) {m.Lock()defer m.Unlock()p(c)}}
关于性能的这两个方面,让我着实踩了一些坑。针对于性能方面的第一项,我也对 gin-contrib/cache
提了一个 pull request。但是其他方面, 尤其是接口设计方面,让我觉得这个库或许不是最终的答案。
在踩了这个库的坑后,我决定不如实现一个新的库,可以满足我这些需求。
在踩了 gin-contrib/cache
的坑后,gin-cache 就随之诞生。其具体实现可以看 github.com/chenyahui/gin-cache。
app.GET("/hello", cache.CacheByPath(cache.Options{ CacheDuration: 5 * time.Second, CacheStore: persist.NewMemoryStore(1 * time.Minute), }), func(c *gin.Context) { c.String(200, "hello world") },)
Cache( func(c *gin.Context) (string, bool) { return c.Request.RequestURI, true }, options,)
当然,我也提供了一些快捷方法:CacheByURI
和 CacheByPath
,分别以 url 为 key 以及忽略 url 中的 query 参数为 key 进行 cache,这样可以满足大部分的需求。
在性能方面,相比于 gin-contrib/cache
边写边 cache 的方式,gin-cache 只会在整个 handler 结束后,cache 最终的 response 内容。整个过程只会涉及一次写 cache 的操作。
除此之外,gin-cache 也有其他方面的性能优化:
在缓存设计中,会遇到一个常见的问题: 缓存击穿 。缓存击穿指的是:当某个热点 key 在其缓存过期的一瞬间,大量的请求将访问不到这个 key 对应的缓存,这时请求将直接打到下游存储或服务中。一瞬间的大量请求,可能会对下游服务造成极大压力。
关于此问题,golang 官方有一个 singleflight 库: golang.org/x/sync/singleflight,可以有效的解决缓存击穿问题。其原理非常简单,有兴趣的可以直接在 Github 搜源码看就可以了,本文不再展开讨论。
使用 Linux CPU 8 核,16G 内存的系统配置下,我使用 wrk 对 gin-contrib/cache 和 gin-cache 做了 benchmark 压力测试。
我们使用如下命令进行压测:
wrk -c 500 -d 1m -t 5 http://127.0.0.1:8080/hello
我们分别对MemoryCache和RedisCache两种存储后端进行了压测。从下图来看,最终的压测结果非常惊人。
而且,从二者的设计对比来看,在当 handler 请求耗时越大,gin-cache 的优势将更加明显。
]]>下线服务不仅仅是运维层面的工作,需要整个 RPC 实现、服务架构以及运维体系的配合,才能完美的实现服务的优雅下线。本文将基于服务下线的整个流程,分析如何实现微服务的优雅终止。主要包含以下方面:
如果服务使用了服务注册中心(例如 Consul、etcd 等),那第一步就是首先将服务从注册中心下线。这样可以尽快保证新的请求不会打到这台节点上。
虽然绝大部分的服务注册中心都有节点的心跳和超时自动清理的机制,但是心跳也是有固定间隔的,注册中心需要等到预设的心跳超时后才能发现节点的下线。因此,主动下线可以极大缩短这个异常发现的过程。
如果服务是基于 k8s 进行管理和调度,那这件事情就做起来非常方便了。
首先,k8s 本身自带了一个可靠的 服务发现,在 k8s 上进行 pod 的上下线,k8s 自然都会第一时间感知到。
如果使用的是外置的名字服务,则可以使用 k8s 的 preStop
功能。k8s 原生支持了 容器生命周期回调, 我们可以定义 pod 的 preStop
钩子,来实现服务下线前的清理操作。如下:
例如:
containers:- name: my-app-container image: my-app-image lifecycle: preStop: exec: command: ["/bin/sh","-c","/app/pre_stop.sh"]
pod在下线之前,首先会执行 /app/pre_stop.sh
命令,在这个命令中,我们可以做很多预清理策略。
将服务节点从名字服务中摘除,可以阻挡新流量进入到该节点,这是优雅终止的第一步。但是,对于该节点上已建立的客户端连接,如果贸然下线,将会造成正在的业务逻辑的突然中止。因此,我们需要实现RPC级别的,对连接和请求处理进行优雅终止,以保证业务逻辑尽量少的受到影响。
以 gRPC-Go 为例,gRPC 实现了两个停止接口 GracefulStop
和 Stop
,分别代表服务的优雅终止和非优雅终止。我们来看下 gRPC 是如何优雅终止的。
func (s *Server) GracefulStop() {s.quit.Fire()defer s.done.Fire() ...s.mu.Lock()// 首先关闭监听 socket,保证不会有新的连接到来for lis := range s.lis {lis.Close()}s.lis = nilif !s.drain {for st := range s.conns {st.Drain()}s.drain = true}// Wait for serving threads to be ready to exit. Only then can we be sure no// new conns will be created.s.mu.Unlock()s.serveWG.Wait()s.mu.Lock()for len(s.conns) != 0 {s.cv.Wait()}...s.mu.Unlock()}
s.quit.Fire()
。当该语句执行后,gRPC对于所有新 Accept 到来的连接,都会直接丢弃。lis.Close()
。关闭监听 Socket,这样将不会再有新连接到达。st.Drain()
。由于 gRPC 是基于 HTTP2 实现,因此这里将会应用到 HTTP2 的 goAway
帧。goAway
帧相当于服务器端主动给客户端发送的连接关闭的信号,客户端收到这个信号后,将会关闭该连接上所有的 HTTP2 的流。这样客户端侧可以主动感知到连接关闭,同时不会继续发送新的请求过来。s.serveWG.Wait()
。保证 gRPC 的 Serve 函数已正常退出。s.cv.Wait()
。这个逻辑用于等待所有已建立连接的业务处理逻辑的正常结束。这样就不会因为服务的突然关闭,造成业务逻辑的异常。以上就是 gRPC 的优雅终止过程。简单来说,gRPC 需要从外至内的保证了各层逻辑的正常关闭。
但是,这里有个问题可能容易忽视。最后一步调用 s.cv.Wait()
,用来等待业务处理逻辑的正常结束。但这里可能有异常情况是,如果业务逻辑由于代码 bug,发生了死锁或者死循环,那么业务逻辑将永远无法结束,s.cv.Wait()
也将会一直卡住。这样,GracefulStop 也将永远无法结束。
针对于这个问题,需要配合外置的部署系统,对服务进行强行的超时终止。接下来,我们看下 k8s 是如何实现这一点的。
在 k8s 下线 pod 之前,集群并不会强制的杀死 pod,而是需要执行一系列步骤才会让 pod 体面的下线。
SIGTERM
信号。SIGTERM
其实就对应于 linux 命令 kill -15
。这就需要 RPC 自行监听 SIGTERM
信号,一旦收到信号,即可执行优雅终止。SIGKILL
信号,相当于 linux 命令 kill -9
,pod 将被强行终止。而等待的时长取决于 pod 配置的优雅终止时间 terminationGracePeriodSeconds
参数,默认为 30 秒。这个时候,突然想到了《让子弹飞》里面的一句话:“黄老爷是个体面人,他要是体面,你就让他体面。他要是不体面,你就帮他体面。”
k8s 允许 pod 体面的下线,如果 pod 不体面,那么就强行让他体面的下线。
以上内容分别讲了如何在各个方面实现服务的优雅终止,总结下整个优雅终止的流程:
基于以上一整套流程,可以实现服务的优雅终止, 这对于无状态服务来说基本已经够用了。但对于有状态服务,优雅终止的挑战会更难一些。TiDB 这里有一篇文章,讲述了 有状态分布式应用的优雅终止挑战,有兴趣的同学可以扩展看一下。
本文主要讲了服务的优雅终止,那么既然有优雅终止,那同时也会对应服务的优雅启动。服务的优雅终止是从外至內的,首先关闭掉最外层的流量进入,再逐步向内停止逻辑。而优雅启动要从内至外地保证各层逻辑正常打开,才能完成最终的上线。
本文属于 《Golang源码剖析系列》
一致性 Hash 常用于缓解分布式缓存系统扩缩容节点时造成的缓存大量失效的问题。一致性 Hash 与其说是一种 Hash 算法,其实更像是一种负载均衡策略。
GroupCache 是 golang 官方提供的一个分布式缓存库,其中包含了一个简单的一致性 Hash 的实现。其代码在 github.com/golang/groupcache/consistenthash。本文将会基于 GroupCache 的一致性 Hash 实现,深入剖析一致性 Hash 的原理。
本文会着重探讨以下几点内容:
我们先看下传统的 Hash 式负载均衡,当集群扩缩容时会遇到哪些问题。
假设我们有三台缓存服务器,每台服务器用于缓存一部分用户的信息。最常见的 Hash 式负载均衡做法是:对于指定用户,我们可以对其用户名或者其他唯一信息计算 hash 值,然后将该 hash 值对 3 取余,得到该用户对应的缓存服务器。如下图所示:
而当我们需要对集群进行扩容或者缩容时,增加或者减少部分服务器节点,将会带来大面积的缓存失效。
例如需要扩容一台服务器,即由 3 台缓存服务器增加为 4 台,那么之前 hash(username) % 3
这种策略,将变更为 hash(username) % 4
。整个负载均衡的策略发生了彻底的变化,对于任何一个用户都会面临Hash失效的风险。
而一旦缓存集体失效,所有请求无法命中缓存,直接打到后端服务上,系统很有可能发生崩溃。
针对以上问题,如果使用一致性 Hash 作为缓存系统的负载均衡策略,可以有效缓解集群扩缩容带来的缓存失效问题。
相比于直接对 hash 取模得到目标 Server 的做法,一致性 Hash 采用 有序 Hash 环 的方式选择目标缓存 Server。如下图所示:
对于该有序 Hash 环,环中的每个节点对应于一台缓存 Server,同时每个节点也包含一个整数值。各节点按照该整数值从小到大依次排列。
对于指定用户来说,我们依然首先出计算用户名的 hash 值。接着,在 Hash 环中按照值大小顺序,从小到大依次寻找,找到 第一个大于等于该 hash 值的节点,将其作为目标缓存 Server。
例如,我们 hash 环中的三个节点 Node-A
、Node-B
、Node-C
的值依次为 3、7、13。假设对于某个用户来说,我们计算得到其用户名的 hash 值为 9,环中第一个大于 9 的节点为 Node-C
,则选用 Node-C
作为该用户的缓存 Server。
以上就是正常情况下一致性 Hash 的使用,接下来我们看下,一致性 Hash 是如何应对集群的扩缩容的。
当我们对集群进行扩容,新增一个节点 New-Node
, 假设该节点的值为 11。那么新的有序 Hash 环如下图所示:
我们看下此时的缓存失效情况:在这种情况下, 只会造成 hash 值范围在 Node-B
和 NewNode
之间(即(7, 11])的数据缓存失效。这部分数据原本分配到节点 Node-C
(值为 13),现在都需要迁移到新节点 NewNode
上。
而原本分配到 Node-A
、Node-B
两个节点上的缓存数据,不会受到任何影响。之前值范围在 NewNode
和 Node-B
之间(即(11, 13])的数据,被分配到了 Node-C
上面。新节点出现后,这部分数据依然属于 Node-C
,也不会受到任何影响。
一致性 Hash 利用有序 Hash 环,巧妙的缓解了集群扩缩容造成的缓存失效问题。注意,这里说的是 “缓解”,缓存失效问题无法完全避免,但是可以将其影响降到最低。
这里有个小问题是,因为有序 Hash 环需要其中每个节点有持有一个整数值,那这个整数值如何得到呢?一般做法是,我们可以利用该节点的特有信息计算其 Hash 值得到, 例如 hash(ip:port)
。
以上介绍了一致性 hash 的基本过程,这么看来,一致性 hash 作为缓解缓存失效的手段,的确是行之有效的。
但我们考虑一个极限情况,假设整个集群就两个缓存节点: Node-A
和 Node-B
。则 Node-B
中将存放 Hash 值范围在 (Node-A, Node-B]
之间的数据。而 Node-A
将承担两部分的数据: hash < Node-A
和 hash > Node-B
。
从这个值范围,我们可以轻易的看出,Node-A
的值空间实际上远大于 Node-B
。当数据量较大时,Node-A
承担的数据也将远超于 Node-B
。实际上,当节点过少时,很容易出现分配给某个节点的数据远大于其他节点。这种现象我们往往称之为 “数据倾斜”。
对于此类问题,我们可以引入虚拟节点的概念,或者说是副本节点。每个真实的缓存 Server 在 Hash 环上都对应多个虚拟节点。如下图所示:
对于上图来说,我们其实依然只有三个缓存 Server。但是每个 Server 都有一个副本,例如 V-Node-A
和 Node-A
都对应同一个缓存 Server。
GroupCache 提供了一个简单的一致性 hash 的实现。其代码在 github.com/golang/groupcache/consistenthash。
我们先看下它的使用方法:
import ("fmt""github.com/golang/groupcache/consistenthash")func main() {// 构造一个 consistenthash 对象,每个节点在 Hash 环上都一共有三个虚拟节点。hash := consistenthash.New(3, nil)// 添加节点hash.Add("127.0.0.1:8080","127.0.0.1:8081","127.0.0.1:8082",)// 根据 key 获取其对应的节点node := hash.Get("cyhone.com")fmt.Println(node)}
consistenthash 对外提供了三个函数:
New(replicas int, fn Hash)
:构造一个 consistenthash 对象,replicas
代表每个节点的虚拟节点个数,例如 replicas 等于 3,代表每个节点在 Hash 环上都对应有三个虚拟节点。fn
代表自定义的 hash 函数,传 nil 则将会使用默认的 hash 函数。Add
函数:向 Hash 环上添加节点。Get
函数:传入一个 key,得到其被分配到的节点。我们先看下其 Add 函数的实现。Add 函数用于向 Hash 环上添加节点。其源码如下:
func (m *Map) Add(keys ...string) {for _, key := range keys {for i := 0; i < m.replicas; i++ {hash := int(m.hash([]byte(strconv.Itoa(i) + key)))m.keys = append(m.keys, hash)m.hashMap[hash] = key}}// 排序,这个动作非常重要,因为只有这样,才能构造一个有序的 Hash 环sort.Ints(m.keys)}
在 Add 函数里面涉及两个重要的属性:
[]int
。这个其实就是我们上面说的有序 Hash 环,这里用了一个数组表示。数组中的每一项都代表一个虚拟节点以及它的值。map[int]string
。这个就是虚拟节点到用户传的真实节点的映射。map 的 key 就是 keys
属性的元素。在这个函数里面有生成虚拟节点的操作。例如用户传了真实节点为 ["Node-A", "Node-B"]
, 同时 replicas 等于 2。则 Node-A
会对应 Hash 环上两个虚拟节点:0Node-A
,1Node-A
,这两个节点对应的值也是直接进行对其计算 hash 得到。
需要注意的是,每次 Add 时候,函数最后会对 keys
进行排序。因此最好一次把所有的节点都加进来,以避免多次排序。
接下来我们分析下 Get 函数的使用,Get 函数用于给指定 key 分配对应节点。其源码如下:
func (m *Map) Get(key string) string {if m.IsEmpty() {return ""}hash := int(m.hash([]byte(key)))// Binary search for appropriate replica.// 二分查找idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash })// Means we have cycled back to the first replica.// 如果没有找到,则使用首元素if idx == len(m.keys) {idx = 0}return m.hashMap[m.keys[idx]]}
首先计算用户传的 key 的 hash 值,然后利用 sort.Search
在 keys
中二分查找,得到数组中满足情况的最小值。因为 keys
是有序数组, 所以使用二分查找可以加快查询速度。
如果没有找到则使用首元素,这个就是环形数组的基本操作了。最后利用 hashMap[keys[idx]]
, 由虚拟节点,得到其真实的节点。
以上就是 Groupcache 对一致性 Hash 的实现了。这个实现简单有效,可以帮助我们快速理解一致性 Hash 的原理。
]]>本文属于 《Golang源码剖析系列》
sync.Cond 条件变量是 Golang 标准库 sync 包中的一个常用类。sync.Cond 往往被用在一个或一组 goroutine 等待某个条件成立后唤醒这样的场景,例如常见的生产者消费者场景。
本文将基于 go-1.13 的源码 分析 sync.Cond 源码,将会涉及以下知识点:
在正式讲 sync.Cond 的原理之前,我们先看下 sync.Cond 是如何使用的。这里我给出了一个非常简单的单生产者多消费者的例子,代码如下:
var mutex = sync.Mutex{}var cond = sync.NewCond(&mutex)var queue []intfunc producer() {i := 0for {mutex.Lock()queue = append(queue, i)i++mutex.Unlock()cond.Signal()time.Sleep(1 * time.Second)}}func consumer(consumerName string) {for {mutex.Lock()for len(queue) == 0 {cond.Wait()}fmt.Println(consumerName, queue[0])queue = queue[1:]mutex.Unlock()}}func main() {// 开启一个 producergo producer()// 开启两个 consumergo consumer("consumer-1")go consumer("consumer-2")for {time.Sleep(1 * time.Minute)}}
在以上代码中,有一个 producer 的 goroutine 将数据写入到 queue 中,有两个 consumer 的 goroutine 负责从队列中消费数据。而 producer 和 consumer 对 queue 的读写操作都由 sync.Mutex
进行并发安全的保护。
其中 consumer 因为需要等待 queue 不为空时才能进行消费,因此 consumer 对于 queue 不为空这一条件的等待和唤醒,就用到了 sync.Cond
。
我们看下 sync.Cond
接口的用法:
sync.NewCond(l Locker)
: 新建一个 sync.Cond 变量。注意该函数需要一个 Locker 作为必填参数,这是因为在 cond.Wait()
中底层会涉及到 Locker 的锁操作。cond.Wait()
: 等待被唤醒。唤醒期间会解锁并切走 goroutine。cond.Signal()
: 只唤醒一个最先 Wait 的 goroutine。对应的另外一个唤醒函数是 Broadcast
,区别是 Signa
l 一次只会唤醒一个 goroutine,而 Broadcast
会将全部 Wait 的 goroutine 都唤醒。接下来,我们将分析下 sync.Cond
底层是如何实现这些操作的。
sync.Cond
的 struct 定义如下:
type Cond struct {noCopy noCopy// L is held while observing or changing the conditionL Lockernotify notifyListchecker copyChecker}
其中最核心的就是 notifyList
这个数据结构, 其源码在 runtime/sema.go#L446:
type notifyList struct { wait uint32notify uint32// List of parked waiters.lock mutexhead *sudogtail *sudog}
以上代码中,notifyList 包含两类属性:
wait
和 notify
。这两个都是ticket值,每次调 Wait 时,ticket 都会递增,作为 goroutine 本次 Wait 的唯一标识,便于下次恢复。 wait 表示下次 sync.Cond
Wait 的 ticket 值,notify 表示下次要唤醒的 goroutine 的 ticket 的值。这两个值都只增不减的。利用 wait 和 notify 可以实现 goroutine FIFO式的唤醒,具体见下文。head
和 tail
。等待在这个 sync.Cond
上的 goroutine 链表,如下图所示:我们先分析下当调用 sync.Cond
的 Wait
函数时,底层做了哪些事情。代码如下:
func (c *Cond) Wait() {c.checker.check()// 获取tickett := runtime_notifyListAdd(&c.notify)// 注意这里,必须先解锁,因为 runtime_notifyListWait 要切走 goroutine// 所以这里要解锁,要不然其他 goroutine 没法获取到锁了c.L.Unlock()// 将当前 goroutine 加入到 notifyList 里面,然后切走 goroutineruntime_notifyListWait(&c.notify, t)// 这里已经唤醒了,因此需要再度锁上c.L.Lock()}
Wait 函数虽然短短几行代码,但里面蕴含了很多重要的逻辑。整个逻辑可以拆分为 4 步:
第一步:调用 runtime_notifyListAdd
获取 ticket。ticket 是一次 Wait 操作的唯一标识,可以用来防止重复唤醒以及保证 FIFO 式的唤醒。
它的生成也非常简单,其实就是对 notifyList
的 wait
属性进行原子自增。其实现如下:
func notifyListAdd(l *notifyList) uint32 {return atomic.Xadd(&l.wait, 1) - 1}
第二步:c.L.Unlock()
先把用户传进来的 locker 解锁。因为在 runtime_notifyListWait
中会调用 gopark
切走 goroutine。因此在切走之前,必须先把 Locker 解锁了。要不然其他 goroutine 获取不到这个锁,将会造成死锁问题。
第三步:runtime_notifyListWait
将当前 goroutine 加入到 notifyList 里面,然后切走goroutine。下面是 notifyListWait
精简后的代码:
func notifyListWait(l *notifyList, t uint32) {lock(&l.lock)...s := acquireSudog()s.g = getg()s.ticket = tif l.tail == nil {l.head = s} else {l.tail.next = s}l.tail = s// go park 切走 goroutinegoparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3)// 注意:这个时候,goroutine 已经切回来了, 释放 sudogreleaseSudog(s)}
从以上代码可以看出,notifyListWait 的逻辑并不复杂,主要将当前 goroutine 追加到 notifyList
链表最后以及调用 gopark 切走 goroutine。
第四步:goroutine 被唤醒。如果其他 goroutine 调用了 Signal
或者 Broadcast
唤醒了该 goroutine。那么将进入到最后一步:c.L.Lock()
。此时将会重新把用户传的 Locker 上锁。
以上就是 sync.Cond 的 Wait 过程,可以简单用下图表示:
正如最开始的例子中展示的,在 producer 的 goroutine 里面调用 Signal
函数将会唤醒正在 Wait 的 goroutine。而且这里需要注意的是,Signal 只会唤醒一个 goroutine,且该 goroutine 是最早 Wait 的。
我们接下来看下,Signal 是如何唤醒 goroutine 以及如何实现 FIFO 式的唤醒。
代码如下:
func (c *Cond) Signal() {runtime_notifyListNotifyOne(&c.notify)}func notifyListNotifyOne(l *notifyList) {// 如果二者相等,说明没有需要唤醒的 goroutineif atomic.Load(&l.wait) == atomic.Load(&l.notify) {return}lock(&l.lock)t := l.notifyif t == atomic.Load(&l.wait) {unlock(&l.lock)return}// Update the next notify ticket number.atomic.Store(&l.notify, t+1)for p, s := (*sudog)(nil), l.head; s != nil; p, s = s, s.next {if s.ticket == t {n := s.nextif p != nil {p.next = n} else {l.head = n}if n == nil {l.tail = p}unlock(&l.lock)s.next = nil// 唤醒 goroutinereadyWithTime(s, 4)return}}unlock(&l.lock)}
我们上面讲 Wait 实现的时候讲到,每次 Wait 的时候,都会同时生成一个 ticket,这个 ticket 作为此次 Wait 的唯一标识。ticket 是由 notifyList.wait
原子递增而来,因此 notifyList.wait
也同时代表当前最大的 ticket。
那么,每次唤醒的时候,也会对应一个 notify
属性。例如当前 notify
属性等于 1,则去逐个检查 notifyList
链表中 元素,找到 ticket
等于 1 的 goroutine 并唤醒,同时将 notify
属性进行原子递增。
那么问题来了,我们知道 sync.Cond 的底层 notifyList
是一个链表结构,我们为何不直接取链表最头部唤醒呢?为什么会有一个 ticket 机制?
这是因为 notifyList
会有乱序的可能。从我们上面 Wait 的过程可以看出,获取 ticket
和加入 notifyList
,是两个独立的行为,中间会把锁释放掉。而当多个 goroutine 同时进行时,中间会产生进行并发操作,那么有可能后获取 ticket 的 goroutine,先插入到 notifyList
里面, 这就会造成 notifyList
轻微的乱序。Golang 的官方解释如下:
Because g’s queue separately from taking numbers, there may be minor reorderings in the list.
因此,这种 逐个匹配 ticket
的方式 ,即使在 notifyList 乱序的情况下,也能取到最先 Wait 的 goroutine。
这里有个问题是,对于这种方法我们需要逐个遍历 notifyList
, 理论上来说,这是个 O(n)
的线性时间复杂度。Golang 也对这里做了解释:其实大部分场景下只用比较一两次之后就会很快停止,因此不用太担心性能问题。
sync.Cond 在使用时还是有一些需要注意的地方,否则使用不当将造成代码错误。
panic("sync.Cond is copied")
错误panic("sync: unlock of unlocked mutex")
错误。代码如下:c.L.Lock()for !condition() { c.Wait()}... make use of condition ...c.L.Unlock()
sync.WaitGroup
是 Golang 中常用的并发措施,我们可以用它来等待一批 Goroutine 结束。
WaitGroup 的源码也非常简短,抛去注释外也就 100 行左右的代码。但即使是这 100 行代码,里面也有着关乎内存优化、并发安全考虑等各种性能优化手段。
本文将基于 go-1.13 的源码 进行分析,将会涉及以下知识点:
在正式分析源码之前,我们先看下 WaitGroup 的基本用法:
func main() {var wg sync.WaitGroupfor i := 0; i < 5; i++ {wg.Add(1)go func() {defer wg.Done()println("hello")}()}wg.Wait()}
从上述代码可以看出,WaitGroup 的用法非常简单:使用 Add
添加需要等待的个数,使用 Done
来通知 WaitGroup 任务已完成,使用 Wait
来等待所有 goroutine 结束。
我们首先看下 WaitGroup 的组成结构,代码如下:
type WaitGroup struct {noCopy noCopystate1 [3]uint32}
其中 noCopy
是 golang 源码中检测禁止拷贝的技术。如果程序中有 WaitGroup 的赋值行为,使用 go vet
检查程序时,就会发现有报错。但需要注意的是,noCopy 不会影响程序正常的编译和运行。
state1 [3]uint32
字段中包含了 WaitGroup 的所有状态数据。该字段的整个设计其实非常复杂,为了便于快速理解 WaitGroup 的主流程,我们将在后面部分单独剖析 state1
。
为了便于理解 WaitGroup 的整个实现过程,我们暂时先不考虑内存对齐和并发安全等方面因素。那么 WaitGroup 可以近似的看做以下代码:
type WaitGroup struct {counter int32waiter uint32sema uint32}
其中:
counter
代表目前尚未完成的个数。WaitGroup.Add(n)
将会导致 counter += n
, 而 WaitGroup.Done()
将导致 counter--
。waiter
代表目前已调用 WaitGroup.Wait
的 goroutine 的个数。sema
对应于 golang 中 runtime 内部的信号量的实现。WaitGroup 中会用到 sema 的两个相关函数,runtime_Semacquire
和 runtime_Semrelease
。runtime_Semacquire
表示增加一个信号量,并挂起 当前 goroutine。runtime_Semrelease
表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine。WaitGroup 的整个调用过程可以简单地描述成下面这样:
WaitGroup.Add(n)
时,counter 将会自增: counter += n
WaitGroup.Wait()
时,会将 waiter++
。同时调用 runtime_Semacquire(semap)
, 增加信号量,并挂起当前 goroutine。WaitGroup.Done()
时,将会 counter--
。如果自减后的 counter 等于 0,说明 WaitGroup 的等待过程已经结束,则需要调用 runtime_Semrelease 释放信号量,唤醒正在 WaitGroup.Wait
的 goroutine。以上就是 WaitGroup 实现过程的简略版。但实际上,WaitGroup 在实现过程中对并发性能以及内存占用优化上,都有一些非常巧妙的设计点,我们接下来要着重讨论下。
我们回来讨论 WaitGroup 中 state1
的内存结构。state1
长度为 3 的 uint32 数组,但正如我们上文讨论,其中 state1
中包含了三个变量的语义和行为,其内存结构如下:
我们在图中提到了 Golang 内存对齐的概念。简单来说,如果变量是 64 位对齐 (8 byte), 则该变量的起始地址是 8 的倍数。如果变量是 32 位对齐 (4 byte),则该变量的起始地址是 4 的倍数。
从图中看出,当 state1
是 32 位对齐和 64 位对齐的情况下,state1
中每个元素的顺序和含义也不一样:
state1
是 32 位对齐:state1
数组的第一位是 sema,第二位是 waiter,第三位是 counter。state1
是 64 位对齐:state1
数组的第一位是 waiter,第二位是 counter,第三位是 sema。为什么会有这种奇怪的设定呢?这里涉及两个前提:
前提 1:在 WaitGroup 的真实逻辑中, counter 和 waiter 被合在了一起,当成一个 64 位的整数对外使用。当需要变化 counter 和 waiter 的值的时候,也是通过 atomic 来原子操作这个 64 位整数。但至于为什么合在一起,我们会在下文WaitGroup-的无锁实现中详细解释原因。
前提 2:在 32 位系统下,如果使用 atomic 对 64 位变量进行原子操作,调用者需要自行保证变量的 64 位对齐,否则将会出现异常。golang 的官方文档 sync/atomic/#pkg-note-BUG 原文是这么说的:
On ARM, x86-32, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.
因此,在前提 1 的情况下,WaitGroup 需要对 64 位进行原子操作。那根据前提 2,WaitGroup 则需要自行保证 count+waiter
的 64 位对齐。这也是 WaitGroup 采用 [3]uint32
存储变量的目的:
state1
变量是 64 位对齐时,也就意味着数组前两位作为 64 位整数时,自然也可以保证 64 位对齐了。state1
变量是 32 位对齐时,我们把数组第 1 位作为对齐的 padding,因为 state1
本身是 uint32 的数组,所以数组第一位也有 32 位。这样就保证了把数组后两位看做统一的 64 位整数时是64位对齐的。这个方法非常的巧妙,只不过是改变 sema
的位置顺序,就既可以保证 counter+waiter
一定会 64 位对齐,也可以保证内存的高效利用。
Golang 官方文档中也给出了 判断当前变量是 32 位对齐还是 64 位对齐的方法::
uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0
WaitGroup 中从 state1
中取变量的方法如下:
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]} else {return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]}}
注: 有些文章会讲到,WaitGroup 两种不同的内存布局方式是 32 位系统和 64 位系统的区别,这其实不太严谨。准确的说法是 32 位对齐和 64 位对齐的区别。因为在 32 位系统下,state1
变量也有可能恰好符合 64 位对齐。
我们上文讲到,在 WaitGroup 中,其实是把 counter
和 waiter
看成一个 64 位整数进行处理,但为什么要这么做呢?分成两个 32 位变量岂不是更方便?这其实是 WaitGroup 的一个性能优化手段。
counter
和 waiter
在改变时需要保证并发安全。对于这种场景,我们最简单的做法是,搞一个 Mutex
或者 RWMutex
锁, 在需要读写 counter
和 waiter
的时候,加锁就完事。但是我们知道加锁必然会造成额外的性能开销,作为 Golang 系统库,自然需要把性能压榨到极致。
WaitGroup 直接把 counter
和 waiter
看成了一个统一的 64 位变量。其中 counter
是这个变量的高 32 位,waiter
是这个变量的低 32 位。
在需要改变 counter
时, 通过将累加值左移 32 位的方式:atomic.AddUint64(statep, uint64(delta)<<32)
,即可实现 count += delta
同样的效果。
在 Wait 函数中,通过 CAS 操作 atomic.CompareAndSwapUint64(statep, state, state+1)
, 来对 waiter 进行自增操作,如果 CAS 操作返回 false,说明 state 变量有修改,有可能是 counter
发生了变化,这个时候需要重试检查逻辑条件。
还有一个小细节值得一提的是,因为 WaitGroup 是可以复用的。因此在 Wait 结束的时候需要将 waiter--
,重置状态。但这肯定会涉及到一次原子变量操作。如果调用 Wait 的 goroutine 比较多,那这个原子操作也会随之进行很多次。
WaitGroup 这里直接在 Done 的时候,判断如果 counter 等于 0 ,直接将 counter+waiter
整个 64 位整数全部置 0,既可以达到重置状态的效果,也免于进行多次原子操作。
Waitgroup 虽然只有 100 行左右的代码。作为语言的内置库,我们从中可以看出作者对每个细节的极致打磨,非常精细的针对场景优化性能,这也给我们写程序带来了很多启发。
本文属于 《Golang源码剖析系列》
依赖注入是一个经典的设计模式,可有效地解决项目中复杂的对象依赖关系。
对于有反射功能的语言来说,实现依赖注入都比较方便一些。在 Golang 中有几个比较知名的依赖注入开源库,例如 google/wire、uber-go/dig 以及 facebookgo/inject 等。
本文将基于 facebookgo/inject
介绍依赖注入, 接下来将会着重讨论以下几点内容:
对于稍微复杂些的项目,我们往往就会遇到对象之间复杂的依赖关系。手动管理和初始化这些管理关系将会极其繁琐,依赖注入可以帮我们自动实现依赖的管理和对象属性的赋值,将我们从这些繁琐的依赖管理中解放出来。
以一个常见的 HTTP 服务为例,我们在开发后台时往往会把代码分为 Controller、Service 等层次。如下:
type UserController struct {UserService *UserServiceConf *Conf}type PostController struct {UserService *UserServicePostService *PostServiceConf *Conf}type UserService struct {Db *DBConf *Conf}type PostService struct {Db *DB}type Server struct { UserApi *UserController PostApi *PostController}
上述的代码例子中,有两个 Controller:UserController 和 PostController,分别用来接收用户和文章的相关请求逻辑。除此之外还会有 Service 相关类、Conf 配置文件、DB 连接等。
这些对象之间存在比较复杂的依赖关系,这就给项目的初始化带来了一些困扰。对于以上代码,对应初始化逻辑大概就会是这样:
func main() {conf := loadConf()db := connectDB()userService := &UserService{Db: db,Conf: conf,}postService := &PostService{Db: db,}userHandler := &UserController{UserService: userService,Conf: conf,}postHandler := &PostController{UserService: userService,PostService: postService,Conf: conf,}server := &Server{UserApi: userHandler,PostApi: postHandler,}server.Run()}
我们会有一大段的逻辑都是用来做对象初始化,而当接口越来越多的时候,整个初始化过程就会异常的冗长和复杂。
针对以上问题,依赖注入可以完美地解决。
接下来,我们试着使用 facebookgo/inject 的方式,对这段代码进行依赖注入的改造。如下:
type UserController struct {UserService *UserService `inject:""`Conf *Conf `inject:""`}type PostController struct {UserService *UserService `inject:""`PostService *PostService `inject:""`Conf *Conf `inject:""`}type UserService struct {Db *DB `inject:""`Conf *Conf `inject:""`}type PostService struct {Db *DB `inject:""`}type Server struct {UserApi *UserController `inject:""`PostApi *PostController `inject:""`}func main() {conf := loadConf() // *Confdb := connectDB() // *DBserver := Server{}graph := inject.Graph{}if err := graph.Provide(&inject.Object{Value: &server,},&inject.Object{Value: conf,},&inject.Object{Value: db,},); err != nil {panic(err)}if err := graph.Populate(); err != nil {panic(err)}server.Run()}
首先每一个需要注入的字段都需要打上 inject:""
这样的 tag。所谓依赖注入,这里的依赖指的就是对象中包含的字段,而注入则是指有其它程序会帮你对这些字段进行赋值。
其次,我们使用 inject.Graph{}
创建一个 graph 对象。这个 graph 对象将负责管理和注入所有的对象。至于为什么叫 Graph,其实这个名词起的非常形象,因为各个对象之间的依赖关系,也确实像是一张图一样。
接下来,我们使用 graph.Provide()
将需要注入的对象提供给 graph
。
graph.Provide(&inject.Object{Value: &server,},&inject.Object{Value: &conf,},&inject.Object{Value: &db,},);
Populate
函数,开始进行注入。从代码中可以看到,我们一共就向 Graph 中 Provide 了三个对象。我们提供了 server
对象,是因为它是一个顶层对象。提供了 conf
和 db
对象,是因为所有的对象都依赖于它们,可以说它们是基础对象了。
但是其他的对象呢? 例如 UserApi
和 UserService
呢?我们并没有向 graph
调用 Provide 过。那么它们是怎么完成赋值和注入的呢?
其实从下面这张对象依赖图能够很简单的看清楚。
从这个依赖图中可以看出,conf
和 db
对象是属于根节点,所有的对象都依赖和包含着它们。而 server
属于叶子节点,不会有其他对象依赖它了。
我们需要提供给 Graph 的就是根节点和叶子节点,而对于中间节点来说,完全可以通过根节点和叶子节点推导出来。Graph 会通过 inject:""
标签,自动将中间节点 Provide 到 Graph 中,进行注入。
对以上例子,我们深入剖析下 Graph 内部进行 Populate 时都发生了哪些动作:
UserApi
和 PostApi
。其类型 UserController
和 PostController
, Graph 中从未出现过这两个类型。因此,Graph 会自动对该字段调用 Provide,提供给 Graph。UserService
和 Conf
。对于 UserService
这种 Graph 中未登记过的类型,会自动 Provide。而对 Conf
, Graph 中之前已经注册过了,因此直接将注册的对象赋值给该字段即可。以上就是整个依赖注入的流程了。
这里需要注意的是,在我们上面的示例中,以这种方式注入,其中所有的对象都相当于单例对象。即一个类型,只会在 Graph 中存在一个实例对象。比如 UserController
和 PosterController
中的 UserService
实际上是同一个对象。
我们的 main 函数使用 inject 进行改造后,将会变得非常简洁。而且即使随着业务越来越复杂,Handler 和 Service 越来越多,这个 main 函数中的注入逻辑也不会任何改变,除非有新的根节点对象出现。
当然,对于 Graph 来说,也不是只能 Provide 根节点和叶子节点,我们也可以自行 Provide 一个 UserService 的实例进去,对于 Graph 的运作是没有任何影响的。只不过只 Provide 根节点和叶子节点,代码会看起来更简洁一些。
我们在声明 tag 时,除了声明为 inject:""
这种默认用法外,还可以有其他三种高级的用法:
inject:"private"
。私有注入。inject:"inline"
。内联注入。inject:"object_name"
。命名注入,这里的 object_name 可以取成任意的名字。我们上文讲过,默认情况下,所有的对象都是单例对象。一个类型只会有一个实例对象存在。但也可以不使用单例对象,private 就是提供了这种可能。
例如:
type UserController struct {UserService *UserService `inject:"private"`Conf *Conf `inject:""`}
我们将 UserController 中的 UserService 属性声明为 private 注入。这样的话,graph 遇到 private 标签时,会自动的 new 一个全新的 UserService
对象,将其赋值给该字段。
这样 Graph 中就同时存在了两个 UserService 的实例,一个是 UserService 的全局实例,给默认的 inject:""
使用。一个是专门给 UserController 实例中的 UserService 使用。
但在实际开发中,这种 private 的场景似乎也比较少,大部分情况下,默认的单例对象就足够了。
默认情况下,需要注入的属性必须得是 *Struct
。但是也是可以声明为普通对象的。例如:
type UserController struct {UserService UserService `inject:"inline"`Conf *Conf `inject:""`}
注意,这里的 UserService 的类型,并非是 *UserService
指针类型了,而是普通的 struct 类型。struct 类型在 Go 里面都是值语义,这里当然也就不存在单例的问题了。
如果我们需要对某些字段注入专有的对象实例,那么我们可能会用到命名注入。使用方法就是在 inject
的 tag 里写上专有的名字。如下:
type UserController struct {UserService UserService `inject:"named_service"`Conf *Conf `inject:""`}
当然,这个命名肯定不能命名为 private
和 inline
,这两个属于inject的保留词。
同时,我们一定要把这个命名实例 Provide 到 graph 里面,这样 graph 才能把两个对象联系起来。
graph.Provide(&inject.Object{Value: &namedService,Name: "named_service",},);
我们除了可以注入对象外,还可以注入 map。如下:
type UserController struct {UserService UserService `inject:"inline"`Conf *Conf `inject:""`UserMap map[string]string `inject:"private"`}
需要注意的是,map 的注入 tag 一定要是 inject:"private"
。
facebookgo/inject 固然很好用,只要声明 inject:""
的 tag,提供几个对象,就可以完全自动的注入所有依赖关系。
但是由于Golang本身的语言设计, facebookgo/inject 也会有一些缺陷和短板:
所有需要注入的字段都需要是 public 的。 这也是 Golang 的限制,不能对私有属性进行赋值。所以只能对public的字段进行注入。但这样就会把代码稍显的不那么优雅,毕竟很多变量我们其实并不想 public。
只能进行属性赋值,不能执行初始化函数。 facebookgo/inject只会帮你注入好对象,把各个属性赋值好。但很多时候,我们往往需要在对象赋值完成后,再进行其他一些动作。但对于这个需求场景,facebookgo/inject并不能很好的支持。
这两个问题的原因总结归纳为:Golang没有构造函数…
]]>本文属于 《Golang源码剖析系列》
本文将基于 Golang 源码对 Timer 的底层实现进行深度剖析。主要包含以下内容:
time.Sleep
的实现细节,Golang 如何实现 Goroutine 的休眠。注:本文基于 go-1.13 源码进行分析,而在 go 的 1.14 版本中,关于定时器的实现略有一些改变,以后会再专门写一篇文章进行分析。
我们在日常开发中会经常用到 time.NewTicker
或者 time.NewTimer
进行定时或者延时的处理逻辑。
Timer 和 Ticker 在底层的实现基本一致,本文将主要基于 Timer 进行探讨研究。Timer 的使用方法如下:
import ( "fmt" "time")func main() {timer := time.NewTimer(2 * time.Seconds)<-timer.Cfmt.Println("Timer fired")}
在上面的例子中,我们首先利用 time.NewTimer
构造了一个 2 秒的定时器,同时使用 <-timer.C
阻塞等待定时器的触发。
对于 time.NewTimer
函数,我们可以轻易地在 go 源码中找到它的实现,其代码位置在 time/sleep.go#L82。如下:
func NewTimer(d Duration) *Timer {c := make(chan Time, 1)t := &Timer{C: c,r: runtimeTimer{when: when(d),f: sendTime,arg: c,},}startTimer(&t.r)return t}
NewTimer 主要包含两步:
C
属性和 r
属性。r
属性是 runtimeTimer
类型。startTimer
函数,启动 timer。在 Timer 结构体中的属性 C
不难理解,从最开始的例子就可以看到,它是一个用来接收 Timer 触发消息的 channel。注意,这个 channel 是一个有缓冲 channel,缓冲区大小为 1。
我们主要看的是 runtimeTimer
这个结构体:
when
: when 代表 timer 触发的绝对时间。计算方式就是当前时间加上延时时间。f
: f 则是 timer 触发时,调用的 callback。而 arg
就是传给 f 的参数。在 Ticker 和 Timer 中,f 都是 sendTime。timer 对象构造好后,接下来就调用了 startTimer
函数,从名字来看,就是启动 timer。具体里面做了哪些事情呢?
startTimer 具体的函数定义在 runtime/time.go
中,里面实际上直接调用了另外一个函数 addTimer
。我们可以看下 addTimer 的代码 /runtime/time.go#L131:
func addtimer(t *timer) {// 得到要被插入的 buckettb := t.assignBucket()// 加锁,将 timer 插入到 bucket 中lock(&tb.lock)ok := tb.addtimerLocked(t)unlock(&tb.lock)if !ok {badTimer()}}
可以看到 addTimer 至少做了两件事:
assignBucket
,得到获取可以被插入的 timersBucketaddtimerLocked
将 timer 插入到 timersBucket 中。从函数名可以看出,这同时也是个加锁操作。那么问题来了,timersBucket 是什么?timer 插入到 timersBucket 中后,会以何种方式触发?
在 go 1.13 的 runtime 中,共有 64 个全局的 timersBucket。每个 timersBucket 负责管理一些 timer。
timer 的整个生命周期包括创建、销毁、唤醒和睡眠等都由 timersBucket 管理和调度。
每个 timersBucket 实际上内部是使用最小四叉堆来管理和存储各个 timer。
最小堆是非常常见的用来管理 timer 的数据结构。在最小堆中,作为排序依据的 key 是 timer 的 when
属性,也就是何时触发。即最近一次触发的 timer 将会处于堆顶。如下图:
关于四叉堆的具体实现,这里没有什么特殊需要介绍的,与二叉树基本一致。有兴趣的同学可以直接参考二叉树相关实现即可。
每个 timersBucket 负责管理一堆这样有序的 timer,同时每个 timersBucket 都有一个对应的名为 timerproc 的 goroutine 来负责不断调度这些 timer。代码在 /runtime/time.go#L247
对于每个 timersBucket 对应的 timeproc,该 goroutine 也不是时时刻刻都在监听。timerproc 的主要流程概括起来如下:
go timerproc(tb)
, 创建该 goroutine。runtimeTimer
结构体中的 f
属性。gopark
挂起该 goroutine。当 timer 触发时,timerproc 会调用对应的 callback。对于 timer 和 ticker 来说,其 callback 都是 sendTime 函数,如下:
func sendTime(c interface{}, seq uintptr) {select {case c.(chan Time) <- Now():default:}}
这里的 c interface{}
,也就是我们上文中提到的,在定义 timer 或 ticker 时,timer 对象中的 C
属性, 在 timer 和 ticker 中,它都被初始化为长度为 1 的有缓冲 channel。
调用 sendTime 时,会向 channel 中传递一个值。由于是缓冲为 1 的 buffer,因此当缓冲为空时,sendTime 可以无阻塞地把数据放到 channel 中。
如果定时时间过短,也不用担心用户调用 <-timer.C
接收不到触发事件,因为事件已经放到了 channel 中。
而对于 ticker 来说,sendTime
会被调用多次,而 channel 的缓冲长度只有 1。如果 ticker 没有来得及消费 channel,会不会导致 timerproc 调用 callback 阻塞呢?
答案是不会的。因为我们可以看到,在这个 select
语句中,有一个 default
选项,如果 channel 不可写,会触发 default。
对于 ticker 来说,如果之前的触发事件没有来得及消费,那新的触发事件到来,就会被立即丢弃。
因此对于 timerproc 来说,调用 sendTime 的时候,永远不会阻塞。这样整个 timerproc 的过程也不会因为用户侧的行为,导致某个 timer 没有来得及消费而造成阻塞。
64 个 timersBucket 的定义代码如下,在 /runtime/time.go#L39 可以看到。
const timersLen = 64var timers [timersLen]struct {timersBucket// The padding should eliminate false sharing// between timersBucket values.pad [cpu.CacheLinePadSize - unsafe.Sizeof(timersBucket{})%cpu.CacheLinePadSize]byte}
不过为什么是 64 个 timersBucket,而不是一个,或者为什么不干脆与 GOMAXPROCS 的大小保持一致呢?
首先,在 go 1.10 之前,go runtime 中的确只有一个 timers 对象,负责管理所有 timer。这个时候也就没有分桶了,整个定时器调度模型非常简单。但问题也非常的明显:
因此,在 go 1.10 中,引入了全局 64 个 timer 分桶的策略。将 timer 打散到分桶内,每个桶负责自己分配到的 timer 即可。好处也非常明显,可以有效降低了锁粒度和 timer 调度的负担。
至于为什么是 64 个 timersBucket,这点在源码注释中也有详细的说明:
Ideally, this would be set to GOMAXPROCS, but that would require dynamic reallocation.
The current value is a compromise between memory usage and performance that should cover the majority of GOMAXPROCS values used in the wild.
理想情况下,分桶的个数和保持 GOMAXPROCS 一致是最优解。但是这就会涉及到 go 启动时的动态内存分配。作为语言的runtime应该尽量减少程序负担,而 64 个 timersBucket 则是内存占用和性能之间的权衡结果了。
每个 timersBucket 具体负责管理的 timer 和 go 调度模型 GMP 中 P 有关,代码如下:
func (t *timer) assignBucket() *timersBucket {id := uint8(getg().m.p.ptr().id) % timersLent.tb = &timers[id].timersBucketreturn t.tb}
可以看到,timer 获取其对应的 timersBucket 时,是根据 golang 的 GMP
调度模型中的 P
的 id 进行取模。而当 GOMAXPROCS > 64
, 一个 timersBucket 将会同时负责管理多个 P
上的 timer。
timersBucket 里面使用最小堆管理 Timer,但与我们常见的使用二叉树来实现最小堆不同,Golang 这里采用了四叉堆 (4-heap) 来实现。这里 Golang 并没有直接给出解释。
这里直接贴一段 知乎网友对二叉堆和 N 叉堆的分析。
- 上推节点的操作更快。假如最下层某个节点的值被修改为最小,同样上推到堆顶的操作,N 叉堆需要的比较次数只有二叉堆的 倍。
- 对缓存更友好。二叉堆对数组的访问范围更大,更加随机,而 N 叉堆则更集中于数组的前部,这就对缓存更加友好,有利于提高性能。
C 语言知名开源网络库 libev,其timer定时器实现可以在编译时选择采用四叉堆还是二叉堆。在它的注释里提到四叉堆相比来说缓存更加友好。 根据benchmark,在 50000 + 个 timer 的场景下,四叉堆会有 5% 的性能优势。具体可见 libev/ev.c#L2227
我们通常使用 time.Sleep(1 * time.Second)
来将 goroutine 暂时休眠一段时间。sleep 操作在底层实现也是基于 timer 实现的。代码在 runtime/time.go#L84。有一些比较有意思的地方,单独拿出来讲下。
我们固然也可以这么做来实现 goroutine 的休眠:
timer := time.NewTimer(2 * time.Seconds)<-timer.C
这么做当然可以。但 golang 底层显然不是这么做的,因为这样有两个明显的额外性能损耗。
既然都可以放在 runtime 里面做。golang 里面做的更加干净:
G
对象上,都有一个 timer 属性,这是个 runtimeTimer 对象,专门给 sleep 使用。当第一次调用 sleep 的时候,会创建这个 runtimeTimer,之后 sleep 的时候会一直复用这个 timer 对象。sendTime
函数,而是直接调 goready
唤醒被挂起的 goroutine。这个做法和libco的poll实现几乎一样:sleep时切走协程,时间到了就唤醒协程。
分析 timer 的实现,可以明显的看到整个设计的演进,从最开始的全局 timers 对象,到分桶 bucket,以及到 go1.14 最新的 timer 调度。整个过程也可以学习到整个决策的走向和取舍。
本文主要记录了 ES 的一些必要的基础知识,也是自己在学习和使用 ES 的一些总结。当然,要系统和深入学习还是要依靠官方文档:Elasticsearch Reference 和不断地实践。
本文会涉及以下内容:
在正式学习,有一些名词和概念需要简单的了解下。
文档指的是用户提交给 ES 的一条数据。需要注意的是,这里的文档并非指的是一个纯字符串文本,在 ES 中文档指的是一条 JSON 数据。如果对 MongoDB 有了解的话,这里文档的含义和 MongoDB 中的基本类似。
JSON 数据中可以包含多个字段,这些字段可以类比为 MySQL 中每个表的字段。
例如:
{ "message": "this is my blog", "author": "cyhone"}
这样我们后期进行搜索和查询的时候,也可以分别针对 message
字段和 author
字段进行搜索。
Index(索引) 可以理解为是文档的集合,同在一个索引中的文档共同建立倒排索引。
也有很多人会把索引类比于 MySQL 中 schema 的概念。但在 ES 中 Index 更加灵活,用起来也更加方便。
此外,提交给同一个索引中的文档,最好拥有相同的结构。这样对于 ES 来说,不管是存储还是查询,都更容易优化。
Type 可以理解为是 Index 的子集,类似于 MySQL 中 schema 和 table 的关系。Type 原来存在的目的是为了在同一个 Index 存储异构数据。但其实 ES 中的索引用起来足够方便和灵活,对于异构数据,完全可以再建另外单独的 Index 存储。
所以在 Elasticsearch 的新版本中,已经逐步淡化和移除了 Type 的概念。在 7.0 版本中,对于每个 Index,ES 直接内置了一个 _doc
的 Type,且一个 Index 只能包含一个 Type。如果用户在添加数据时用到了其他 Type,则会报错。
所以不管是新旧版本,大家在使用 ES 的时候,也忘记 Type 这个存在就好,用 _doc
即可。
我们接下来看下如何在 ES 中存储和查询一个文档,也是常说的 CRUD
操作。
在 ES 中,用户的一切操作和行为都是围绕 REST 风格的 HTTP API 进行的。ES 中所有接口的语义都严格遵守 REST 规范。
要想搜索内容的前提肯定是先把内容交给 ES 进行存储和索引。
我们有两种方法向对应索引中新增文档:
POST /es-test/_doc{ "message": "this is my blog", "author": "cyhone"}
对于以上请求来说,我们通过 POST
把对应的数据存储在了索引 es-test
中。
这里需要注意的是,Index 并不需要提前建好。对于用户指定的 Index,如果不存在,ES 会自动建立对应的 Index。
PUT /es-test/_doc/1{ "message": "this is my blog", "author": "cyhone"}
在上面例子里面,我们通过 PUT
在索引 es-test
中,新增了一条数据。与 POST 不一样的是,通过 PUT 新增数据需要手动指定该条数据的唯一 id。也就是上述的 /es-test/_doc/1
中的 1
。这个唯一 id 不必要是数字,任何合法字符串均可。
POST 和 PUT 的行为都非常符合 REST 风格:
这也意味着,我们可以用 PUT + 指定唯一 id 的方式,来修改和更新文档。
我们可以使用 DELETE 来删除一个文档。例如:
DELETE /es-test/_doc/1
DELETE 也是幂等性操作,在使用的时候也需要指定唯一 ID。
查询和搜索文档相对来说非常复杂,不过这也是很多人使用 ES 的原因。作为一个搜索引擎,自然需要提供足够强大的查询功能。
本文仅介绍几种常用的查询方法,其他复杂的查询方式和聚合、分析等操作,以后会单独写一篇文章总结。
我们可以通过以下语法,提供关键词,搜索所有字段进行查询。
GET /es-test/_search?q=blog
或者我们也可以指定查询某个字段,如下:
GET /es-test/_search{ "query":{ "match": { "message": "elasticsearch" } }}
以上例子中,我们指定查询 message 字段中包含有 elasticsearch 的文档。
对于查询得到的结果,数目过多的情况下,es 默认会进行分页。分页主要有两个参数进行控制:
我们可以通过直接在 url 中指定分页参数,如下:
GET /es-test/_search?size=5&from=10
也可以在请求体中指定分页参数,如下:
GET /es-test/_search{ "query":{ "match": { "message": "elasticsearch" } }, "size": 10, "from": 5}
我们通常自己开发搜索引擎的时候,往往需要对搜索结果中的关键词高亮这种功能。如下:
ES 可以非常简单的实现关键词的高亮。我们可以构建如下请求体:
{ "query": { "match": { "message": "blog" } }, "highlight": { "fields": { "message": {} } }}
其实就是增加一个 highlight 属性,里面指明了要高亮的字段。其返回的消息体如下:
{ "took" : 41, "timed_out" : false, "_shards" : { "total" : 1, "successful" : 1, "skipped" : 0, "failed" : 0 }, "hits" : { "total" : { "value" : 1, "relation" : "eq" }, "max_score" : 0.2876821, "hits" : [ { "_index" : "es-test", "_type" : "_doc", "_id" : "PNyBzHABTSSzPOmql8i9", "_score" : 0.2876821, "_source" : { "message" : "this is my blog", "author" : "cyhone" }, "highlight" : { "message" : [ "this is my <em>blog</em>" ] } } ] }}
在返回体中有一个 highlight 字段,里面对 message 字段进行高亮处理: 关键词使用了 <em></em>
标签包围了。
我们可以利用 css 修改对 <em>
标签的样式,以实现其关键词高亮效果。
在 ES 中可以非常方便地在多个索引中通过搜索文档。
例如你有两个索引: es-test-1
和 es-test-2
。
你可以这样直接在 URL 中指明两个索引:
GET /es-test-1,es-test-2/_search
或者如下的模糊搜索的方式
GET /es-test-*/_search
如果有必要的话,甚至可以这样:
GET /a*, b*/_search
以上方式都可以在多个索引中同时搜索文档,把多个索引看做一个使用。
其实这也意味着,我们在存储的时候,没必要把所有的文档都存在一个 Index 中。
很常见的一个操作是,我们可以将文档按天分索引存储。例如: es-test-2020-03-11
,es-test-2020-03-12
等,
在查询的时候,指定 es-test-*
查询即可,这样对外看来,文档似乎还是存储在一起,同时也减轻了 Index 的存储压力。(一个 ES 分片最多能存储 Integer.MAX_VALUE - 128
个文档)
上文讲到的通过 POST、PUT 来新增或修改数据,都是基于单条数据的。但是我们知道网络 IO 是网络操作中最耗时的部分,对于大数据量写入的场景下,我们通常希望写入方可以提供批量修改的接口,以避免频繁的网络交互,更大限度地提升写入性能。
ES 当然也提供了批量修改的接口。在批量接口中,我们一次可以进行多个新增、更新和删除等修改行为的动作。例如:
POST _bulk{"index" : { "_index" : "es-test"} }{"message" : "this is my blog"}{"create" : { "_index" : "es-test", "_id" : "3"} }{"message" : "this is my blog"}{"delete" : { "_index" : "es-test", "_id" : "2"} }{"update" : {"_id" : "1", "_index" : "test"} }{"message" : "this is my blog"}
以上这个批量操作有些复杂。里面包含了 4 种操作 index
、create
、delete
和 update
。
其中 index
、create
和 update
都包含两行,一行是具体的操作,一行是文档内容。
index
和 create
的区别在于,create 会携带一个唯一 id,如果该 id 存在,则插入失败。
有一点值得注意的是,本文中的例子都是用了 message
字段来进行 match 搜索,如果换成字段名换成了其他,例如 content
可能就不行。
这是因为在我这边的 ES 有一个默认的动态映射,将长度低于 2048 的字符串认定为 keyword
类型。但是字段名是 message
的话,则为 text
类型。keyword
类型不进行分词处理,不适合进行关键词搜索处理。
这样就需要我们不得不关注 ES 的动态映射。此部分内容以后会再单独分一篇文章讲解。
]]>在中英文排版最重要的就是中英文之间的空格,Github 有一个热门仓库《中文文案排版指北》(sparanoid/chinese-copywriting-guidelines), 详细的说明了不加空格的严重性:
有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。
所以就有了自动排版加空格的工具 pangu.js 的诞生。
pangu.js 有两种使用方法:
我个人更喜欢用第二种方式,这样就不用在页面中再单独引入一个 js 文件,尽可能的保持页面的精简。当然前提是用vscode打开和编写markdown。
我的博客 cyhone.com 一直是用 hexo 搭建的,平常也更习惯用 markdown 写文章。但是微信公众号并不支持 markdown,所以刚开始一直在找 markdown 转微信公众号的方式。
尝试了好几款推荐度比较高的方法,这里推荐下 mdnice.com。个人觉得用起来非常舒服,不仅支持多款 markdown 主题以及代码主题, 更重要的是功能维护和更新都非常及时。
插图是一个博客非常重要的组成部分,好的绘图可以帮助文章把问题解释的更加清楚。我自己也在探索和实践中,目前更习惯用的是 draw.io 和 processon。
这里也推荐一篇 《技术文章配图指南》一文。
文章中作者对比了各类绘图工具的优劣,更重要的是给出了绘图的一些建议,例如图片内容展示,配色和字号等方面。
上文讲到绘图是博客非常重要的组成部分。但是图片如果过多,则会影响页面的加载速度,给文章的观感和用户的流量都不是很好。
这里推荐下 tinypng 这个在线图片压缩工具。下图中的图片是我截屏的图片,压缩率往往可以达到 70% 左右。
Tinypng 采用的是有损压缩算法,选择性减少图片中的一些肉眼几乎分别不出来的颜色点,起到压缩图片的作用。
在我们自己维护和运营博客的时候,往往遇到打开速度比较慢的情况,而一时间不知道该如何下手优化。
这时候可以使用 Google 家的 Pagespeed Insights。其地址在: developers.google.com/speed/pagespeed/insights/。
把网址输进去,Pagespeed Insights就可以给你的网页打开速度进行打分,并给出有价值的优化建议。
这个工具更适合个人网站进行针对性优化~
好的工具帮助提升效率,节约更多的时间。本文会不定时更新,分享自己遇到的好工具~
]]>本文属于 《Golang源码剖析系列》
channel 是 Golang 中一个非常重要的特性,也是 Golang CSP 并发模型的一个重要体现。简单来说就是,goroutine 之间可以通过 channel 进行通信。
channel 在 Golang 如此重要,在代码中使用频率非常高,以至于不得不好奇其内部实现。本文将基于 go 1.13 的源码,分析 channel 的内部实现原理。
在正式分析 channel 的实现之前,我们先看下 channel 的最基本用法,代码如下:
package mainimport "fmt"func main() { c := make(chan int) go func() { c <- 1 // send to channel }() x := <-c // recv from channel fmt.Println(x)}
在以上代码中,我们通过 make(chan int)
来创建了一个类型为 int 的 channel。
在一个 goroutine 中使用 c <- 1
将数据发送到 channel 中。在主 goroutine 中通过 x := <- c
从 channel 中读取数据并赋值给 x。
以上代码对应了 channel 的两种基本操作:send 操作 c <- 1
和 recv 操作 x := <- c
, 分别表示发送数据到 channel 和从 channel 中接收数据。
此外,channel 还分为有缓存 channel 和无缓存 channel。上述代码中,我们使用的是无缓冲的 channel。对于无缓冲的 channel,如果当前没有其他 goroutine 正在接收 channel 数据,则发送方会阻塞在发送语句处。
我们可以在 channel 初始化时指定缓冲区大小。例如,make(chan int, 2)
则指定缓冲区大小为 2。在缓冲区未满之前,发送方无阻塞地可以往 channel 发送数据,无需等待接收方准备好。而如果缓冲区已满,则发送方依然会阻塞。
在探究 channel 源码之前,我们肯定首先需要先找到 channel 在 Golang 的具体实现在哪。因为我们在使用 channel 时,用的是 <-
符号,并不能直接在 go 源码中找到其实现。但是 Golang 编译器必然会将 <-
符号翻译成底层对应的实现。
我们可以使用 Go 自带的命令: go tool compile -N -l -S hello.go
, 将代码翻译成对应的汇编指令。
或者,直接可以使用 Compiler Explorer
这个在线工具。对于上述示例代码可以直接在这个链接看其汇编结果: go.godbolt.org/z/3xw5Cj。如下图:
通过仔细查看以上示例代码对应的汇编指令,可以发现以下的对应关系:
make(chan int)
, 对应的是 runtime.makechan
函数c <- 1
, 对应的是 runtime.chansend1
函数x := <- c
, 对应的是 runtime.chanrecv1
函数以上几个函数的实现都位于 go 源码中的 runtime/chan.go
代码文件中。我们接下来针对这几个函数,探究下 channel 的实现。
channel 的构造语句 make(chan int)
,将会被 golang 编译器翻译为 runtime.makechan
函数, 其函数签名如下:
func makechan(t *chantype, size int) *hchan
其中,t *chantype
即构造 channel 时传入的元素类型。size int
即用户指定的 channel 缓冲区大小,不指定则为 0。该函数的返回值是 *hchan
。hchan 则是 channel 在 golang 中的内部实现。其定义如下:
type hchan struct {qcount uint // buffer 中已放入的元素个数dataqsiz uint // 用户构造 channel 时指定的 buf 大小buf unsafe.Pointer // bufferelemsize uint16 // buffer 中每个元素的大小closed uint32 // channel 是否关闭,== 0 代表未 closedelemtype *_type // channel 元素的类型信息sendx uint // buffer 中已发送的索引位置 send indexrecvx uint // buffer 中已接收的索引位置 receive indexrecvq waitq // 等待接收的 goroutine list of recv waiterssendq waitq // 等待发送的 goroutine list of send waiterslock mutex}
hchan 中的所有属性大致可以分为三类:
makechan
的整个过程基本都是一些合法性检测和对 buffer
、hchan
等属性的内存分配,此处不再进行深入讨论了,有兴趣的可以直接看此处的源码。
通过简单分析 hchan 的属性,我们可以知道其中有两个重要的组件,buffer
和 waitq
。hchan
所有行为和实现都是围绕这两个组件进行的。
channel 的发送和接收流程很相似,我们先分析下 channel 的发送过程 (如 c <- 1
), 对应于 runtime.chansend
函数的实现。
在尝试向 channel 中发送数据时,如果 recvq
队列不为空,则首先会从 recvq
中头部取出一个等待接收数据的 goroutine 出来。并将数据直接发送给该 goroutine。代码如下:
if sg := c.recvq.dequeue(); sg != nil {send(c, sg, ep, func() { unlock(&c.lock) }, 3)return true}
recvq 中是正在等待接收数据的 goroutine。当某个 goroutine 使用 recv 操作 (例如,x := <- c
),如果此时 channel 的缓存中没有数据,且没有其他 goroutine 正在等待发送数据 (即 sendq
为空),会将该 goroutine 以及要接收的数据地址打包成 sudog
对象,并放入到 recvq 中。
继续接着讲上面的代码,如果此时 recvq
不为空,则调用 send 函数将数据拷贝到对应的 goroutine 的堆栈上。
send 函数的实现主要包含两点:
memmove(dst, src, t.size)
进行数据的转移,本质上就是一个内存拷贝。goready(gp, skip+1)
goready 的作用是唤醒对应的 goroutine。而如果 recvq
队列为空,则说明此时没有等待接收数据的 goroutine,那么此时 channel 会尝试把数据放到缓存中。代码如下:
if c.qcount < c.dataqsiz {// 相当于 c.buf[c.sendx]qp := chanbuf(c, c.sendx)// 将数据拷贝到 buffer 中typedmemmove(c.elemtype, qp, ep)c.sendx++if c.sendx == c.dataqsiz {c.sendx = 0}c.qcount++unlock(&c.lock)return true}
以上代码的作用其实非常简单,就是把数据放到 buffer 中而已。此过程涉及了 ring buffer 的操作,其中 dataqsiz
代表用户指定的 channel 的 buffer 大小,如果不指定则默认为 0。其他具体的详细操作后续过程会在 ring buffer 一节详细讲到。
如果用户使用的是无缓冲 channel 或者此时 buffer 已满,则 c.qcount < c.dataqsiz
条件不会满足, 以上流程也并不会执行到。此时会将当前的 goroutine 以及要发送的数据放入到 sendq
队列中,同时会切出该 goroutine。整个流程对应代码如下:
gp := getg()mysg := acquireSudog()mysg.releasetime = 0if t0 != 0 {mysg.releasetime = -1}mysg.elem = epmysg.waitlink = nilmysg.g = gpmysg.isSelect = falsemysg.c = cgp.waiting = mysggp.param = nilc.sendq.enqueue(mysg)// 将 goroutine 转入 waiting 状态,并解锁goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)
以上代码中,goparkunlock 就是解锁传入的 mutex,并切出该 goroutine,将该 goroutine 置为 waiting 状态。gopark
和上面的 goready
对应,互为逆操作。gopark
和 goready
在 runtime 的源码中会经常遇到,涉及了 goroutine 的调度过程,这里就不再深入讨论,以后会单独写一篇文章讲解。
调用 gopark
后,对于用户侧来看,该向 channel 发送数据的代码语句会进行阻塞。
以上过程就是 channel 的发送语句 (如,c <- 1
) 的内部工作流程,同时整个发送过程都使用 c.lock
进行加锁,保证并发安全。
简单来说,整个流程如下:
sudog
对象放入到 sendq
中。并将当前 goroutine 置为 waiting 状态。从 channel 中接收数据的过程基本与发送过程类似,此处不再赘述了。具体接收过程涉及到的 buffer 的相关操作,会在后面进行详细的讲解。
这里需要注意的是,channel 的整个发送过程和接收过程都使用 runtime.mutex
进行加锁。runtime.mutex
是 runtime 相关源码中常用到的一个轻量级锁。整个过程并不是最高效的 lockfree 的做法。golang 在这里有个 issue:go/issues#8899,给出了 lockfree 的 channel 的方案。
channel 中使用了 ring buffer(环形缓冲区) 来缓存写入的数据。ring buffer 有很多好处,而且非常适合用来实现 FIFO 式的固定长度队列。
在 channel 中,ring buffer 的实现如下:
hchan
中有两个与 buffer 相关的变量: recvx
和 sendx
。其中 sendx
表示 buffer 中可写的 index, recvx
表示 buffer 中可读的 index。 从 recvx
到 sendx
之间的元素,表示已正常存放入 buffer 中的数据。
我们可以直接使用 buf[recvx]
来读取到队列的第一个元素,使用 buf[sendx] = x
来将元素放到队尾。
当 buffer 未满时,将数据放入到 buffer 中的操作如下:
qp := chanbuf(c, c.sendx)// 将数据拷贝到 buffer 中typedmemmove(c.elemtype, qp, ep)c.sendx++if c.sendx == c.dataqsiz {c.sendx = 0}c.qcount++
其中 chanbuf(c, c.sendx)
相当于 c.buf[c.sendx]
。以上过程非常简单,就是将数据拷贝到 buffer 的 sendx
的位置上。
接着,将 sendx
移到下一个位置上。如果 sendx
已到达最后一位,则将其置为 0,这是一个典型的头尾相连的做法。
当 buffer 未满时,此时 sendq
里面也一定是空的 (因为如果 buffer 未满,用于发送数据的 goroutine 肯定不会排队,而是直接放数据到 buffer 中,具体逻辑参考上文向 channel 发送数据一节),这时候对于 channel 的读取过程 chanrecv
就比较简单了,直接从 buffer 中读取即可,也是一个移动 recvx
的过程。与上文 buffer 的写入基本一致。
而 sendq
里面有已等待的 goroutine 的时候,此时 buffer 一定是满的。这个时候 channel 的读取逻辑如下:
// 相当于 c.buf[c.recvx]qp := chanbuf(c, c.recvx)// copy data from queue to receiverif ep != nil {typedmemmove(c.elemtype, ep, qp)}// copy data from sender to queuetypedmemmove(c.elemtype, qp, sg.elem)c.recvx++if c.recvx == c.dataqsiz {c.recvx = 0}c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
以上代码中,ep
接收数据的变量对应的地址。例如,在 x := <- c
中,ep
表示变量 x
的地址。
而 sg
代表从 sendq 中取出的第一个 sudog
。并且:
typedmemmove(c.elemtype, ep, qp)
表示 buffer 中的当前可读元素拷贝到接收变量的地址处。typedmemmove(c.elemtype, qp, sg.elem)
表示将 sendq 中 goroutine 等待发送的数据拷贝到 buffer 中。因为此后进行了 recv++
, 因此相当于把 sendq 中的数据放到了队尾。简单来说,这里 channel 将 buffer 中队首的数据拷贝给了对应的接收变量,同时将 sendq 中的元素拷贝到了队尾,这样可以才可以做到数据的 FIFO(先入先出)。
接下来可能有点绕,c.sendx = c.recvx
, 这句话实际的作用相当于 c.sendx = (c.sendx+1) % c.dataqsiz
,因为此时 buffer 依然是满的,所以 sendx == recvx
是成立的。
channel 作为 golang 中最常用设施,了解其源码可以帮助我们更好的理解和使用。同时也不会过于迷信和依赖 channel 的性能,channel 就目前的设计来说也还有很多的优化空间。
定时器有许多经典高效的实现。例如,libevent 采用了最小堆实现定时器,redis 则结合自己场景直接使用了简单粗暴的双向链表。
时间轮也是一种非常经典的定时器实现方法。Linux 2.6 内核之前就采用了多级时间轮作为其低精度定时器的实现。而在微信的协程库 libco 中,则用了单级时间轮来管理其内部的超时事件。
在 libco 的时间轮实现中,对超时事件的添加删除查询操作均可以达到 O(1)
的时间复杂度,是一个非常高效的数据结构。
同时,最新版的 libco 时间轮也支持了无限超时时间,见下文。
在 libco 中,使用以下数据结构表示一个时间轮:
struct stTimeout_t{stTimeoutItemLink_t *pItems;int iItemSize;long long llStartIdx;unsigned long long ullStart;};
时间轮 stTimeout_t
负责管理 libco 中所有超时事件。 其中各属性的意义如下:
pItems
是一个数组,数组长度为 iItemSize。而数组中的每个元素是 stTimeoutItemLink_t
类型,这是一个双向链表。而对于同一个链表中的每个元素,它们的超时时间都是相同的。llStartIdx
代表当前最近超时时间对应的 index。ullStart
代表当前最近超时时间的时间戳,单位是毫秒总体来说,libco 的时间轮是一个环形数组的实现,如下图所示:
在这个环形数组中,数组中每个元素代表 1ms。而 libco 将环形数组的总长度设为 60*1000
, 即最多可以表达 1 分钟以内的超时事件,且超时精度是毫秒。
而且,有可能会有多个超时事件在同一时刻发生,因此数组中的元素是个链表,代表同在该时刻触发的超时事件。
在 libco 初始化时,ullStart
被初始化为当前时刻的时间戳 (单位为毫秒),llStartIdx
初始化为 0。
我们看下 libco 是怎么添加一个超时事件的:
unsigned long long now = GetTickMS();apItem.ullExpireTime = now + timeout;
这点不难理解,只有统一成标准的时间表示,才可以和其他超时事件统一的放在一起。
ullStart
的时间差值int diff = apItem->ullExpireTime - apTimeout->ullStart;
计算得到了这个时间差值,才可以进一步计算新的超时事件在时间轮中的位置。
当然,在把超时事件放入时间轮之前,需要先判断下该超时事件是否越界了。如果比 ullStart
大于 1 分钟, 则 libco 时间轮没有办法表示这个超时事件,将会报错。相关代码如下
if(diff>= apTimeout->iItemSize ){co_log_err("CO_ERR: AddTimeout line %d diff %d",__LINE__,diff);return __LINE__;}
AddTail(apTimeout->pItems + ( apTimeout->llStartIdx + diff ) % apTimeout->iItemSize , apItem );
这里其实有两步:
apTimeout->pItems + ${index}
,即是该超时事件所在的位置。libco 是如何判断事件是否超时以及取出所有已超时的事件呢?过程如下:
ullStart
,说明目前没有事件超时ullStart
,用当前时间减去 ullStart
,就可以得出一共过去了多少毫秒,一毫秒代表一个数组元素,从 llStartIdx
开始遍历即可。时间轮是典型的空间换时间的做法,需要预先把环形数组的内存空间都分配好,这也是 libco 的超时事件存取高效的原因。
讲到这里,其实 libco 的整个时间轮算法已经全部分析完成了。
但是对于 libco 的时间轮大家可能会有一些疑问:
那接下来我们就简单讲下对于时间轮的进一步优化:
O(1)
了。上文说到libco最多支持60s的超时时间。对于此问题,libco已在这个commit中支持了无限超时时间的支持了: 8ce6dfef26。同时也在issues/46中有相关讨论。
具体的支持方法非常简单,当插入时间时,即使超时时间超出1分钟,依然允许插入。同时当进行超时时间判断时,只要这个槽符合超时时间的判断,那这个槽的所有时间事件都会被取出来。
那这样也意味着,这个槽里面可能会有时间事件未触发。在具体遍历每个超时事件时,再单独判断是否真的超时,如果未超时,则会将其再重新放回时间轮中。
if (lp->bTimeout && now < lp->ullExpireTime) {int ret = AddTimeout(ctx->pTimeout, lp, now);if (!ret) {lp->bTimeout = false;lp = active->head;continue;}}
]]>一般情况下,我们使用 log input 的方式如下,只需要指定一系列 paths 即可。
filebeat.inputs:- type: log paths: - /var/log/messages - /var/log/*.log
但其实除了基本的 paths 配置外,log input 还有大概十几个配置项需要我们关注。
这些配置项或多或少都会影响到 Filebeat 的使用方式以及性能。虽然其默认值基本足够日常使用,但是还是需要深刻理解每个配置项背后的含义,这样才能够对其完全把控。
同时,在 filebeat 的日常线上运维中,也会涉及到这些配置参数的调节。
我们可以指定一系列的 paths 作为信息输入源,在指定 path 的时候,注意以下规则:
/**/
形式,Filebeat 将会展开 8 层嵌套目录。Glob 模式支持通配符匹配,目前支持的语法有:
通配符 | 解释 | 示例 | 匹配 |
---|---|---|---|
* | 匹配任意数目的任意字符 | La* | Law, Lawyer |
? | 匹配任意的单字符 | ?at | Cat, cat, Bat or bat |
[abc] | 匹配一个在中括号的字符 | [CB]at | Cat or Bat |
[a-z] | 匹配一个指定范围的字符 | Letter[0-9] | Letter0, Letter1, Letter2 up to Letter9 |
此外,filebeat 对传统的 Glob 模式进行了扩展,支持用户指定 /**/
模式的路径,filebeat 可以将其展开为 8 层的 Glob 路径。
例如,假如指定了 /home/data/**/my*.log
, filebeat 将会把 /**/
翻译成 8 层的子目录,如下:
/home/data/my*.log/home/data/*/my*.log/home/data/*/*/my*.log/home/data/*/*/*/my*.log/home/data/*/*/*/*/my*.log/home/data/*/*/*/*/*/my*.log/home/data/*/*/*/*/*/*/my*.log/home/data/*/*/*/*/*/*/*/my*.log/home/data/*/*/*/*/*/*/*/*/my*.log
加上不带子目录的 Glob 路径,一共会有 8 条 Glob 路径。这些路径都会作为 input 的输入源路径进行搜索。
但是在使用的时候需要注意:
/**/
模式,对于 /data**/
模式不支持recursive_glob.enabled
配置项关闭是否开启递归的 Glob 模式,默认为 true。
指定日志编码,默认是 plain。即 ASCII 模式
可指定多个正则表达式,来去除某些不需要上报的行。例如:
filebeat.inputs:- type: log ... exclude_lines: ['^DBG']
该配置将会去除以 DBG
开头的行。
可指定多项正则表达式,来仅上报匹配的行。例如:
filebeat.inputs:- type: log ... include_lines: ['^ERR', '^WARN']
该配置将会仅上报以 ERR
和 WARN
开头的行。
问题来了,如果同时指定了 exclude_lines 和 include_lines 会怎么处理?
对于这种情况,Filebeat 将会先校验 include_lines,再校验 exclude_lines,其代码实现如下:
func (h *Harvester) shouldExportLine(line string) bool {if len(h.config.IncludeLines) > 0 {if !harvester.MatchAny(h.config.IncludeLines, line) {// drop linereturn false}}if len(h.config.ExcludeLines) > 0 {if harvester.MatchAny(h.config.ExcludeLines, line) {return false}}return true}
可指定多个正则表达式,匹配到的文件名将不会被处理。
例如:
exclude_files: ['.gz$']
这里需要注意的是,不管是 exclude_files,还是 exclude_lines、include_lines, 声明正则的时候,最好使用单引号引用正则表达式,不要用双引号。否则 yaml 会报转义问题
读文件时的 buffer 大小,最终会应用在 golang 的 File.Read
函数上面。
func (f *File) Read(b []byte) (n int, err error)
默认是 16384。即 16k。
表示一条 log 消息的最大 bytes 数目。超过这个大小,剩余就会被截断。
默认值为 10485760(即 10MB)。
multiline 是为了解决需要多行聚合在一起发送的情况,例如 Java Stack Traces 信息等。
虽然 filebeat 默认不开启 multiline,但是官方的配置文件给了一个例子,可以支持 Java Stack Traces 或者是 C 语言式的换行连续符 \
, 可在 Examples of multiline configuration 中查看。
由于大部分场景不涉及 multiline,本文不再进行深入讨论。关于 multiline 配置的详细资料可查看官方文档:
https://www.elastic.co/guide/en/beats/filebeat/7.5/multiline-examples.html
ignore_older 表示对于最近修改时间距离当前时间已经超过某个时长的文件,就暂时不进行处理。默认值为 0,表示禁用该功能。
注意:ignore_older 只是暂时不处理该文件,并不会在 Registrar 中改变该文件的状态。
其代码实现如下:
func (p *Input) isIgnoreOlder(state file.State) bool {// ignore_older is disableif p.config.IgnoreOlder == 0 {return false}modTime := state.Fileinfo.ModTime()if time.Since(modTime) > p.config.IgnoreOlder {return true}return false}
log input 中有一系列以 close_开头配置,这些配置决定了 Harvester 何时结束对文件的读取。
不过即使 Harvester 关闭了也关系不大。因为根据 filebeat 会定时扫描文件,如果关闭后又有了新增内容,filebeat 依然是可以检查出来的。
clean_开头的一系列配置用来清理 Registrar 中的文件状态,同时也可以起到减小 Registrar 文件大小、防止 inode 复用等作用。
clean_inactive
表示一个时间段。用于移除已经一长段时间没有新产生内容的日志文件,默认为 0,表示禁用该功能。
clean_removed
在 Registrar 中移除那些已经不存在的文件。默认为 true,表示当文件不存在时,则从 Registrar 中移除。
代表 input 的扫描频率,默认为 10s。
input 会按照此频率,启动定时器定时扫描路径,以发现新文件和文件的改动情况。
这两个配置项需要放在一起讲。scan.sort
可取的值为: modtime 和 filename。默认值为空,不进行排序。scan.order
可取的值为:asc 和 desc。默认值为 asc。scan.order
仅在 scan.sort
非空时生效。
需要注意的是:该功能目前为实验功能,可能会在以后版本移除。
默认情况下,Harvester 处理文件时,会文件头开始读取文件。开启此功能后,filebeat 将直接会把文件的 offset 置到末尾,从文件末尾监听消息。默认值是 false。
注意: 开启了 tail_files, 则所有文件中的当前内容将不会被上报,只有新产生消息时才会上报。
在真实的实现中,tail_files 被当做 ignore_older=1ns
处理。因此,在启动的时候,只要是新文件,里面的内容都会被忽略,直接把 offset 置为文件末尾。
所以使用该配置项时千万要谨慎!
harvester_limit 决定了一个 input 最多同时有多少个 harvester 启动。默认为 0,代表不对 harvester 个数进行限制。
在使用时要注意两点:
代表是否要对符号链接进行处理,默认值为 false,代表不处理。
我们上文讲到 close_eof
选项,当读取到 eof 时,且 close_eof 为 false,则 Harvester 还会一直尝试读取文件。
在这种情况下,Harvester 继续读取之前,其实 filebeat 还会等待一段时间。等待的时长就是由 backoff
、backoff_factor
和 max_backoff
三个配置项共同决定。
对应的代码实现为:
func (f *Log) wait() {// Wait before trying to read file again. File reached EOF.select {case <-f.done:returncase <-time.After(f.backoff):}// Increment backoff up to maxBackoffif f.backoff < f.config.MaxBackoff {f.backoff = f.backoff * time.Duration(f.config.BackoffFactor)if f.backoff > f.config.MaxBackoff {f.backoff = f.config.MaxBackoff}}}
其中,backoff
默认值为 1s, backoff_factor
默认值为 2,max_backoff
默认值为 10s。
该配置项意味着,如果读到 EOF,则 filebeat 将会等待一段时间再去读文件。
等待时间开始为 1s,如果一直是 EOF,则会逐渐增大等待时间,每次的等待时间是前一次的两倍,且一次最长等待 10s。
再结合 close_inactive
选项,如果等待时间超过了默认值 5 分钟,则 Harvester 结束。
此外,如果等待的时候文件又追加了新的数据,则 backoff 将会重新置为初始值。
除了 log input 相关的属性外,有一些全局属性也需要我们注意。
filebeat 会将 event 暂时存放在 queue 里面。filebeat 的 queue 目前有 mem 和 spool 两种实现,默认是 mem。
本文只介绍下 mem 的相关配置项。
queue: mem: events: 4096 flush.min_events: 2048 flush.timeout: 1s
events 代表 queue 最多能够承载的 event 的个数。如果个数达到最大值,则 input 将不能再向 queue 中插入数据,直至 output 将数据消费。
flush.min_events
代表只有 queue 里面的数据到达了指定个数,才将数据发送给 output。设为 0 代表直接发送给 output,不进行等待。默认值是2048。
flush.timeout
代表定时刷新 event 到 output 中,即使其个数没有达到 flush.min_events
。该配置项只会在 flush.min_events
大于 0 时生效。
registry
。注意,这里指定的只是 registry 的目录,最终的 registry 文件的路径会是这样:
${filebeat.registry.path}/filebeat/data.json
filebeat.registry.flush
将 registry 文件内容定时刷新到磁盘中。默认为 0s,代表每次更新时直接写文件。
配置了该选项可以提高些 filebeat 的性能,避免频繁写磁盘,但是也增加了一定数据丢失的风险。
filebeat.registry.file_permissions
默认为 0600,即只有拥有者可以读写该用户, 其他用户不可以修改。
filebeat 可以对输出日志的进行相关配置,filebeat 提供了如下日志相关的配置:
logging.level: info # 日志输出的最小级别logging.selectors: [] # 过滤器,用户可在 logp.NewLogger 时指定logging.to_stderr: false # 将日志输出到 stderrlogging.to_syslog: false # 将日志输出到 syslog (主要用于 unix)logging.to_eventlog: false # 将日志输出到 windows 的 event loglogging.to_files: true # 将日志输出到文件中logging.files:path: ${filebeat_bin_path}/logs/ # 日志目录name: filebeat # 文件名 filebeat filebeat.1 filebeat.2rotateonstartup: true # 在 filebeat 启动时进行日志轮替rotateeverybytes: 10485760 # = 10MB 日志轮替的默认值keepfiles: 7 # 日志保留个数permissions: 0600 # 日志权限interval: 0 # 日志轮替logging.metrics.enabled: truelogging.metrics.period: 30slogging.json: false
filebeat 可以选择将日志输出到许多地方,在线上运营时我们常常会将日志输出到文件, 所以接下来讲下文件相关的配置。
我们可以配置日志文件的所在目录以及文件名,分别对应 logging.files.path
和 logging.files.name
。
默认情况下,日志的输出目录是在 filebeat 的 bin 文件所在目录下的 logs 文件。
filebeat 会进行日志轮替,一般情况下,常见的日志轮替规则有按大小和按时间,filebeat 两种规则均支持。
其中:
rotateeverybytes
决定了日志文件的最大值,如果日志文件超过了该值,将发生日志轮替,默认值为 10MB。rotateonstartup
是说明是否在每次启动时都进行一次日志轮替,这样的话,每次启动的日志都会从一个新文件开始。默认为 true按文件大小进行轮替后,日志文件名将会变成 filebeat、filebeat.1、filebeat.2 这种格式,后缀越大文件越旧。
filebeat 也支持按时间进行轮替,可以配置 logging.files
下的 interval 属性,支持按照秒、分钟、小时、周、月、年进行轮替,对应值为 1s
,1m
, 1h
, 24h
, 7*24h
, 30*24h
, 和 365*24h
。当然,最小值是 1s。
按照时间进行轮替时,时间将会以连字符进行分割, 例如:按照 1 小时进行轮替的话,文件格式为:filebeat-2019-11-28-15
。filebeat 目前还不支持日期格式的自定义。
同时,我们也可以指定日志的保留策略,目前只能通过设置 keepfiles
来决定保留日志的个数。
在日志里面还有 logging.metrics
相关配置,filebeat 会定时输出一些当前的运行指标,例如输出下当前 ack 成功的数目、当前的内存占用情况等:
logging.metrics.enabled
决定是否开启指标搜集logging.metrics.period
决定指标输出的间隔我们可以在使用配置文件中直接使用环境变量,使用方式如下:
fields:env: ${ENV_NAME}
我们可以直接用 ${ENV_NAME}
来引用系统的环境变量。
除了直接引用外,filebeat 还提供了两个表达式配合使用:
${VAR:default_value}
。如果没有环境变量 VAR
, 则使用默认值 default_value${VAR:?error_text}
。如果没有环境变量 VAR
,则显示错误提示 error_text
filebeat 也支持在启动时指定命令行参数来提供环境变量: -E name=${NAME}
ae*.h/cpp
这些文件中找到。AE 本身就是 Redis 的一部分,所以整体设计原则就是够用就行。也正因为这个背景,AE 的代码才可以简短干净,非常适合阅读和学习。
本文将基于 Redis 5.0.6 的源码分析下其事件循环器 (AE) 的实现原理。
同时本人也提供了一个 Redis 注释版,用以辅助理解 Redis 的源码。
Redis 通过以下接口进行 eventloop 的创建和释放。
aeEventLoop *aeCreateEventLoop(int setsize);void aeDeleteEventLoop(aeEventLoop *eventLoop);
Redis 通过将对应事件注册到 eventloop 中,然后不断循环检测有无事件触发。目前 eventloop 支持超时事件和网络 IO 读写事件的注册。
我们可以通过 aeCreateEventLoop 来创建一个 eventloop。可以看到在创建 EventLoop 的时候,必须指定一个 setsize 的参数。
setsize 参数表示了 eventloop 可以监听的网络事件 fd 的个数(不包含超时事件),如果当前监听的 fd 个数超过了 setsize,eventloop 将不能继续注册。
我们知道,Linux 内核会给每个进程维护一个文件描述符表。而 POSIX 标准对于文件描述符进行了以下约束:
Redis 充分利用了文件描述符的这些特点,来存储每个 fd 对应的事件。
在 Redis 的 eventloop 中,直接用了一个连续数组来存储事件信息:
eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);for (i = 0; i < setsize; i++) eventLoop->events[i].mask = AE_NONE;
可以看到数组长度就是 setsize,同时创建之后将每一个 event 的 mask 属性置为 AE_NONE(即是 0),mask 代表该 fd 注册了哪些事件。
对于 eventLoop->events
数组来说,fd 就是这个数组的下标。
例如,当程序刚刚启动时候,创建监听套接字,按照标准规定,该 fd 的值为 3。此时就直接在 eventLoop->events
下标为 3 的元素中存放相应 event 数据。
不过也基于文件描述符的这些特点,意味着 events 数组的前三位一定不会有相应的 fd 赋值。
那么,Redis 是如何指定 eventloop 的 setsize 的呢?以下是 Redis 创建 eventloop 的相关代码:
server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
其中:
--maxclients
指定,默认为 10000。也正是因为 Redis 利用了 fd 的这个特点,Redis 只能在完全符合 POSIX 标准的系统中工作。其他的例如 Windows 系统,生成的 fd 或者说 HANDLE 更像是个指针,并不符合 POSIX 标准。
Redis 通过以下接口进行网络 IO 事件的注册和删除。
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData);void aeDeleteFileEvent(aeEventLoop *eventLoop, int fd, int mask);
aeCreateFileEvent 表示将某个 fd 的某些事件注册到 eventloop 中。
目前可注册的事件有三种:
而 mask 就是这几个事件经过或运算后的掩码。
aeCreateFileEvent 在 epoll 的实现中调用了 epoll_ctl 函数。Redis 会根据该事件对应之前的 mask 是否为 AE_NONE,来决定使用 EPOLL_CTL_ADD 还是 EPOLL_CTL_MOD。
同样的,aeDeleteFileEvent 也使用了 epoll_ctl,Redis 判断用户是否是要完全删除该 fd 上所有事件,来决定使用 EPOLL_CTL_DEL 还是 EPOLL_CTL_MOD。
AE 中最不值得分析的大概就是定时器了,因为实现的实在是太简单了,甚至可以说是简陋。
Redis 通过以下两个接口进行定时器的注册和取消。
long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds, aeTimeProc *proc, void *clientData, aeEventFinalizerProc *finalizerProc);int aeDeleteTimeEvent(aeEventLoop *eventLoop, long long id);
在调用 aeCreateTimeEvent 注册超时事件的时候,调用方需要提供两个 callback: aeTimeProc 和 aeEventFinalizerProc。
Redis 的定时器其实做的非常简陋,只是一个普通的双向链表,链表也并不是有序的。每次最新的超时事件,直接插入链表的最头部。
当 AE 要遍历当前时刻的超时事件时,也是直接暴力的从头到尾遍历链表,看看有没有超时的事件。
当时我看到这里源码的时候,还是很震惊的。因为一般来说,定时器都会采用最小堆或者时间轮等有序数据结构进行存储,
为什么 Redis 的定时器做的这么简陋?
《Redis 的设计与实现》一书中说,在 Redis 3.0 版本中,只使用到了 serverCon 这一个超时事件。
所以这种情况下,也无所谓性能了,虽然是个链表,但其实用起来就只有一个元素,相当于当做一个指针在用。
虽然还不清楚 5.0.6 版本里面超时事件有没有增多,不过可以肯定的是,目前依然达不到花点时间去优化的程度。
Redis 在注释里面也说明了这事,并且给出了以后的优化方案:
用 skiplist 代替现有普通链表,查询的时间复杂度将优化为 O(1), 插入的时间复杂度将变成 O(log(N))
虽然定时器做的这么简陋,但是对于一些时间上的异常情况,Redis 还是做了下基本的处理。具体可见如下代码:
if (now < eventLoop->lastTime) { te = eventLoop->timeEventHead; while(te) { te->when_sec = 0; te = te->next; }}
这段代码的意思是,如果当前时刻小于 lastTime, 那意味着时间有可能被调整了。
对于这种情况,Redis 是怎么处理的呢:
直接把所有的事件的超时时间都置为 0, te->when_sec = 0
。这样的话,接下来检查有哪些超时时间到期的时候,所有的超时事件都会被判定为到期。相当于本次遍历把所有超时事件一次性全部激活。
因为 Redis 认为,在这种异常情况下,与其冒着超时事件可能永远无法触发的风险,还不如把事情提前做了。
还是基于 Redis 够用就行的原则,这个解决方案在 Redis 中显然是被接受的。
但是其实还有更好的做法,比如 libevent 就是通过相对时间的方式进行处理这个问题。为了解决这个几乎不会出现的异常 case,libevent 也花了大量代码进行处理。
Redis 中关于处理等待事件的函数有以下两个:
int aeProcessEvents(aeEventLoop *eventLoop, int flags);void aeMain(aeEventLoop *eventLoop);
aeMain 的实现很简单, 就是我们所说的事件循环了,真的就是个循环:
void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP); }}
而 aeProcessEvents 代表处理一次事件循环,那么 aeProcessEvents 都做了那些事情呢?
为什么要取出最近的一次超时事件?这是因为对于 epoll_wait 来说,必须要指定一个超时时间。以下是 epoll_wait 的定义:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
timeout 参数单位是毫秒,如果指定了大于 0 的超时时间,则在这段时间内即使如果没有网络 IO 事件触发,epoll_wait 到了指定时间也会返回。
如果超时时间指定为 - 1,则 epoll_wait 将会一直阻塞等待,直到网络事件触发。
epoll_wait 的超时时间一定要指定为最近超时事件的时间间隔,这样可以防止万一这段时间没有网络事件触发,超时事件也可以正常的响应。
同时,eventloop 还有两个 callback: beforesleep 和 aftersleep,分别会在 epoll_wait 之前和之后调用。
接着,我们看下 Redis 是怎么处理已触发的网络事件的:
一般情况下,Redis 会先处理读事件 (AE_READABLE),再处理写事件 (AE_WRITABLE)。
这个顺序安排其实也算是一点小优化,先读后写 可以让一个请求的处理和回包都是在同一次循环里面,使得请求可以尽快地回包,
前面讲到,网络 IO 事件注册的时候,除了正常的读写事件外,还可以注册一个 AE_BARRIER 事件,这个事件就是会影响到先读后写的处理顺序。
如果某个 fd 的 mask 包含了 AE_BARRIER,那它的处理顺序会是 先写后读。
针对这个场景,redis 举的例子是,如果在 beforesleep 回调中进行了 fsync 动作,然后需要把结果快速回复给 client。这个情况下就需要用到 AE_BARRIER 事件,用来翻转处理事件顺序了。
Redis 不仅支持 Linux 下的 epoll,还支持其他的 IO 复用方式,目前支持如下四种:
几个 IO 复用方式使用的判断顺序如下:
#ifdef HAVE_EVPORT#include "ae_evport.c"#else #ifdef HAVE_EPOLL #include "ae_epoll.c" #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" #else #include "ae_select.c" #endif #endif#endif
这个顺序其实也代表了四种 IO 复用方式的性能高低。
对于每种 IO 复用方式,只要实现以下 8 个接口就可以正常对接 Redis 了:
int aeApiCreate(aeEventLoop *eventLoop);void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask);void aeApiResize(aeEventLoop *eventLoop, int setsize);void aeApiFree(aeEventLoop *eventLoop);int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask);void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask);int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp);char *aeApiName(void);
在这个 8 个接口下面,其实底层并没有做太多的优化,只是简单的对原有 API 封装而已。
与其他通用型的事件循环库 (如 libevent) 不一样的是,Redis 的事件循环库不用考虑太多的用户侧因素:
本文属于 《Golang源码剖析系列》
Filebeat 是使用 Golang 实现的轻量型日志采集器,也是 Elasticsearch stack 里面的一员。本质上是一个 agent,可以安装在各个节点上,根据配置读取对应位置的日志,并上报到相应的地方去。
Filebeat 的可靠性很强,可以保证日志 At least once 的上报,同时也考虑了日志搜集中的各类问题,例如日志断点续读、文件名更改、日志 Truncated 等。
Filebeat 并不依赖于 Elasticsearch,可以单独存在。我们可以单独使用 Filebeat 进行日志的上报和搜集。filebeat 内置了常用的 Output 组件, 例如 kafka、Elasticsearch、redis 等。出于调试考虑,也可以输出到 console 和 file。我们可以利用现有的 Output 组件,将日志进行上报。
当然,我们也可以自定义 Output 组件,让 Filebeat 将日志转发到我们想要的地方。
filebeat 其实是 elastic/beats 的一员,除了 filebeat 外,还有 HeartBeat、PacketBeat。这些 beat 的实现都是基于 libbeat 框架。
下图是 Filebeat 官方提供的架构图:
除了图中提到的各个组件,整个 filebeat 主要包含以下重要组件:
filebeat 的整个生命周期,几个组件共同协作,完成了日志从采集到上报的整个过程。
Filebeat 不仅支持普通文本日志的作为输入源,还内置支持了 redis 的慢查询日志、stdin、tcp 和 udp 等作为输入源。
本文只分析下普通文本日志的处理方式,对于普通文本日志,可以按照以下配置方式,指定 log 的输入源信息。
filebeat.inputs:- type: log enabled: true paths: - /var/log/*.log
其中 Input 也可以指定多个, 每个 Input 下的 Log 也可以指定多个。
filebeat 启动时会开启 Crawler,对于配置中的每条 Input,Crawler 都会启动一个 Input 进行处理,代码如下所示:
func (c *Crawler) Start(...){ ... for _, inputConfig := range c.inputConfigs { err := c.startInput(pipeline, inputConfig, r.GetStates()) if err != nil { return err } } ...}
由于指定的 paths 可以配置多个,而且可以是 Glob 类型,因此 Filebeat 将会匹配到多个配置文件。
Input 对于每个匹配到的文件,都会开启一个 Harvester 进行逐行读取,每个 Harvester 都工作在自己的的 goroutine 中。
Harvester 的工作流程非常简单,就是逐行读取文件,并更新该文件暂时在 Input 中的文件偏移量(注意,并不是 Registrar 中的偏移量),读取完成则结束流程。
同时,我们需要考虑到,日志型的数据其实是在不断增长和变化的:
为了解决这两个情况,filebeat 采用了 Input 定时扫描的方式。代码如下,可以看出,Input 扫描的频率是由用户指定的 scan_frequency
配置来决定的 (默认 10s 扫描一次)。
func (p *Runner) Run() {p.input.Run()if p.Once {return}for {select {case <-p.done:logp.Info("input ticker stopped")returncase <-time.After(p.config.ScanFrequency): // 定时扫描logp.Debug("input", "Run input")p.input.Run()}}}
此外,如果用户启动时指定了 --once
选项,则扫描只会进行一次,就退出了。
我们之前讲到 Registrar 会记录每个文件的状态,当 Filebeat 启动时,会从 Registrar 恢复文件处理状态。
其实在 filebeat 运行过程中,Input 组件也记录了文件状态。不一样的是,Registrar 是持久化存储,而 Input 中的文件状态仅表示当前文件的读取偏移量,且修改时不会同步到磁盘中。
每次,Filebeat 刚启动时,Input 都会载入 Registrar 中记录的文件状态,作为初始状态。Input 中的状态有两个非常重要:
对于每次定时扫描到的文件,概括来说,会有三种大的情况:
对于这种第三种情况,我们需要考虑到一些异常情况,Filebeat 是这么处理的:
对于第二种情况,Filebeat 似乎有一个逻辑上的问题: 如果文件被 Truncate 过,后来又新增了数据,且文件大小也比之前 offset 大,那么 Filebeat 是检查不出来这个问题的。
除此之外,一个比较有意思的点是,Filebeat 甚至可以处理文件名修改的问题。即使一个日志的文件名被修改过,Filebeat 重启后,也能找到该文件,从上次读过的地方继续读。
这是因为 Filebeat 除了在 Registrar 存储了文件名,还存储了文件的唯一标识。对于 Linux 来说,这个文件的唯一标识就是该文件的 inode ID + device ID。
至此,我们可以清楚的知道,Filebeat 是如何采集日志文件,同时做到监听日志文件的更新和修改。而日志采集过程,Harvest 会将数据写到 Pipeline 中。我们接下来看下数据是如何写入到 Pipeline 中的。
Haveseter 会将数据写入缓存中,而另一方面 Output 会从缓存将数据读走。整个生产消费的过程都是由 Pipeline 进行调度的,而整个调度过程也非常复杂。
此外,Filebeat 的缓存目前分为 memqueue 和 spool。memqueue 顾名思义就是内存缓存,spool 则是将数据缓存到磁盘中。本文将基于 memqueue 讲解整个调度过程。
我们首先看下 Haveseter 是如何将数据写入缓存中的,如下图所示:
Harvester 通过 pipeline 提供的 pipelineClient 将数据写入到 pipeline 中,Haveseter 会将读到的数据会包装成一个 Event 结构体,再递交给 pipeline。
在 Filebeat 的实现中,pipelineClient 并不直接操作缓存,而是将 event 先写入一个 events channel 中。
同时,有一个 eventloop 组件,会监听 events channel 的事件到来,等 event 到达时,eventloop 会将其放入缓存中。
当缓存满的时候,eventloop 直接移除对该 channel 的监听。
每次 event ACK 或者取消后,缓存不再满了,则 eventloop 会重新监听 events channel。
以上是 Pipeline 的写入过程,此时 event 已被写入到了缓存中。
但是 Output 是如何从缓存中拿到 event 数据的?
整个消费的过程非常复杂,数据会在多个 channel 之间传递流转,如下图所示:
首先再介绍两个角色:
与 producer 类似,consumer 也不直接操作缓存,而是会向 get channel 中写入消费请求。
consumer 本身是个后台 loop 的过程,这个消费请求会不断进行。
eventloop 监听 get channel, 拿到之后会从缓存中取数据。并将数据写入到 resp channel 中。
consumer 从 resp channel 中拿到 event 数据后,又会将其写入到 workQueue。
workQueue 也是个 channel。client worker 会监听该 channel 上的数据到来,将数据交给 Output client 进行 Publish 上报。
而且,Output 收到的是 Batch Events,即会一次收到一批 Events。BatchSize 由各个 Output 自行决定。
至此,消息已经递交给了 Output 组件。
filebeat 之所以可以保证日志可以 at least once 的上报,就是基于其 Ack 机制。
简单来说,Ack 机制就是,当 Output Publish 成功之后会调用 ACK,最终 Registrar 会收到 ACK,并修改偏移量。
而且, Registrar 只会在 Output 调用 batch 的相关信号时,才改变文件偏移量。其中 Batch 对外提供了这些信号:
type Batch interface {Events() []Event// signalsACK()Drop()Retry()RetryEvents(events []Event)Cancelled()CancelledEvents(events []Event)}
Output 在 Publish 之后,无论失败,必须调用这些函数中的其中一个。
以下是 Output Publish 成功后调用 Ack 的流程:
可以看到其中起核心作用的组件是 Ackloop。AckLoop 中有一个 ackChanList,其中每一个 ackChan,对应于转发给 Output 的一个 Batch。
每次新建一个 Batch,同时会建立一个 ackChan,该 ackChan 会被 append 到 ackChanList 中。
而 AckLoop 每次只监听处于 ackChanList 最头部的 ackChan。
当 Batch 被 Output 调用 Ack 后,AckLoop 会收到对应 ackChan 上的事件,并将其最终转发给 Registrar。同时,ackChanList 将会 pop 头部的 ackChan,继续监听接下来的 Ack 事件。
了解了 Filebeat 的实现原理,我们才有会明白 Filebeat 配置中各个参数对程序的最终影响。同时,由于 FileBeat 是 At least once 的上报,但并不保证 Exactly once, 因此一条数据可能会被上报多次,所以接收端需要自行进行去重过滤。
本文属于 《Golang源码剖析系列》
uber 在 Github 上开源了一套用于服务限流的 go 语言库 ratelimit, 该组件基于 Leaky Bucket(漏桶) 实现。
我在之前写过一篇 《Golang 限流器 time/rate 实现剖析》,分析了 Golang 标准库中基于 Token Bucket 实现限流组件的 time/rate
原理,同时也讲了限流的一些背景。
相比于 TokenBucket 中,只要桶内还有剩余令牌,调用方就可以一直消费的策略。Leaky Bucket 相对来说更加严格,调用方只能严格按照预定的间隔顺序进行消费调用。(虽然 uber-go 对这个限制也做了一些优化,具体可以看下文详解)
还是老规矩,在正式讲其实现之前,我们先看下 ratelimit 的使用方法。
我们直接看下 uber-go 官方库给的例子:
rl := ratelimit.New(100) // per secondprev := time.Now()for i := 0; i < 10; i++ { now := rl.Take() fmt.Println(i, now.Sub(prev)) prev = now}
在这个例子中,我们给定限流器每秒可以通过 100 个请求,也就是平均每个请求间隔 10ms。
因此,最终会每 10ms 打印一行数据。输出结果如下:
// Output:// 0 0// 1 10ms// 2 10ms// 3 10ms// 4 10ms// 5 10ms// 6 10ms// 7 10ms// 8 10ms// 9 10ms
要实现以上每秒固定速率的目的,其实还是比较简单的。
在 ratelimit 的 New 函数中,传入的参数是每秒允许请求量 (RPS)。
我们可以很轻易的换算出每个请求之间的间隔:
limiter.perRequest = time.Second / time.Duration(rate)
以上 limiter.perRequest
指的就是每个请求之间的间隔时间。
如下图,当请求 1 处理结束后, 我们记录下请求 1 的处理完成的时刻, 记为 limiter.last
。
稍后请求 2 到来, 如果此刻的时间与 limiter.last
相比并没有达到 perRequest
的间隔大小,那么 sleep 一段时间即可。
对应 ratelimit 的实现代码如下:
sleepFor = t.perRequest - now.Sub(t.last)if sleepFor > 0 {t.clock.Sleep(sleepFor)t.last = now.Add(sleepFor)} else {t.last = now}
传统的 Leaky Bucket 每个请求的间隔是固定的,然而在实际上的互联网应用中,流量经常是突发性的。对于这种情况,uber-go 对 Leaky Bucket 做了一些改良,引入了最大松弛量 (maxSlack) 的概念。
我们先理解下整体背景: 假如我们要求每秒限定 100 个请求,平均每个请求间隔 10ms。但是实际情况下,有些请求间隔比较长,有些请求间隔比较短。如下图所示:
请求 1 完成后,15ms 后,请求 2 才到来,可以对请求 2 立即处理。请求 2 完成后,5ms 后,请求 3 到来,这个时候距离上次请求还不足 10ms,因此还需要等待 5ms。
但是,对于这种情况,实际上三个请求一共消耗了 25ms 才完成,并不是预期的 20ms。在 uber-go 实现的 ratelimit 中,可以把之前间隔比较长的请求的时间,匀给后面的使用,保证每秒请求数 (RPS) 即可。
对于以上 case,因为请求 2 相当于多等了 5ms,我们可以把这 5ms 移给请求 3 使用。加上请求 3 本身就是 5ms 之后过来的,一共刚好 10ms,所以请求 3 无需等待,直接可以处理。此时三个请求也恰好一共是 20ms。
如下图所示:
在 ratelimit 的对应实现中很简单,是把每个请求多余出来的等待时间累加起来,以给后面的抵消使用。
t.sleepFor += t.perRequest - now.Sub(t.last)if t.sleepFor > 0 { t.clock.Sleep(t.sleepFor) t.last = now.Add(t.sleepFor) t.sleepFor = 0} else { t.last = now}
注意:这里跟上述代码不同的是,这里是 +=
。而同时 t.perRequest - now.Sub(t.last)
是可能为负值的,负值代表请求间隔时间比预期的长。
当 t.sleepFor > 0
,代表此前的请求多余出来的时间,无法完全抵消此次的所需量,因此需要 sleep 相应时间, 同时将 t.sleepFor
置为 0。
当 t.sleepFor < 0
,说明此次请求间隔大于预期间隔,将多出来的时间累加到 t.sleepFor
即可。
但是,对于某种情况,请求 1 完成后,请求 2 过了很久到达 (好几个小时都有可能),那么此时对于请求 2 的请求间隔 now.Sub(t.last)
,会非常大。以至于即使后面大量请求瞬时到达,也无法抵消完这个时间。那这样就失去了限流的意义。
为了防止这种情况,ratelimit 就引入了最大松弛量 (maxSlack) 的概念, 该值为负值,表示允许抵消的最长时间,防止以上情况的出现。
if t.sleepFor < t.maxSlack { t.sleepFor = t.maxSlack}
ratelimit 中 maxSlack 的值为 -10 * time.Second / time.Duration(rate)
, 是十个请求的间隔大小。我们也可以理解为 ratelimit 允许的最大瞬时请求为 10。
ratelimit 的 New 函数,除了可以配置每秒请求数 (QPS), 其实还提供了一套可选配置项 Option。
func New(rate int, opts ...Option) Limiter
Option 的类型为 type Option func(l *limiter)
, 也就是说我们可以提供一些这样类型的函数,作为 Option,传给 ratelimit, 定制相关需求。
但实际上,自定义 Option 的用处比较小,因为 limiter
结构体本身就是个私有类型,我们并不能拿它做任何事情。
我们只需要了解 ratelimit 目前提供的两个配置项即可:
WithoutSlack
我们上文讲到 ratelimit 中引入了最大松弛量的概念,而且默认的最大松弛量为 10 个请求的间隔时间。
但是确实会有这样需求场景,需要严格的限制请求的固定间隔。那么我们就可以利用 WithoutSlack 来取消松弛量的影响。
limiter := ratelimit.New(100, ratelimit.WithoutSlack)
WithClock(clock Clock)
我们上文讲到,ratelimit 的实现时,会计算当前时间与上次请求时间的差值,并 sleep 相应时间。
在 ratelimit 基于 go 标准库的 time 实现时间相关计算。如果有精度更高或者特殊需求的计时场景,可以用 WithClock 来替换默认时钟。
通过该方法,只要实现了 Clock 的 interface,就可以自定义时钟了。
type Clock interface {Now() time.TimeSleep(time.Duration)}
clock &= MyClock{}limiter := ratelimit.New(100, ratelimit.WithClock(clock))