コンピュータにはさまざまなストレージが接続されています。 ハードディスクや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() |
FileMode (uint32 のエイリアス) |
ファイルのモード(0777 など) |
ModTime() |
time.Time |
変更日時 |
IsDir() |
bool |
ディレクトリかどうかのフラグ |
実際にどうやって使うか、サンプルを紹介しましょう。 次のコードは、os.FileInfo
とos.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()
を使うと、デバイス番号にあたるVolumeSerialNumber
、Ino
にあたるFileIndexHigh
、FileIndexLow
、リンク数を表す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以降では問題なく使用できます (内部ではCreateHardLinkWやCreateSymbolicLinkWを呼び出して作成します)。 なお、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.File
のSync()
メソッドを呼びます。
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パッケージだけでは実現できない、少し高度なファイル操作を紹介します。
脚注
- Computer Systems: A programmer's perspective 3rd edition (Pearson Education Limited) ISBN 978-1-292-10176-7↩
この連載の記事
-
第20回
プログラミング+
Go言語とコンテナ -
第19回
プログラミング+
Go言語のメモリ管理 -
第18回
プログラミング+
Go言語と並列処理(3) -
第17回
プログラミング+
Go言語と並列処理(2) -
第16回
プログラミング+
Go言語と並列処理 -
第15回
プログラミング+
Go言語で知るプロセス(3) -
第14回
プログラミング+
Go言語で知るプロセス(2) -
第13回
プログラミング+
Go言語で知るプロセス(1) -
第12回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(3) -
第11回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(2) - この連載の一覧へ