04 Feb 2021

net.Conn, net/http と io, bufio の整理

net.Connnet/http の Server.Serve あたりを利用する際、理解が甘い部分があり、あらためて整理したメモ。

net.Conn

  • net.Conn はストリーム指向のネットワーク接続
    • 今回の用途では TCP 接続を現している
    • io.Reader をはじめ io.Writerio.Closer を満たしている
  • net/http の Server.Serve は HTTP サーバ実装の入り口
    • net.Listener (listen の結果返される tcp 接続の構造体) を受け取り、goroutine を起動し、リクエストをパース、Handler を呼び出すなどする
  • リクエストのパースは readRequest というプライベートメソッドが担当する
    • net.Conn から Read して (こちらなど)、それをパースしている
  • net/http では net.Conn に bufio をかませている
    • 生の Read 呼び出しよりも便利な bufio.ReadLine などを利用している
  • net.Conn.Read は最終的に read(2) を呼んでいる

ここまでで、io と bufio の関係や、システムコールレベルでの tcp 接続の read(2) の挙動などの理解が曖昧だった、というのが今回の背景。

io

https://pkg.go.dev/io

  • IO に関する基本的なインタフェースを提供しているパッケージ
    • Package io provides basic interfaces to I/O primitives
  • io.Reader.Read
    • 指定したバッファにデータを読み取って入れる
    • 読み取れたデータの長さが指定したバッファの長さより短くでも、残りのバッファ領域が内部的に利用される可能性がある
    • まだ読み取れるデータがあり、かつ読み取り済みデータ量がバッファ以下の場合。通常は残りのデータを待つのではなく、利用可能になった分だけを呼び出し元へ返す
    • ファイル末尾 (EOF) に達したときの挙動は、実装によってバリエーションがある
      • 読み込めたバイト数 n と err == EOF を同時に返す
      • err == nil を返し次の Read 呼び出しで n == 0 && err == EOF を返す
      • どちらのパターンでも、その後の Read 呼び出しでは必ず n == 0 && err == EOF になることが保証されている
    • 呼び出し元は err の内容に関わらず、読み取れた n byte を必ず処理しないといけない
    • Read の実装側は n == 0 && err == nil を返さないようにする必要がある。呼び出し側はそのようなケースでは何も起こっていない (EOF でもない) とみなすべき
  • io.Writer.Write
    • 書き込めたバイト数 n が指定よりも少ない場合、必ず err != nil
  • いろいろな関数
    • io.Copy
      • EOF まで読み込んで書き込む
      • 正常終了時に EOF を err として返さない (err == nil を返す)
      • 内部でバッファ用にメモリを確保する。それを避けたければ CopyBuffer を使えば良い
    • io.Pipe
      • つながっている reader, writer のセットを作る
      • バッファはなく、並列に読み書きしても内部で直列化
    • io.ReadFull
      • 指定したバイト数の読み取り
      • EOF が返されるのは読み込めるデータが無いとき
      • データを読み取れたが指定したバイト数に満たずに EOF となった場合は ErrUnexpectedEOF
      • 指定したバイト数読み取れた場合は err == nil

io.EOF

bufio

  • 読み書きのバッファリング機能を提供 + いくつかのユーティリティ
    • NewReader, NewWriter で io.Reader, io.Writer にバッファリング機能を追加できる
  • バッファリングすると?
    • 書き込み
      • 一定のデータを内部で貯めてからまとめて書き込みを行う
      • 書きこみする先のもの (デバイスやネットワーク等) に細かく何度も IO することを防ぐことができる
        • 極端に言うと、そうでない場合 (たいした量や回数の読み書きをしない場合) はバッファリングし無くてもシンプルでいいのかも?
    • 読み込み
      • 一定のデータを内部に貯めて、そこで充足するなら読み出し要求にはバッファから返す
      • 小さく頻度の多い Read 要求が多い場合、バッファを導入したほうが実 IO 数を減らせる
      • 加えてバッファに一度貯めることで ReadLine や Scanner 系など、アプリケーションからのデータ読み取りに便利なツールも載せられている
    • Linux の標準入出力ライブラリのバッファリング機能を参考にすると、
      • APUE の 5.4 より引用

標準入出力ライブラリでバッファリングする目標は、readとwriteの呼び出し回数を最小にすることです。(図3.6では、さまざまなバッファサイズを用いた入出力動作に必要なCPU時間を示した。)さらに、各入出力ストリームのバッファリングを自動化し、アプリケーションで気にする必要がないようにします。残念ながら、もっとも混乱しやすい標準入出力ライブラリの一面がバッファリングです。

  • 例えば Reader は、ラップ対象の io.Reader (underlying Reader) とバッファ ([]byte)、あとはバッファ読み取り位置のカーソルなどを保持している
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
}
  • bufio.Reader.Read
  • Scanner
    • 改行区切りのテキストなど、特定のデリミタ (SplitFunc) ごとにデータを読み取るための便利なインタフェース
    • なんとなくユーザーからすると、bufio というパッケージ名なのでこの機能は別パッケージなっていたほうがわかりやすい気がした
      • けれども bufio の上に Scanner が乗っているし、他に良い置き場所もなさそう、独立させるには分量が少なそうなので、今が最適解という気もする

tcp と read(2)

APUEUNIXネットワークプログラミング を読み返した。

  • APUE 16.2 ソケット記述子 より

