Open11

「Go から学ぶ I/O」をやる

nukopynukopy

ファイルの読み書き

ファイルオブジェクト os.File

Go でファイルを扱うときは os.File 型を使用する。

  • src/os/types.go: os.File
type File struct {
	*file // os specific
}

os.File 構造体に埋め込まれた file は OS ごとに実行が異なる。手元の環境では、UNIX 系の OS のため、その実装は以下のようになっている。

  • src/os/file_unix.go: 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
}
  • src/internal/poll/fd_unix.go: poll.FD
// 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.
type FD struct {
	// Lock sysfd and serialize access to Read and Write methods.
	fdmu fdMutex

	// System file descriptor. Immutable until Close.
	Sysfd int

	// I/O poller.
	pd pollDesc

	// Writev cache.
	iovecs *[]syscall.Iovec

	// Semaphore signaled when file is closed.
	csema uint32

	// Non-zero if this file has been set to blocking mode.
	isBlocking uint32

	// 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

	// Whether this is a file rather than a network socket.
	isFile bool
}

ファイルディスクリプタ

Notion の方にまとめる

nukopynukopy

ファイルを読み込んで標準出力する

  • test.txt
Hello, world!
Hello, Golang!
  • main.go
package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	// open file
	f, err := os.Open("./test.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	// read file
	buf := make([]byte, 1024)
	count, err := f.Read(buf) // reader to buf
	if err != nil {
		log.Fatal(err)
	}

	// output
	str := string(buf[:count])
	fmt.Printf("read %d bytes: %q\n", count, buf[:count])
	fmt.Println(str)

	/*
	read 28 bytes: "Hello, world!\nHello, Golang!"
	Hello, world!
	Hello, Golang!
	*/
}
nukopynukopy

ファイルへの書き込み

  • main.go
main.go
package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	// create file
	f, err := os.Create("./file_io_write.txt")
	// f, err := os.Open("./file_io_write.txt")
	/* os.Open だと書き込み権限がないため、以下のようなランタイムエラーが発生する
	error: write ./file_io_write.txt: bad file descriptor
	*/
	if err != nil {
		fmt.Println(err)
		return
	}
	// defer f.Close()
	defer func() {
		err := f.Close()
		if err != nil {
			fmt.Println(err)
		}
	}()

	// write file
	str := "Write this file by Golang!\nHello, world!\nHello, Golang!"
	data := []byte(str)
	count, err := f.Write(data) // buf to writer
	if err != nil {
		fmt.Println(err)
		return
	}

	// output
	fmt.Printf("write %d bytes\n", count)

	/* 出力結果
	write 55 bytes
	*/
}

ポイント:f.Close() のエラーハンドリングを行う

https://zenn.dev/hsaki/books/golang-io-package/viewer/file#応用

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

func (f *File) Close() error

このように、実は返り値にエラーがある。

ファイルを開いた後に行う操作が「読み込み」だけの場合、元のファイルはそのまま(ファイルへの書き込みが起きない)ため、f.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処理等を書く
nukopynukopy

補足:f.Close の中身

src/os/file_posix.go: File.Close

  • f.Close の処理の実装は f.file.close() にある
// Close closes the File, rendering it unusable for I/O.
// On files that support SetDeadline, any pending I/O operations will
// be canceled and return immediately with an ErrClosed error.
// Close will return an error if it has already been called.
func (f *File) Close() error {
	if f == nil {
		return ErrInvalid
	}
	return f.file.close()
}

コードのコメントの日本語訳

Close はファイルを閉じ、I/O に対して使用できなくする。
SetDeadlineをサポートするファイルでは、保留中のI/O操作はキャンセルされ、すぐにErrClosedエラーで返される。
Closeは、すでに呼び出されている場合にエラーを返す。

src/os/file_unix.go: file.close(OS specific)

func (file *file) close() error {
        // file が nil ならエラーを返す
	if file == nil {
		return syscall.EINVAL // src/syscall/zerrors_darwin_arm64.go で定数で定義されている
	}

        // ディレクトリ情報のクローズ(ファイルがディレクトリの場合の特別な処理)
	if file.dirinfo != nil {
		file.dirinfo.close()
		file.dirinfo = nil
	}

        // !!!!!ここがファイルを閉じるメインの処理!!!!!
        // ファイルディスクリプタのクローズ
	var err error
	if e := file.pfd.Close(); e != nil {
		if e == poll.ErrFileClosing {
			e = ErrClosed
		}
		err = &PathError{Op: "close", Path: file.name, Err: e}
	}

	// no need for a finalizer anymore
	runtime.SetFinalizer(file, nil)
	return err
}

