あけましておめでとうございます。
昨年の末ごろから、なんとなーくプロセス間のソケット受け渡しを Go でやってみたいなーと思って少しずつコードを書いていたんですが、年末年始の時間を使って書き上げたので、ここで報告します。
軽い気持ちで書き始めたものの、なかなかハマることが多くて結構な時間がかかってしまいました。。。
成果物
https://github.com/navel3/go-ipc
https://godoc.org/github.com/navel3/go-ipc
Motivation
- TCP接続をプロセス間で授受することで、コネクションレスなリバースプロキシ的なものができるのではないかと思った
- Go だけで低レイヤーのコードを書いてみたかった
- テストからドキュメントまで Go Way に従って書く題材が欲しかった
実装
以下、実装について説明していきます。
プロセス間通信
Linux, Windows 共に名前付きパイプで通信してます。
Linux
普通に net.UnixConn
を使ってます。特筆すべきこともないので詳細は割愛。
Windows
Microsoft が公開している microsoft/go-winioを使いました。
natefinch/npipe というのもありますが、2016年から更新が停止しています。microsoft/go-winio の README によれば、これから発想を得て実装したとのこと。
プロセス間のTCP接続の授受
パイプ経由でソケット情報の受け渡しをしています。ここで付加情報として peeked []byte
を渡すことで、通信を先読みした情報を、ソケット受領側で再現できるようにしています。当初、recv(2)
には MSG_PEEK
があるので、この機能は不要かと思っていましたが、go ではシステムコールを直に叩かない限り peek できないため実装しました。
また、Microsoft サポートによれば peek は色々問題を引き起こすので実行するなとの記事も。(ページ消えちゃってますが)
[INFO] Winsock ではデータのピークは実行しない
Linux
net.TCPConn.SyscallConn()
でファイルディスクリプタを取り出し、システムコール sendmsg(2)
を叩きます。net.TCPConn.File().Fd()
で取り出すことも可能ですが、これは dup(2)
により実装されており、ディスクリプタが複製されてしまうので SyscallConn を使います。
rawConn, err := conn.(*net.UnixConn).SyscallConn()
if err != nil {
return
}
gw.wbuf.Reset()
writeWithLength(&gw.wbuf, func(b *bytes.Buffer) error {
return s.serialize(b)
})
var n int
rawConn.Control(func(connFd uintptr) {
rights := unix.UnixRights(fd)
for {
n, err = unix.SendmsgN(int(connFd), gw.wbuf.Bytes(), rights, nil, 0)
if err == nil || err != unix.EAGAIN {
break
}
// TODO: There is no way to get write deadline of conn
if ok, _ := waitIOEvent(waitWrite, fd, waitForever); !ok {
break
}
}
Go で作られた Socket は non-blocking に設定されているため、EAGAIN が発生する可能性があります。
とりあえず select(2)
で wait 実装しました。しかし、これでは待っている間 goroutine がスレッドを占有してしまうという問題があります。runtime では定期的に epoll で監視する実装になっているようなので、参考にして今後改善したいポイントです。
- 参考にさせていただいた記事: Golangのスケジューラあたりの話
- runtime/proc.go
- runtime/netpoll_epoll.go
func waitIOEvent(mode, fd int, timeout *unix.Timeval) (bool, error) {
fds := &unix.FdSet{}
fds.Set(fd)
var n int
var err error
if mode == waitWrite {
n, err = unix.Select(fd+1, nil, fds, nil, timeout)
} else {
n, err = unix.Select(fd+1, fds, nil, nil, timeout)
}
if err != nil {
return false, err
}
return n == 1, nil
}
受信側でも同様にディスクリプタを取り出して recvmsg(2)
で受領します。
rawConn, err := conn.(*net.UnixConn).SyscallConn()
if err != nil {
return
}
rights := unix.UnixRights(0)
var dlen uint32
rawConn.Control(func(connFd uintptr) {
var buf [4]byte
for {
n, _, _, _, err := syscall.Recvmsg(int(connFd), buf[:], rights, 0)
if err == nil {
if n != 4 {
panic(fmt.Sprintf("n must be 4 but was %v", n))
}
break
}
if err != unix.EAGAIN {
break
}
// TODO: There is no way to get read deadline of conn
if ok, _ := waitIOEvent(waitRead, fd, waitForever); !ok {
break
}
}
dlen = binary.BigEndian.Uint32(buf[:])
})
Windows
Linux と同じく net.TCPConn.SyscallConn()
でファイルディスクリプタを取り出します。これをシステムコール WSADuplicateSocket
で通信相手のプロセスにハンドルを複製します。WSADuplicateSocket
は標準packageでは提供されていないため、自前で定義しました。(Go の標準パッケージにないシステムコールを使う)
Pipe への書き込みは microsoft/go-winio のレイヤーを使っているので、Linux のようなタイムアウトの考慮は不要です。
rawSock, err := sock.SyscallConn()
if err != nil {
return
}
rawSock.Control(func(fd uintptr) {
sd := socketData{
laddr: *sock.LocalAddr().(*net.TCPAddr),
raddr: *sock.RemoteAddr().(*net.TCPAddr),
peeked: peeked,
withData: len(msg) > 0,
}
err = gw.sendImpl(conn, msg, func() (s serializer, err error) {
err = winsys.WSADuplicateSocket(windows.Handle(fd), uint32(gw.pid), &sd.ProtocolInfo)
if err != nil {
return
}
return &sd, nil
})
})
受信側では、受け取った情報から WSASocket
(これも標準にないので自前定義) でソケットを作ります。ソケットは WSAIoctl
で non-blocking にしておきます。
後述しますが、ここも要改善ポイント。
err = gw.receiveImpl(conn, &sd)
if err != nil {
return
}
fd, err := winsys.WSASocket(winsys.FROM_PROTOCOL_INFO,
winsys.FROM_PROTOCOL_INFO,
winsys.FROM_PROTOCOL_INFO,
&sd.ProtocolInfo,
0,
0)
if err != nil {
return
}
// set non-blocking mode to enable deadline
const finbio = uint32(0x8004667e)
on := uint32(1)
var retsize uint32
err = windows.WSAIoctl(fd, finbio, (*byte)(unsafe.Pointer(&on)), 4, nil, 0, &retsize, nil, 0)
if err != nil {
return
}
独自 TCPConn 実装
受領したディスクリプタを wrap して net.Conn
として扱えるようにしてます。
送信側で先読みしたデータを peeked []byte
として保持しておき、初回読み込み時はその内容を返すようにしました。peeked を全て読んだ後は、ソケットにデータがあるならば読み込みます。ここに問題があります。
続く。
func (c *tcpConn) Read(b []byte) (n int, err error) {
if len(c.peeked) > 0 {
n = copy(b, c.peeked)
c.peeked = c.peeked[n:]
if yes, _ := c.sysSocket.isReadableState(); !yes {
return
}
}
b = b[n:]
if len(b) == 0 {
return
}
nn, err := c.sysSocket.read(b)
runtime.KeepAlive(c)
n += nn
return
}
前項で触れた通り、ソケットの write or read が EAGAIN or EWOULDBLOCK を返す(=ソケットバッファが一杯or空)ケースへの対応として、システムコールで wait するよう実装したのですが、これだと、そのシステムコールを実行した goroutine がスレッドを占有してしまいうという問題があります。試していないですが、runtime.GOMAXPROCS の TCPConn がIO待ちになると、プログラムがハングすると思います。
microsoft/go-winio は Overlapped I/O を使ってこの辺をうまく制御しているようなので、今後改善していきたいなと思います。
他プロセスとのファイルハンドルの授受
TCPConn の実装したついでにファイルハンドルの授受も実装してみました。
こちらは os.NewFile()
でファイルディスクリプタから os.File
を作れるので簡単。
Linux は socket の実装とほぼ同じ。Windows は OpenProcess()
でプロセスハンドルを取得して、 DuplicateHandle()
で複製。
Benchmark
次のような単純な通信を、PIPEを介して接続を授受するパターンとしないパターンで性能測定をしてみました。
- TCP接続
- HTTP GET を書き出す
- 200 OK を応答する
- 接続を閉じる
測定環境
- CPU: Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz
- MEM: 16GB
- OS
Linux: Archlinux(Linux x1carbon 4.19.91-1-lts #1 SMP Sat, 21 Dec 2019 16:34:46 +0000 x86_64 GNU/Linux)
Windows: Windows 10 Home
実測
まず Linux。
$ go test -bench 'TCP'
goos: linux
goarch: amd64
pkg: github.com/navel3/go-ipc
BenchmarkTCPDirect-8 18794 60392 ns/op
BenchmarkTCPIPC-8 10000 111099 ns/op
PASS
ok github.com/navel3/go-ipc 2.949s
次に Windows。
$ go test -bench 'TCP'
goos: windows
goarch: amd64
pkg: github.com/navel3/go-ipc
BenchmarkTCPDirect-8 5229 214000 ns/op
BenchmarkTCPIPC-8 3250 368860 ns/op
PASS
ok github.com/navel3/go-ipc 2.657s
圧倒的に Linux が速いですが、どちらも直使用に対して半分弱の性能落ち込み。想定していた程は遅くなりまりませんでした。
ただ、このベンチマークでは同一プロセス内で実行しているため、異プロセス間で通信する場合、異なる結果になるかもしれません。
最後に
SyscallConn を使えばC言語感覚でシステムコール使えるかなーと簡単に考えていたんですが、syscall 系の package のドキュメントがまるで書かれていなかったり、非同期IOの実装の難しさなど、なかなか骨が折れる題材でした。
runtime の I/O polling 周りの仕組みをユーザが使えたり、os.File
と同じように net.TCPConn
もファイルディスクリプタから作ることができれば、もう少し楽に書けるのになーと思います。
正直、車輪の再発明をしている感が
まだまだ課題が残っていますが、いろいろ勉強にはなるので、今後も時間をみて修正してきたいと思います。