はじめに
この章ではネットワークについて扱います。
「ネットワークに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
}
net.Conn
インターフェースは8つのメソッドセットで構成されており、これを満たす構造体としてはnet
パッケージの中だけでもnet.IPConn
, net.TCPConn
, net.UDPConn
, net.UnixConn
があります。
コネクションを取得
サーバー側から取得する
サーバー側からnet.Conn
インターフェースを取得するためには、以下のような手順を踏みます。
-
net.Listen(通信プロトコル, アドレス)
関数からnet.Listener
型の変数(ln
)を得る -
ln
のAccept()
メソッドを実行する
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)
}
conn
がnet.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.TCPConn
のWrite
メソッドを利用して以下のように実装されます。
// コネクションを得る
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.TCPConn
のRead
メソッドを利用して書きます。
// コネクションを得る
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.File
のRead
メソッドと同じです。
引数にとった[]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
メソッドでやっていることは中々複雑なのですが、核としては
-
syscall.Socket
経由でシステムコールsocket()を呼んで、URLやポート番号からfdをゲットする - 1で得たfdを
poll.FD
型にする - 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メソッド
の中身も中々複雑ですが、核は
-
syscall.Socket
経由でシステムコールsocket()を呼んで、URLやポート番号からfdをゲットする - 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
メソッドとして機能します。
その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
メソッドの処理手順をまとめます。
-
net.conn
型のRead
メソッドを呼ぶ - 1の中で
net.netFD
型のRead
メソッドを呼ぶ - 2の中で
poll.FD
型のRead
メソッドを呼ぶ - 3の中で
syscall.Read
メソッドを呼ぶ - OSカーネルのシステムコールで読み込み処理
Writeメソッド
net.TCPConn
型のWrite()
メソッドのほうもRead
メソッドと同様の流れで実装されています。
-
net.conn
型のWrite
メソッドを呼ぶ - 1の中で
net.netFD
型のWrite
メソッドを呼ぶ - 2の中で
poll.FD
型のWrite
メソッドを呼ぶ - 3の中で
syscall.Write
メソッドを呼ぶ - OSカーネルのシステムコールで書き込み処理
まとめ
前章・本章とファイル・ネットワークのI/Oについて取り上げました。
しかし、I/Oする対象こそ違えど、内部的な構造は両方とも
- fdがある(=ファイルへのI/Oと見れる)
-
Read()
メソッド、Write()
メソッドのシグネチャが同じ - 裏でシステムコールread()/write()を呼んでいる
等々、似ているところがあります。
次章では、これらI/Oをまとめてひっくるめて扱う抽象化の手段を紹介します。