Go 存儲(chǔ)基礎(chǔ) — 文件 IO 的姿勢(shì)

大綱



兩大 IO 分類

我們都知道計(jì)算的體系架構(gòu),CPU,內(nèi)存,網(wǎng)絡(luò),IO。那么 IO 是啥呢?一般理解成 Input、Output 的縮寫,通俗話就是輸入輸出的意思。
IO 分為網(wǎng)絡(luò)和存儲(chǔ) IO 兩種類型(其實(shí)網(wǎng)絡(luò) IO 和磁盤 IO 在 Go 里面有著根本性區(qū)別,以后會(huì)就此深入分析)。網(wǎng)絡(luò) IO 對(duì)應(yīng)的是網(wǎng)絡(luò)數(shù)據(jù)傳輸過程,網(wǎng)絡(luò)是分布式系統(tǒng)的基石,通過網(wǎng)絡(luò)把離散的物理節(jié)點(diǎn)連接起來,形成一個(gè)有機(jī)的系統(tǒng)。
存儲(chǔ) IO 對(duì)應(yīng)的就是數(shù)據(jù)存儲(chǔ)到物理介質(zhì)的過程,通常我們物理介質(zhì)對(duì)應(yīng)的是磁盤,磁盤上一般會(huì)分個(gè)區(qū),然后在上面格式化個(gè)文件系統(tǒng)出來,所以我們普通程序員最??匆姷氖俏募?IO 的形式。
在 Golang 里可以歸類出兩種讀寫文件的方式:
標(biāo)準(zhǔn)庫封裝:操作對(duì)象 File;系統(tǒng)調(diào)用 :操作對(duì)象 fd;

讀寫數(shù)據(jù)要素

首先我們回憶下,文件的讀寫最核心的要素是什么?
通俗來講:讀文件,就是把磁盤上的文件的特定位置的數(shù)據(jù)讀到內(nèi)存的 buffer 。寫文件,就是把內(nèi)存 buffer 的數(shù)據(jù)寫到磁盤的文件的特定位置。
這里注意到兩個(gè)關(guān)鍵詞:
特定位置; 內(nèi)存 buffer;
特定位置怎么理解?怎么指定所謂的特定位置?
很簡(jiǎn)單,用 [ offset, length ] 這兩個(gè)參數(shù)就能標(biāo)識(shí)一段位置。

也就是 IO 偏移和長(zhǎng)度,Offset 和 Length。
內(nèi)存 buffer 怎么理解?
歸根結(jié)底,文件的數(shù)據(jù)和誰直接打交道?內(nèi)存,寫的時(shí)候是從內(nèi)存寫到磁盤文件的,讀的時(shí)候是從磁盤文件讀到內(nèi)存的。
本質(zhì)上,下面的 IO 函數(shù)都離不開 Offset,Length,buffer 這三個(gè)要素。

標(biāo)準(zhǔn)庫封裝

