繰り返しコマンドを実行する際都度プロセスを生成する場合と常駐させておきUNIXドメインソケットで入出力を行う場合の速度比較

linuxgolang

一部の処理が異なる言語で書かれているなどしてコマンドを繰り返し実行するとプロセス生成の度にメモリ領域の確保などのオーバーヘッドが発生する。常時プロセスを動かしておきプロセス間通信で入出力を行えばこれを回避できそうだが実際どれくらい速度に差が出るのか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ドメインソケットを用いたBenchmarkSocketDialEveryTimeBenchmarkSocketDialOnceよりもプロセスを都度生成するBenchmarkProcessの方が速い結果になったが、回数が増えると逆転し 10000回実行だとBenchmarkProcessが50秒ほどかかるところBenchmarkSocketDialOnceだと1秒で終わっている。

サーバー起動を除いたBenchmarkSocketAlreadyProcessStartedと、毎回接続するBenchmarkSocketDialEveryTimeおよび接続を使い回すBenchmarkProcessの結果を見るに、接続のコストはサーバーの起動ほどは大きくないが、それでも10000回実行するとほぼ倍速になっているので可能なら使い回すと良さそうだ。