FIONREADによると、Linux SO_RCVLOWATはepollによって違反されています。

FIONREADによると、Linux SO_RCVLOWATはepollによって違反されています。

ソケットを処理するより伝統的な方法は、接続ごとにバッファを持ち、ソケットを読み取ることができるようになったら、できるだけ多くのバイトを徐々に読み取ることです。

TCPを介して実行されるフレームワークプロトコルの場合、より効率的なアプローチは、カーネルがすでに各接続に対してデータをバッファリングしているという事実を利用することです。 Linuxは、必要なバイト数が得られるまで、ポーリング/選択/epollがソケットを読み取り可能としてマークするのを防ぐためにSO_RCVLOWATフラグを提供します。これは、FIONREAD ioctlと共に使用して、処理ループ内の部分的な読み取りを防ぐためにすぐに消費できるバイト数を読み取ることができます。この構造では、フレーム全体を単一の共有バッファ(最大フレームサイズにサイズ変更)として一度に読み取って所定の位置で処理でき、次のフレームで上書きする準備ができています。

しかし、実装が中断されました。 Epollは、FIONREADによって報告された値がSO_RCVLOWATより小さい場合でもREADイベントを開始します。 MSG_PEEKを使用してrecv()を呼び出すと、FIONREADと一致する値が返されるため、ソケットは実際にはいいえ少なくともSO_RCVLOWATバイトはすぐに読み取ることができるので、epollから読み取ることができると見なすべきではありません。

以下は、問題を再現するいくつかのサンプルサーバー/クライアントコードと一緒に実装したものです。https://github.com/MrSonicMaster/broken

特に:

static void handle_reads(proto_state *s) {
  uint32_t bav;
  ioctl(s->fd, FIONREAD, &bav);

  uint32_t lowat;
  getsockopt(s->fd, SOL_SOCKET, SO_RCVLOWAT, &lowat,
             &(socklen_t){sizeof lowat});

  printf("EPOLL FIRED READ EVENT FIONREAD=%d SO_RCVLOWAT=%d\n", bav, lowat);

  if (bav < lowat) {
    /* debug code */
#define CAP (1 << 15)
    uint8_t *largebuf = alloca(CAP);
    ssize_t recvd = recv(s->fd, largebuf, CAP, MSG_PEEK);
    printf(
        "is FIONREAD lying? actual bav via recv with MSG_PEEK = %ld (cap %d)\n",
        recvd, CAP);
  }

  printf("BAV %d NEED %d\n", bav, s->hdr.len);

  msghdr hdr = s->hdr;

  while (bav >= hdr.len) {
    ssize_t read_bytes = read(s->fd, rbuffer, hdr.len);

    if (read_bytes != hdr.len) {
      fprintf(stderr, "WTF HOW? BROKE!\n");
      break;
    }

    bav -= read_bytes;

    if (s->need_hdr) {
      hdr = *(msghdr *)rbuffer;

      if (hdr.len > 16384) {
        fprintf(stderr, "msg too large %d\n", hdr.len);
        close(s->fd);
        handle_close(s);
        return;
      }

      // printf("READ HEADER, CODE %d LEN %d (udat %d)\n", hdr.code, hdr.len,
      //       hdr.udat);

      if (hdr.len == 0) {
        /* handle zero-length message */
        s->frame_cb(s, hdr, NULL);
        hdr.len = sizeof(msghdr);
        continue;
      }

      s->need_hdr = 0;
    } else {
      // printf("READ FRAME, CODE %d LEN %d\n", hdr.code, hdr.len);
      s->frame_cb(s, hdr, rbuffer);
      hdr.len = sizeof(msghdr);
      s->need_hdr = 1;
    }
  }

  s->hdr = hdr;

out_setlowat:
  if (hdr.len != s->lowat) {
    setsockopt(s->fd, SOL_SOCKET, SO_RCVLOWAT, &hdr.len, sizeof hdr.len);
    s->lowat = hdr.len;

    printf("SET lowat %d\n", s->lowat);
  }
}

...

void proto_loop() {
  int nevents = epoll_wait(ep, events, MAX_EVENTS, 0);
  if (nevents == -1) {
    perror("proto_loop() epoll_wait()");
    return;
  }

  for (int i = 0; i < nevents; i++) {
    struct epoll_event event = events[i];

    void *ptr = (void *)(((uintptr_t)event.data.ptr) & ~1);

    // printf("EVENT %d %p\n", event.events, ptr);

    if (ptr != event.data.ptr) {
      listen_desc *d = ptr;
      handle_accept(d->fd, d->cb);
      return;
    }

    if (event.events & EPOLLERR || event.events & EPOLLHUP ||
        event.events & EPOLLRDHUP)
      handle_close(ptr);
    else {
      if (event.events & EPOLLIN)
        handle_reads(ptr);
      if (event.events & EPOLLOUT)
        handle_writes(ptr);
    }
  }
}

私が試したすべての設定で、最終的に次の問題が発生しました。

EPOLL FIRED READ EVENT FIONREAD=29103 SO_RCVLOWAT=14764
BAV 29103 NEED 14764
got frame with code 0 len 14764
got frame with code 0 len 5232
SET lowat 9647
EPOLL FIRED READ EVENT FIONREAD=9083 SO_RCVLOWAT=9647
is FIONREAD lying? actual bav via recv with MSG_PEEK = 9083 (cap 32768)
EPOLL FIRED READ EVENT FIONREAD=9083 SO_RCVLOWAT=9647
is FIONREAD lying? actual bav via recv with MSG_PEEK = 9083 (cap 32768)
EPOLL FIRED READ EVENT FIONREAD=9083 SO_RCVLOWAT=9647
is FIONREAD lying? actual bav via recv with MSG_PEEK = 9083 (cap 32768)
EPOLL FIRED READ EVENT FIONREAD=9083 SO_RCVLOWAT=9647
is FIONREAD lying? actual bav via recv with MSG_PEEK = 9083 (cap 32768)
...

レベルトリガー epoll を使用し、バッファーを静的サイズに強制してカーネルバッファー自動サイジングを無効にします。どのサイズに設定したかは重要ではないようです(輻輳ウィンドウを完全に閉じずにフレーム全体に収まるほど大きい場合(例:最大フレームサイズの2倍以上))

注目すべきもう一つのことは、サーバーをシャットダウンしても、クライアントで EPOLLRDHUP イベントが生成されないことです。

残念ながら、他の人がこれを行う例を見つけることができないようで、これがうまくいくかもしれません。

答え1

だから私は実際にこの問題を解決しましたが、答えを投稿できませんでした。

非ブロックのLinuxソケットにバッファスペースが足りなくなると、SO_RCVLOWAT設定に違反し、とにかく読み取り可能とマークされます。この動作はどこにも文書化されていません。言及された唯一のものは、Linuxカーネルソースツリーへのいくつかのコミットです。問題を示すコードでこの問題が発生してはならないので、最初はこれが本当だとは思わなかった。これは、UNIXドメインソケットの文書化されていない追加の最適化が原因であると考えられます。これにより、送信側SNDBUFが受信側RCVBUFに送信されます。一括送信を一度に入れることができるよりも大きいチャンクで送信する方法です(単一転送より大きくなければなりません)。 SO_RCVLOWATに違反したときにSO_ERRORでgetsockopt()を呼び出すとENOBUFSが返されるため、これが問題であることを確認できます。

UNIXドメインソケットでこれを行うソリューションは、受信者RCVBUFが全体の送信者SNDBUFより多くを保持するのに十分な大きさであることを確認して、Linux内で実行された最適化/バッチ処理によってプログラムロジックが中断されないようにすることです。

関連情報