Go 對(duì)文件進(jìn)行讀寫非常簡(jiǎn)單,因?yàn)?Go 已經(jīng)幫我們封裝了一個(gè)非常便捷的使用接口,位于標(biāo)準(zhǔn)庫 os 中。Go 標(biāo)準(zhǔn)庫對(duì)文件 IO 的封裝也就是 Go 推薦我們對(duì)文件進(jìn)行 IO 時(shí)使用的姿勢(shì)。
打開文件(Open)
func OpenFile(name string, flag int, perm FileMode) (*File, error)
Open 文件之后,獲取到一個(gè)句柄,也就是 File 結(jié)構(gòu),之后對(duì)文件的讀寫都是基于 File 結(jié)構(gòu)之上進(jìn)行的。
type File struct {
*file // os specific
}
普通程序員如果不關(guān)系里面的實(shí)現(xiàn),那么只需要知道,之后的文件讀寫只需要針對(duì)這個(gè)句柄結(jié)構(gòu)體做操作即可。
另外有一點(diǎn)隱藏起來的知識(shí)點(diǎn)必須要提一下:偏移。也就是我們最開始強(qiáng)調(diào)的讀寫 3 要素之一的 Offset 。打開(Open)文件的時(shí)候,文件當(dāng)前偏移量默認(rèn)設(shè)置為 0,也就是說 IO 的起始位置就是文件的最開頭。舉個(gè)例子,如果這個(gè)時(shí)候,你寫 4K 的數(shù)據(jù)到文件,那么就是寫 [0, 4K] 這個(gè)位置的數(shù)據(jù),如果之前這上面已經(jīng)有數(shù)據(jù)了,那么就會(huì)是覆蓋寫。
除非你 Open 文件的時(shí)候指定 O_APPEND 選項(xiàng),偏移量會(huì)設(shè)置為文件末尾,那么 IO 都是從文件末尾開始。
文件寫操作(Write)
文件 File 句柄對(duì)象有兩個(gè)寫方法:
第一種:寫一個(gè) buffer 到文件 ,使用文件當(dāng)前偏移
func (f *File) Write(b []byte) (n int, err error)
注意:該寫操作會(huì)導(dǎo)致文件偏移量的增加。
第二種:從指定文件偏移,寫入 buffer 到文件
func (f *File) WriteAt(b []byte, off int64) (n int, err error)
注意:該寫操作不會(huì)更新文件偏移量
文件讀操作(Read)
和寫對(duì)應(yīng),文件 File 句柄對(duì)象有兩個(gè)讀方法:
第一種:從文件當(dāng)前偏移讀一個(gè) buffer 的數(shù)據(jù)上來
func (f *File) Read(b []byte) (n int, err error)
注意:該讀操作會(huì)導(dǎo)致文件偏移量的增加。
第二種:從指定文件偏移,讀一個(gè) buffer 大小的數(shù)據(jù)上來
func (f *File) ReadAt(b []byte, off int64) (n int, err error)
注意:該讀操作不會(huì)更新文件偏移量
指定偏移量(Seek)
func (f *File) Seek(offset int64, whence int) (ret int64, err error)
這個(gè)句柄方法允許用戶指定文件的偏移位置。這個(gè)很容易理解,舉個(gè)例子,文件剛開始是 0 字節(jié),寫 1M 的數(shù)據(jù)下去,大小變成 1M,Offset 往后挪 1M ,默認(rèn)就是往后挪。
現(xiàn)在 Seek 方法允許你把寫的偏移定位到任意位置,可以你就可以從任意地方覆蓋寫入數(shù)據(jù)。
所以在 Go 里面,文件 IO 非常簡(jiǎn)單,先 Open 一個(gè)文件,拿到 File 句柄,然后就可以使用這個(gè)句柄 Write ,Read,Seek 就能進(jìn)行 IO 了。

底層的原理

Go 的標(biāo)準(zhǔn)庫 os 給我們的極其方便的封裝,但是,你不好奇這個(gè) os 的封裝底層的原理嗎?我們深入最原始的本質(zhì),你會(huì)發(fā)現(xiàn)最核心的東西:系統(tǒng)調(diào)用。
Go 標(biāo)準(zhǔn)庫的文件存儲(chǔ) IO 就是基于系統(tǒng)調(diào)用之上的。可以稍微跟一下 os.OpenFile 的調(diào)用:
os 庫的 OpenFile 函數(shù):
func OpenFile(name string, flag int, perm FileMode) (*File, error) {
f, err := openFileNolog(name, flag, perm)
if err != nil {
return nil, err
}
f.appendMode = flag&O_APPEND != 0
return f, nil
}
稍微看下 openFileNolog 函數(shù):
func openFileNolog(name string, flag int, perm FileMode) (*File, error) {
var r int
for {
var e error
r, e = syscall.Open(name, flag|syscall.O_CLOEXEC, syscallMode(perm))
if e == nil {
break
}
if runtime.GOOS == "darwin" && e == syscall.EINTR {
continue
}
return nil, &PathError{"open", name, e}
}
return newFile(uintptr(r), name, kindOpenFile), nil
}
我們看到 syscall.Open ,這個(gè)函數(shù)獲取到一個(gè)整數(shù),也就是我們?cè)谠?c 語言里最常見的 fd 句柄,而 File 結(jié)構(gòu)體則僅僅是基于這個(gè)的一層封裝而已。
思考下,為什么會(huì)有標(biāo)準(zhǔn)庫封裝這一層存在?
劃重點(diǎn):為了屏蔽操作系統(tǒng)的區(qū)別,使用這個(gè)標(biāo)準(zhǔn)庫的所有操作都是跨平臺(tái)的。換句話說,如果是特殊操作系統(tǒng)才有的特性,那么你在 os 庫里就找不到對(duì)應(yīng)封裝的 IO 操作。

