tcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話


はじめに

サーバがコネクションを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状態にするので、そのあと、プログラム側でreadwriteをしようとすると、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したい側がreadwriteもする気がないことをシステムコールで伝えるのは不可能なのかというと、setsockoptシステムコールでソケットの属性を変更してあげることでRESETパケットを意図的にとばせば、可能ではあります。

この場合、writeECONNRESETですぐエラーになります。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