Go 存儲基礎 — 文件 IO 的姿勢

大綱

兩大 IO 分類

我們都知道計算的體系架構,CPU,內存,網絡,IO。那麼 IO 是啥呢?一般理解成 Input、Output 的縮寫,通俗話就是輸入輸出的意思。

IO 分爲網絡和存儲 IO 兩種類型(**其實****網絡 IO 和磁盤 IO 在 Go 裏面有着根本性區別,**以後會就此深入分析)。網絡 IO 對應的是網絡數據傳輸過程,網絡是分佈式系統的基石,通過網絡把離散的物理節點連接起來,形成一個有機的系統。

存儲 IO 對應的就是數據存儲到物理介質的過程,通常我們物理介質對應的是磁盤,磁盤上一般會分個區,然後在上面格式化個文件系統出來,所以我們普通程序員最常看見的是文件 IO 的形式。

在 Golang 裏可以歸類出兩種讀寫文件的方式:

  1. 標準庫封裝:操作對象 File;

  2. 系統調用 :操作對象 fd;

讀寫數據要素

首先我們回憶下,文件的讀寫最核心的要素是什麼?

通俗來講:讀文件,就是把磁盤上的文件的特定位置的數據讀到內存的 buffer 。寫文件,就是把內存 buffer 的數據寫到磁盤的文件的特定位置

這裏注意到兩個關鍵詞:

  1. 特定位置;

  2. 內存 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 從效果上來講等於 SeekRead 組合起來使用,那麼是否可以認爲 Pread 就可以被 Seek + Read 替代呢?

不行!根本原因在於 Seek + Read 是在用戶層就是兩步操作,而 Pread 雖然是 Seek + Read 的效果,但是操作系統給到用戶的語義是:Pread 是一個原子操作。還有一個重要區別,Pread 不會改變當前文件的偏移量(普通的 Read 調用會更新偏移量)。

所以,我們總結下,Pread 和順序調用 Seek 後調用 Read  有兩點重要區別:

  1. Pread 對用戶提供的語義是原子操作,在調用 Pread 時,無法中斷 SeekRead 操作;

  2. Pread 調用不會更新當前文件偏移量;

文件 Write

func Write(fd int, p []byte) (n int, err error)   
func Pwrite(fd int, p []byte, offset int64) (n int, err error)

文件寫對應也是有兩種接口,WrtiePwrite 分別是對應 ReadPread 。同樣的,Pwrite 作用上也是相當於先調用 Seek  再調用 Write ,但是同樣的也有兩點不同

  1. Pwrite  完成 SeekWrite 對外是原子操作的語義;

  2. Pwrite 調用不會更新當前文件偏移量;

文件 Seek

func Seek(fd int, offset int64, whence int) (off int64, err error)

這個函數調用允許用戶指定偏移(這個會影響到 ReadWrite 讀寫的位置)。一般來說,每個打開文件都有一個相關聯的 “當前文件偏移量”( current file offset )。讀(Read)、寫(Write)操作都是從當前文件偏移量處開始,並且 ReadWrite 會導致偏移量增加,增加量就是所讀寫的字節數。

小結一下:我們看了核心的 Open,Read,Write,Seek 幾個系統調用,你會發現一個明顯不同與標準 IO 庫的區別:系統調用操作對象是一個整數句柄Open 文件得到一個整數 fd,之後的所有 IO 都是針對這個 fd 來操作的。這個明顯和標準庫不同,os 標準庫 OpenFile 得到的是一個 File 結構體,所有的 IO 也是針對這個結構體的。

層次架構

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

這張圖就非常形象的講明白了整個 Unix 體系結構。

總結

  1. IO 大類分爲網絡 IO 和磁盤 IO,IO 對文件來說就是讀寫操作,寫的時候數據從內存到磁盤,讀的時候數據從磁盤到內存

  2. Go 文件 IO 最常用的是 os 庫,使用 Go 封裝的標準庫,os.OpenFile 打開,File.WriteFile.Read 進行讀寫,操作對象都是 File 結構體;

  3. Go 標準庫對 IO 的封裝是爲了屏蔽複雜的系統調用,提供跨平臺的使用姿勢。然後單獨提供 syscall 庫,讓程序員自我決策使用要使用更豐富的系統調用功能,當然後果自負;

  4. Go 標準庫 IO 操作對象是 File ,系統調用 IO 操作對象是 fd(非負整數),而這個 fd 則大有來頭,我們後面專門分析

  5. Open 文件默認當前偏移量是 0 (文件最開始),加上 O_APPEND 參數之後偏移量會是文件末尾。通過 Seek 調用可以任意指定文件偏移,從而影響文件 IO 的位置;

  6. ReadWrite 函數只有 buffer (buffer 有長度),偏移則使用當前文件偏移量;

  7. PreadPwrite 的系統調用效果等同於 Seek 偏移量然後 ReadWrite,但是又大有不同。對外語義是原子操作,並且不更新當前文件偏移量;

後記

今天討論的是 Go 的存儲基礎(通用的存儲知識),涉及到一些 IO 基礎,今天梳理了 Go 的兩種 IO 的姿勢,分別是 os 標準庫封裝和 syscall 系統調用。後面會就文件句柄 fd,系統調用等知識深入思考,形成一個存儲系列的文章,帶你逐步揭祕 Go 存儲技術基礎,敬請期待。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kHGxXEz7lahDeXhrDxvmHw