GoでソケットのCloseがハングして大ハマりした件　〜Goはシステムプログラミングに向いているのか〜

BayServerの開発で、Keep-Aliveのタイムアウト処理をしているときに、Closeがハングしてしまいソケットがクローズできないと言う問題が発生してしまい、解決に何日もかかってしまったのでシェアします。ちなみに、

:::note info
BayServerは、横浜ベイキット(http://baykit.yokohama) が開発している爆速Webサーバーで、Java、Ruby、Pythonなどさまざまな言語で開発されています。
:::

ことの発端は、Keep-Aliveのタイムアウト処理を書いているときになります。

:::note info
Keep-Aliveとは
Webサーバーはクライアントに応答を返した後も、パフォーマンス上の理由からクライアントとの接続を維持します。接続を切ってしまうと、再度接続が来た際にまた初期化処理をしなければならずそれが無駄になるからですね。このように接続しっぱなしにしておくことをKeep-Aliveと言います。
:::

Keep-Aliveはいつまで続くかと言うと、つなぎっぱなしにしておくのもOSのリソースを消費しますので、一定時間が経ったら接続を切ります。（クライアントから切ることもあります）

プログラム的には、クライアントからのデータを待ちながら接続を切ることになります。つまり、Goで言うと、ソケットからのデータをRead()して待っているゴルーチンと、ソケットをCloseで切断するゴルーチンの2つのゴルーチンが同時に動いているわけです。

で、このとき、Closeがハングしてしまい、返ってこないと言う現象が見られました。

それで、ChatGPTさんにいろいろ聞いたのですが、言われた通りしても解決せず、デバッガでAPIの中までデバッグしても全く分からず、数日を費やしてしまい、結局ネットで探して以下の記事が参考になりました。ドンピシャではないので、あくまでも参考にしたと言う感じですが。

https://stackoverflow.com/questions/28967701/golang-tcp-socket-cant-close-after-get-file

結論から言うと、ソケットを表す TcpConnに対し、File()関数を呼びFileを取り出し、さらにFd()関数でファイルディスクリプタを取り出すと、ソケットがブロッキングモードになってしまい、参照カウンタがうまく更新できず、Closeがハングするようです。（ちょっと最後の方はよく分からないですが、中のコードを追うとそんな感じに見えます）

簡単な再現コードは以下。（半分以上ChatGPTさんに書いてもらってます笑）


```go
package main

import (
	"fmt"
	"io"
	"net"
	"os"
	"time"
)

func main() {
	// サーバーを起動
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		panic(err)
	}
	defer listener.Close()
	fmt.Println("Server is listening on :8080")

	for {
		// クライアント接続を受け入れる
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Connection error:", err)
			continue
		}
		fmt.Println("Client connected:", conn.RemoteAddr())

		// 接続を処理するgoroutineを起動
		go handleConnection(conn)
	}
}

// クライアント接続を処理する関数
func handleConnection(conn net.Conn) {
	tcpConn := conn.(*net.TCPConn)

	// データを読み取るgoroutine
	go func() {
		buf := make([]byte, 1024)
		for {
			n, err := conn.Read(buf)
			if err != nil {
				if err == io.EOF {
					fmt.Println("Client disconnected.")
					return
				}
				fmt.Println("Read error:", err)
				return
			}
			fmt.Printf("Received: %s\n", string(buf[:n]))
		}
	}()

	file, err := tcpConn.File()
	if err != nil {
		fmt.Println("File() error:", err)
		os.Exit(1)
	}
    // fileは複製されたディスクリプタを参照しているので要クローズ
	defer file.Close()

	fmt.Println("file=%s", file)
	fmt.Println("fd=%s", file.Fd())

	time.Sleep(3 * time.Second)
	fmt.Println("Closing")
	conn.Close()
	fmt.Println("Closed")
}
```

これでブラウザからlocalhost:8080を叩くと、Closeでハングする現象が確認できます。

で、なんでFd()関数を呼んでいるかというと、syscall.GetsockoptInt()関数を使ってソケットのオプションを取得するのにファイルディスクリプタが必要だったからです。具体的には、ソケットのバッファサイズ、すなわち読み込むデータの最大バイト数を取得して、そのサイズ分のバッファを準備したかったわけです。

:::note note
ちなみに、syscall.GetsockoptInt()関数を使宇野をやめて、例えばバッファサイズを8192バイトとか固定にしてみたら、Closeは成功しました。
:::

あと、TcpConnのFile()関数は、ファイルディスクリプタを複製してそれを参照しているので、こちらもクローズしないと、ファイルディスクリプタを消費したままになります。

今回は何とか解決できたので良かったですが、この2つの動きは要注意です。

:::note warn
- TcpConnのFile()関数はファイルディスクリプタを複製し、それを参照するFileを返す
- FileのFd()関数はファイルディスクリプタをブロッキングモードに設定する
:::

システムプログラミングを行う身としては、裏であまり余計なことをしないでほしい（するならきちんとドキュメント化して欲しい）ところであります笑

:::note alert
というか、Goでシステムプログラミングなんかするなって話もありますが笑
:::

ちなみに、メッチャ余談ですが、BayServer for Goの開発時、当初はゴルーチンを使わず、epollやkqueueを使ってシングルスレッドで爆速に処理するって言う方法を実装していました。

ただ、結局この方法もFd()でファイルディスクリプタを取り出して使う関係上、クローズに支障をきたしてしまい、結局うまく行かなかったんですね笑

:::note note
Goなら素直にゴルーチン使いましょって話ですね。
:::

ま、とにかく、Goはシステムプログラミングにはあまり向いていないかと思われます笑

と言うわけで、BayServerもよろしくお願いします！