src/internal/poll/fd_unix.go: FD.Close

// Close closes the FD. The underlying file descriptor is closed by the
// destroy method when there are no remaining references.
func (fd *FD) Close() error {
	if !fd.fdmu.increfAndClose() {
		return errClosing(fd.isFile)
	}

	// Unblock any I/O.  Once it all unblocks and returns,
	// so that it cannot be referring to fd.sysfd anymore,
	// the final decref will close fd.sysfd. This should happen
	// fairly quickly, since all the I/O is non-blocking, and any
	// attempts to block in the pollDesc will return errClosing(fd.isFile).
	fd.pd.evict()

	// The call to decref will call destroy if there are no other
	// references.
	err := fd.decref()

	// Wait until the descriptor is closed. If this was the only
	// reference, it is already closed. Only wait if the file has
	// not been set to blocking mode, as otherwise any current I/O
	// may be blocking, and that would block the Close.
	// No need for an atomic read of isBlocking, increfAndClose means
	// we have exclusive access to fd.
	if fd.isBlocking == 0 {
		runtime_Semacquire(&fd.csema)
	}

	return err
}
nukopynukopy

Q&A

defer f.Close() はなぜ必要?go run main.go で起動したプロセスが停止すれば自動で閉じられるのではないか?

ChatGPT

defer file.Close()を使用する理由は、プログラムの終了時に自動的にファイルが閉じられることを期待するのではなく、ファイルが正確に閉じられることを保証するためです。

確かに、プロセスが終了すると、オペレーティングシステムによって開いているファイルは自動的に閉じられることが多いです。しかし、プログラム内で明示的にファイルを閉じることには以下のような利点があります。

  1. リソースの節約: オペレーティングシステムのリソース(ファイルディスクリプタなど)は限られているため、不要なファイルが開いたままになっていると、それらのリソースが枯渇する可能性があります。ファイルをすぐに閉じることで、これらのリソースを解放し、他の部分で使用できるようにします。
  2. 予測可能な動作: プログラムがファイルを開いたままにしていると、そのファイルに対する他の操作(他のプロセスでの書き込みなど)が影響を受ける可能性があります。ファイルを明示的に閉じることで、そのような不確実性を排除できます。
  3. コードの明確さ: defer file.Close()を使用することで、ファイルのライフサイクルがコード内で明確に示され、そのスコープがはっきりします。この明確さは、コードの読みやすさと保守性を向上させる助けとなります。

最後に、defer文を使用すると、ファイルがどのパスで閉じられるべきかを心配せずに、エラーが発生した場合でも正しく閉じられるようにできます。この方法は、Goのエラー処理のパターンとして一般的です。

こっちの方が良さそう

https://zenn.dev/hsaki/books/golang-io-package/viewer/file#(おまけ)ファイルクローズ

ここまで見てきたファイル操作の裏には、どれもシステムコールがありました。
なので「ファイルの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型を使えなくする後始末
    という側面が強いです。
nukopynukopy

OS は、プロセスとファイルディスクリプタの紐付けはどうやって保持しているのか?

オペレーティングシステム(OS)において、プロセスとファイルディスクリプタの紐付けは、カーネルレベルで管理される複雑な仕組みによって行われます。以下は、この紐付けがどのように行われるかの一般的な説明です。

  1. ファイルディスクリプタテーブル: オペレーティングシステムは、各プロセスに対してファイルディスクリプタテーブルを保持します。このテーブルは、プロセスによって開かれたファイル、ソケット、パイプなどのリソースへの参照を格納します。各エントリ(ファイルディスクリプタテーブルの各レコード)は一意の非負整数値(=ファイルディスクリプタ)に関連付けられ、アプリケーションプログラムからこれらのリソースへアクセスする際に使用されます。
  2. ファイルハンドルとオブジェクト: ファイルディスクリプタテーブルの各エントリは、カーネル内で管理されるファイルハンドルやオブジェクトへの参照を保持します。これらのオブジェクトには、ファイルのパス、アクセス権限、現在のオフセット位置、他のメタデータなど、ファイル操作に必要な情報が格納されます。
  3. マルチプレクシング: 一般に、複数のプロセスが同じファイルにアクセスすることがあります。OSは、これらの共有リソースへのアクセスを効率的に管理するため、参照カウンティングやキャッシングなどの仕組みを使用します。
  4. セキュリティとアクセス制御: OSは、プロセスのユーザーID、グループID、アクセス権限などを考慮して、ファイルへのアクセスを制御します。適切な権限がないプロセスは、特定のファイル操作を行うことができません。

これらの仕組みは、UNIX、Linux、Windowsなど、様々なオペレーティングシステムで共通ですが、内部の実装詳細はOSによって異なることがあります。

