2
2

More than 3 years have passed since last update.

Go で ipc package を書いてみた

Last updated at Posted at 2020-01-03

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

昨年の末ごろから、なんとなーくプロセス間のソケット受け渡しを 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 を使います。

src

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 で監視する実装になっているようなので、参考にして今後改善したいポイントです。

src

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

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 のようなタイムアウトの考慮は不要です。

src

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

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

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を介して接続を授受するパターンとしないパターンで性能測定をしてみました。

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

測定環境

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

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2