ファイルの読み書きについて
多くのGoエンジニアが、ファイルの読み書き時に以下のように defer 処理でファイルのClose処理を行っていると思いますが、そもそも、
- なぜClose処理をしないといけないのか?
- Close処理しないと何が問題になるのか?
を調査・検証してみました。
読み込み
# 読み込み用ファイル用意
$ echo hello > tmpfile
$ cat tmpfile
hello
func main() {
file, err := os.Open("tmpfile")
if err != nil {
log.Fatal(err)
}
defer file.Close()
buf := make([]byte, 100)
len, err := file.Read(buf)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(buf[:len]))
}
// 出力
// $ go run ./main.go
// hello
書き込み
func main() {
file, err := os.Create("newfile")
if err != nil {
log.Fatal(err)
}
// p.s. 特に書き込み時はエラー処理を握りつぶさないよう無名関数でエラーハンドリングを行う
defer func() {
err := file.Close()
if err != nil {
log.Fatal(err)
}
}()
file.WriteString("hoge fuga")
}
// 出力
// $ cat newfile
// hoge fuga%
結論
-
Close()
はエラーハンドリング、および *File型の後始末をしているだけ - ファイルディスクリプタは、実際に
Open()
やCreate()
を発行した時点で、 「発行したプロセスが終了したらファイルディスクリプタを閉じる」 ように設定済みである - リソースの観点でいうと、Webサーバーのような常駐プロセスでは、ファイルディスクリプタが延々とオープンされていくため、いずれ上限に達し致命的な問題になる。
- 即時終了するようなプロセスであれば、最悪
Close()
を呼び出さなくてもファイルディスクリプタの圧迫にはならない(↑上記で述べたように、プロセス終了時にファイルディスクリプタを閉じるようになっているから)
- 即時終了するようなプロセスであれば、最悪
- セキュリティの観点で言えば、特に書き込み時においては、
Close()
し忘れた場合、意図しない書き込みや不正な書き込みが行われる可能性があるため、書き込み完了後は即座にClose()
を呼び出すべき。
リソースの観点
「ファイルをOpen()
したら必ずClose()
するように。そうしないとメモリ圧迫になるから。」と慣習のようによく言われていると思いますが、本当にメモリ圧迫するのか?Close()
しないとどうなるのか?と疑問に思ったので、実際にあえて Close()
しない処理を行って、その挙動を確認してみました。
func main() {
_, err := os.Open("tmpfile")
if err != nil {
log.Fatal(err)
}
// close処理を行わない
// defer file.Close()
time.Sleep(10 * time.Second)
}
上記のように、ファイルをOpen()
した後にClose()
を行わず、10秒間スリープさせる中で、ファイルディスクリプタはどうなるのか?プロセス終了後も残ったままになってしまうのか?を検証してみました。
# backgroundで起動
$ go run ./main.go &
[1] 69999
# 現在のディレクトリ下でtmpfileを開いているファイルディスクリプタを表示
$ lsof +d ./ | grep tmpfile
main 70020 m.masafumi 3r REG 1,14 6 79489415 ./tmpfile
~ 10秒後 ~
[1] + done go run ./main.go
$ lsof +d ./ | grep tmpfile
# 結果なし
結果は上記の通りとなり、Close()
を呼び出さなくても、プロセス終了後ファイルディスクリプタは閉じられていることが分かります。
理由は、Open()
を呼び出した時点で、内部的に「発行したプロセスが終了したらファイルディスクリプタを閉じる」ように設定されているからです。
この点に関しては分かりやすい資料があったので以下に引用します。
Goから学ぶI/O > Chapter2 ファイルの読み書き
(おまけ)ファイルクローズ
ここまで見てきたファイル操作の裏には、どれもシステムコールがありました。
なので「ファイルのClose()メソッドも、裏ではclose()のシステムコールを呼んでいるんでしょ?」と推測する方もいるかもしれません。
しかし実は、os.File型のClose()メソッドを掘り下げても、closeシステムコールに繋がるsyscall.Closeは出てきません。
これはなぜかというと、ファイルオープンの時点で「ファイルオープンしたプロセスが終了したら、自動的にファイルを閉じてください」というO_CLOEXECフラグを立てているからなのです。
そのため、Close()メソッドがやっているのは
エラー処理
対応するos.File型を使えなくする後始末
という側面が強いです。
常駐プロセスの場合は、f.closeしないとファイルディスクリプタは閉じられない
上記のような、小規模のプログラムでは問題ありませんでしたが、Webサービスなどの常駐プロセスでは、f.closeしない場合ファイルディスクリプタが閉じられません。
理由は、常駐プロセスなので、プロセスの終了がなく、したがって「発行したプロセスが終了したらファイルディスクリプタを閉じる」条件に当てはまらないためです。
例えば、以下のように GET /read
リクエストを受け取った場合、ローカルのtmpfileをOpenするバックエンドサーバーを実装してみます。
package main
import (
"io"
"net/http"
"os"
"time"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/read", func(c echo.Context) error {
// tmpfileファイルを開く
file, err := os.Open("tmpfile")
if err != nil {
return c.String(http.StatusInternalServerError, "ファイルを開けませんでした")
}
// ファイルクローズを忘れている!!
// defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return c.String(http.StatusInternalServerError, "ファイルの読み出しに失敗しました")
}
return c.String(http.StatusOK, string(content))
})
e.Start(":8080")
}
この場合に、 GET /read
リクエストを送ると、以下のようにファイルディスクリプタが閉じられず、常に新しいファイルディスクリプタのOpenが追加されてしまいます。
$ go run ./main.go
$ curl localhost:8080/read
hello
# レスポンス完了後もファイルディスクリプタが閉じられていない
$ lsof +d ./ | grep tmpfile
main 64644 m.masafumi 8r REG 1,18 6 79489415 ./tmpfile
main 64644 m.masafumi 9r REG 1,18 6 79489415 ./tmpfile
※ ↑閉じられなくなった上記ファイルディスクリプタはサーバーを止めれば閉じることができます。(サーバープロセスが終了したため)
セキュリティの観点
特にファイル書き込み時においては、Close()
は重要です。
Close()
処理を行わない場合、書き込み処理を際限なく行うことができてしまいます。
func main() {
file, err := os.Create("newfile")
if err != nil {
log.Fatal(err)
}
file.WriteString("hoge\n")
// close処理を行わない
// defer func() {
// err := file.Close()
// if err != nil {
// log.Fatal(err)
// }
// }()
file.WriteString("この処理は書き込みたくない\n")
}
// 出力
// $ go run ./main.go && cat newfile
// hoge
// この処理は書き込みたくない
従って、書き込みたい内容を全て終えたら、明示的にClose()
を行った方がより安全でしょう。
まとめ
Close()
を行わないとなぜダメなのか?何が問題なのか?という部分を理解することができました。
「ファイルを開いたら閉じる」 というのは直感的にも理解しやすいと思うので、Close()
処理は必ず行っていきたいと思います。
参考
追記
- 2023-09-18 : 常駐プロセスの場合、f.closeしないとファイルディスクリプタが閉じないことを追記。thx!!@ktat