JuiceFS 预读机制: ReadAhead
JuiceFS官网介绍了代码中存在着预读和预取两种缓存机制,其中预取机制较为简单。这里看预读机制。
本文以github上开源的Juicefs代码为例讲解,截止到文章发布,juicefs更新到了commit e407fa58aa3147afe16ed4927e0830a7c0f91fc5
预读机制实现在vfs这个包里,可以看reader.go这个文件。
FileReader和DataReader接口
这个文件定义并实现了两个接口,readahead主要是FileReader实现的
type FileReader interface {
Read(ctx meta.Context, off uint64, buf []byte) (int, syscall.Errno)
GetLength() uint64
Close(ctx meta.Context)
}
type DataReader interface {
Open(inode Ino, length uint64) FileReader
Truncate(inode Ino, length uint64)
Invalidate(inode Ino, off, length uint64)
}
从这两个接口来看,DataReader能够打开一个FileReader,FileReader能够读取文件,获取文件长度,DataReader还能够执行Truncate操作(这一点存疑,因为在hadoop中truncate属于写操作),invalidate似乎是用来无效文件的?DataReader的两个行为偏向于写操作,或许是jfs的实现有所不同。往下看。
frange
type frange struct {
off uint64
len uint64
}
func (r *frange) String() string { return fmt.Sprintf("[%d,%d,%d)", r.off, r.len, r.end()) }
func (r *frange) end() uint64 { return r.off + r.len }
func (r *frange) contain(p uint64) bool { return r.off < p && p < r.end() }
func (r *frange) overlap(a *frange) bool { return a.off < r.end() && r.off < a.end() }
func (r *frange) include(a *frange) bool { return r.off <= a.off && a.end() <= r.end() }
frange,全称应该是filerange,表达文件的一个范围,off表示开头,len表示大小,通过end()就能计算出结尾,contain表示一个位置是否在这个范围内,不包括左右端点,overlap和include是判断两个frange的关系的。
我们大概可以总结以下三个观点:
- frange包括一个坐标,即该坐标位于frange的开头到结尾的开区间。
- frange包括另一个frange,即后者是前者的子区间,包括相同的情况。
- frange被另一个frange覆盖,后者是前者的父区间,不包括相同的情况。
清楚了这三点,可以去看下一个类sliceReader,sliceReader并不是FileReader或者DataReader的实现。
sliceReader
type sliceReader struct {
file *fileReader
block *frange
state sstate
page *chunk.Page
indx uint32
currentPos uint32
lastAccess time.Time
cond *utils.Cond
next *sliceReader
prev **sliceReader
refs uint16
}
sliceReader应该是被fileReader包含的,因为它有一个指向fileReader的指针。sliceReader有一个名为block的frange,指向的是该文件的负责范围,sstate应该是该sliceReader的状态。sstate是一个uint8类,这个定义不是很明显。
const (
NEW = iota
BUSY
REFRESH
BREAK
READY
INVALID
)
type sstate uint8
作者还给出了这个状态的转化过程(有点抽象),没关系我们自己看。
/*
* state of sliceReader
*
* <-- REFRESH
* | |
* NEW -> BUSY -> READY
* | |
* BREAK ---> INVALID
*/
我们接着看,sliceReader有一个next和prev指针,这表示它是由链表方式存储的。refs是一个引用计数,可能是销毁时用的。page这个是chunk包的,这个是用来读完数据的时候暂存的。indx,这个类似于索引,currentPos应该是定位当前位置的,lastAccess是一个时间,估计和删除有关。看下Cond,不知道被用来干什么。
type Cond struct {
L sync.Locker
signal chan struct{}
}
为了搞清楚这些变量的意义,先看一下newSlice这个函数,这个函数用来创建一个sliceReader
func (f *fileReader) newSlice(block *frange) *sliceReader {
s := &sliceReader{}
s.file = f
s.lastAccess = time.Now()
s.indx = uint32(block.off / meta.ChunkSize)
s.block = &frange{block.off, block.len} // random read
blockend := (block.off/f.r.blockSize + 1) * f.r.blockSize
if s.block.end() > f.length {
s.block.len = f.length - s.block.off
}
if s.block.end() > blockend {
s.block.len = blockend - s.block.off
}
block.off = s.block.end()
block.len -= s.block.len
s.page = chunk.NewOffPage(int(s.block.len))
s.cond = utils.NewCond(&f.Mutex)
s.prev = f.last
*(f.last) = s
f.last = &(s.next)
go s.run()
atomic.AddInt64(&readBufferUsed, int64(s.block.len))
return s
}
indx的意义很清楚了,frange开头的位置位于第几个块。page也是依据block创建的,预留的空间就是block的长度。
func NewOffPage(size int) *Page {
if size <= 0 {
panic("size of page should > 0")
}
p := utils.Alloc(size)
page := &Page{refs: 1, offheap: true, Data: p}
if pageStack {
page.stack = debug.Stack()
}
runtime.SetFinalizer(page, func(p *Page) {
refcnt := atomic.LoadInt32(&p.refs)
if refcnt != 0 {
logger.Errorf("refcount of page %p (%d bytes) is not zero: %d, created by: %s", p, cap(p.Data), refcnt, string(p.stack))
if refcnt > 0 {
p.Release()
}
}
})
return page
}
看下cond这个函数,这个可能要看了fileRead才能明白。
func NewCond(lock sync.Locker) *Cond {
return &Cond{lock, make(chan struct{})}
}
我们还是看sliceReader,它首先有以下几个函数
func (s *sliceReader) delay(delay time.Duration)
func (s *sliceReader) done(err syscall.Errno, delay time.Duration)
func (s *sliceReader) run()
func (s *sliceReader) invalidate()
func (s *sliceReader) drop()
func (s *sliceReader) delete()
这里先有一个retry_time函数,计算下次重试间隔的时间
``go
func retry_time(trycnt uint32) time.Duration {
if trycnt < 30 {
return time.Millisecond * time.Duration((trycnt-1)*300+1)
}
return time.Second * 10
}
delay函数也很简单,等待一段时间后执行run
```go
func (s *sliceReader) delay(delay time.Duration) {
time.AfterFunc(delay, s.run)
}
done和run两个函数比较重要。先看done
func (s *sliceReader) done(err syscall.Errno, delay time.Duration) {
f := s.file
switch s.state {
case BUSY:
s.state = NEW // failed
case BREAK:
s.state = INVALID
case REFRESH:
s.state = NEW
}
if err != 0 {
if !f.closing {
logger.Errorf("read file %d: %s", f.inode, err)
}
f.err = err
}
if f.shouldStop() {
s.state = INVALID
}
switch s.state {
case NEW:
s.delay(delay)
case READY:
s.cond.Broadcast()
case INVALID:
if s.refs == 0 {
s.delete()
if f.closing && f.slices == nil {
f.r.Lock()
if f.refs == 0 {
f.delete()
}
f.r.Unlock()
}
} else {
s.cond.Broadcast()
}
}
runtime.Goexit()
}
首先设置一个状态,之后根据error报错,在此判断fileReader状态并设置状态,之后再根据状态确定执行的动作。两种情况下会产生广播,分别是进入ready状态和金瑞invalid状态但引用数不为0。两种情况下会重试,分别是一开始是Busy或Refresh状态且没有被定为Invalid状态;如果Busy或者INVALID没有触发删除,即refs为0,会delete本sliceReader,并检测对应fileReader状态并尝试删除fileReader.
run函数主要是梳理一下流程:
- 状态检查,进入这里必须是new状态,否则进入done,这里大概率直接更新为new然后重试了。注意,done函数里面有一个runtime.Goexit(),所以一旦调用了done,就确定run已经中止了。
- 设置为Busy状态。
- 从元数据读取inode数据片段,错误重试,成功则根据文件长度对block进行修正和判断,block off肯定不能大于文件长度,大于则直接返回,end也不能大于文件长度,大于则对齐到文件长度。
- 现在我们终于可以直接去读取了,f.r.m.Read读的是元数据,真正数据的读取要去f.r.Read。
- 读完之后,先检测本身状态,状态异常直接done。然后看有没有读取成功?4中的read函数返回读取到的长度,这个长度应该等于need,也就是block的长度,这两个相等就读成了,Pos被置为n读位置最后更新时间置为读完的时间,并把重试时间置为0。
- 如果不相等就视为失败,重试次数+1,然后进入重试或失败。
func (s *sliceReader) run() {
f := s.file
f.Lock()
defer f.Unlock()
if s.state != NEW || f.shouldStop() {
s.done(0, 0)
}
s.state = BUSY
indx := s.indx
inode := f.inode
f.Unlock()
f.Lock()
length := f.length
f.Unlock()
var slices []meta.Slice
err := f.r.m.Read(meta.Background, inode, indx, &slices)
f.Lock()
if s.state != BUSY || f.err != 0 || f.closing {
s.done(0, 0)
}
if err == syscall.ENOENT {
s.done(err, 0)
} else if err != 0 {
f.tried++
trycnt := f.tried
if trycnt > f.r.maxRetries {
s.done(syscall.EIO, 0)
} else {
s.done(0, retry_time(trycnt))
}
}
s.currentPos = 0
if s.block.off > length {
s.block.len = 0
s.state = READY
s.done(0, 0)
} else if s.block.end() > length {
s.block.len = length - s.block.off
}
need := s.block.len
f.Unlock()
p := s.page.Slice(0, int(need))
defer p.Release()
var n int
ctx := context.TODO()
n = f.r.Read(ctx, p, slices, (uint32(s.block.off))%meta.ChunkSize)
f.Lock()
if s.state != BUSY || f.shouldStop() {
s.done(0, 0)
}
if n == int(need) {
s.state = READY
s.currentPos = uint32(n)
s.file.tried = 0
s.lastAccess = time.Now()
s.done(0, 0)
} else {
s.currentPos = 0 // start again from beginning
err = syscall.EIO
f.tried++
_ = f.r.m.InvalidateChunkCache(meta.Background, inode, indx)
if f.tried > f.r.maxRetries {
s.done(err, 0)
} else {
s.done(0, retry_time(f.tried))
}
}
}
注意一下invalidate这个函数,go语言有一点不同,这里的NEW不会有任何操作。
func (s *sliceReader) invalidate() {
switch s.state {
case NEW:
case BUSY:
s.state = REFRESH
// TODO: interrupt reader
case READY:
if s.refs > 0 {
s.state = NEW
go s.run()
} else {
s.state = INVALID
s.delete() // nobody wants it anymore, so delete it
}
}
}
drop这个函数,state小于3,即new(0),busy(1),refresh(2),处于未执行完的状态,设置为break,run会被阻断。其他情况如果没有被引用会直接删除,否则如果是ready状态置为invalid,invalid不操作
func (s *sliceReader) drop() {
if s.state <= BREAK {
if s.refs == 0 {
s.state = BREAK
// TODO: interrupt reader
}
} else {
if s.refs == 0 {
s.delete() // nobody wants it anymore, so delete it
} else if s.state == READY {
s.state = INVALID // somebody still using it, so mark it for removal
}
}
}
delete 标准的链表删除操作
func (s *sliceReader) delete() {
*(s.prev) = s.next
if s.next != nil {
s.next.prev = s.prev
} else {
s.file.last = s.prev
}
s.page.Release()
atomic.AddInt64(&readBufferUsed, -int64(s.block.len))
}
结论
由于作者太懒,不想画图,所以我们用文字来总结一下sliceReader的几个重要的状态:
const (
NEW = iota //新建
BUSY //正在读取,该状态被done会转化为NEW并重试,被incalidate会REFRESH
REFRESH //该状态被DONE会转化为NEW并重试
BREAK //该状态被DONE会INVALID并尝试删除
READY //读取执行完的样子,被invalid时如果被引用会置为NEW并重试,否则被删除;被DROP如果被引用会置为INVALID会删除,不然置为INVALID
INVALID //等待删除的状态,被done时如果被引用会广播,不然直接删除
)
它的几个函数:
func (s *sliceReader) delay(delay time.Duration) //等待一段时间后重新执行run
func (s *sliceReader) done(err syscall.Errno, delay time.Duration) //读取完成或失败时判定
func (s *sliceReader) run() //执行读取主要流程
func (s *sliceReader) invalidate() //刷新
func (s *sliceReader) drop() //终止任务,尝试删除
func (s *sliceReader) delete() //删除列表中的该节点
fileReader
fileReader是FileReader的一个也是唯一的实现。先看一下这个类的属性。
type fileReader struct {
// protected by itself
inode Ino
length uint64
err syscall.Errno
tried uint32
sessions [readSessions]session
slices *sliceReader
last **sliceReader
sync.Mutex
closing bool
// protected by r
refs uint16
next *fileReader
r *dataReader
}
预读机制主要在fileReader里实现,出于文章长度考虑,我们认为下面这些真理是不证自明的:inode表示文件在文件系统中的id,err表示过程中的错误,tried表示重试次数,每一个sliceReader都会读取这个重试次数,一旦有一个sliceReader成功,重试次数就会置零,这在一定程度上揭示了重试策略。next指针表示fileReader也是一个链表,slices指向对应的sliceReader链表,last指向上一个slices指针;closing用于标记文件状态。看起来session是一个管理会话的数组:
type session struct {
lastOffset uint64
total uint64
readahead uint64
atime time.Time
}
session各属性的意义我们之后再讨论,fileReader还涉及另一个结构体:
type req struct {
frange
s *sliceReader
}
首先我们要看一个fileReader是怎么创建的:
func (r *dataReader) Open(inode Ino, length uint64) FileReader {
f := &fileReader{
r: r,
inode: inode,
length: length,
}
f.last = &(f.slices)
r.Lock()
f.refs = 1
f.next = r.files[inode]
r.files[inode] = f
r.Unlock()
return f
}
不难看出,inode,length都是由Open传入的,在fileReader里面讨论这些没有意义,我们应该关注它会不会被改变。包括下面这个函数,关注它自身也是没有意义的:
func (f *fileReader) GetLength() uint64 {
f.Lock()
defer f.Unlock()
return f.length
}
newSLice这个函数我们已经看过了,它创建了一个SliceReader并移步执行了它go s.run(),这几乎意味着发送了一个读请求。这个函数只会被两个函数调用,readAhead和prepareRequests,这两个都是fileReader的成员函数。我们还是一个个来看fileReader的函数。
func (f *fileReader) delete() {
r := f.r
i := r.files[f.inode]
if i == f {
if i.next != nil {
r.files[f.inode] = i.next
} else {
delete(r.files, f.inode)
}
} else {
for i != nil {
if i.next == f {
i.next = f.next
break
}
i = i.next
}
}
f.next = nil
}
这个函数会把这个fileReader删除。由fileReader的创建我们可以看出,最新创建的fileReader总是在最前面,也就是说,如果这个fileReader的next为nil,这就说明这是为一个指针了。
func (f *fileReader) acquire() {
f.r.Lock()
defer f.r.Unlock()
f.refs++
}
func (f *fileReader) release() {
f.r.Lock()
defer f.r.Unlock()
f.refs--
if f.refs == 0 && f.slices == nil {
f.delete()
}
}
acquire和release,相对应的函数。release会尝试删除fileReader,另一个删除的引用在slice的done里,之前已经说过。fileReader被删除只有这两种途径。
func (f *fileReader) shouldStop() bool {
return f.err != 0 || f.closing
}
func (f *fileReader) visit(fn func(s *sliceReader)) {
var next *sliceReader
for s := f.slices; s != nil; s = next {
next = s.next
fn(s)
}
}
func (f *fileReader) Close(ctx meta.Context) {
f.Lock()
f.closing = true
f.visit(func(s *sliceReader) {
s.drop()
})
f.release()
f.Unlock()
}
这三个函数也没啥好说的。
我们按照fileReader Read的流程来看函数:
func (f *fileReader) Read(ctx meta.Context, offset uint64, buf []byte) (int, syscall.Errno) {
f.Lock()
defer f.Unlock()
f.acquire()
defer f.release()
if f.err != 0 || f.closing {
return 0, f.err
}
size := uint64(len(buf))
if offset >= f.length || size == 0 {
return 0, 0
}
block := &frange{offset, size}
if block.end() > f.length {
block.len = f.length - block.off
}
f.cleanupRequests(block)
var lastBS uint64 = 32 << 10
if block.off+lastBS > f.length {
lastblock := frange{f.length - lastBS, lastBS}
if f.length < lastBS {
lastblock = frange{0, f.length}
}
f.readAhead(&lastblock)
}
ranges := f.splitRange(block)
reqs := f.prepareRequests(ranges)
defer func() {
for _, req := range reqs {
s := req.s
s.refs--
if s.refs == 0 && s.state == INVALID {
s.delete()
}
}
}()
f.checkReadahead(block)
return f.waitForIO(ctx, reqs, buf)
}
首先经过异常检查后,会依据传入的buf和offset上称一个frange,之后依据f.length对齐,然后执行cleanuoRequests,之后根据条件判断是否需要readAhead,无论是否readAhead,将block进行splitRange,然后prepareRequests,之后checkReadahead,waitForIO,然后对slice进行删除,最后尝试release掉fileReader.
cleanupRequests
// cleanup unused requests
func (f *fileReader) cleanupRequests(block *frange) {
now := time.Now()
var cnt int
f.visit(func(s *sliceReader) {
if !s.state.valid() ||
!block.overlap(s.block) && (s.lastAccess.Add(time.Second*30).Before(now) || !f.need(s.block)) {
s.drop()
} else if !block.overlap(s.block) {
cnt++
}
})
f.visit(func(s *sliceReader) {
if !block.overlap(s.block) && cnt > f.r.maxRequests {
s.drop()
cnt--
}
})
}
func (m sstate) valid() bool { return m != BREAK && m != INVALID }
这个函数检查fileReader里此前所有的sliceReader,任何满足一下条件的sliceReader都会被drop: 状态无效(为break或invalid),block没有覆盖目标slice且距离上次创建或成功时间已过30秒,不再需要(这个逻辑后面讲);否则当请求block没有覆盖目标block时,cnt+1。
这个cnt统计了什么呢?创建且没有超过30且还在被需要的请求个数。
那么第二个visit就很容易理解了,它会在maxRequests范围内,逐个清理没有被本块覆盖的slice请求。每有一个Read请求,这个函数就会触发一次,实现了对请求的清理。
readAhead
block.off+lastBS > f.length这是预读分支的条件,如果读的位置加固定大小超过文件的长度才会读。读的内容是文件最后一个固定大小的长度,如果文件不到一个固定大小,那就读整个文件。也就是仅当读的起始点距离文件末尾最后一个固定大小以内时,才会预读。
func (f *fileReader) readAhead(block *frange) {
f.visit(func(r *sliceReader) {
if r.state.valid() && r.block.off <= block.off && r.block.end() > block.off {
if r.state == READY && block.len > f.r.blockSize && r.block.off == block.off && r.block.off%f.r.blockSize == 0 {
// next block is ready, reduce readahead by a block
block.len -= f.r.blockSize / 2
}
if r.block.end() <= block.end() {
block.len = block.end() - r.block.end()
} else {
block.len = 0
}
block.off = r.block.end()
}
})
if block.len > 0 && block.off < f.length && uint64(atomic.LoadInt64(&readBufferUsed)) < f.r.readAheadTotal {
if block.len < f.r.blockSize {
block.len += f.r.blockSize - block.end()%f.r.blockSize // align to end of a block
}
f.newSlice(block)
if block.len > 0 {
f.readAhead(block)
}
}
}
进入readAhead后,首先对block进行更新:只有目标块有效,且当前block的起始位置被目标块左包含时更新,遵从以下规则:
- 目标块执行成功,且当前块过大(大于blockSize),且目标块的起始点与当前块相同,且目标块的大小模块大小为0,这种情况下下一个块大概率已经缓存了,所以让block的大小减去1/2个块大小
- 目标块的终点先于当前块,可能当前块过大了,减去两者的差值
- 目标块的终点大于当前块,当前块已被包含,len设置为0,没有必要重复请求
- 无论如何,将当前块的开启点设置为目标块的终点,因为目标块这一部分已经有请求了
第一步为什么减去1/2个块的大小呢?考虑这种情况,无论如何,请求块的起点会被设为目标块的终点,请求已经超过了一个块,由于目标块以整个块为起点,如果其终点在块中,那么期望的长度是1/2个块,起点相同,block必定比目标块长度大,否则被置为0,又因为目标块至少跨过了一个块,这是最乐观的情况,大概有1/2个块已经被请求了。
在规范了block之后,再次进行判定,主要是看block是否还有效,并将block长度增加到一整个块大小的整数倍,如果block.len仍然大于0,递归调用f.readAhead。
需要注意,能改变block状态的有readAhead和f.newSlice,两个函数的作用下大概率不会递归到第三次
splitRange
func (f *fileReader) splitRange(block *frange) []uint64 {
ranges := []uint64{block.off, block.end()}
contain := func(p uint64) bool {
for _, i := range ranges {
if i == p {
return true
}
}
return false
}
f.visit(func(s *sliceReader) {
if s.state.valid() {
if block.contain(s.block.off) && !contain(s.block.off) {
ranges = append(ranges, s.block.off)
}
if block.contain(s.block.end()) && !contain(s.block.end()) {
ranges = append(ranges, s.block.end())
}
}
})
sort.Slice(ranges, func(i, j int) bool {
return ranges[i] < ranges[j]
})
return ranges
}
它创建了一个数组range,包括block起点和终点,然后对所有存在的slice进行遍历,如果block包括了目标block的起始点,且ranges没有记录这个值,追加这个值;如果包括了目标block终点且ranges没有记录这个值,追加这个值,最后排序,得到一个数组。
prepareRequests
func (f *fileReader) prepareRequests(ranges []uint64) []*req {
var reqs []*req
edges := len(ranges)
for i := 0; i < edges-1; i++ {
var added bool
b := frange{ranges[i], ranges[i+1] - ranges[i]}
f.visit(func(s *sliceReader) {
if !added && s.state.valid() && s.block.include(&b) {
s.refs++
s.lastAccess = time.Now()
reqs = append(reqs, &req{frange{ranges[i] - s.block.off, b.len}, s})
added = true
}
})
if !added {
for b.len > 0 {
s := f.newSlice(&b)
s.refs++
reqs = append(reqs, &req{frange{0, s.block.len}, s})
}
}
}
return reqs
}
这函数会生成一个req切片,对ranges进行处理,对每一个可行的范围,进行以下操作:
- 创建一个block,以该位为起点,下减去该位为长度
- 遍历现在所有的SliceReader,如果有目标切片状态存活且包括这个块,将这个块的引用数+1,并添加到reqs中,新的range为ranges中该位的值减去目标起点,也就是相较于目标切片向左偏移了,之后查看下一位。
- 如果没有满足2,创建一个新请求,以0为起点,长度为新请求的大小,并把其添加到reqs中。到这里我们看到一个大的请求被分割成很多小的,并且充分利用了已经有的slice,这些slice都被添加到了reqs中,但是我们不知道req中的这个frange是什么意义。
接下来Read中定义了一个清理函数
defer func() {
for _, req := range reqs {
s := req.s
s.refs--
if s.refs == 0 && s.state == INVALID {
s.delete()
}
}
}()
WaitForIO
我们必须搞清楚reqs中的range是什么用处,不然很难理解prepareRequests这个函数,所以我们先看waitForIO
func (f *fileReader) waitForIO(ctx meta.Context, reqs []*req, buf []byte) (int, syscall.Errno) {
start := time.Now()
for _, req := range reqs {
s := req.s
for s.state != READY && uint64(s.currentPos) < s.block.len {
if s.cond.WaitWithTimeout(time.Second) {
if ctx.Canceled() {
logger.Warnf("read %d interrupted after %d", f.inode, time.Since(start))
return 0, syscall.EINTR
}
}
if f.shouldStop() {
return 0, f.err
}
}
}
var n int
for _, req := range reqs {
s := req.s
if req.off < s.block.len && s.block.off+req.off < f.length {
if req.end() > s.block.len {
logger.Warnf("not enough bytes (%d < %d), restart read", s.block.len, req.end())
return 0, syscall.EAGAIN
}
if s.block.off+req.end() > f.length {
req.len = f.length - s.block.off - req.off
}
n += copy(buf[n:], s.page.Data[req.off:req.end()])
}
}
return n, 0
}
原来req的range是要复制的值在Page缓存中的起始点和长度。这一个函数的作用就是把结果赋给buff。
session guessSession checkReadahead和need
这些和readahead没有太大关系,主要是会话管理 //TODO:哪天想起来再更,愿那一天永远不会到来

浙公网安备 33010602011771号