ソケットSOCK_STREAMはバイトストリームサービスを提供します。アプリケーションにはメッセージ境界は分りません。つまり、ソケットSOCK_STREAMからデータを読み取ると、送り手が書き出したバイト数と同じ数を返さないこともあります。最終的には送られたものをすべて受け取りますが、それには数回の関数呼び出しが必要になるでしょう。

  • man socket(2) より
    • 第二引数でタイプ指定 (SOCK_STREAMSOCK_DGRAM など)
  • tcp はバイトストリーム指向のプロトコル
    • つまり接続している間は区切り無くデータを read できる
    • 接続がクローズされているときに read できたバイト数が 0 になる
      • このケースを Go が io.EOF として扱う
    • tcp より上位レイヤーのプロトコル目線で、アプリケーションが気にするべきこと
      • 一度の read 呼び出しでは、アプリケーションが必要とするデータをすべて読み取れないことがある。これが正常であると認識する必要がある
      • よってループで複数回 read を呼び出すような実装が必要
        • 当たり前だけど buf より長いメッセージが来たら複数回の read 呼び出しが必要
      • データの「区切り」は自分で処理する必要がある
        • HTTP/1x ならテキストとして改行区切りで扱うなど
  • なお udp はデータグラム通信
    • バイトストリームに対して、固定最大長、接続という概念がない、非信頼性の通信といった違いがある

ここまでの実験

delve, lsof, strace (dtruss), gops で Go のプロセスを解析する - Please Sleep とほぼ同じだが、自分の理解のため改めて確認した。

  • 以下のサンプルコードの動作を追ってみる
    • 2000 番ポートで tcp 接続を待ち受け
    • 接続確立したら read
    • strace でシステムコールの状況確認
    • 今回は ubuntu 16.04 でやってみた
vagrant@ubuntu-xenial:~$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.7 LTS"
// main.go
package main

import (
	"log"
	"net"
)

func main() {
	l, err := net.Listen("tcp", ":2000")
	if err != nil {
		log.Fatal(err)
	}
	defer l.Close()

	for {
		conn, err := l.Accept()
		if err != nil {
			log.Fatal(err)
		}
		defer conn.Close()
		go func(c net.Conn) {
			for {
				buf := make([]byte, 100)
				n, err := c.Read(buf)
				if err != nil {
					log.Println(n, "read error", err)
					return
				}
				log.Println(n, string(buf))
			}
		}(conn)
	}
}
  • 実行手順例
$ go build -o svr . 
$ sudo strace -t -f ./svr

# 別セッションで
$ telnet 127.0.0.1 2000
  // 適当にデータを送信する
  // 最後にコネクションを切る
^]
telnet> quit
Connection closed.
  • strace の実行
    • 今回のサンプルでは接続確立後に goroutine (スレッド) を起動しているので -f オプション も必要
      • sudo strace -t -f -p <PID> など
  • strace の結果
    • socket は NONBLOCK でオープンされ、epoll と組み合わせて使われている
    • Go 側で指定した 100 バイトずつ read している
    • close すると read の読み取り結果が 0 バイトで、Go 側では EOF としてハンドリングしている
    • 100 バイト以上のデータをクライアントから送信した場合、複数回 read が走る
// socket は SOCK_NONBLOCK で開かれる
// fd は 3 番
[pid  1997] 04:54:51 socket(PF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3

// bind に fd 3 番を渡す
[pid  1997] 04:54:51 bind(3, {sa_family=AF_INET6, sin6_port=htons(2000), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0

// listen も fd 3 番を渡す
[pid  1997] 04:54:51 listen(3, 128)     = 0

// epoll
// epoll_ctl で fd 3 を EPOLL_CTL_ADD
// socket が nonblock なので accept からは EAGAIN が即座に返っている (はず)
[pid  1997] 04:54:51 epoll_create1(EPOLL_CLOEXEC) = 4
[pid  1997] 04:54:51 epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1796096696, u64=139940420534968}}) = 0
[pid  1997] 04:54:51 getsockname(3, {sa_family=AF_INET6, sin6_port=htons(2000), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0
[pid  1997] 04:54:51 accept4(3, 0xc820043b28, 0xc820043b24, SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)  
[pid  1997] 04:54:51 epoll_wait(4, [], 128, 0) = 0

// 通信が確立できた際の accept
// fd 5 番が返される
[pid  1997] 05:00:03 accept4(3, {sa_family=AF_INET6, sin6_port=htons(60258), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5

// 細かく見ていないが accept 後は select も多用されているのが見える
[pid  1998] 05:00:03 select(0, NULL, NULL, NULL, {0, 20}) = 0 (Timeout)

// fd 5 から read
// クライアントから送られた文字列を読み取ることができる
[pid  1997] 05:01:00 read(5, "abc abc\r\n", 100) = 9

// 100 バイト以上のデータをクライアントから送った場合
// 2 度の read で全体が読み込まれている
[pid  1999] 05:03:18 read(5, "01234567890123456789012345678901"..., 100) = 100
[pid  1999] 05:03:18 read(5, "123\r\n", 100) = 5

// このときの Go からの標準出力
// こちらも 100 バイトずつ 2 度の Read 呼び出しに分かれている
2021/02/04 05:03:18 100 012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678
2021/02/04 05:03:18 5 123

// 接続の close 時には読み取りバイト数が 0
[pid  1999] 05:06:10 read(5, "", 100) = 0

// このときの Go からの標準出力
// EOF として扱われている
2021/02/04 05:06:10 0 read error EOF
詳解UNIXプログラミング 第3版
W. Richard Stevens (著), Stephen A. Rago (著), 大木 敦雄 (監修) 形式: Kindle版
UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI
W.リチャード スティーヴンス (著), W.Richard Stevens (原著), 篠田 陽一 (翻訳)