なるほど、プロセスごとにファイルディスクリプタを管理するテーブルが作成されるのか。おそらく以下の記事で書いてある per-process file-system table ってやつだね。

https://kotaroito.hatenablog.com/entry/20120108/1326030769

エントリにファイルハンドルとかオブジェクトへの参照(ポインタ)が含まれるのね。使いたいときはファイルディスクリプタでファイルディスクリプタテーブルへ問い合わせをして、情報を取得する。

あるプロセスでファイルを開き、そのファイルを再度そのプロセスが参照するときの処理のステップを詳細に教えて。

プロセスがあるファイルを開いた際、オペレーティングシステムはそのプロセス専用のファイルディスクリプタテーブルにエントリを作成します。このエントリには、ファイルに対する操作を行うために必要な情報が格納されます。プロセスがそのファイルを再度参照したい場合は、ファイルディスクリプタの値を使用してアクセスします。以下は、このプロセスの詳細です:

  1. ファイルオープン: プロセスがファイルを開くと、OSはそのプロセスのファイルディスクリプタテーブルに新しいエントリを作成します。このエントリは、ファイルに対する操作(読み取り、書き込みなど)を行うためのハンドルや情報を保持します。
  2. ファイルディスクリプタの割り当て: オペレーティングシステムは、新しく作成したエントリに対して一意の整数値(ファイルディスクリプタ)を割り当てます。この値はプロセスに返され、プロセスはその値を使用してファイルに対する以降の操作を行います。
  3. ファイルへの再アクセス: プロセスが同じファイルに対して再度操作を行いたい場合、先に割り当てられたファイルディスクリプタの値を使用します。この値を引数としてシステムコール(例:read、writeなど)に渡すことで、そのファイルに対する操作が行われます。
  4. 紐付けの管理: プロセスとファイルディスクリプタの紐付けは、OSのカーネル内でプロセスごとに管理されるファイルディスクリプタテーブルを通して実現されます。プロセスが終了するか、ファイルが閉じられると、そのエントリはテーブルから削除されます。

この仕組みによって、プロセスは開いたファイルに対して効率的にアクセスでき、オペレーティングシステムはプロセス間でのリソースの隔離と管理を実現します。

なる

  1. ファイルへの再アクセス: プロセスが同じファイルに対して再度操作を行いたい場合、先に割り当てられたファイルディスクリプタの値を使用します。この値を引数としてシステムコール(例:read、writeなど)に渡すことで、そのファイルに対する操作が行われます。

ここ、Go のファイル操作で、os.File.file.pfd.Read() を実行するときにシステムコールにファイルディスクリプタ渡してるの見れるね。最後 ignoringEINTRIO でラップされてるから見づらいけど、形としては syscall.Read(fd.Sysfd, p) になってて、システムコールにファイルディスクリプタ渡してる。

// src/os/types.go: os.File
type File struct {
	*file // os specific
}

// src/os/file_unix.go: 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
}

// src/internal/poll/fd_unix.go: poll.FD
// 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.
type FD struct {
	// Lock sysfd and serialize access to Read and Write methods.
	fdmu fdMutex

	// System file descriptor. Immutable until Close.
	Sysfd int

	// I/O poller.
	pd pollDesc

	// Writev cache.
	iovecs *[]syscall.Iovec

	// Semaphore signaled when file is closed.
	csema uint32

	// Non-zero if this file has been set to blocking mode.
	isBlocking uint32

	// 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

	// Whether this is a file rather than a network socket.
	isFile bool
}

// src/internal/poll/fd_unix.go: FD.Read
// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
	if err := fd.readLock(); err != nil {
		return 0, err
	}
	defer fd.readUnlock()
	if len(p) == 0 {
		// If the caller wanted a zero byte read, return immediately
		// without trying (but after acquiring the readLock).
		// Otherwise syscall.Read returns 0, nil which looks like
		// io.EOF.
		// TODO(bradfitz): make it wait for readability? (Issue 15735)
		return 0, nil
	}
	if err := fd.pd.prepareRead(fd.isFile); err != nil {
		return 0, err
	}
	if fd.IsStream && len(p) > maxRW {
		p = p[:maxRW]
	}
	for {
		n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
		if err != nil {
			n = 0
			if err == syscall.EAGAIN && fd.pd.pollable() {
				if err = fd.pd.waitRead(fd.isFile); err == nil {
					continue
				}
			}
		}
		err = fd.eofError(n, err)
		return n, err
	}
}

