Chapter 03

ネットワーク

さき(H.Saki)
さき(H.Saki)
2021.04.23に更新

はじめに

この章ではネットワークについて扱います。
「ネットワークにI/Oがなんの関係があるの?」と思う方もいるかもしれませんが、「サーバーからデータを受け取る」「クライアントからデータを送る」というのは、言い換えると「コネクションからデータを読み取る・書き込む」ともいえるのです。

netパッケージのドキュメントには以下のように記載されています。

Package net provides a portable interface for network I/O, including TCP/IP, UDP, domain name resolution, and Unix domain sockets.
(訳)netパッケージでは、TCP/IP, UDP, DNS, UNIXドメインソケットを含むネットワークI/Oのインターフェース(移植性あり)を提供します。
出典:pkg.go.dev - net package

ネットワークをI/Oと捉える言葉が明示されているのがわかります。

ここからは、TCP通信で短い文字列を送る・受け取るためのGoのコードについて解説していきます。

ネットワークコネクション

ネットワーク通信においては、「クライアント-サーバー」間を繋ぐコネクションが形成されます。
このコネクションパイプをGoで扱うインターフェースがnet.Connインターフェースです。

type Conn interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
    Close() error
    LocalAddr() Addr
    RemoteAddr() Addr
    SetDeadline(t time.Time) error
    SetReadDeadline(t time.Time) error
    SetWriteDeadline(t time.Time) error
}

出典:pkg.go.dev - net#Conn

net.Connインターフェースは8つのメソッドセットで構成されており、これを満たす構造体としてはnetパッケージの中だけでもnet.IPConn, net.TCPConn, net.UDPConn, net.UnixConnがあります。

コネクションを取得

サーバー側から取得する

サーバー側からnet.Connインターフェースを取得するためには、以下のような手順を踏みます。

  1. net.Listen(通信プロトコル, アドレス)関数からnet.Listener型の変数(ln)を得る
  2. lnAccept()メソッドを実行する
ln, err := net.Listen("tcp", ":8080")
if err != nil {
    fmt.Println("cannot listen", err)
}
conn, err := ln.Accept()
if err != nil {
    fmt.Println("cannot accept", err)
}

connnet.Connインターフェースの変数で、今回の場合、その実体はTCP通信のために使うnet.TCPConn型構造体です。

クライアント側から取得する

クライアント側からnet.Connインターフェースを取得するためには、net.Dial(通信プロトコル, アドレス)関数を実行します。

conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
    fmt.Println("error: ", err)
}

このconnも実体はnet.TCPConn型です。

サーバー側からのデータ発信

サーバー側から、TCPコネクションを使って文字列"Hello, net pkg!"を一回送信する処理は、net.TCPConnWriteメソッドを利用して以下のように実装されます。

// コネクションを得る
ln, err := net.Listen("tcp", ":8080")
if err != nil {
    fmt.Println("cannot listen", err)
}
conn, err := ln.Accept()
if err != nil {
    fmt.Println("cannot accept", err)
}

// ここから送信

str := "Hello, net pkg!"
data := []byte(str)
_, err = conn.Write(data)
if err != nil {
    fmt.Println("cannot write", err)
}

Writeメソッドの挙動は、os.File型のWriteメソッドのものとそう変わりません。
引数にとった[]byte列の内容をコネクションに書き込み、そして何byte書き込めたかの値が第一返り値になります。

クライアント側がデータ受信

クライアントがTCPコネクションから、文字列データを受け取るコードをnet.TCPConnReadメソッドを利用して書きます。

// コネクションを得る
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
    fmt.Println("error: ", err)
}

// ここから読み取り

data := make([]byte, 1024)
count, _ := conn.Read(data)
fmt.Println(string(data[:count]))

// 出力結果
// Hello, net pkg!

Readメソッドの挙動もos.FileReadメソッドと同じです。
引数にとった[]byte列の中に、コネクションから読み取った内容を入れて、そして何byte読めたかの値が第一返り値になります。

低レイヤで何が起きているのか

