このページの本文へ

Goならわかるシステムプログラミング 第10回

ファイルシステムと、その上のGo言語の関数たち(1)

2017年01月25日 16時00分更新

文● 渋川よしき、編集● 鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

コンピュータにはさまざまなストレージが接続されています。 ハードディスクやSSD、取り外し可能なSDカード、読み込み専用のDVD-ROMやBlu-Ray、書き込み可能なDVD-RWなど、種類を網羅するのが困難なほどです。

種類はいろいろありますが、どのストレージも、基本的にはビットの羅列を保存できるだけです。 そこで、そのストレージスペースを、特定の決まったルールで管理するための仕組みが必要になります。

たとえば、自分のローカルフォルダにあるテキストファイルをエディタで開き、編集して書き込みたいとします。 ストレージのどこかにテキストファイルの内容を表すビット列があるはずですが、その実体のある場所を、ファイル名から探し出せる必要があります。 また、そこから内容を読み込んだり、新しい内容を上書きすることが、アプリケーションから不自由なく実現できなければなりません。 そのためにOSに備わっている仕組みが ファイルシステム です。

現在のOSには、マルチスレッドや複雑な権限管理、ネットワークなど、さまざまな機能が備わっていますが、それらの機能を必ずしもすべてのOSが最初から備えていたわけではありません。 しかしファイルシステムは、太古の時代から、ほとんどのOSで必ずと言っていいほど提供されてきた機能です。

今回は、まずファイルシステムとは何なのかをざっくりと話してから、その上に実装されているファイル操作のためのGo言語の基本関数を一気に説明していきます。 なお、OS内部に関する説明では基本的にLinuxを前提にしています。

ファイルシステムの基礎

ストレージに対するファイルシステムでは、まず、ストレージの全領域を512バイト〜4キロバイトの固定長のデータ(セクタ)の配列として扱います。 そして、そこにファイルの中身だけを格納していくのではなく、ファイルの管理情報を格納する領域も用意しておきます。 この管理情報は、現在のLinuxでは、 inode と呼ばれます (WindowsではNTFS、macOSではHFS+というファイルシステムが使われていますが、それぞれマスターファイルテーブルおよびカタログノードという、inodeの代わりになる仕組みがあります)。

inodeに格納されるファイルの管理情報には、実際のファイルの中身の物理的な配置情報も含まれます。 また、inodeにはユニークな識別番号がついています。 その識別番号がわかればinodeにアクセスでき、inodeにアクセスできれば実際のファイルの配置場所がわかり、その中身にアクセスできる、という仕掛けです。

私たちがふだん見ている、ディレクトリで整理されたファイル構造は、この仕掛けを使って実現されています。 ディレクトリというのは、実をいうと、配下に含まれるファイル名とそのinodeのインデックスの一覧表が格納されている特別なファイルです。 そしてルートディレクトリは、かならず決まった番号(2番)のinodeに格納されています。 起点となるinodeの番号が2番とわかっているので、そのinodeにアクセスでき、そこで示されている実際のファイルはルートディレクトリなので、そこから配下の各ディレクトリやファイルのinodeのインデックスがわかり、 ……という具合に、ファイル構造とストレージのセクタとが対応づけできるわけです。

複雑なファイルシステムとVFS

先の説明だと、ストレージ上にファイルシステムが一つしかないように読めるかもしれませんが、実際のストレージはもっと複雑に入り組んでいます。 ファイルシステムに他のファイルシステムをぶら下げたり(マウント)、仮想的なファイルシステムがあったりします。 仮想的なファイルシステムは、物理的なストレージと対応するものではありません。 仮想的なファイルシステムとしては、たとえば/proc以下の情報があります。 /proc以下は、各プロセスの詳細情報がファイルとして見られるように、カーネルが動的に作り出したファイルシステムになっています。

また最近では、ジャーナリングファイルシステムといって、 書き込み中に瞬断が発生してもストレージの管理領域と実際の内容に不整合が起きにくくする仕組みも当たり前のように利用されています。 さらに、近年ではDockerなどのコンテナがよく使われていますが、 こうしたコンテナではファイルシステムの一部を切り出して、特定のプロセスに対して、あたかもそれがファイルシステム全体であるかのように見せかける仕掛けがあります。 具体的には、chrootというシステムコールを使い、擬似的なミニOS環境を作り出してサービスを隔離する方法が使われることがあります。

