소켓을 처리하는 보다 전통적인 방법은 연결당 버퍼를 갖고 소켓을 읽을 수 있게 되면 가능한 한 많은 바이트를 점진적으로 읽는 것입니다.
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 내에서 수행된 최적화/일괄 처리로 인해 프로그램 논리가 중단되지 않도록 하는 것입니다.