那么怎么使用系統(tǒng)調(diào)用?
直接使用 syscall 庫,也就是系統(tǒng)調(diào)用。從名字也能看出來,系統(tǒng)調(diào)用是和操作系統(tǒng)強(qiáng)相關(guān)的,因?yàn)槭遣僮飨到y(tǒng)提供給你的調(diào)用接口,所以系統(tǒng)調(diào)用會(huì)因?yàn)椴僮飨到y(tǒng)不同而導(dǎo)致不同的特性,不同的接口。
所以,如果你直接使用 syscall 庫來使用系統(tǒng)調(diào)用,那么需要你自己來承受系統(tǒng)帶來的兼容性問題。

系統(tǒng)調(diào)用

系統(tǒng)調(diào)用在 syscall 里有一層最薄的封裝:
文件 Open
func Open(path string, mode int, perm uint32) (fd int, err error)
文件 Read
func Read(fd int, p []byte) (n int, err error)
func Pread(fd int, p []byte, offset int64) (n int, err error)
func Pread(fd int, p []byte, offset int64) (n int, err error)
文件讀有兩個(gè)接口,一個(gè) Read 是從當(dāng)前默認(rèn)偏移讀一個(gè) buffer 數(shù)據(jù),Pread 接口則是從指定位置讀數(shù)據(jù)的接口。
思考一個(gè)問題:Pread 從效果上來講等于 Seek 和 Read 組合起來使用,那么是否可以認(rèn)為 Pread 就可以被 Seek + Read 替代呢?
不行!根本原因在于 Seek + Read 是在用戶層就是兩步操作,而 Pread 雖然是 Seek + Read 的效果,但是操作系統(tǒng)給到用戶的語義是:Pread 是一個(gè)原子操作。還有一個(gè)重要區(qū)別,Pread 不會(huì)改變當(dāng)前文件的偏移量(普通的 Read 調(diào)用會(huì)更新偏移量)。
所以,我們總結(jié)下,Pread 和順序調(diào)用 Seek 后調(diào)用 Read 有兩點(diǎn)重要區(qū)別:
Pread對(duì)用戶提供的語義是原子操作,在調(diào)用Pread時(shí),無法中斷Seek和Read操作;Pread調(diào)用不會(huì)更新當(dāng)前文件偏移量;
文件 Write
func Write(fd int, p []byte) (n int, err error)
func Pwrite(fd int, p []byte, offset int64) (n int, err error)
func Pwrite(fd int, p []byte, offset int64) (n int, err error)
文件寫對(duì)應(yīng)也是有兩種接口,Wrtie 和 Pwrite 分別是對(duì)應(yīng) Read 和 Pread 。同樣的,Pwrite 作用上也是相當(dāng)于先調(diào)用 Seek 再調(diào)用 Write ,但是同樣的也有兩點(diǎn)不同:
Pwrite完成Seek和Write對(duì)外是原子操作的語義;Pwrite調(diào)用不會(huì)更新當(dāng)前文件偏移量;
文件 Seek
func Seek(fd int, offset int64, whence int) (off int64, err error)
這個(gè)函數(shù)調(diào)用允許用戶指定偏移(這個(gè)會(huì)影響到 Read 和 Write 讀寫的位置)。一般來說,每個(gè)打開文件都有一個(gè)相關(guān)聯(lián)的“當(dāng)前文件偏移量”( current file offset )。讀(Read)、寫(Write)操作都是從當(dāng)前文件偏移量處開始,并且 Read 和 Write 會(huì)導(dǎo)致偏移量增加,增加量就是所讀寫的字節(jié)數(shù)。
小結(jié)一下:我們看了核心的 Open,Read,Write,Seek 幾個(gè)系統(tǒng)調(diào)用,你會(huì)發(fā)現(xiàn)一個(gè)明顯不同與標(biāo)準(zhǔn) IO 庫的區(qū)別:系統(tǒng)調(diào)用操作對(duì)象是一個(gè)整數(shù)句柄。Open 文件得到一個(gè)整數(shù) fd,之后的所有 IO 都是針對(duì)這個(gè) fd 來操作的。這個(gè)明顯和標(biāo)準(zhǔn)庫不同,os 標(biāo)準(zhǔn)庫 OpenFile 得到的是一個(gè) File 結(jié)構(gòu)體,所有的 IO 也是針對(duì)這個(gè)結(jié)構(gòu)體的。

