Go 存儲基礎 — 文件 IO 的姿勢
大綱
兩大 IO 分類
我們都知道計算的體系架構,CPU,內存,網絡,IO。那麼 IO 是啥呢?一般理解成 Input、Output 的縮寫,通俗話就是輸入輸出的意思。
IO 分爲網絡和存儲 IO 兩種類型(**其實****網絡 IO 和磁盤 IO 在 Go 裏面有着根本性區別,**以後會就此深入分析)。網絡 IO 對應的是網絡數據傳輸過程,網絡是分佈式系統的基石,通過網絡把離散的物理節點連接起來,形成一個有機的系統。
存儲 IO 對應的就是數據存儲到物理介質的過程,通常我們物理介質對應的是磁盤,磁盤上一般會分個區,然後在上面格式化個文件系統出來,所以我們普通程序員最常看見的是文件 IO 的形式。
在 Golang 裏可以歸類出兩種讀寫文件的方式:
-
標準庫封裝:操作對象
File
; -
系統調用 :操作對象
fd
;
讀寫數據要素
首先我們回憶下,文件的讀寫最核心的要素是什麼?
通俗來講:讀文件,就是把磁盤上的文件的特定位置的數據讀到內存的 buffer 。寫文件,就是把內存 buffer 的數據寫到磁盤的文件的特定位置。
這裏注意到兩個關鍵詞:
-
特定位置;
-
內存 buffer;
特定位置怎麼理解?怎麼指定所謂的特定位置
?
很簡單,用 [ offset, length ]
這兩個參數就能標識一段位置。
也就是 IO 偏移和長度,Offset 和 Length。
內存 buffer 怎麼理解?
歸根結底,文件的數據和誰直接打交道?內存,寫的時候是從內存寫到磁盤文件的,讀的時候是從磁盤文件讀到內存的。
本質上,下面的 IO 函數都離不開 Offset,Length,buffer 這三個要素。
標準庫封裝
Go 對文件進行讀寫非常簡單,因爲 Go 已經幫我們封裝了一個非常便捷的使用接口,位於標準庫 os 中。Go 標準庫對文件 IO 的封裝也就是 Go 推薦我們對文件進行 IO 時使用的姿勢。
打開文件(Open)
func OpenFile(name string, flag int, perm FileMode) (*File, error)
Open 文件之後,獲取到一個句柄,也就是 File
結構,之後對文件的讀寫都是基於 File
結構之上進行的。
type File struct {
*file // os specific
}
普通程序員如果不關係裏面的實現,那麼只需要知道,之後的文件讀寫只需要針對這個句柄結構體做操作即可。
另外有一點隱藏起來的知識點必須要提一下:偏移。也就是我們最開始強調的讀寫 3 要素之一的 Offset 。打開(Open
)文件的時候,文件當前偏移量默認設置爲 0,也就是說 IO 的起始位置就是文件的最開頭。舉個例子,如果這個時候,你寫 4K 的數據到文件,那麼就是寫 [0, 4K] 這個位置的數據,如果之前這上面已經有數據了,那麼就會是覆蓋寫。
除非你 Open
文件的時候指定 O_APPEND
選項,偏移量會設置爲文件末尾,那麼 IO 都是從文件末尾開始。
文件寫操作(Write)
文件 File
句柄對象有兩個寫方法:
第一種:寫一個 buffer 到文件 ,使用文件當前偏移
func (f *File) Write(b []byte) (n int, err error)
注意:該寫操作會導致文件偏移量的增加。
第二種:從指定文件偏移,寫入 buffer 到文件
func (f *File) WriteAt(b []byte, off int64) (n int, err error)
注意:該寫操作不會更新文件偏移量
文件讀操作(Read)
和寫對應,文件 File
句柄對象有兩個讀方法:
第一種:從文件當前偏移讀一個 buffer 的數據上來
func (f *File) Read(b []byte) (n int, err error)
注意:該讀操作會導致文件偏移量的增加。
第二種:從指定文件偏移,讀一個 buffer 大小的數據上來
func (f *File) ReadAt(b []byte, off int64) (n int, err error)
注意:該讀操作不會更新文件偏移量
指定偏移量(Seek)
func (f *File) Seek(offset int64, whence int) (ret int64, err error)
這個句柄方法允許用戶指定文件的偏移位置。這個很容易理解,舉個例子,文件剛開始是 0 字節,寫 1M 的數據下去,大小變成 1M,Offset 往後挪 1M ,默認就是往後挪。
現在 Seek 方法允許你把寫的偏移定位到任意位置,可以你就可以從任意地方覆蓋寫入數據。
所以在 Go 裏面,文件 IO 非常簡單,先 Open 一個文件,拿到 File
句柄,然後就可以使用這個句柄 Write ,Read,Seek 就能進行 IO 了。
底層的原理
Go 的標準庫 os
給我們的極其方便的封裝,但是,你不好奇這個 os
的封裝底層的原理嗎?我們深入最原始的本質,你會發現最核心的東西:系統調用。
Go 標準庫的文件存儲 IO 就是基於系統調用之上的。可以稍微跟一下 os.OpenFile
的調用:
os 庫的 OpenFile
函數:
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
函數:
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
,這個函數獲取到一個整數,也就是我們在在 c 語言裏最常見的 fd 句柄,而 File
結構體則僅僅是基於這個的一層封裝而已。
思考下,爲什麼會有標準庫封裝這一層存在?
劃重點:爲了屏蔽操作系統的區別,使用這個標準庫的所有操作都是跨平臺的。換句話說,如果是特殊操作系統纔有的特性,那麼你在 os 庫裏就找不到對應封裝的 IO 操作。
那麼怎麼使用系統調用?
直接使用 syscall 庫,也就是系統調用。從名字也能看出來,系統調用是和操作系統強相關的,因爲是操作系統提供給你的調用接口,所以系統調用會因爲操作系統不同而導致不同的特性,不同的接口。
所以,如果你直接使用 syscall 庫來使用系統調用,那麼需要你自己來承受系統帶來的兼容性問題。
系統調用
系統調用在 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)
文件讀有兩個接口,一個 Read
是從當前默認偏移讀一個 buffer 數據,Pread
接口則是從指定位置讀數據的接口。
思考一個問題:Pread
從效果上來講等於 Seek
和 Read
組合起來使用,那麼是否可以認爲 Pread
就可以被 Seek
+ Read
替代呢?
不行!根本原因在於 Seek
+ Read
是在用戶層就是兩步操作,而 Pread
雖然是 Seek
+ Read
的效果,但是操作系統給到用戶的語義是:Pread
是一個原子操作。還有一個重要區別,Pread
不會改變當前文件的偏移量(普通的 Read
調用會更新偏移量)。
所以,我們總結下,Pread
和順序調用 Seek
後調用 Read
有兩點重要區別:
-
Pread
對用戶提供的語義是原子操作,在調用Pread
時,無法中斷Seek
和Read
操作; -
Pread
調用不會更新當前文件偏移量;
文件 Write
func Write(fd int, p []byte) (n int, err error)
func Pwrite(fd int, p []byte, offset int64) (n int, err error)
文件寫對應也是有兩種接口,Wrtie
和 Pwrite
分別是對應 Read
和 Pread
。同樣的,Pwrite
作用上也是相當於先調用 Seek
再調用 Write
,但是同樣的也有兩點不同:
-
Pwrite
完成Seek
和Write
對外是原子操作的語義; -
Pwrite
調用不會更新當前文件偏移量;
文件 Seek
func Seek(fd int, offset int64, whence int) (off int64, err error)
這個函數調用允許用戶指定偏移(這個會影響到 Read
和 Write
讀寫的位置)。一般來說,每個打開文件都有一個相關聯的 “當前文件偏移量”( current file offset )。讀(Read
)、寫(Write
)操作都是從當前文件偏移量處開始,並且 Read
和 Write
會導致偏移量增加,增加量就是所讀寫的字節數。
小結一下:我們看了核心的 Open,Read,Write,Seek 幾個系統調用,你會發現一個明顯不同與標準 IO 庫的區別:系統調用操作對象是一個整數句柄。Open
文件得到一個整數 fd,之後的所有 IO 都是針對這個 fd 來操作的。這個明顯和標準庫不同,os 標準庫 OpenFile 得到的是一個 File
結構體,所有的 IO 也是針對這個結構體的。
層次架構
那麼究竟封裝的層次一般是什麼樣的呢?我還記得 Unix 編程裏面開篇就有一張如下圖:
這張圖就非常形象的講明白了整個 Unix 體系結構。
-
內核是最核心的實現,包括了和 IO 設備,硬件交互等功能。與內核緊密的一層是內核提供給外部調用的系統調用,系統調用提供了用戶態到內核態調用的一個通道;
-
對於系統調用,各個語言的標準庫會有一些封裝,比如 C 語言的 libc 庫,Go 語言的 os ,syscall 庫都是類似的地位,這個就是所謂的公共庫。這層封裝的作用最主要是簡化普通程序員使用效率,並且屏蔽系統細節,爲跨平臺提供基礎(同樣的,爲了跨平臺的特性,可能會閹割很多不兼容的功能,所以纔會有直接調用系統掉調用的需求);
-
當然,我們右上角還看到一個缺口,應用程序除了可以使用公共函數庫,其實是可以直接調用系統調用的,但是由此帶來的複雜性又應用自己承擔。這種需求也是很常見的,標準庫封裝了通用的東西,同樣割捨了很多系統調用的功能,這種情況下,只能通過系統調用來獲取;
總結
-
IO 大類分爲網絡 IO 和磁盤 IO,IO 對文件來說就是讀寫操作,寫的時候數據從內存到磁盤,讀的時候數據從磁盤到內存;
-
Go 文件 IO 最常用的是 os 庫,使用 Go 封裝的標準庫,
os.OpenFile
打開,File.Write
,File.Read
進行讀寫,操作對象都是File
結構體; -
Go 標準庫對 IO 的封裝是爲了屏蔽複雜的系統調用,提供跨平臺的使用姿勢。然後單獨提供
syscall
庫,讓程序員自我決策使用要使用更豐富的系統調用功能,當然後果自負; -
Go 標準庫 IO 操作對象是
File
,系統調用 IO 操作對象是 fd(非負整數),而這個 fd 則大有來頭,我們後面專門分析; -
Open
文件默認當前偏移量是 0 (文件最開始),加上O_APPEND
參數之後偏移量會是文件末尾。通過 Seek 調用可以任意指定文件偏移,從而影響文件 IO 的位置; -
Read
,Write
函數只有 buffer (buffer 有長度),偏移則使用當前文件偏移量; -
Pread
,Pwrite
的系統調用效果等同於Seek
偏移量然後Read
,Write
,但是又大有不同。對外語義是原子操作,並且不更新當前文件偏移量;
後記
今天討論的是 Go 的存儲基礎(通用的存儲知識),涉及到一些 IO 基礎,今天梳理了 Go 的兩種 IO 的姿勢,分別是 os 標準庫封裝和 syscall 系統調用。後面會就文件句柄 fd,系統調用等知識深入思考,形成一個存儲系列的文章,帶你逐步揭祕 Go 存儲技術基礎,敬請期待。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/kHGxXEz7lahDeXhrDxvmHw