// src/internal/poll/fd_unix.go: ignoringEINTRIO
// ignoringEINTRIO is like ignoringEINTR, but just for IO calls.
func ignoringEINTRIO(fn func(fd int, p []byte) (int, error), fd int, p []byte) (int, error) {
	for {
		n, err := fn(fd, p)
		if err != syscall.EINTR {
			return n, err
		}
	}
}
nukopynukopy

ファイルディスクリプタの衝突は起きないの?また、1 つのプロセスで最大何エントリまでファイルディスクリプタテーブルにエントリを保持できるの?

  1. ファイルディスクリプタの衝突: ファイルディスクリプタはプロセスごとに管理されるため、異なるプロセス間での衝突は発生しません。同一プロセス内でも、OSがファイルディスクリプタを管理するため、通常は衝突が発生しないようになっています。ファイルディスクリプタは通常、使用可能な最小の整数値が自動的に割り当てられるので、既存のディスクリプタと衝突することはありません。
  2. 最大エントリの制限: オペレーティングシステムは、プロセスごとに開けるファイルの最大数を制限しています。この制限はulimitなどのコマンドで確認・設定することができます。この限界に達すると、プロセスは新しいファイルを開くことができず、エラーが発生します。この制限は、システムのリソースを保護し、一部のプロセスが他のプロセスに対して不当なリソースを消費するのを防ぐために存在します。

したがって、ファイルディスクリプタの衝突は通常は発生せず、プロセスが多くのファイルを同時に開くこともできますが、OSの設定による最大数の制限があります。この制限に達すると、新しいファイルのオープンはエラーとなります。

ファイルディスクリプタは整数値なので負の数が割り当てられることもある?

ファイルディスクリプタは非負の整数値で、通常は0以上の値が割り当てられます。Unix系のオペレーティングシステムでは、ファイルディスクリプタ0, 1, 2はそれぞれ標準入力、標準出力、標準エラーに通常割り当てられています。それ以上のファイルディスクリプタは、新しく開かれる各ファイルに対して割り当てられる値です。

したがって、ファイルディスクリプタとして負の数が割り当てられることは通常ありません。もし関連するシステムコールがエラーを返す場合、エラー情報を示すために負の値が関数の戻り値として使用されることはありますが、これはファイルディスクリプタとして割り当てられる値ではなく、エラー状態を示すための値です。

nukopynukopy

ネットワーク I/O

Notion の方でまとめる。サンプル実装は以下のリポジトリの network_io ディレクトリを参照。動かし方はリポジトリの README の「ネットワーク I/O」を参照。

https://github.com/nukopy/learn-io-from-golang

nukopynukopy

buffered I/O

bufio.Reader

  • src/bufio/bufio.go: bufio.Reader
// Reader implements buffering for an io.Reader object.
type Reader struct {
	buf          []byte
	rd           io.Reader // reader provided by the client
	r, w         int       // buf read and write positions
	err          error
	lastByte     int // last byte read for UnreadByte; -1 means invalid
	lastRuneSize int // size of last rune read for UnreadRune; -1 means invalid
}
  • src/bufio/bufio.go: bufio.Reader.Read
// Read reads data into p.
// It returns the number of bytes read into p.
// The bytes are taken from at most one Read on the underlying Reader,
// hence n may be less than len(p).
// To read exactly len(p) bytes, use io.ReadFull(b, p).
// If the underlying Reader can return a non-zero count with io.EOF,
// then this Read method can do so as well; see the [io.Reader] docs.
func (b *Reader) Read(p []byte) (n int, err error) {
	n = len(p)
	if n == 0 {
		if b.Buffered() > 0 {
			return 0, nil
		}
		return 0, b.readErr()
	}
	if b.r == b.w {
		if b.err != nil {
			return 0, b.readErr()
		}
		if len(p) >= len(b.buf) {
			// Large read, empty buffer.
			// Read directly into p to avoid copy.
			n, b.err = b.rd.Read(p)
			if n < 0 {
				panic(errNegativeRead)
			}
			if n > 0 {
				b.lastByte = int(p[n-1])
				b.lastRuneSize = -1
			}
			return n, b.readErr()
		}
		// One read.
		// Do not use b.fill, which will loop.
		b.r = 0
		b.w = 0
		n, b.err = b.rd.Read(b.buf)
		if n < 0 {
			panic(errNegativeRead)
		}
		if n == 0 {
			return 0, b.readErr()
		}
		b.w += n
	}

	// copy as much as we can
	// Note: if the slice panics here, it is probably because
	// the underlying reader returned a bad count. See issue 49795.
	n = copy(p, b.buf[b.r:b.w])
	b.r += n
	b.lastByte = int(b.buf[b.r-1])
	b.lastRuneSize = -1
	return n, nil
}