これらのさまざまなファイルシステムは、LinuxではVFS(Virtual File System)というAPIで、すべて統一的に扱えるようになっています。 そのため、対象のファイルシステムが何であれ、システムコール上は違いを気にする必要はありません。 VFSの下では、デバイスドライバが抽象的な操作を各ファイルシステム用の操作に翻訳し、実際の読み書きを行います。

ファイル・ディレクトリを扱うGo言語の関数たち

ファイルシステムの話はこれくらいにして、Go言語でファイルやディレクトリを扱う方法を見ていきましょう。 ファイルシステム内部は高いパフォーマンスと柔軟性を両立するために複雑な仕組みになっていますが、アプリケーションからはVFSだけしか見えないためとてもシンプルに見えます。 今回の記事ではosパッケージの基本的な機能だけを紹介します。

ファイル作成・読み込み

Go言語でファイルを新しく作成するにはos.Create()を使います。 また、すでに存在しているファイルはos.Open()関数で開くことができます。

これらの関数は、構造体のos.Fileを返します。 このos.Fileは、io.Writerインタフェースとio.Readerインタフェースを実装しているので、 連載の第2回から第4回で紹介した方法を使ってデータを読み書きできます。 復習になりますが、それぞれの使い方は次のとおりです。

package main
 
import (
    "fmt"
    "io"
    "os"
)
 