層次架構(gòu)

那么究竟封裝的層次一般是什么樣的呢?我還記得 Unix 編程里面開篇就有一張如下圖:

這張圖就非常形象的講明白了整個(gè) Unix 體系結(jié)構(gòu)。
內(nèi)核是最核心的實(shí)現(xiàn),包括了和 IO 設(shè)備,硬件交互等功能。與內(nèi)核緊密的一層是內(nèi)核提供給外部調(diào)用的系統(tǒng)調(diào)用,系統(tǒng)調(diào)用提供了用戶態(tài)到內(nèi)核態(tài)調(diào)用的一個(gè)通道;
對(duì)于系統(tǒng)調(diào)用,各個(gè)語言的標(biāo)準(zhǔn)庫會(huì)有一些封裝,比如 C 語言的 libc 庫,Go 語言的 os ,syscall 庫都是類似的地位,這個(gè)就是所謂的公共庫。這層封裝的作用最主要是簡(jiǎn)化普通程序員使用效率,并且屏蔽系統(tǒng)細(xì)節(jié),為跨平臺(tái)提供基礎(chǔ)(同樣的,為了跨平臺(tái)的特性,可能會(huì)閹割很多不兼容的功能,所以才會(huì)有直接調(diào)用系統(tǒng)掉調(diào)用的需求);
當(dāng)然,我們右上角還看到一個(gè)缺口,應(yīng)用程序除了可以使用公共函數(shù)庫,其實(shí)是可以直接調(diào)用系統(tǒng)調(diào)用的,但是由此帶來的復(fù)雜性又應(yīng)用自己承擔(dān)。這種需求也是很常見的,標(biāo)準(zhǔn)庫封裝了通用的東西,同樣割舍了很多系統(tǒng)調(diào)用的功能,這種情況下,只能通過系統(tǒng)調(diào)用來獲??;

總結(jié)

IO 大類分為網(wǎng)絡(luò) IO 和磁盤 IO,IO 對(duì)文件來說就是讀寫操作,寫的時(shí)候數(shù)據(jù)從內(nèi)存到磁盤,讀的時(shí)候數(shù)據(jù)從磁盤到內(nèi)存; Go 文件 IO 最常用的是 os 庫,使用 Go 封裝的標(biāo)準(zhǔn)庫, os.OpenFile打開,File.Write,File.Read進(jìn)行讀寫,操作對(duì)象都是File結(jié)構(gòu)體;Go 標(biāo)準(zhǔn)庫對(duì) IO 的封裝是為了屏蔽復(fù)雜的系統(tǒng)調(diào)用,提供跨平臺(tái)的使用姿勢(shì)。然后單獨(dú)提供 syscall庫,讓程序員自我決策使用要使用更豐富的系統(tǒng)調(diào)用功能,當(dāng)然后果自負(fù);Go 標(biāo)準(zhǔn)庫 IO 操作對(duì)象是 File,系統(tǒng)調(diào)用 IO 操作對(duì)象是 fd(非負(fù)整數(shù)),而這個(gè) fd 則大有來頭,我們后面專門分析;Open文件默認(rèn)當(dāng)前偏移量是 0 (文件最開始),加上O_APPEND參數(shù)之后偏移量會(huì)是文件末尾。通過 Seek 調(diào)用可以任意指定文件偏移,從而影響文件 IO 的位置;Read,Write函數(shù)只有 buffer (buffer 有長(zhǎng)度),偏移則使用當(dāng)前文件偏移量;Pread,Pwrite的系統(tǒng)調(diào)用效果等同于Seek偏移量然后Read,Write,但是又大有不同。對(duì)外語義是原子操作,并且不更新當(dāng)前文件偏移量;

后記

今天討論的是 Go 的存儲(chǔ)基礎(chǔ)(通用的存儲(chǔ)知識(shí)),涉及到一些 IO 基礎(chǔ),今天梳理了 Go 的兩種 IO 的姿勢(shì),分別是 os 標(biāo)準(zhǔn)庫封裝和 syscall 系統(tǒng)調(diào)用。后面會(huì)就文件句柄 fd,系統(tǒng)調(diào)用等知識(shí)深入思考,形成一個(gè)存儲(chǔ)系列的文章,帶你逐步揭秘 Go 存儲(chǔ)技術(shù)基礎(chǔ),敬請(qǐng)期待。
推薦閱讀
