출처: <https://jameshfisher.com/2017/04/05/set_socket_nonblocking/>
전통적인 unix 시스템 콜은 blocking이다.
예를 들면 accept()는 커넥션이 있을 때까지 호출한 클래스를 블럭한다.
만약 소켓에서 메시지를 전송하기 위해 저장해야하는 메시지 버퍼의 크기가 충분하지 않으면, send()를 블럭한다.
반대로 소켓에서 새로 들어온 메시지가 없다면, 새로운 메시지가 도착할 때까지 recv() 콜은 기다린다.
우리는 서버를 구축할 때, 많은 종류의 이벤트에 반응할 수 있도록 항시 대기여야 한다. 예를 들어, 새로운 커넥션이 만들어질 수 있고, 클라이언트가 우리에게 요청을 보낼 수 있고, 클라이언트가 커넥션을 끊을 수도 있다.
만약 우리가 accept()를 호출해야 한다면, 이 호출은 프로그램을 블럭시킬 것이고, 우리는 다른 이벤트에 응답하는 능력을 잃게 된다.
이런 문제에 대한 전통적인 해결법은 select() 시스템 콜이다.
The traditional answer to this problem is the select system call. We call select indicating various blocking calls we’re interested in. select then blocks until one or more of those blocking calls is ready, meaning that calling it will not block.
If our server only makes calls which select has indicated will not block, will everything be OK? No!
두 가지 오퍼레이션이 있다.
Non-blocking 콜을 이끄는 select
서버가 콜을 부를 때까지, 상황은 바뀔 수 있다. 예를 들면 우리가 accept하기 전에 기다리고 있던 커넥션이 사라질 수도 있다. 혹은 클라이언트로부터 온 데이터를 읽으려고 read() 하기 전에 데이터가 사라질 수도 있다. 데이터는 우리가 얻기 전에 소켓의 다른 프로세스에 의해 읽혀질 수도 있다.
이것에 대한 해결책은 non-blocking I/O이다. 우리는 소켓의 플래그를 non-blocking으로 마크한다. 그러면 read나 write같은게 호출됐으나 온전히 완료되지 않은 상황에서 에러를 넘겨준다. (EWOULBLOCK, EAGAIN)
소켓을 non-blocking올 마크하기 위해서, 우리는 fcntl 시스템 콜을 사용한다.
아래 예제가 있다.
int flags = guard(fcntl(socket_fd, F_GETFL), "could not get file flags"); guard(fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK), "could not set file flags");
아래는 완성된 예제 코드이다. 이 서버는 TCP 포트 8080을 열고, 리스닝 소켓을 non-blocking으로 마크한다. 그러면 서버는 loop를 돌꺼고 반복적으로 새로운 connection이 있는지 물을 것이다.
만약 서버가 커넥션을 얻으면, 서버는 어떤 데이터를 write하고 그 소켓을 close한다.
만약 서버가 아무런 커넥션도 얻지 못했다면, 그러면 서버는 다시 시도하기 전까지 1초간 sleep할 것이다.
https://yoopaark.tistory.com/6
서버로 기능하기 위해서는 다수의 클라이언트와 연결되어 있는 상태로 정보를 주고받을 수 있어야 합니다. 하지만 서버의 프로세스는 하나이므로, 클라이언트 fd가 blocking으로 열려 있다면 정보를 받을 때 무한히 기다리게 될 수도 있을 것입니다.
따라서 클라이언트 fd는 non-blocking으로 열어야 되며, 이 경우에 멀티 플렉싱(도이셍 여러 클라이언트로부터 정보를 주고받는 방법)은 크게 두 가지가 있습니다.
첫째는 폴링(polling)으로, 일정 주기를 가지고 EAGAIN이 나오든 성공적으로 읽든 무조건 전체 클라이언트를 iterate하는 것입니다. 하지만 불필요하게 시스템 자원이 소모된다는 단점이 있습니다. (한번 보내고 받으면 그것으로 끝이기 때문에 정보를 보내는 사건이 훨씬 희소할 거싱라는 걸 유추할 수 있습니다.) 따라서 그 대안으로 select함수를 주로 사용합니다.
Select를 사용하면 프로세스는 커널에게 특정 이벤트를 기다리도록 요청하고, 원하는 이벤트가 도달되는 경우에만 프로세서를 깨우는 방식을 사용합니다. 따라서 폴링과 정반대로 FD_ISSET이 true인 경우에만 I/O operation을 하도록 설정한다면 EAGAIN을 원천적으로 나오지 않게 만듭니다.
그렇다면 ready to read와 read to write은 어떤 상황을 의미할까요?
예를 하나 들어봅시다. Select를 한 번 거친 뒤 특정fd의 read_set이 켜졌고, 그 fd로부터 recv를 1회 진행하였습니다. 그 다음에 select를 거치면 (다시 그 사이에 다른 요청이 들어오지 않는 한) 그 fd의 read_set은 꺼질겁니다. 이러한 상황에서 select를 한번만 거친 상태에서 recv를 2회 진행하면 어떻게 될까요? 아마도 EAGAIN 에러가 반환될 겁니다. 그렇게 되면 select를 하는 이유가 없어지고, 따라서 one select, on recv/send operation이라는 규칙이 발생하게 됩니다.
여기서 one operation이라는 말은 아주 당연하게도 fd당 1회입니다. 따라서 아래와 같이 send를 여러 번 쓰더라도 if-else if로 연결된 경우에는 문제가 없습니다.
하지만 recv1회, send 1회 라는 말이 아니라 특정 fd당 recv와 send 합쳐서 1회만 이루어져야 한다는 것을 의미합니다. ( 이부분은 확실하지 않으나, 보다 안전한 방법이긴 합니다.)
따라서 한번이라도 operation을 거쳤다면 continue를 사용하여 재빠르게 fd_set을 다시 세팅하도록 해야 합니다.
#include <fcntl.h> fcntl(fd, F_SETFL, O_NONBLOCK);
#include <sys/socket.h> send(fd, msg, N, MSG_DONTWAIT); recv(fd, buf, N, MSG_DONTWAIT);
Non-blocking 방식으로 I/O를 진행하는 방식에는 두가지가 있습니다. 첫째는 파일 디스크립터 자체의 특성을 Non-blocking으로 반영구적으로 변경하는 것이며, 둘째는 보내거나 받을 때만 일시적으로 Non-blocking으로 바꾸는 것입니다.
https://beej.us/guide/bgnet/
https://dgkim5360.tistory.com/entry/beej-guide-to-network-programming-translation-part-2-slightly-advanced-techniques
이렇게 non-blocking socket을 설정하면 우리는 소켓을 polling할 수 있다.
Non-blocking sockt으로부터 데이터를 바등려고 했는데 아직 데이터가 오지 않았다면, block할 수 없으므로 -1을 반환하고, errno에 EAGAIN 이나 EWOULDBLOCK이 저장될 것이다.
하지만 일반적으로 이런 busy-wait polling은 좋은 생각이 아니다. 왜냐하면 이렇게 지속적으로 socket이 데이터를 받았는지 말았는지 확인하는 것은 CPU에 부하를 많이 주기 때문이다. 더 좋은 방법은 select()를 사용하는 것이다.
Select - Synchronoush I/O Multiplexing
서버가 incoming connection을 계속 기다리면서도, 이미 들어온 연결이 가지고 있는 데이터도 동시에 읽고 싶은 상황을 생각해보자.
앞서 설명했던 accept() 후 recv()는 사실 그렇게 빠르지 않다. Accept()가 block 한다면, 동시에 recv()를 통해 데이터를 읽을 수가 없는 것이다. 그렇다고해서 non-blocking socket을 사용하기엔 CPU파워를 너무 잡아먹는다.
Select는 여러 socket을 동시에 저먹ㅁ할 수 있게 해준다. 어느 socket이 데이터를 읽을 준비가 됐는지, 어느 socket이 데이터를 쓸 준비가 됐는지, 아니면 오류를 내보낼 준비가 됐는지를 알려준다.
반면 요즘 시대엔 select()가 여러 socket을 모니터링하는 가장 느린 방법 중 하나이기도 하다. (매오 후환성이 좋기는 하지만)