// 新規作成
func open() {
    file, err := os.Create("textfile.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    io.WriteString(file, "New file content\n")
}
 
// 読み込み
func read() {
    file, err := os.Open("textfile.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    fmt.Println("Read file:")
    io.Copy(os.Stdout, file)
}
 
func main() {
    open()
    read()
}

なお、os.Create()os.Open()os.OpenFile()をラップする便利関数だと紹介しましたが (第3回「io.Reader前編」を参照)、 このos.OpenFile()を直接使えばファイルを追記モードで開くこともできます(ファイルを追記モードで開く便利関数は標準では提供されていません)。

// 追記モード
func append() {
    file, err := os.OpenFile("textfile.txt", os.O_RDWR|os.O_APPEND, 0666)
    if err != nil {
        panic(err)
    }
    defer file.Close()
    io.WriteString(file, "Appended content\n")
}
 
func main() {
    append()
}

ディレクトリの作成

ディレクトリの作成にはos.Mkdir()os.MkdirAll()を使います。

// フォルダを1階層だけ作成
os.Mkdir("setting", 0644)
 
// 深いフォルダを一回で作成
os.MkdirAll("setting/myapp/networksettings", 0644)

ファイルの削除・移動・リネーム

ファイルや子の要素を持たないディレクトリの削除にはos.Remove()を使います。 C言語レベルのシステムコールでは、ファイルの削除(unlink())とディレクトリの削除(rmdir())とが別の機能になっています。 しかしGo言語のos.Remove()は、先にファイルの削除を行い、失敗したらディレクトリの削除を呼び出します。 したがって、対象がどちらであっても削除可能です。

対象がディレクトリで、その子供のファイルも含めてすべて再帰的に削除するときは、os.RemoveAll()を使います。

// ファイルや空のディレクトリの削除
os.Remove("server.log")
 
// ディレクトリを中身ごと削除
os.RemoveAll("workdir")

特定の長さでファイルを切り落とすos.Truncate()という関数もあります。 os.FileオブジェクトのTruncate()メソッドを使うこともできます。

// 先頭100バイトで切る
os.Truncate("server.log", 100)
 
// Truncateメソッドを利用する場合
file, _ := os.Open("server.log")
file.Truncate(100)

ファイル名を移動・リネームするにはos.Rename()を使います。 シェルのmvコマンドでは移動先がディレクトリの場合には同じファイル名でディレクトリだけを移動できますが、os.Rename()では移動先のファイル名まで指定する必要があります。 (ドキュメントには、OSによってはリネーム先が同じディレクトリでないとダメという制約について触れられていますが、デスクトップOS(Windows、Linux、macOS)であれば問題ありません。)

Windowsの場合、os.Rename()は同一ドライブ内の移動にしか使えません。 これは、利用しているMoveFileEx()というWin32 APIで別ドライブへのコピーを許容するオプションが設定されていないためです。

// リネーム
os.Rename("old_name.txt", "new_name.txt")
 
// 移動
os.Rename("olddir/file.txt", "newdir/file.txt")
 
// 移動先はディレクトリではダメ
os.Rename("olddir/file.txt", "newdir/") // エラー発生!

POSIX系OSであっても、マウントされていて、元のデバイスが異なる場合には rename システムコールでの移動はできません。 下記のエラーメッセージは、macOSで tmpfs というオンメモリの一時ファイルシステム(昔の人はRAMディスクと呼んでいました)を作って os.Rename() を実行したときに返されるエラーです。

err := os.Rename("sample.rst", "/tmp/sample.rst")
if err != nil {
    panic(err)
    // ここが実行され、コンソールに次のエラーが表示される
    // rename sample.rst /tmp/sample.rst: cross-device link
}

デバイスやドライブが異なる場合にはファイルを開いてコピーする必要があります。 FreeBSDの mv コマンドも、最初に rename システムコールを試してみて(参照)、失敗したら入出力先のファイルを開いてコピーし、その後にソースファイルを消しています。

oldFile, err := os.Open("old_name.txt")
if err != nil {
    panic(err)
}
newFile, err := os.Create("/other_device/new_file.txt")
if err != nil {
    panic(err)
}
defer newFile.Close()
_, err = io.Copy(newFile, oldFile)
if err != nil {
    panic(err)
}
oldFile.Close()
os.Remove("old_name.txt")

ファイルの属性の取得

ファイルの属性は、os.Stat()と、os.LStat()で取得できます。 これらは対象がシンボリックリンクだった場合の挙動が異なります。 os.Stat()は、指定したパスがシンボリックリンクだった場合に、そのリンク先の情報を取得します。 os.LStat()は、そのシンボリックリンクの情報を取得します。

すでにos.Fileを取得しているときは、このインスタンスのStat()メソッドでも属性を取得できます。

これらの返り値であるos.FileInfo構造体からは、次の情報が取得できます。

メソッド 情報
Name() string ディレクトリ部を含まないファイルの名前
Size() int64 ファイルサイズ
Mode() FileModeuint32のエイリアス) ファイルのモード(0777など)
ModTime() time.Time 変更日時
IsDir() bool ディレクトリかどうかのフラグ

実際にどうやって使うか、サンプルを紹介しましょう。 次のコードは、os.FileInfoos.FileModeの各メソッドを実行してファイルの情報をコンソールに出力するものです。

package main
 
import (
    "fmt"
    "os"
)
 
func main() {
    if len(os.Args) == 1 {
        fmt.Printf("%s [exec file name]", os.Args[0])
        os.Exit(1)
    }
    info, err := os.Stat(os.Args[1])
    if err == os.ErrNotExist {
        fmt.Printf("file not found: %s\n", os.Args[1])
    } else if err != nil {
        panic(err)
    }
    fmt.Println("FileInfo")
    fmt.Printf("  ファイル名: %v\n", info.Name())
    fmt.Printf("  サイズ: %v\n", info.Size())
    fmt.Printf("  変更日時 %v\n", info.ModTime())
    fmt.Println("Mode()")
    fmt.Printf("  ディレクトリ? %v\n", info.Mode().IsDir())
    fmt.Printf("  読み書き可能な通常ファイル? %v\n", info.Mode().IsRegular())
    fmt.Printf("  Unixのファイルアクセス権限ビット %o\n", info.Mode().Perm())
    fmt.Printf("  モードのテキスト表現 %v\n", info.Mode().String())
}
$ fileinfo move.go ⏎
FileInfo
  ファイル名: move.go
  サイズ: 129
  変更日時 2017-01-15 10:45:33 +0900 JST
Mode()
  ディレクトリ? false
  読み書き可能な通常ファイル? true
  Unixのファイルアクセス権限ビット 644
  モードのテキスト表現 -rw-r--r--

FileModeタイプ

Go言語特有の機能について少し補足します。

FileModeは、実体は32ビットの非負の整数ですが、メソッドがいくつか使えます。 このようにエイリアス型であってもメソッドが追加できるのは、Go言語の特殊なオブジェクト指向の機能です。

なお、FileModeには対応する定数がいろいろ定義されていて、より詳細なファイルタイプをビット演算を使って取得できます。 詳しくはドキュメントを参照してください。

ファイルの存在チェック

os.Stat()は、ファイルの存在チェックでもイディオムとしてよく使われます(この方法でしか存在チェックができないわけではありません)。

info, err := os.Stat(ファイルパス)
if err == os.ErrNotExist {
    // ファイルが存在しない
} else if err != nil {
    // それ以外のエラー
} else {
    // 正常ケース
}

存在チェックそのもののシステムコールは提供されていません。 Pythonの os.path.exists() も内部で同じシステムコールを呼ぶ os.stat() を使っていますし、C言語でも stat() や、 access() を代わりに使います。 access() は現在のプロセスの権限でアクセスできるかどうかを診断するシステムコールで、Go言語は syscall.Access() としてPOSIX系OSで使えます。

ただし、存在チェックそのものが不要であるというのが現在主流のようです。 詳しい説明が Node.jsのドキュメント に書かれています。

仮に存在チェックを行ってファイルがあることを確認しても、その後のファイル操作までの間に他のプロセスやスレッドがファイルを消してしまうことも考えられます。 ファイル操作関数を直接使い、エラーを正しく扱うコードを書くことが推奨されています。

OS固有のファイル属性を取得する

ファイル属性にはOS固有のものもあります。それらを取得するにはos.FileInfo.Sys()を使います。 os.FileInfo.Sys()は、ドキュメントにも「interface{}を返す」としか書かれておらず、使い方に関する情報がいっさいない機能です。 基本的には下記のようにOS固有の構造体にキャストして利用します。

// Windows
internalStat := info.Sys().(syscall.Win32FileAttributeData)
 
// Windows以外
internalStat := info.Sys().(*syscall.Stat_t)

Go言語のランタイムの挙動としては、まずこのOS固有の構造体を取得し、共通情報をos.FileInfoに転記する処理になっています。 os.FileInfoに転記されないOS固有のデータとしては下記のようなものがあります。

   
意味 Windows Linux macOS
デバイス番号 Dev Dev
inode番号 Ino Ino
ブロックサイズ Blksize Blksize
ブロック数 Blocks Blocks
リンクされている数 NLink NLink
ファイル作成日時 CreatinTime Birthtimespec
最終アクセス日時 LastAccessTime Atim Atimespec
属性変更日時 Ctim Ctimespec

この表はWindows(64ビット)、Linux(64ビット)、macOS(64ビット)の情報から作りました。 その他のOSやプロセッサの実装の詳細はGo言語のソースに含まれるsyscall/ztypes_(OS)_(プロセッサ).goというファイルを参照してください。

この表を見ると、Windowsで取得できる属性が極端に少ないのですが、これは内部ロジックの違いでOSから渡ってくる情報があまりSys()に残されていないためです。 Windows用の\ os.Stat()実装内部で使っているsyscall.GetFileInformationByHandle()を使うと、デバイス番号にあたるVolumeSerialNumberInoにあたるFileIndexHighFileIndexLow、リンク数を表すNumberOfLinksが得られます。

Linuxでファイル作成日時が取得できないのは、Rubyのissueの小崎さんのコメントから推察するに、 カーネル内に情報は持っているものの、それを取り出す口がないので、アプリケーションからは使用できないようですね。

ファイルの属性の設定

属性の取得と比べると、設定に関する機能はシンプルです。 モード変更とオーナー変更はos.Fileの同名のメソッドでも行えます。

// ファイルのモードを変更
os.Chmod("setting.txt", 0644)
 
// ファイルのオーナーを変更
os.Chown("setting.txt", os.Getuid(), os.Getgid())
 
// ファイルの最終アクセス日時と変更日時を変更
os.Chtimes("setting.txt", time.Now(), time.Now())

リンク

ハードリンク、シンボリックリンクの作成もGo言語から行えます。

// ハードリンク
os.Link("oldfile.txt", "newfile.txt")
 
// シンボリックリンク
os.Symlink("oldfile.txt", "newfile-symlink.txt")
 
// シンボリックリンクのリンク先を取得
link, err := os.ReadLink("newfile-sysmlink.txt")

WindowsではPOSIXのように気軽にリンクを使えないイメージがありますが、ハードリンクもシンボリックリンクもVista以降では問題なく使用できます (内部ではCreateHardLinkWCreateSymbolicLinkWを呼び出して作成します)。 なお、Windowsでシンボリックリンクを作成するにはSeCreateSymbolicLinkPrivilege権限が必要です。

ディレクトリ情報の取得

ディレクトリ一覧の取得はosパッケージ直下の関数としては提供されていません。 ディレクトリをos.Open()で開き、os.Fileのメソッドを使って、ディレクトリ内のファイル一覧を取得します。

package main
 
import (
    "fmt"
    "os"
)
 
func main() {
    dir, err := os.Open("/")
    if err != nil {
        panic(err)
    }
    fileInfos, err := dir.Readdir(-1)
    if err != nil {
        panic(err)
    }
    for _, fileInfo := range fileInfos {
        if fileInfo.IsDir() {
            fmt.Printf("[Dir]  %s\n", fileInfo.Name())
        } else {
            fmt.Printf("[File] %s\n", fileInfo.Name())
        }
    }
}

Readdir()メソッドはos.FileInfoの配列を返します。 ファイル名しか必要がないときはReaddirnames()メソッドを使えます。 このメソッドは文字列の配列を返します。

Readdir()Readdirnames()は数値を引数に取ります。正の整数を与えると、最大でその個数の要素だけを返します。 0以下の数値を渡すと、ディレクトリ内の全要素を返します。

OS内部におけるファイル操作の高速化

最後に、OSの内部で行われるファイル操作の高速化についても触れておきます。 通常のアプリケーションでは意識する必要はまずありませんが、データベース管理システムを実装するようなケースでは気にする必要があるかもしれません。

CPUにとってディスクの読み書きはとても遅い処理であり、なるべく最後までやらないようにしたいタスクです。 そこでLinuxでは、VFSの内部に設けられているバッファを利用することで、ディスクに対する操作をなるべく回避しています。

Linuxでファイルを読み書きする場合には、まずバッファにデータが格納されます。 そのため、ファイルへデータを書き込むと、バッファに蓄えられた時点でアプリケーションに処理が返ります。 ファイルからデータを読み込むときも、いったんバッファに蓄えられますし、すでにバッファに乗っており、そのファイルに対する書き込みが行われていない(バッファが新鮮)ならバッファだけにしかアクセスしません。 したがって、アプリケーションによるファイル入出力は、実際にはLinuxが用意したバッファとの入出力になります。 バッファと、実際のストレージとの同期は、アプリケーションが知らないところで非同期で行われるわけです。

Go言語で、ストレージへの書き込みを確実に保証したい場合は、os.FileSync()メソッドを呼びます。

file.Sync()

参考までに、最近のコンピュータで利用されているさまざまなキャッシュとそのレイテンシの関係を紹介します。 1 この表からわかるように、すでにメインメモリのバッファに載っているデータなら200サイクルで取得できます。 しかし、実際のストレージからウェブブラウザのキャッシュを取得してくるとしたら、その5万倍も遅い数値になってしまいます。

種類 何をキャッシュするか どこにキャッシュするか レイテンシ(サイクル数) 誰が制御するか
CPUレジスタ 4バイト/8バイトのデータ CPU内蔵のレジスタ 0 コンパイラ
L1キャッシュ 64バイトブロック CPU内蔵のL1キャッシュ 4 CPU
L2キャッシュ 64バイトブロック CPU内蔵のL2キャッシュ 10 CPU
L3キャッシュ 64バイトブロック CPU内蔵のL3キャッシュ 50 CPU
仮想メモリ 4KBのページ(と呼ばれるメモリブロック) メインメモリ 200 CPU+OS
バッファキャッシュ ファイルの一部 メインメモリ 200 OS
ディスクキャッシュ ディスクのセクター ディスクコントローラ 100,000 コントローラファームウェア
ブラウザキャッシュ ウェブページ ローカルディスク 10,000,000 ウェブブラウザ

ちなみに、Linuxにおけるファイルアクセス高速化の仕組みはキャッシュ以外にもあります。 たとえば、ストレージがハードディスクの場合にはヘッドを動かす時間を節約するため、中心からの距離が近いファイル単位で操作をまとめて、ディスク入出力の効率を上げる処理(エレベータ処理)を行います。 ただし、エレベータ処理には、操作をまとめるための待ち時間もかかります。 ストレージがSSDの場合は、そのような待ち処理が逆にオーバーヘッドになってしまうため、エレベータ処理を使わない設定のほうがスループットが向上します。

まとめと次回予告

OSが内部で行っているファイルシステムの管理はとても高度なものです。 オペレーティングシステムやLinuxカーネル関連の書籍は何冊もでていますので、より詳細に知りたい方はそちらを参照するとよいでしょう。 また、現在発売中の技術評論社のSoftware Design誌の第二特集が青田 直大氏によるLinuxのファイルシステムの紹介になっています。 こちらでは書籍化もされていないような新しいファイルシステムや、ジャーナリングの仕組みも詳しく解説されています。

アプリケーションのほうは、OSが水面下で努力しているおかげで、極めてシンプルな関数でファイル操作を行えます。 少し細かい操作をしようとすると、OSごとの差を気にしなければならない場面もありますが、多少面倒とはいえ決して難しくありません。 GitHubなどを探してみれば、OSごとの差を吸収するコードが出てくることもあります。

次回は、OSパッケージだけでは実現できない、少し高度なファイル操作を紹介します。

脚注

  1. Computer Systems: A programmer's perspective 3rd edition (Pearson Education Limited) ISBN 978-1-292-10176-7

カテゴリートップへ

この連載の記事
ピックアップ