はじめに
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
型という非公開型で行われており、その内部構造については外から直接見ることができないようになっています。
ファイルを開く(open)
読み込み権限onlyで開く
Go言語でファイルを扱い読み書きするためには、まずはそのファイルを"open"して、os.File
型を取得しなくてはいけません。
os.File
型を得るためには、os.Open(ファイルパス)
関数を使います。
f, err := os.Open("text.txt")
得られる第一返り値f
がos.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
フラグがついた状態になっています。
書き込み権限付きで開く
書き込み権限がついた状態のファイルが欲しい場合、os.Create(ファイルパス)
関数を使います。
f, err := os.Create("write.txt")
Open()
と同様に、これも第一返り値f
がos.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
フラグがついた状態になっています。
ファイル内容の読み込み(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])
とすることで、ファイルから読み込まれた文字列をそのまま得ることができます。
ファイルへの書き込み(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
型で得られます。
ファイルを閉じる(close)
基本
ファイルを閉じるときはos.File
型Close
メソッドを用います。
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
型はファイルディスクリプタです。net
やos
パッケージでは、ネットワークコネクションやファイルを表す構造体の内部フィールドとしてこの型を使用しています。
出典: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
ファイルオープン
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.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]
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_RDONLY
やO_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
メソッドは以下のような実装となっています。
-
os.file
型のRead
メソッドを呼ぶ - 1の中で
os.file
型のread
メソッドを呼ぶ - 2の中で
poll.FD
型のRead
メソッドを呼ぶ - 3の中で
syscall.Read
メソッドを呼ぶ - OSカーネルのシステムコールで読み込み処理
Writeメソッド
os.File
型のWrite()
メソッドのほうもRead
メソッドと同様の流れで実装されています。
-
os.file
型のWrite
メソッドを呼ぶ - 1の中で
os.file
型のwrite
メソッドを呼ぶ - 2の中で
poll.FD
型のWrite
メソッドを呼ぶ - 3の中で
syscall.Write
メソッドを呼ぶ - 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」について扱いたいと思います。
-
pfdはおそらくpollパッケージのFD型の略です。 ↩︎