Go で ipc package を書いてみた


あけましておめでとうございます。

昨年の末ごろから、なんとなーくプロセス間のソケット受け渡しを 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](https://github.com/microsoft/go-winio)を使いました。
[natefinch/npipe](https://github.com/natefinch/npipe) というのもありますが、2016年から更新が停止しています。microsoft/go-winio の README によれば、これから発想を得て実装したとのこと。


プロセス間のTCP接続の授受
----------------------------------------

パイプ経由でソケット情報の受け渡しをしています。ここで付加情報として `peeked []byte` を渡すことで、通信を先読みした情報を、ソケット受領側で再現できるようにしています。当初、`recv(2)` には `MSG_PEEK` があるので、この機能は不要かと思っていましたが、go ではシステムコールを直に叩かない限り peek できないため実装しました。
また、Microsoft サポートによれば peek は色々問題を引き起こすので実行するなとの記事も。(ページ消えちゃってますが)

[\[INFO\] Winsock ではデータのピークは実行しない](https://support.microsoft.com/es-pa/help/192599/info-avoid-data-peeking-in-winsock)

### Linux

`net.TCPConn.SyscallConn()` でファイルディスクリプタを取り出し、システムコール `sendmsg(2)` を叩きます。`net.TCPConn.File().Fd()` で取り出すことも可能ですが、これは `dup(2)` により実装されており、ディスクリプタが複製されてしまうので SyscallConn を使います。

[src](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/gateway_linux.go#L27)

```go
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のスケジューラあたりの話](https://qiita.com/takc923/items/de68671ea889d8df6904)
- [runtime/proc.go](https://github.com/golang/go/blob/f376b8510ed7884c69a09fbcf61418f7285f2787/src/runtime/proc.go#L4467)
- [runtime/netpoll_epoll.go](https://github.com/golang/go/blob/f376b8510ed7884c69a09fbcf61418f7285f2787/src/runtime/netpoll_epoll.go#L99)


[src](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/gateway_linux.go#L132)

```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)` で受領します。

[src](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/gateway_linux.go#L67)

```go
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 の標準パッケージにないシステムコールを使う](https://qiita.com/navel3/items/2f464163cc0a07458bb1))

Pipe への書き込みは microsoft/go-winio のレイヤーを使っているので、Linux のようなタイムアウトの考慮は不要です。

[src](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/socket_gateway_windows.go#L17)

```go
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 にしておきます。

後述しますが、ここも要改善ポイント。

[src](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/socket_gateway_windows.go#L45)

```go
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 を全て読んだ後は、ソケットにデータがあるならば読み込みます。ここに問題があります。

続く。

[src](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/tcpsock.go#L23)

```go
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()` で複製。

- [src(Linux)](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/file_gateway_windows.go#L41)
- [src(Windows)](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/file_gateway_windows.go#L41)


Benchmark
========================================

次のような単純な通信を、PIPEを介して接続を授受するパターンとしないパターンで性能測定をしてみました。

1. TCP接続
1. HTTP GET を書き出す
1. 200 OK を応答する
1. 接続を閉じる

- [src - PIPEなし: BenchmarkTCPDirect](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/ipc_test.go#L577)
- [src - PIPEあり: BenchmarkTCPIPC](https://github.com/navel3/go-ipc/blob/729dfd92305c513b9e7035c5177896672f9b1d02/ipc_test.go#L643)

測定環境
----------------------------------------

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

```sh
$ 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。

```sh
$ 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` もファイルディスクリプタから作ることができれば、もう少し楽に書けるのになーと思います。
正直、車輪の再発明をしている感が :disappointed_relieved:

まだまだ課題が残っていますが、いろいろ勉強にはなるので、今後も時間をみて修正してきたいと思います。