ここからは、os.File型のときにやったのと同様のコードリーディングを行います。
ネットワークまわりのI/Oの実装では、どのようなシステムコールにつながっているのでしょうか。低レイヤの話に深く潜り込んでいきます。

ネットワークコネクション(net.TCPConnの正体)

net.TCPConn構造体の正体は、非公開の構造体net.conn型です。

type TCPConn struct {
	conn
}

出典:[https://go.googlesource.com/go/+/go1.16.2/src/net/tcpsock.go#85]

そしてこのnet.conn型の中身は、netFD型構造体そのものです。

type conn struct {
	fd *netFD
}

出典:[https://go.googlesource.com/go/+/go1.16.2/src/net/net.go#170]

このnetFD型は一体何なのでしょうか。これも定義を見てみましょう。

type netFD struct {
	pfd poll.FD
	// immutable until Close
	family      int
	sotype      int
	isConnected bool // handshake completed or use of association with peer
	net         string
	laddr       Addr
	raddr       Addr
}

出典:[https://go.googlesource.com/go/+/go1.16.2/src/net/fd_posix.go#17]

前章で出てきたpoll.FD型のpfdフィールドがここでも登場しました。これは一体どういうことでしょうか。

実はLinuxの設計思想として "everything-is-a-file philosophy" というものがあります。これは、キーボードからの入力も、プリンターへの出力も、ハードディスクやネットワークからのI/Oもありとあらゆるものを全て「OSのファイルシステム上にあるファイルへのI/Oとして捉える」という思想です。
今回のようなネットワークからのデータ読み取り・書き込みも、OS内部的には通常のファイルI/Oと変わらないのです。そのため、ネットワークコネクションに対しても、通常ファイルと同様にfdが与えられるのです。

コネクションオープン

では、通信するネットワークに対応するfdはどのように決まるのでしょうか。
また、コネクションに対応したfdが入ったnet.Conn(ここではnet.TCPConn型構造体)はどのようにして得られるのでしょうか。

これを理解するためには、

  • クライアント側でnet.Dial()を実行
  • サーバー側でnet.Listen()ln.Accept()を実行

それぞれにおいて裏で何が起きているのか、コードを読んで深掘りしていきましょう。

クライアント側からのコネクションオープン

まずは、クライアント側からnet.Connを得るために呼ぶnet.Dial(通信プロトコル, アドレス)の中身をみてみます。
すると、今私たちが欲しい「コネクションに割り当てられたfdをもつnet.TCPConn」を作っているのは、実質net.Dialer型のDialContextメソッドであることがわかります。

func Dial(network, address string) (Conn, error) {
	var d Dialer
	return d.Dial(network, address)
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/net/dial.go#317]

func (d *Dialer) Dial(network, address string) (Conn, error) {
	return d.DialContext(context.Background(), network, address) // net.TCPConnを作っているのはここ
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/net/dial.go#347]

net.Dialer型のDialContextメソッドは、「引数として渡されたプロトコル・URL・ポート番号に対応したnet.Connを作る」ためのメソッドです。

DialContext connects to the address on the named network using the provided context.
出典:pkg.go.dev - net#Dialer.DialContext

このDialContextメソッドでやっていることは中々複雑なのですが、核としては

  1. syscall.Socket経由でシステムコールsocket()を呼んで、URLやポート番号からfdをゲットする
  2. 1で得たfdをpoll.FD型にする
  3. 2で得たpoll.FD型のfdを使いnewTCPConn(fd)を実行→これがTCPConnになる

という流れです。

結局のところ、システムコールsocket()を内部で呼んで得たfdをTCPConn型にラップしている、ということです。

サーバー側からのコネクションオープン

サーバー側でnet.Listen()ln.Accept()という手順を踏んだ場合は何が起こっているのでしょうか。
net.Listen()関数の実装を確認してみます。

func Listen(network, address string) (Listener, error) {
	var lc ListenConfig
	return lc.Listen(context.Background(), network, address)
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/net/dial.go#704]

net.ListenConfig型のListenメソッドを内部で呼んでいます。
このListenメソッドの中身も中々複雑ですが、核は

  1. syscall.Socket経由でシステムコールsocket()を呼んで、URLやポート番号からfdをゲットする
  2. 1で得たfdを内部フィールドに含んだTCPListener型を生成し、返り値にする

となっています。
ここでも、コネクションに対応したfdを得るからくりはsocket()システムコールです。

ですがまだnet.Listenerが得られただけで、実際に通信に使うTCPConn構造体がまだです。
実は、この「リスナーからコネクションを得る」ためのメソッドがAccept()メソッドなのです。その中身をみてみます。

func (l *TCPListener) Accept() (Conn, error) {
	// (略)
	c, err := l.accept()
	// (略)
	return c, nil
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/net/tcpsock.go#257]

内部では非公開メソッドaccept()を呼んでいました。その中身は以下のようになっています。

func (ln *TCPListener) accept() (*TCPConn, error) {
	// リスナー本体からfdを取得
	fd, err := ln.fd.accept()
	// (略)

	// fdからTCPConnを作成
	tc := newTCPConn(fd)
	// (略)

	return tc, nil
}

出典:[https://go.googlesource.com/go/+/go1.16.3/src/net/tcpsock_posix.go#138]

要するに、「リスナーからコネクションを得る」=「リスナーからfdを取り出して、それをTCPConnにラップする」ということなのです。

Readメソッド

net.TCPConn型のRead()の中身を掘り下げます。

先述した通り、net.TCPConn型の実体は非公開構造体connです。そのため、conn型のReadメソッドがそのままnet.TCPConn型のReadメソッドとして機能します。

(c *TCPConn) Readが定義されていなくても、内部フィールド構造体の(c *conn) ReadがそのままTCPConn型のメソッドとして機能する挙動のことをメソッド委譲といいます。

そのconn型のReadメソッドは、内部ではフィールドfd(netFD型)のReadメソッドを呼んでいます。

func (c *conn) Read(b []byte) (int, error) {
	// (略)
	n, err := c.fd.Read(b)
	// (略)
}

出典:[https://go.googlesource.com/go/+/go1.16.2/src/net/net.go#179]

netFD型のRead()メソッドの中身では、pfdフィールド(poll.FD型)のReadメソッドを呼んでいます。

func (fd *netFD) Read(p []byte) (n int, err error) {
	n, err = fd.pfd.Read(p)
	// (略)
}

出典:[https://go.googlesource.com/go/+/go1.16.2/src/net/fd_posix.go#54]

このpoll.FD型のReadメソッドというのは、前章のファイルI/Oでも出てきたものです。ここから先は通常ファイルのI/Oと同じく、対応したfdのファイルの中身を読み込むためのシステムコールsyscall.Readにつながります。
"everything-is-a-file"思想の名の通り、ネットワークコネクションからのデータ読み取りも、OSの世界においてはファイルの読み取りと変わらずreadシステムコールで処理されるのです。

net.TCPConn型のReadメソッドの処理手順をまとめます。

  1. net.conn型のReadメソッドを呼ぶ
  2. 1の中でnet.netFD型のReadメソッドを呼ぶ
  3. 2の中でpoll.FD型のReadメソッドを呼ぶ
  4. 3の中でsyscall.Readメソッドを呼ぶ
  5. OSカーネルのシステムコールで読み込み処理

Writeメソッド

net.TCPConn型のWrite()メソッドのほうもReadメソッドと同様の流れで実装されています。

  1. net.conn型のWriteメソッドを呼ぶ
  2. 1の中でnet.netFD型のWriteメソッドを呼ぶ
  3. 2の中でpoll.FD型のWriteメソッドを呼ぶ
  4. 3の中でsyscall.Writeメソッドを呼ぶ
  5. OSカーネルのシステムコールで書き込み処理

まとめ

前章・本章とファイル・ネットワークのI/Oについて取り上げました。
しかし、I/Oする対象こそ違えど、内部的な構造は両方とも

  • fdがある(=ファイルへのI/Oと見れる)
  • Read()メソッド、Write()メソッドのシグネチャが同じ
  • 裏でシステムコールread()/write()を呼んでいる

等々、似ているところがあります。

次章では、これらI/Oをまとめてひっくるめて扱う抽象化の手段を紹介します。