Directed IO

学习一下 IO 的两种方式。

参考资料

block

磁盘一次物理读写的基本单位是扇区(一般 512Byte)。扇区的空间比较小且数目众多,在寻址时比较困难,操作系统的虚拟文件文件系统就将多个扇区组合在一起,形成一个更大的单位,就是(block)。虚拟文件系统通过(一般为 4KB)作为读取等操作数据的基本单位(即文件系统读写的最小粒度为)。

/images/Directed IO/block.png
block

总结

  1. 扇区是对硬盘而言,是物理层的;块是对虚拟文件系统而言,是逻辑层的。
  2. 磁盘可以看成是一个由 block 组成的数组。

页缓存(Page Cache)

页缓存

Page Cache(页缓存)是位于内核地址空间中,以内存页为单位,缓存数据块
当一个文件被读取时,文件系统首先会先检查其内容是否已经保存在页缓存中。如果文件数据已经保存在页缓存中,则文件系统直接从页缓冲中读取数据返回给应用程序;否则,文件系统会在页缓存中创建新的内存页,并从存储设备中读取相关的数据,然后将其保存在创建的内存页中。之后,文件系统同样会再次检查内存页中找到并读取相应的页数据,返回给应用程序。

预读机制

操作系统为基于 Page Cache 的读缓存机制提供预读机制(PAGE_READAHEAD),一个例子是:
用户线程仅仅请求读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于文件系统的的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。 但是操作系统出于局部性原理会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page。

延迟写入

大多数现代操作系统将写入在内存中缓冲 5-30s,即只是修改内存中的数据,并且把该内存页设置为脏页。系统中存在定期任务(表现形式为内核线程),周期性地将文件系统中文件脏数据块写回磁盘(即异步 Write Back 机制)。将内存写回磁盘时间延长,则可以通过批处理写入磁盘,减少 I/O 次数,提高性能。
如果对于数据要求较高的,可以利用系统调用fsync(int fd):将 fd 代表的文件的脏数据和脏元数据全部刷新至磁盘中。

直接 I/O 和 缓存 I/O

根据是否利用操作系统的页缓存(page cache),可以把文件 I/O 分为直接 I/O 与缓存 I/O。

/images/Directed IO/DIO.png
缓存 IO 和 DIO 对比
  • 缓存 I/O(标准I/O):读操作时,数据先从磁盘 copy 到内核页缓存中,然后再从内核页缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。(缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。)
  • 直接 I/O(Direct I/O,DIO):直接IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理IO缓存区。
    应用程序可以在打开文件时,通过附带的O_DIRECT标志,提示文件系统不要使用页缓存。

缓存I/O

  • 优点:在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全;可以减少 I/O的次数,从而提高性能。
  • 缺点:数据多次拷贝,性能降低(一些应用程序(如数据库)会自己实现缓存机制对数据进行缓存和管理,此时,操作系统的缓存是冗余的);数据缓存在内核空间中,一段时间再写入磁盘,断电丢失。

直接I/O

  • 优点:数据写的时候直接写回磁盘,确保掉电不丢失;减少内核 page cache 的内存使用,业务层自己控制内存,更加灵活。

DIO 实践

直接 I/O 对齐操作

DIO 模式由于自己维护缓冲区(即直接从磁盘 copy 到自定义的缓冲区),需要程序自己保证对齐规则,否则 IO 会报错。
为什么对齐的是扇区大小?我猜的原因:因为物理磁盘读写的最小粒度是扇区。

  • 用于传递数据的缓冲区,其大小必须和扇区大小(一般 512Byte)(即 IO 的大小是扇区大小的倍数)。
  • 数据传输的开始点,即文件的读写偏移位置(offset)必须是扇区大小的倍数。
  • 用于传递数据的缓冲区的地址(内存边界)必须与扇区大小对齐(eg:对齐 512 扇区,则虚拟地址的后 9 位 必须全部是 0)。

代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ./dio.go
package dio

import (
	"errors"
	"unsafe"
)

var (
	// 默认扇区大小为 512
	sectorSize = 512
)

// 调整默认扇区大小
func AlterSectorSize(size int) {
	sectorSize = size
}

