8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

File.Close()しないと何が問題なのか?

Last updated at Posted at 2023-09-09

ファイルの読み書きについて

多くの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
8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?