はじめに
サーバがコネクションをclose後しばらくして、クライアントがwrite
しました。関数呼び出しの結果はエラーでしょうか?
上記の正解は「何事もなく、成功する」です。この挙動が理解できている方はこの記事を読む必要はないかもしれません。
自分は「接続先がclose
したソケットにwrite
した場合はプロセスがSIG-PIPE
を受け取るかシグナルをハンドリングしていた場合はEPIPE
が返ってくる」と思っていました。
それは、Linuxのマニュアルに以下の記載があるためです。
EPIPE fd is connected to a pipe or socket whose reading end is closed.
When this happens the writing process will also receive a SIG-
PIPE signal. (Thus, the write return value is seen only if the
program catches, blocks or ignores this signal.)
何でエラーにならないのか?
これを理解するのにWriting to a closed, local TCP socket not failingに全てが載っているのですが、なかなか読み解くのが難しいのでこちらを解説するかたちで書いていきたいと思います。
なお、C言語のシステムコールを前提に書いていきます。各言語の挙動は若干異なるかもしれませんが、C言語の挙動が理解できればマニュアルを読めばわかると思います。
TL;DR
- TCPの仕様上、FINパケットが渡ってきても通信相手がソケットをまだ読もうとしている場合もあるし、もうcloseしている可能性もあるのでFINパケットを受け取ったOS側で通信相手がどちらの状態なのか判断できない。
-
write
した場合、前者の場合は自分の方からclose
するまで通常通りパケットを送り続けることが可能だが、後者の場合は通信相手側で意図していないパケットを受け取るかたちになりRESET
パケットを送ってくる。いずれにしろ最初のwrite
時にはエラーにはならない。 -
RESET
パケットを受け取るとOS側でコネクションを放棄しCLOSED状態にするので、そのあと、プログラム側でread
やwrite
をしようとすると、ECONNRESET(connection reset by peer)
もしくはEPIPE(broken pipe)
/SIGPIPE(broken pipeのシグナル)
を受け取る
動機
自分はGo言語でアプリのバックエンドシステムを開発しているのでここまでlow levelの挙動を普段意識することが少ないのですが、気になったきっかけを書きます。
以前、アプリからDBにSQLを投げたところ、コネクションがinvalidだというエラーが起きました。この原因自体はとても簡単でサーバ側(DB側)のコネクションを保持するタイムアウト設定がクライアントよりも短かったというだけなのですが、「これってクライアントライブラリ側でソケットにwriteした時点でエラーになるんだからハンドリングしてコネクションプールに保持している他のコネクションをよしなに使ってよ!!」と思ったのでした。
でも、それって本当にできるの?と思って調べてみたのがきっかけです。
tcpのコネクション解放の流れの復習
tcpのコネクションの解放をおさらいします。ネット上にたくさん説明が存在するので、わざわざ説明するほどでもないですが、軽く触れておきます。
こちらやこちらあたりがまとまっていてサッと眺めるにはいいかもしれません。
FINによる通常の切断
通常のFINによるコネクションの切断の流れです。
ポイントとしては、
- 切断を宣告された(passive close)側は相手からの
FIN
を受け取った後の状態(つまりclose_wait
)でさらにパケットを相手側に送りつけるのはTCPの仕様上合法であるし、FIN
を送った(active close)側もそれをread
して読むのも合法 -
FIN
を送った(active close)側は相手からFIN
を受け取ってもすぐにはコネクションを解放しない。通常数分ほどTIME_WAIT
になる。これはネットワーク上で遅れていたパケットが到着する可能性を考慮し、同じシーケンス番号、ポート番号などを利用しないようにするため。
RSTによる強制切断
コネクションの強制切断です。プロトコルでは回復できない誤りが検出されたときに投げられ、通常はアプリケーションから意図しては送らない。TCPバッファ上の未送信データや受信済み/未受理データは消去されるようなので、安全な切断方法ではありません。
closeとshutdown
close
の説明は不要でしょうが、shutdown
は知らない人も多いかもしれません。
int shutdown(int s, int how)
引数sにソケット記述子を指定します。引数howに以下のいずれかの定数を指定
SHUT_RD: 今後このソケットでデータを受信しない。今後このソケットで読もうとするとエラーになります
SHUT_WR: 今後このソケットでデータを送信しない。通信相手にはEOFが送られ、今後このソケットに描こうとするとエラーになります
SHUT_RDWR: 今後このソケットで送受信しない。上の療法の効果が得られます。
よほど凝った実装をしない限りは普通は何も考えず、shutdown(s, SHUT_RDWR)
してclose(s)
をすればいいものと自分は理解してます。
なお、close(s)
だけでもこのソケットで送受信しない効果があります(ソケットを解放するので当然ですが)。では、どのような場合に使うのでしょうか。
参考までに詳解UNIXプログラミング 第3版p.554を引用します。
ソケットをcloseできるのに、なぜshutdownが必要なのでしょう?理由はいくつかあります。まず、closeは使用中の最後の参照がクローズされたときにのみネットワークの端点を解放します。ソケットを(例えばdupで)複製している場合、それを参照する最後のファイル記述子をクローズするまではソケットは解放されません。関数shutdownは、ソケットを参照する使用中のファイル記述子の個数に関係なくソケットを非活性にできます。第2に、ソケットのある方向だけを止めると便利なことがあります。例えば、通信相手のプロセスにデータ送出を完了したことを伝えるためにソケットへの書き込みを閉じても、当該ソケットを使って相手プロセスからデータを受信し続けることができます。
2020/07/28 追記
Goのnet/http/server.goのソースをよんでいてたまたま気付きましたが、興味深い実装がされてました。
リンクのコメントにあるとおり、BSDやWindowsなど一部のOSでは全てのデータをRead
せずにClose
した場合、RST
がクライアント側に飛ぶようです。
RSTを受け取るとクライアント側はデータをロストする可能性が高いため、リクエストデータが大きすぎるなどの理由で全てのデータをRead
せずにサーバがレスポンスしたい場合、バッファリングされたデータをソケットに全てWriteした後、shutdown(SHUT_WR)
をcallすることでFIN
を飛ばしたあと、500msの間Sleepし、Close
する実装になっているようです。この500msはクライアント側がFIN
に反応し正常に終了処理することを期待する時間であり、この数字に特に根拠はないとのこと。
本題の説明
事前知識を復習できたので、Writing to a closed, local TCP socket not failingが理解できると思います。まず、このstackoverflowはどのような質問かというと、
クライアント側でソケットをclose
して、FIN_WAIT2
/CLOSE_WAIT
の状態になるまでは想定通りだが、サーバ側(passive close側にあたる)がwrite
した際、SIG-PIPE
シグナルを受け取ることもEPIPE
で返ることもなかった。これはなぜ??
という自分と全く同じ疑問を持っていたことになります。これに対して、jxhさんがかなり丁寧に説明してくれてます。netstat
コマンドの状態やtcpのダンプが以下の状態にあることを確かめてくれてます。
- 質問者のようにクライアント側が
close
した場合 -
FIN_WAIT2
/CLOSE_WAIT
の状態で、サーバ側がwrite
する -
RESET
パケットがクライアントからサーバへとぶ - コネクションが強制解放されたので、
netstat
の出力はすぐに消える - クライアント側が
shutdown(SHUT_WR)
した場合 -
FIN_WAIT2
/CLOSE_WAIT
の状態で、サーバ側がwrite
する -
FIN_WAIT2
/CLOSE_WAIT
の状態のまま
この一見奇妙な現象はなぜ起きるのかというと、以下のように説明してくれてます。サーバ側はクライアント側がどのシステムコールを叩いたのかはわからず、わかるのはtcpの状態だけなので、write
してクライアント側にパケットが届いてその反応をみて初めて相手がどちらのシステムコールを読んだのか知るのです。この点が今回一番伝えたかった点です!!
So, the server doesn't know the client will reset the connection until after it tries to send some data to it. The reason for the reset is because the client called close, instead of something else.
The server cannot know for certain what system call the client has actually issued, it can only follow the TCP state.
じゃあ、closeしたい側がread
もwrite
もする気がないことをシステムコールで伝えるのは不可能なのかというと、setsockopt
システムコールでソケットの属性を変更してあげることでRESET
パケットを意図的にとばせば、可能ではあります。
この場合、write
がECONNRESET
ですぐエラーになります。Connection reset by peer
というエラーメッセージは見かけたことがある方も多いのではないでしょうか。ただ前述のようにパケットのバッファが全てなくなるので安全ではありません。
え、でも待って、、じゃあ、「接続先がcloseしたソケットにwriteした場合はプロセスがSIG-PIPEを受け取るかシグナルをハンドリングしていた場合はEPIPEが返ってくる」という記述は嘘?と思う方もいるかもしれませんが、実は状況次第で正しく、たとえば、単純に(通信相手がではなく)自分がshutdown(s_sock, SHUT_WR)
したソケットに対してwrite
するとちゃんとSIG-PIPEが発生するのこと。
おまけ
ややこしいので細かいことが気になる方だけ。
上の記述のままだと通信相手がFIN
パケットを飛ばした時には、write
時にSIG-PIPEに必ずならないと誤解されそうなので補足です。実は、ECONNRESET in Send Linux Cによると、通信相手がclose
したソケットに対して、二回write
を呼ぶと2回目でSIG-PIPEになるようです。(当然ですが、1回目は何度も書いているようにエラーにならない)
ECONNRESET
になるべきでは?というのがstackoverflowの質問ですが、Steffen Ullrichさんによると、以下のようにFIN
の後のRESET
か否かでエラーが変わるようです。難しいけど、納得はできますね!!
the client does not have the knowledge. The difference is that in the case of ECONNRESET only the RST is sent by the peer (hard close with data inside socket buffer) while in case of EPIPE first a FIN is sent (normal close). The RST is only sent after the peer received more data. And this difference (RST vs. FIN followed by RST) can be seen at the client side.
簡単にまとめると、
-
RST
の前にFIN
を受け取っていた場合EPIPE
/SIGPIPE
-
RST
のみ受け取った場合はECONNRESET
で、後者がどのような時におきるかというと、サーバがOSのソケットのバッファを全部readしていない状態でサーバ自身がcloseした場合などでおきます。
感想
素人の率直な感想ですが、tcpの仕様としてコネクション解放の過程で通信相手がまだread
する可能性があるのかはわかるようにした方が何かと便利だったのではと思ってしまいました。
また、この辺りちゃんと勉強しようとしたら、やはりUNIXNetwork Programmingを読まないといけないんだろうと改めて思いましたが、なかなか読む気には、、
追記
続編を以下に書きました
https://qiita.com/behiron/items/6548718cf422e87f7cbe
まさにこの挙動起因によるものなのでリンクを以下に紹介
https://stackoverflow.com/questions/43189375/why-is-golang-http-server-failing-with-broken-pipe-when-response-exceeds-8kb