func align(buf []byte) int {
	//return int(uintptr(unsafe.Pointer(&buf[0])) % uintptr(sectorSize))
	// 上下结果相同,位运算会快一点
	return int(uintptr(unsafe.Pointer(&buf[0])) & uintptr(sectorSize-1))
}

// NewDioBuf bufSize 必须是 sectorSize 的整数倍
func NewDioBuf(bufSize int) ([]byte, error) {
	if bufSize%sectorSize != 0 {
		panic("缓冲区的大小必须和扇区大小对齐")
	}
	buf := make([]byte, bufSize+sectorSize)
	offset := sectorSize - align(buf)
	if offset != 0 {
		buf = buf[offset : offset+bufSize]
	} else {
		buf = buf[:bufSize]
	}
	// 再判断一次
	if judgeAlign(buf) {
		return buf, nil
	}
	return buf, errors.New("err")
}

func judgeAlign(buf []byte) bool {
	if len(buf)%sectorSize == 0 {
		if align(buf) == 0 {
			return true
		}
	}
	return false
}

测试用例

1
2
# ./test.txt utf8
IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理IO缓存区,这样做的目的是减少一次内核缓冲区到用户程序缓存的数据复制。引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘。而当进程需要向文件写入数据是,实际上只是写到了内核缓冲区便告诉进程已经写成功,而真正写入磁盘是通过一定的策略进行延时的。​ 然而,对于一些较复杂的应用,比如数据库服务器,他们为了充分提高性能。希望绕过内核缓冲区,由自己在用户态空间时间并管理IO缓冲区,包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库可以根据加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存.直接IO的缺点:如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘加载,这种直接加载会非常缓存。通常直接IO与异步IO结合使用,会得到比较好的性能。(异步IO:当访问数据的线程发出请求之后,线程会接着去处理其他事,而不是阻塞等待)。缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令。缓存I/O的优点:首先,在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全;其次,可以减少读盘的次数,从而提高性能。缓存I/O的缺点:在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样,数据在传输过程中需要在应用程序地址空间(用户空间)和缓存(内核空间)之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。

测试代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// ./dio_test.go
package dio

import (
	"fmt"
	"os"
	"testing"
)

func TestNewDioBuf(t *testing.T) {
	_, err := NewDioBuf(4096)
	if err != nil {
		_ = fmt.Errorf("%s", err)
	}
}

// 必须在 linux 环境下测试
func TestDIO(t *testing.T) {
	buf, _ := NewDioBuf(512)
	// syscall.O_DIRECT = 0x4000
	// 第三个参数是权限模式 permission mode
	fp, _ := os.OpenFile("./test.txt", 0x4000|os.O_RDWR, 666)
	defer fp.Close()
	fp.Read(buf)
	fmt.Printf("%s", buf)
	buf[0] = 'O'
	buf[1] = 'I'
	fp.WriteAt(buf, 0)
	fp.ReadAt(buf, 0)
	fmt.Print(" \n 更改后 \n")
	fmt.Printf("%s", buf)
	fp.Read(buf)
	fmt.Print(" \n 下一个 512B \n")
	fmt.Printf("%s", buf)
}

测试结果

1
2
3
4
5
IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理IO缓存区,这样做的目的是减少一次内核缓冲区到用户程序缓存的数据复制。引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘。而当进程需要向文件写入数据是,实际上只是写到了内核缓冲区� 
 更改后 
OI就是应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理IO缓存区,这样做的目的是减少一次内核缓冲区到用户程序缓存的数据复制。引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘。而当进程需要向文件写入数据是,实际上只是写到了内核缓冲区� 
 下一个 512B 
��告诉进程已经写成功,而真正写入磁盘是通过一定的策略进行延时的。​ 然而,对于一些较复杂的应用,比如数据库服务器,他们为了充分提高性能。希望绕过内核缓冲区,由自己在用户态空间时间并管理IO缓冲区,包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库可以根据加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销

tip:因为 test.txt 为 utf8 格式,一个中文可能为 3B 或 4B,很明显上面最后一个中文为 3B,因为分开读了,出现了乱码的情况。所以 DIO 在读取时可能需要增加一些措施来保证读取内容完整。

End

0%