一部の処理が異なる言語で書かれているなどしてコマンドを繰り返し実行するとプロセス生成の度にメモリ領域の確保などのオーバーヘッドが発生する。常時プロセスを動かしておきプロセス間通信で入出力を行えばこれを回避できそうだが実際どれくらい速度に差が出るのかGoでコマンドを作ってベンチマークを取ってみる。
Unixでの高速なプロセス間通信の手段としては共有メモリのほかにパイプやUNIXドメインソケットがあって、 例えばnewrelic-lambda-extensionはパイプを通してアプリケーションからイベントを受け取り送信しているが、 今回は入出力を行いたいので単方向しか通信できないパイプではなくUNIXドメインソケットを用いる。 全体のコードはGitHubにある。
CloudWatch Logsを介さずにLambdaのテレメトリを行うnewrelic-lambda-extensionとその仕組み - sambaiz-net
コマンドの作成
cobraでコマンドを作成した。
$ go install github.com/spf13/[email protected]
$ cobra-cli init
–server フラグを渡した場合はUNIXドメインソケットで待ち構え、そうでない場合は –value で渡された値をechoするようにした。
func handler(in []byte) []byte {
return in
}
var rootCmd = &cobra.Command{
Use: "start-process-vs-unix-domain-socket",
RunE: func(cmd *cobra.Command, args []string) error {
daemon, err := cmd.Flags().GetBool("server")
if err != nil {
return err
}
if daemon {
socketName, err := cmd.Flags().GetString("socket")
if err != nil {
return err
}
if err := listenSocket(socketName); err != nil {
return err
}
} else {
value, err := cmd.Flags().GetString("value")
if err != nil {
return err
}
fmt.Println(string(handler([]byte(value))))
}
return nil
},
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.Flags().Bool("server", false, "Wait for the value with socket")
rootCmd.Flags().String("socket", "rpos.sock", "Socket name to be listen by the server")
rootCmd.Flags().String("value", "", "Value to be processed")
}
UNIXドメインソケットでの通信
サーバー側は次のように net.Listen(“unix”, socketName) して待ち構え、リクエストが来たら Accept() してgoroutineを生成し読み書きする。
func listenSocket(socketName string) error {
l, err := net.Listen("unix", socketName)
if err != nil {
return err
}
defer l.Close()
log.Printf("listen on socket %s", socketName)
connCh := make(chan net.Conn)
errCh := make(chan error)
go func() {
for {
conn, err := l.Accept()
if err != nil {
errCh <- err
return
}
connCh <- conn
}
}()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case conn := <-connCh:
log.Print("connected")
go func() {
defer conn.Close()
buf := make([]byte, 512)
for {
if _, err := conn.Read(buf); err != nil {
log.Print(err)
return
}
if _, err := conn.Write(handler(buf)); err != nil {
log.Print(err)
return
}
}
}()
case err := <-errCh:
return err
case <-sigs:
return nil
}
}
}
クライアント側は net.Dial(“unix”, socketName) して読み書きする。
conn, err := net.Dial("unix", "test.sock")
if err != nil {
b.Fatal(err)
}
conn.Write([]byte(value))
if _, err := conn.Read(buf); err != nil {
b.Fatal(err)
}
_ = conn.Close()
実行結果
-benchtime (100|1000|10000)x でベンチマークを取った。
BenchmarkProcess-4 100 4433338 ns/op
BenchmarkSocketDialEveryTime-4 100 10198513 ns/op
BenchmarkSocketDialOnce-4 100 10109449 ns/op
BenchmarkSocketAlreadyProcessStarted-4 100 101352 ns/op
BenchmarkProcess-4 1000 5761057 ns/op
BenchmarkSocketDialEveryTime-4 1000 1105874 ns/op
BenchmarkSocketDialOnce-4 1000 1026624 ns/op
BenchmarkSocketAlreadyProcessStarted-4 1000 14375 ns/op
BenchmarkProcess-4 10000 5112125 ns/op
BenchmarkSocketDialEveryTime-4 10000 205253 ns/op
BenchmarkSocketDialOnce-4 10000 113940 ns/op
BenchmarkSocketAlreadyProcessStarted-4 10000 13312 ns/op
回数が少ないとサーバー起動のコストがプロセス生成よりも大きく、UNIXドメインソケットを用いたBenchmarkSocketDialEveryTimeやBenchmarkSocketDialOnceよりもプロセスを都度生成するBenchmarkProcessの方が速い結果になったが、回数が増えると逆転し 10000回実行だとBenchmarkProcessが50秒ほどかかるところBenchmarkSocketDialOnceだと1秒で終わっている。
サーバー起動を除いたBenchmarkSocketAlreadyProcessStartedと、毎回接続するBenchmarkSocketDialEveryTimeおよび接続を使い回すBenchmarkProcessの結果を見るに、接続のコストはサーバーの起動ほどは大きくないが、それでも10000回実行するとほぼ倍速になっているので可能なら使い回すと良さそうだ。