学习一下 IO 的两种方式。
参考资料
block
磁盘一次物理读写的基本单位是扇区(一般 512Byte)。扇区的空间比较小且数目众多,在寻址时比较困难,操作系统的虚拟文件文件系统就将多个扇区组合在一起,形成一个更大的单位,就是块(block)。虚拟文件系统通过块(一般为 4KB)作为读取等操作数据的基本单位(即文件系统读写的最小粒度为块)。
总结
- 扇区是对硬盘而言,是物理层的;块是对虚拟文件系统而言,是逻辑层的。
- 磁盘可以看成是一个由 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。
- 缓存 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