Chapter 02

ファイルの読み書き

さき(H.Saki)
さき(H.Saki)
2021.04.23に更新

はじめに

Goでファイルの読み書きに関する処理はosパッケージ中に存在するFile型のメソッドで行います。

この章では

  • os.File型って一体何者?
  • 読み書きってどうやってするの?
  • 低レイヤでは何が起こっているの?
    ということについてまとめていきます。

ファイルオブジェクト

osパッケージにはos.File型が存在し、Goでファイルを扱うときはこれが元となります。

type File struct {
	*file // os specific
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/os/types.go#16]

os.File型の実際の実装はos.file型という非公開型で行われており、その内部構造については外から直接見ることができないようになっています。

このように「公開する構造体の中身を隠したい場合に、隠す中身を非公開の構造体型にしてまとめて、公開型構造体に埋め込む」という手段はGoの標準パッケージ内ではよく見られる手法です。

ファイルを開く(open)

読み込み権限onlyで開く

Go言語でファイルを扱い読み書きするためには、まずはそのファイルを"open"して、os.File型を取得しなくてはいけません。

os.File型を得るためには、os.Open(ファイルパス)関数を使います。

f, err := os.Open("text.txt")

得られる第一返り値fos.File型のファイルオブジェクトです。

os.Open()関数について、ドキュメントでは以下のように書かれています。

Open opens the named file for reading. If successful, methods on the returned file can be used for reading; the associated file descriptor has mode O_RDONLY.

(訳) Open関数は、名前付きのファイルを読み込み専用で開きます。Openが成功すれば、返り値として得たファイルオブジェクトのメソッドを中身の読み込みのために使うことができます。Openから得たファイルは、LinuxでいうO_RDONLYフラグがついた状態になっています。

出典:pkg.go.dev - os package

書き込み権限付きで開く

書き込み権限がついた状態のファイルが欲しい場合、os.Create(ファイルパス)関数を使います。

f, err := os.Create("write.txt")

Open()と同様に、これも第一返り値fos.File型のファイルオブジェクトです。

"create"の名前を見ると「ファイルがない状態からの新規作成にしか対応してないのか?」と思う方もいるでしょうが、引数のファイルパスには既に存在しているファイルの名前も指定することができます。今回の場合、write.txtが既に存在してもしなくても、上のコードは正しく動作します。

ドキュメントに記載されているos.Create()の説明は以下のようになっています。

Create creates or truncates the named file. If the file already exists, it is truncated. If the file does not exist, it is created with mode 0666 (before umask). If successful, methods on the returned File can be used for I/O; the associated file descriptor has mode O_RDWR.

(訳)Create()関数は、名前付きファイルを作成するか、中身を空にして開きます。引数として指定したファイルが既に存在している場合、中身を空にして開くほうの動作がなされます。ファイルが存在していなかった場合は、umask 0666のパーミッションでファイルを作成します。Create()が成功すれば、返り値として得たファイルオブジェクトのメソッドをI/Oのために使うことができます。Createから得たファイルは、LinuxでいうO_RDWRフラグがついた状態になっています。

出典:pkg.go.dev - os package

truncateは、直訳が「切り捨てる」という動詞です。Linuxの文脈では、truncateは「ファイルサイズを指定したサイズにする」という意味で使われることが多いです。これには、ファイルサイズを大きくすることも小さくすることも含まれ、例えば10byteのファイルを20byteにする処理も、訳語に反しますが"truncate"です。ファイルサイズが指定されなかった場合、ファイルサイズ0にtruncateされるととられ、今回のCreateの場合はこちらの動作になります。

ファイル内容の読み込み(read)

同じディレクトリ中にあるtext.txtの内容をすべて読み込むという操作を考えます。

Hello, world!
Hello, Golang!

これをGoで行う場合、os.File型のReadメソッドを用いて以下のように実装できます。

// os.FileオブジェクトをOpen関数か何かで事前に得ておくとする
// 変数fがファイルオブジェクトとする

data := make([]byte, 1024)
count, err := f.Read(data)
if err != nil {
    fmt.Println(err)
    fmt.Println("fail to read file")
}

/*
--------------------------------
挙動の確認
--------------------------------
*/

fmt.Printf("read %d bytes:\n", count)
fmt.Println(string(data[:count]))

/*
出力結果

read 28 bytes:
Hello, world!
Hello, Golang!
*/

Read(b []byte)メソッドの引数としてとる[]byteスライスの中に、読み込まれたファイルの内容が格納されます。

また、Read()メソッドの第一返り値(上でのcount変数に値が格納)には、「Read()メソッドを実行した結果、何byteが読み込まれたか」がint型で入っています。
そのため、string(data[:count])とすることで、ファイルから読み込まれた文字列をそのまま得ることができます。

fmt.Println(string(data[:count]))

fmt.Println(data[:count])
のようにprintする内容を変更すると、「文字列」ではなくて「文字列にエンコードする前のバイト列そのまま」が得られるので注意。
(例)
文字列→"Hello, world!\nHello, Golang!"
エンコード前→バイト列[72 101 108 108 111 44 32 119 111 114 108 100 33 10 72 101 108 108 111 44 32 71 111 108 97 110 103 33]

ファイルへの書き込み(write)

ファイルに何かを書き込むときは、os.File型のWrite()メソッドを利用します。

実際にwrite.txtというテキストファイルに文字列を書き込むコードを実装してみます。

// fはos.Create()で得たファイルオブジェクトとします。

str := "write this file by Golang!"
data := []byte(str)
count, err := f.Write(data)
if err != nil {
    fmt.Println(err)
    fmt.Println("fail to write file")
}

/*
--------------------------------
挙動の確認
--------------------------------
*/
fmt.Printf("write %d bytes\n", count)
/*
出力結果
write 26 bytes
*/
write this file by Golang!

Writeメソッドの引数としてとる[]byteスライス(ここでは変数data)に格納されている内容が、ファイルにそのまま書き込まれることになります。
ここでは引数に「文字列write this file by Golang!をバイト列にキャストしたもの」を使っているので、この文字列がそのままwrite.txtに書き込まれます。

また、Writeメソッドの第一返り値には、「メソッド実行の結果ファイルに何byte書き込まれたか」がint型で得られます。

Writeメソッドを使う予定のファイルオブジェクトは、書き込み権限がついたos.Create()から作ったものでなくてはなりません。
os.Open()で開いたファイルは読み込み専用なので、これにWriteメソッドを使うと、以下のようなエラーが出ます。
write write.txt: bad file descriptor

ファイルを閉じる(close)

基本

ファイルを閉じるときはos.FileCloseメソッドを用います。

f, err := os.Open("text.txt")
if err != nil {
    fmt.Println("cannot open the file")
}
defer f.Close()

// 以下read処理等を書く

上のコードでは、Close()メソッドはdeferを使って呼んでいます。
一般的に、ファイルというのは「開いて使わなくなったら必ず閉じるもの」なので、Close()deferでの呼び出し予約と非常に相性がいいメソッドです。

応用

ところで、Closeメソッドの定義をドキュメントで見てみると、以下のようになっています。

func (f *File) Close() error

出典:pkg.go.dev - os#File.Close
このように、実は返り値にエラーがあるのです。

ファイルを開いた後に行う操作が「読み込み」だけの場合、元のファイルはそのままですからClose()に失敗するということはほとんどありません。
そのため、基本の節ではCloseメソッドから返ってくるエラーをさらっと握り潰してしまいました。

しかし、開いた後に行う操作が「書き込み」のような元のファイルに影響を与えるような操作だった場合、その処理が正常終了しないとCloseできない、という状態に陥ることがあります。
そのため、Writeメソッドを使う場合はCloseの返り値エラーをきちんと処理すべきです。

deferを使いつつエラーを扱うためには、以下のように無名関数を使います。

f, err := os.Create("write.txt")
if err != nil {
	fmt.Println("cannot open the file")
}
- defer f.Close()
+ defer func(){
+	err := f.Close()
+	if err != nil {
+		fmt.Println(err)
+	}
+ }

// 以下write処理等を書く

低レイヤで何が起きているのか

Goのコード上でos.Open()だったりf.Read()だったりを「おまじない」のように唱えることで、実際のファイルを扱うことができるのは一体どうなっているのでしょうか。
これをよく知るためには、OSカーネルへと続く低レイヤなところに視点を下ろす必要があります。
本章ではosパッケージのコードを深く掘り下げることでこれを探っていきます。

ファイルオブジェクト

os.File型の中身は以下のようになっています。

type file struct {
	pfd         poll.FD
	name        string
	dirinfo     *dirInfo // nil unless directory being read
	nonblock    bool     // whether we set nonblocking mode
	stdoutOrErr bool     // whether this is stdout or stderr
	appendMode  bool     // whether file is opened for appending
}

(os.File型の中身が、非公開の構造体os.file型であるのは前述した通りです)
出典:[https://go.googlesource.com/go/+/go1.16.3/src/os/file_unix.go#54]

この中で重要なのはpfd[1]フィールドです。

Linuxカーネルプロセス内部では、openしたファイル1つに非負整数識別子1つを対応付けて管理しており、この非負整数のことをfd(ファイルディスクリプタ)と呼んでいます。
pollパッケージのFD型はこのfdをGo言語上で具現化した構造体なのです。

FD is a file descriptor. The net and os packages use this type as a field of a larger type representing a network connection or OS file.
(訳)FD型はファイルディスクリプタです。netosパッケージでは、ネットワークコネクションやファイルを表す構造体の内部フィールドとしてこの型を使用しています。
出典:pkg.go.dev - internal/poll package

FD型の定義は以下のようになっていて、このSysfdというint型のフィールドがfdの数字そのものを表しています。

type FD struct {
    // System file descriptor. Immutable until Close.
    Sysfd int

    // Whether this is a streaming descriptor, as opposed to a
    // packet-based descriptor like a UDP socket. Immutable.
    IsStream bool

    // Whether a zero byte read indicates EOF. This is false for a
    // message based socket connection.
    ZeroReadIsEOF bool

    // contains filtered or unexported fields
}

出典:pkg.go.dev - internal/poll#FD

ちなみにカーネルでは、openしていない全てのファイルに対しても整数の識別子をつけて管理しており、これをinode番号といいます。
fdはそれとは区別された概念で、こちらは「プロセス中でopenしたファイルに対して順番に割り当てられる番号」です。

そのため、同じファイルを開いたらいつもfdが同じ番号になる、という代物ではありません。
あるプログラムでread.txtを開いたらfdが3になったけど、別のときに別のプログラムでread.txtを開いたらfdが4になる、ということは普通に存在します。

ファイルオープン

os.Open()実装の中身をこれからみていきます。

まず、os.Open自体は、同じosパッケージのOpenFile関数を呼んでいるだけです。

func Open(name string) (*File, error) {
	return OpenFile(name, O_RDONLY, 0)
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/os/file.go#310]

ちなみにos.Create()も内部でOpenFileを呼んでいます。ただし、ファイルに書き込み権限をつけるため、関数に渡している引数が違います。

というよりOpenFile関数そのものが「ファイルを特定の権限で開く」ための一般的な操作を規定したもので、os.Openos.Createはこれをユーザーがよく使う引数でラップしただけ、というのが本来の位置付けです。

os.OpenFile関数の中身を見ると、非公開関数openFileNologを呼んでいるのがわかります。

func OpenFile(name string, flag int, perm FileMode) (*File, error) {
	// (略)
	f, err := openFileNolog(name, flag, perm)
	// (略)
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/os/file.go#329]

このopenFileNoLog関数をみると、内部ではsyscall.Open()というsyscallパッケージの関数が呼ばれています。

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
		}
		// (略:EINTRエラーを握り潰す処理)
	}
	// (略)
	return newFile(uintptr(r), name, kindOpenFile), nil
}

出典:[https://go.googlesource.com/go/+/go1.16.2/src/os/file_unix.go#205]

EINTRは、処理中に割り込み信号(ユーザーによるCtrl+Cなど)があったというエラー番号のこと。

openFileNolog関数の返り値とするために、syscall.Openから得られた返り値rをfdとするos.File型を生成しています。
言い換えると、「ファイルのfdを得る」という根本的な操作をしているのはsyscall.Open関数です。

このsyscallパッケージでは、OSカーネルへのシステムコールをGoのソースコードから呼び出すためのインターフェースを定義しています。

Package syscall contains an interface to the low-level operating system primitives.
出典:pkg.go.dev - syscall package

そしてこのsyscall.Open関数は、OSのopenシステムコールを呼び出すためのラッパーなのです。後の処理はカーネルがやってくれます。

Linuxの場合、システムコールopen()は、指定したパスのファイルを指定したアクセスモードで開き、返り値としてfdを返すものです。

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>


int open(const char *pathname, int flags);

この引数flagsに入れられるフラグとしてO_RDONLYO_RDWRがあり、これによってopenしたファイルが読み込み専用になったり、読み書き可能になったりします。

Readメソッド

次に、os.File型のReadメソッドを掘り下げてみましょう。

先述した通り、os.File型の実体は非公開のos.file型です。
そしてこのos.file型のReadメソッドは、非公開メソッドreadメソッドを経由して、その構造体のフィールドの一つpfd(poll.FD型)のReadメソッドを呼んでいます。

// os.file型の公開Readメソッドの中身
func (f *File) Read(b []byte) (n int, err error) {
	// (中略)
	n, e := f.read(b)  // ここで読み込み(非公開readメソッドを呼び出し)
	return n, f.wrapErr("read", e)
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/os/file.go#113]

// os.file型の非公開readメソッドの中身
func (f *File) read(b []byte) (n int, err error) {
    n, err = f.pfd.Read(b) // ここで読み込み
    // (中略)
    return n, err
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/os/file_posix.go#30]

このpoll.FD型のRead()メソッドの内部実装で、ignoringEINTRIO(syscall.Read, fd.Sysfd, p)というコードが存在します。
ここで呼ばれているsyscall.Read関数が、OSカーネルのreadシステムコールのラッパーです。ここでGoと低レイヤとつながるのです。
出典:[https://go.googlesource.com/go/+/go1.16.2/src/internal/poll/fd_unix.go#162]

順番をまとめると、os.File型のReadメソッドは以下のような実装となっています。

  1. os.file型のReadメソッドを呼ぶ
  2. 1の中でos.file型のreadメソッドを呼ぶ
  3. 2の中でpoll.FD型のReadメソッドを呼ぶ
  4. 3の中でsyscall.Readメソッドを呼ぶ
  5. OSカーネルのシステムコールで読み込み処理

Writeメソッド

os.File型のWrite()メソッドのほうもReadメソッドと同様の流れで実装されています。

  1. os.file型のWriteメソッドを呼ぶ
  2. 1の中でos.file型のwriteメソッドを呼ぶ
  3. 2の中でpoll.FD型のWriteメソッドを呼ぶ
  4. 3の中でsyscall.Writeメソッドを呼ぶ
  5. OSカーネルのシステムコールで書き込み処理

(おまけ)ファイルクローズ

ここまで見てきたファイル操作の裏には、どれもシステムコールがありました。
なので「ファイルのClose()メソッドも、裏ではclose()のシステムコールを呼んでいるんでしょ?」と推測する方もいるかもしれません。

しかし実は、os.File型のClose()メソッドを掘り下げても、closeシステムコールに繋がるsyscall.Closeは出てきません。
これはなぜかというと、ファイルオープンの時点で「ファイルオープンしたプロセスが終了したら、自動的にファイルを閉じてください」というO_CLOEXECフラグを立てているからなのです。

// (再掲)
func openFileNolog(name string, flag int, perm FileMode) (*File, error) {
	// (略)
	// 第二引数が「フラグ」
	r, e = syscall.Open(name, flag|syscall.O_CLOEXEC, syscallMode(perm))
	// (略)
}

そのため、Close()メソッドがやっているのは

  • エラー処理
  • 対応するos.File型を使えなくする後始末

という側面が強いです。

まとめ

ここまでは、ファイルの読み書きについて取り上げました。
ただし、「I/O」というのはファイルだけのものではありません。

次章では、「ファイルではないI/O」について扱いたいと思います。

脚注
  1. pfdはおそらくpollパッケージのFD型の略です。 ↩︎