[Network, OS] Socket, I/O Multiplexing, Event Loop
Socket → I/O Multiplexing → Event Loop 순서로 공부한 내용이다.
따로따로 내용을 분리하기엔 서로 연결된 내용이 많아서 한 번에 작성했다.
0. UNIX 철학
Everything is a file
객체지향에서 "모든 것은 객체로 이루어져 있다"처럼 UNIX 운영체제의 철학은 "모든 것은 파일로 이루어져 있다"이다.
소켓이든, 프로그램이든, 파이프라인이든 모든게 파일로 이루어져 있다는 것이다.
이 내용을 기억해두면 좋은게 '추상적 개념'이 아닌 '파일'이라는 관점으로 보아야 수월하게 이해하는데 도움이 된다.
1. 소켓이란?
[1] 개념
소켓은 운영체제가 제공하는 인터페이스로 애플리케이션 계층의 프로그램이 네트워크 서비스를 사용할 수 있게하는 역할을 한다. TCP/IP 4계층으로 이야기하면 Application 계층과 Transport 계층 사이에 존재하게 된다. 이렇기 때문에 소켓은 애플리케이션이 Transport 계층의 세부 사항을 신경쓰지 않고 네트워크 통신을 할 수 있도록 인터페이스를 제공한다. (transport 계층 역할 예시: checksum, TCP라면 segmentation, ...)
[2] 파일 디스크립터
소켓 번호는 응용 프로그램마다 고유한 번호를 가지고 있는데, 파일 디스크립터 번호를 같이 사용한다. 이때 파일 디스크립터(FD)란 사용자가 특정 파일에 접근하려면 파일 관리자로부터 파일에 접근할 수 있는 권한을 획득해야하는데, 해당 파일 접근 권한을 파일 디스크립터라고 한다. 파일이 열리거나 I/O 작업을 수행해야하면 고유한 파일 디스크립터 번호를 할당하고, 해당 값을 통해 파일의 읽기 / 쓰기 / 실행이 가능하다. 이때 파일 디스크립터 번호에서 0, 1, 2는 값이 이미 정해져있다.
- 0 : 표준 입력 (stdin)
- 1 : 표준 출력 (stdout)
- 2 : 표준 에러 (stderr)
여기서 소켓도 파일이므로 파일 디스크립터 번호를 공유하여 사용한다고 생각하면 된다. 위 그림에서 fd=3, sd=3으로 파일 디스크립터 번호 3이 여러개 있지만, 응용 프로그램마다 고유한 번호를 가지고 있기 때문에 문제가 없다. 그러므로 서로 다른 응용 프로그램이라면 FD 번호가 겹칠 수 있게 된다.
[3] 실습
MacOS 기준 터미널에 lsof를 입력하면 파일 디스크립터를 볼 수 있다. 이때 직접 확인하는 분들은 0r, 1u, 2u는 무조건 있다는걸 알 수 있는데, 뒤에 붙여진 알파벳은 이런 역할을 한다.
- r: 읽기 모드
- w : 쓰기 모드
- u : 읽기, 쓰기 모두 가능한 모드
파일 디스크립터에서 소켓을 확인하는 방법은 파일 디스크립터 바로 오른쪽에 IPv4, IPv6, unix, ... 과 같은 이름으로 되어 있다. 여기서 IPv4와 IPv6는 무엇인지 알텐데, unix의 경우에는 같은 호스트에서 프로세스끼리 통신할 때 사용되는 소켓이다. 이건 TCP/IP를 사용할 필요없이 운영체제의 파일 시스템을 통해 통신하기 때문에 더 빠르고, 부하가 적게 발생하게 된다.
MacOS 기준 터미널에 sudo fs_usage를 입력하게 되면 실시간으로 파일 디스크립터가 무슨 일을 하는지 볼 수 있다. (종료는 Ctrl + C)
- sendto / recvfrom : 데이터 보내기 / 데이터 받기
- F = ?? : 파일 디스크립터 번호
- B = ?? : 데이터를 보내는 / 받는 크기
2. 소켓 통신 과정
[1] 소켓 통신을 위해 알아야할 기본 지식
소켓의 종류는 사용하는 전송 계층의 프로토콜에 따라 달라지게 된다. TCP / UDP에 사용하는 각 소켓이 종류마다 데이터를 통신하는 방법이 다르다.
- TCP : Stream Socket
- UDP : Datagram Socket
소켓은 Transport 계층와 Application 계층 사이에 있기 때문에 두 계층을 연결하려면 아래 정보가 필요하다.
- 소켓 번호 (= 파일 디스크립터 번호)
- 자신의 IP 주소
- 자신의 포트 번호 (= 프로세스에 할당된 포트 번호)
추가적으로 소켓을 통해 패킷을 보내거나, 받기 위해서는 상대방의 주소도 필요하다.
- 전송 계층 프로토콜 종류
- 자신의 IP 주소
- 자신의 포트 번호
- 상대방의 IP 주소
- 상대방의 포트 번호
[2] TCP 소켓 통신 과정
여기서 먼저 알아야할 중요한 내용은 처음 만들어진 TCP 소켓은 일종의 창구 역할을 한다는 점이다.
- socket() : 소켓 생성
- bind() : 서버는 통신할 포트번호, IP 주소가 필요하므로 소켓과 연겨하는 과정이 필요
- lisetn() : 연결 대기
- connect() : 클라이언트가 서버에 연결 요청을 하며 3-way handshake를 수행
- accept() : 연결 요청을 수락하고, 새로운 소켓을 만들어서 연결함 (기존 소켓은 listen() 상태로 기다림)
- recv(), send() : 새로운 소켓에서 클라이언트와 1대1로 데이터를 주고 받음
- close() : 연결 종료를 하며 4-way handshake를 수행
TCP 연결을 하게 된다면 특정 시각에 연결된 클라이언트 수가 N명일 때, 1개의 포트번호에서 N+1개의 소켓을 사용하게 된다.
[3] UDP 소켓 통신 과정
UDP 소켓 통신 과정은 비교적 간단하다. 비연결지향 프로토콜이기 때문에 listen() - connect() - accept() 과정이 필요없다. TCP와 다르게 UDP는 새로운 소켓을 생성하지 않고, 하나의 소켓에서 다 처리하게 된다.
3. 소켓은 몇 개를 생성할 수 있을까?
[1] 소켓 최대 생성 개수 확인하기
가끔가다 면접 질문으로 '소켓은 몇 개를 생성할 수 있을까요?'를 물어보면서 포트 번호로 낚시하는 경우도 있다. 이때 '소켓은 포트 번호 개수만큼 생성됩니다'라고 말하면 완전히 낚여버리는 경우다. 위에서 언급했듯이 소켓은 TCP 통신에서 하나의 포트 번호로 N+1개가 필요하기 때문에 포트 번호 개수만큼 생성하게 된다면 모든 포트 번호를 활용하지 못하는 문제가 발생하게 된다. 그러면 소켓은 몇 개를 생성할 수 있을까? 정답은 소켓은 컴퓨터가 버틸 수 있을 때까지 무한정 만들어 낼 수 있다. 이걸 어디서 확인할 수 있는지 알아보자
MacOS 기준 터미널에 ulimit -a -S, ulimit -a -H를 입력해보면 최대 개수를 확인할 수 있다. 여기서 S는 soft, H는 hard인데 soft는 프로세스가 만들어지자마자 할당 받는 크기이고, hard는 soft로는 부족할 경우 추가적으로 받을 수 있는 리소스이다. 우리가 볼 것은 맨 아래에 file descriptors인데, hard의 file descriptors는 unlimited로 무한정 만들어낼 수 있다. 여기서 unlimited가 아닌 값도 루트 계정으로 값을 수정할 수 있다.
[2] TMI: 스레드는 몇 개를 생성할 수 있을까?
여담으로 소켓은 무한정으로 만들어낼 수 있는데, 스레드는 과연 몇 개를 만들 수 있는지 궁금해졌다. 찾아보니 이론적으로는 메모리가 버틸 수 있을만큼 만들어낼 수 있다. 하지만 터미널에 sysctl kern.num_taskthreads를 입력해보면 프로세스 하나당 만들 수 있는 스레드 개수가 제한되어 있다. 그래서 스프링 프로젝트에 스레드를 생성해보면 4096개 이하로 생성할 수 있음을 알 수 있다. 근데 이것도 루트 계정으로 값을 변경할 수 있다.
1. 종설 프로젝트
2. 우테코 프로젝트
신기한건 OOM이 메모리 용량을 초과해서 에러가 나오는 경우도 있지만, 리소스 제한에 도달해서 OOM이 발생할 수도 있다는 점..
[3] 소켓과 웹소켓
둘은 완전히 다른 개념이다. 하지만 소켓을 구글에 검색하면 1페이지에 나오는 모든 블로그들이 웹소켓 내용과 섞여있는 상태로 정리되어 있다... WebSocket은 HTTP와 같은 7계층 프로토콜로 실시간으로 데이터를 주고 받을 수 있게 커넥션을 유지하는 프로토콜이다. 그리고 TCP 통신으로만 가능하다. 왜 UDP 통신은 불가능한걸까 생각해보면 TCP처럼 따로 연결도 하지 않고, 패킷 순서도 정리해주지 않기 때문에 UDP를 지원해줘도 애초에 잘 사용할 수가 없을 것 같다. 또 웹소켓은 무조건 HTTP / HTTPS 연결에서 Upgrade 헤더를 통해 websocket으로 변경해야한다. Upgrade 헤더는 해당 포스팅에서 중요한 내용은 아니니 더 궁금한 분은 MDN 문서를 참조하는게 좋다.
4. I/O Multiplexing이란?
[1] Multiplexing 정의
Multiplexing (다중화)의 정의를 찾아보면 하나의 통신로를 통해 여러개의 독립된 신호를 전송하는 방식이라고 나와있다.
잘 이해가 안가지만, 네트워크 공부를 하신 분이라면 아래 내용을 봤을 수도 있을 것 같다.
[2] 네트워크에서의 Multiplexing
Transport Layer의 TCP / UDP를 공부하면 multiplexing과 demultiplexing이라는 용어가 나온다.
- multiplexing: 여러 소켓에서 데이터를 받아 세그먼트를 만들고, Network Layer로 전달
- demultiplexing : 수신된 패킷을 IP와 포트번호에 바인딩된 소켓에 전달
HTTP 발전 과정을 살펴보면 HTTP 2.0에서는 Stream이라는 기술이 추가되어서 하나의 Connection으로 여러 개의 요청을 병렬적으로 보낼 수 있게 되었다.
HTTP 1.1에서는 Keep-Alive를 통해 하나의 Connection에서 계속해서 요청과 응답을 받을 수 있었지만, 패킷을 순서대로 보내야한다는 기술적 한계가 있었다. 예를 들어서 www.naver.com으로 GET요청을 보내면 HTML 패킷을 받게 되는데, HTML을 분석하는 과정에서 "/images/logo.png", "/images/icon.png"와 같은 이미지들이 있거나, "/styles/main.css"와 같은 CSS 코드가 있게 되어 추가적인 패킷 요청이 필요하다. 그러면 HTTP 1.1에서는 [logo 이미지 요청 패킷 전송] → [icon 이미지 요청 패킷 전송] → [CSS 요청 패킷 전송]이라는 순서로 데이터를 받아야하고, 응답값도 [logo 이미지 응답 패킷 수신] → [icon 이미지 응답 패킷 수신] → [CSS 응답 패킷 수신] 순서로 받아야 한다. 여기서는 3개의 요청에 대해서만 다뤘지만, 실제 사이트에서는 수많은 이미지와 CSS가 있기 때문에 사실상 하나의 커넥션을 사용하면 성능 저하가 일어나서, 여러개의 Connection으로 병렬 처리를 하게 된다.
이때 HTTP 2.0에서는 Stream 기술을 추가해 Stream ID를 통해 요청/응답을 구분해서 하나의 Connection으로 병렬적으로 패킷을 처리할 수 있게 됐다.
[요청]
- logo 이미지 요청 패킷 전송 (Stream ID: 1)
- icon 이미지 요청 패킷 전송 (Stream ID: 3)
- CSS 요청 패킷 전송 (Stream ID: 5)
[응답]
- Stream ID = 1에서 logo 이미지 응답 패킷 수신
- Stream ID = 3에서 icon 이미지 응답 패킷 수신
- Stream ID = 5에서 CSS 응답 패킷 수신
위와 같이 요청을 Stream ID로 구분지어서 식별할 수 있게 되었고, 패킷을 받는 서버도 해당 요청에 맞는 Stream ID로 응답 패킷을 보낼 수 있기 때문에 하나의 Connection에서 여러 개의 패킷을 보낼 수 있는 multiplexing이 가능해졌다.
이외에도 text → frame, server push, stream priority, 중복된 헤더는 다시보내지 않음 (헤더 압축)등이 HTTP 2.0에 지원하는데, 여기서 중요한 내용은 아니므로 궁금한 분은 Inpa 블로그에 자세히 설명되어 있다.
단순하게 요약하자면 네트워크에서의 Multiplexing은 하나의 연결 통로에서 여러 개의 패킷을 동시에 처리하는 것이다.
5. I/O Multiplexing
[1] I/O란?
I/O는 Input / Output으로 입출력을 뜻한다. UNIX 관점에서 보자면 모든 것은 파일로 이루어져 있으므로 입출력이라는 뜻 자체는 '파일을 읽고 쓰는 작업'이라고 이해할 수 있다. 운영체제에 파일을 읽고 쓰는 작업을 할당하면 어떤 과정이 일어날까? 아무래도 제일 먼저 생각이 드는건 프로세스나 스레드에게 작업을 하라고 시킬 것 같다.
파일 하나당 프로세스/스레드 하나를 할당해서 입출력 작업을 시키면 어떤 단점이 존재하게 될까? 소켓을 통해 여러명이 대화할 수 있는 채팅방을 만든다고 가정해보자. 이때 TCP 연결은 사람이 N명 들어오면 소켓도 N개가 생성되므로, 채팅방 프로그램에는 최소 N개의 소켓이 필요하게 된다. UNIX에서는 소켓도 파일이므로 패킷의 송수신은 곧 파일 입출력과 같다. 그래서 채팅방을 만들게 되면 여러개의 프로세스/스레드가 필요하게 된다.
만약 누군가가 채팅을 보내게 된다면, 혹은 'ㅇㅇ님이 채팅방에 들어왔습니다.'라는 문구를 보내려면 여러개의 프로세스/스레드가 서로 데이터를 공유해야한다. 이때 프로세스에서 데이터를 공유하는 기술은 IPC이고, 스레드에서 데이터를 공유하는 기술의 가장 원시적인 방법은 락과 조건변수를 사용하는 것이겠다. 그런데 IPC, 락과 조건변수를 활용해서 프로그래밍을 하는게 쉬운 방법일까?라는 생각도 들고, 애초에 프로그래밍 방식을 따지기 전에 채팅방 하나를 만든다고 여러개의 프로세스/스레드를 할당하는게 굉장한 자원 낭비가 아닐까?라는 생각이 든다.
[2] I/O Multiplexing 개념
하나의 스레드가 여러 파일을 모니터링하는 기술
여러개의 파일 입출력에 여러개의 프로세스/스레드를 할당하지 않고, 효율적으로 관리하려면 어떤 방법이 필요할까? 당연히 여러개의 파일을 하나의 프로세스/스레드에서 관리하는 것이다. 이게 I/O Multiplexing의 개념이고, 아래에서 자세하게 설명하겠지만 기술 동작 방식은 "하나의 스레드가 여러 파일을 모니터링하는 기술"이라고 보면 된다. 모니터링은 위에서 언급한 파일 디스크립터를 사용해서 모니터링이 가능해진다.
[3] select
I/O Multiplexing의 가장 원시적인 방법은 select 함수이다.
1
2
3
4
5
|
int select (int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict exceptfds,
struct timeval *restrict timeout);
|
cs |
파라미터를 하나하나 살펴보자.
- nfds : 관리하는 파일 디스크립터의 개수 (number of file descriptors)
- readfds : 읽을 데이터가 있는지 검사하기 위한 구조체
- writefds : 쓰여진 데이터가 있는지 검사하기 위한 구조체
- exceptfds : 에러가 발생한 파일이 있는지 검사하기 위한 구조체
- timeout : 지정된 timeout 시간동안 대기
- return int : 데이터가 변경된 파일 디스크립터의 개수
여기서 구조체로 fd_set이 나오게 되는데, 1024 크기를 가지는 배열이다. 이때 비트마스크 방식으로 값을 조작해서 최대 1024개의 파일 디스크립터를 모니터링 할 수 있다. 참고로 1024 값은 변경할 수 있긴한데, 찾아보니 값을 수정하고 커널 컴파일을 해야한다. 근데 더 좋은 기술이 있어서 굳이 커널 값을 수정할 필요는 없다.
select()는 다음과 같이 과정으로 동작한다.
1. select()를 호출한다. 이때 readfds만 사용한다고 가정한다.
2. readfds의 배열 크기만큼 완전 탐색으로 확인하게 된다.
3. 만약 읽을 데이터가 있으면 1로, 읽을 데이터가 없으면 0으로 설정한다.
4. 읽을 데이터가 있는 파일 디스크립터의 수를 반환해준다.
여기서 완전탐색을 사용하기 때문에 O(N)의 시간복잡도를 가지게 된다.
select()는 맨 위에서 언급했듯이 가장 원시적인 기술이다. 그래서 이 기술의 한계가 존재하는데, 위 사진에서 FD_SET()을 사용한 이후, select() 함수를 호출해 4번 파일을 1로 바꾼 것이다. 이때 select()를 실행하면 이전값이 덮어쓰기가 되기 때문에 따로 이전값을 저장해야한다는 단점을 가지고 있다.
[장점]
- 가장 단순한 형태라 쉽게 이해하고 적용할 수 있다.
- 지원하는 OS가 많아서 이식성이 좋다.
[단점]
- 완전 탐색이고, 관리할 수 있는 파일 디스크립터 최대 개수가 1024개이다.
- select를 호출하기 전에 이전 상태 값을 복사해야하는 상황이 존재한다.
[예시 코드]
[4] poll
poll()은 select()에서 최대 1024개의 파일 디스크립터를 관리할 수 있는 한계점을 개선해 무한대의 파일 디스크립터를 모니터링하게 해주는 기술이다. 거기다가 select()는 배열 크기만큼 완전 탐색을 했지만, poll()에서는 관리하는 파일 디스크립터 개수만큼 완전 탐색을 수행해 좀 더 효율적인 O(N)을 가지게 됐다.
1
2
3
|
int poll (struct pollfd *fds,
nfds_t nfds,
int timeout);
|
cs |
- *fds : 동적으로 확장 가능한 파일 디스크럽트를 검사하는 배열
- nfds : 관리하는 파일 디스크립터의 개수
- timeout : 지정된 timeout 시간동안 대기
1
2
3
4
5
|
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
|
cs |
- fd : 파일 디스크립터 번호
- events : 감시할 이벤트 종류 할당 (읽기 - POLLIN, 쓰기 - POLLOUT, ...)
- revents : 실제로 발생한 이벤트 (이벤트가 발생하면 events에서 설정한 값으로 변함)
이벤트 종류는 위와 같이 다양한데, 이중에 POLLERR, POLLHUP, POLLNVAL은 따로 events에 할당할 수 없고, 해당 상황이 발생하면 revents에 저장된다.
[장점 - select()에서 개선된 점]
- 무한대의 파일 디스크립터를 모니터링 할 수 있다.
- 파일 디스크립터의 개수만큼 O(N)의 완전 탐색을 수행한다.
- revents에 담는 이벤트는 커널에서 채워주기 때문에 사용자 <-> 커널 간 데이터 복사를 줄여주어 성능 저하를 개선했다.
[단점]
- 아직도 O(N)의 시간이 걸린다.
- fd_set은 파일 디스크립터 하나당 1과 0으로 동작하는데, pollfd는 파일 디스크립터, 이벤트, 실제 이벤트를 저장하기 때문에 관리할 파일 디스크립터가 많아질수록 성능이 저하된다.
[예시 코드]
[5] epoll
select, poll의 개선점을 생각해보면 다음과 같이 두 개를 생각해 볼 수있다.
- select(), poll()에서 완전 탐색으로 동작하는데, 굳이 파일 디스크립터를 하나하나 확인하는 O(N) 완전 탐색으로 동작해야할까??
- poll()에서는 revents만 커널에서 데이터를 보내주는데, 파일 디스크립터 자체를 커널에서 관리하는게 더 효율적이지 않을까?
이런 내용을 개선한 것이 epoll()이다.
- 파일 디스크립터를 완전 탐색으로 확인하는게 아닌, 이벤트 자체를 저장해서 완전 탐색을 수행하지 않아도 된다.
- 파일 디스크립터 자원을 커널에서 관리한다. 그래서 커널에서 이벤트를 저장하기 때문에 O(1)의 시간이 걸린다.
1
2
3
4
5
6
7
8
9
10
11
|
struct epoll_event {
uint32_t events; // 감시할 이벤트
epoll_data_t data; // 사용자 정의 데이터
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
|
cs |
- events : poll()에서의 events와 비슷하다.
- ptr : 파일 디스크립터 번호뿐만 아니라 추가적인 정보도 관리해야한다면 ptr로 저장 가능
- fd : 파일 디스크립터 번호
- u32, u64 : 추가적인 정보로 숫자값이 필요한 경우 저장하는 용도
epoll_data의 ptr, u32, u64는 사용자가 어떤식으로 쓰냐에 따라 달라진다.
그리고 epoll에는 epoll_create(), epoll_ctl(), epoll_wait() 함수가 있다.
1
2
3
|
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
|
cs |
epoll_create (epoll 인스턴스 생성)
- size : 생성할 파일 디스크립터 수
- return int : epoll 파일 디스크립터 번호
epoll_ctl (epoll 인스턴스에 파일 디스크립터를 추가 / 수정 / 삭제 기능 제공)
- epfd : epoll_create로 생성된 epoll 파일 디스크립터 번호를 입력하는 곳
- op : 파일 디스크립터 operation (FD 추가 - EPOLL_CTL_ADD, FD 수정 - EPOLL_CTL_MOD, FD 삭제 - EPOLL_CTL_DEL)
- fd : operation에 사용할 파일 디스크립터 번호 (ADD, MOD, DEL에서 사용)
- event : 감시할 이벤트와 관련 데이터 설정 (ADD, MOD에서 사용)
epoll_wait (이벤트를 기다림)
- epfd : epoll_create로 생성된 epoll 파일 디스크립터 번호를 입력하는 곳
- events : 감시할 이벤트를 저장하는 배열
- maxevents : events 배열의 크기
- timeout : 지정된 timeout 시간 동안 대기
[단점]
linux 운영체제에서만 해당 기능을 지원해준다. 비슷한 기능으로는 windows는 IOCP를 사용해야하고, FreeBSD는 kqueue를 사용해야한다. 그래서 운영체제마다 구현을 다르게 해야한다.
[예시 코드]
코드가 길기 때문에 아래 링크에서 보는걸 추천한다.
https://github.com/onestraw/epoll-example/blob/master/epoll.c
6. Trigger
[1] Trigger 란?
select(), poll()은 Level Trigger만 지원하지만, epoll()은 Level Trigger, Edge Trigger를 지원하며 기본값은 Level Trigger라고 한다. 여기서 Trigger란 디지털 회로에서 출력의 변화를 감지할 때 사용하는 개념이다.
- Level Trigger는 출력 변화가 일어나는 기간동안 계속 감지한다.
- Edge Trigger는 출력 변화가 일어나는 순간에만 감지한다.
이 개념을 어디에서 사용하는걸까??
소켓에는 위 사진처럼 패킷을 보낼 때 send socket buffer, 패킷을 받는 receive socket buffer를 사용하게 된다. 여기서 Level Trigger는 socket buffer가 비워질 때까지 계속 데이터가 남아있다고 반환해주기 때문에 버퍼에 있는 데이터를 나눠서 읽을 수 있다. 하지만 Edge Trigger는 socket buffer에 데이터가 들어온 순간에 한 번 이벤트가 발생하기 때문에 한 번에 모든 데이터를 읽어야 한다.
예시를 통해 설명하자면 select()를 통해 5번 소켓에서 읽을 데이터가 있다고 가정해보자. 소켓 버퍼에는 데이터가 1000 바이트가 있는데, 처음엔 500 바이트만 읽었다고 해보자. 그러면 소켓 버퍼에 500 바이트가 남아있게 되는데, 이때 다시 select()를 호출하면 5번 소켓의 비트값이 1로 그대로 설정된다. 아직 소켓 버퍼에 데이터가 남아있기 때문이다. 이후 나머지 500 바이트를 읽고 소켓 버퍼가 빈 상태에서 select()를 호출하면 5번 소켓의 비트값은 버퍼에 데이터가 없으므로 0이 된다.
하지만 edge trigger는 어떻게 될까? 딱 한 번 이벤트가 발생하기 때문에 처음 500바이트만 읽었으면 그대로 이벤트를 감지할 수 없게 된다. 게다가 소켓 버퍼가 남아있는 상태에서 epoll_wait를 호출하면 blocking 상태가 되버리고, 잘못하면 영원히 blocking이 될 수도 있다고 한다.
[2] Edge Trigger + Non-Blocking Socket
Edge Trigger 방식을 사용하게 된다면 한 번만 이벤트를 감지하기 때문에 모든 데이터를 한 번에 받아들이는 것이 좋다. 하지만 큰 데이터를 한 번에 받아들인다면 블로킹 상태가 되는데, 그렇기 때문에 소켓 자체를 논블로킹 소켓을 사용해서 소켓 버퍼가 아직 비어있지 않은 상황이면 아직 데이터를 처리중이라면 EAGAIN,. EWOULDBLOCK 에러를 보여줄 수 있게 한다.
[3] 실습
터미널에 sudo fs_usage를 입력하면 [ 35]라는게 있을텐데 이게 에러 코드이다. Slack을 실행하면 자주 볼 수 있다
여기서 4번을 보면 되는데, 괄호는 에러 코드를 의미한다.
35번 에러코드는 EAGAIN이고, 논블로킹 에러임을 확인할 수 있다.
7. Event Loop
[1] event 란?
글에서 이벤트가 자주 언급되는데, 지금까지 글에서 이벤트가 무엇인지 어느정도는 알 수 있을 것 같다. 내가 생각하는 이벤트는 '변화가 발생한 파일과 관련된 상태 변화'라고 생각한다. 그래서 이벤트 등록은 '변화가 발생한 파일의 정보'를 저장하는 것이다. 이때 이벤트를 저장하는 곳은 epoll의 events와 같은 배열이 될 수도 있고, 이벤트 큐라고 불리는 큐가 될 수도 있을 것이다.
[2] Event Loop
예시로 nodejs 구조를 가지고 왔다. 이벤트 루프는 이벤트가 발생하면 이것을 처리할 수 있는 핸들러에게 넘기는 역할을 한다. 보통 스레드 하나가 이벤트 루프를 담당하는데, 스레드 하나가 담당해도 성능이 보장되며 이벤트 큐에 접근하는 이벤트 루프가 1개 이기 때문에 동시성 문제에서 자유롭다는 특징을 가지고 있다. 멀티 스레드 이벤트 루프를 사용하게 된다면 여러 이벤트 루프가 하나의 이벤트 큐에 접근하기 때문에 동시성을 고려해야하고, 까딱하면 성능이 더 안좋을 수도 있다. 하지만 제대로 프로그래밍을 하면 그만큼 CPU를 더 효율적으로 사용할 수 있다고 한다. 싱글 스레드 이벤트 루프를 사용하는 기술은 NodeJS, Redis가 있고, 멀티 스레드 이벤트 루프를 사용하는 기술은 Netty, Vert.x가 있다.
이벤트 루프는 보통 비동기 논블로킹 방식으로 진행되는데 블로킹이 아닌 이유가 있다.
여기서 보면 이벤트 루프를 담당하는 스레드가 핸들러에게 이벤트를 보내게 된다면 '핸들러 작업이 끝날 때까지' 블로킹이 된다. 이로인해 다음 이벤트를 처리하는데 늦어지게 된다는 단점이 존재한다. 하지만 비동기 논블로킹으로 작동하게 된다면 새로운 이벤트가 발생할 때마다 다른 스레드에게 작업을 할당하며, 작업을 처리하는 스레드랑 관계 없이 이벤트 루프 스레드는 또 다른 이벤트를 처리할 수 있게 된다.
[3] Event Loop 코드
이벤트 루프는 어떻게 구현될까? 가장 원시적인 방법부터 말하면 우리는 이미 select(), poll(), epoll_wait()로 모두 확인했다. 다만 무한 루프문 안에서 select(), poll(), epoll_wait()를 계속 호출하는 것이 이벤트 루프이다.
GPT-4o에게 'event loop의 간단한 예시를 알려주세요. c언어로 만들어주세요'라고 하면 아래처럼 작성해준다.
그러면 실제 코드도 이럴까? 싶어서 깃허브에 있는 코드들을 살펴봤다.
Redis는 Event Loop가 stop이 아닐 때까지 계속 돌아간다.
Node JS도 Event Loop가 stop_flag가 1이 되지 않을 떄까지 계속 돌아간다. 이때 r은 Event Loop가 살아있는지 확인하는 용도다.
Netty도 마찬가지로 for(;;)로 무한루프문으로 되어 있다.
nginx도 역시..
8. 그래서 이 지식을 어디에 사용할 수 있을까?
위에 언급한 내용들뿐만 아니라 많은 곳에 쓰인다
[1] Node JS
[2] Redis
[3] Netty
[4] 이외에 다른 기술
- nginx
- Java NIO (New Input / Output)
- Spring WebFlux
- Kafka
- Vert.x
- ...
9. 다른 고성능 기술
I/O Multiplexing + Event Loop 이외에 어떤 고성능 기술이 있을까??
[1] Actor Model
동시성 프로그래밍에 대해 공부할 때, Akka 프레임워크를 참조하면서 블로그 포스팅을 한 적이 있는데, 액터 모델도 고성능을 보장한다.
https://hello70825.tistory.com/594
[2] Offload
오프로드 개념은 CPU에 부담을 줄 수 있는 작업을 다른 하드웨어 / 소프트웨어에게 맡기는 것이다.
이와 관련된 기술은 대표적으로 SSL offloading이 있다. SSL offloading은 클라이언트와 서버가 HTTPS 통신을 할 때, 서버에서 HTTPS 패킷을 암호화/복호화하는데 서버 CPU를 사용하게 된다. CPU 부담을 줄이기 위해서 리버스 프록시 서버에 암호화/복호화를 맡기는 기술이다. 이러면 클라이언트와 프록시 서버는 HTTPS로 통신하게 되고, 프록시 서버와 실제 서버는 HTTP 통신을 하게 된다.
하드웨어 오프로드는 TCP Offload Engine이라는 것이 있는데, 운영체제에서 처리하는 checksum, segmentation을 NIC 카드에서 처리하는 기술이다. 이건 검색해봐도 자료가 많지 않아서 더 자세히는 못찾겠다.
이외에도 Reactive Programming, Coroutine, Fiber 등이 있다.
10. 출처
1. 소켓
http://jkkang.net/unix/netprg/chap2/net2_intro.html
https://www.joinc.co.kr/w/Site/system_programing/File/select
2. I/O Multiplexing, Event Loop
- 네이버 클라우드 기술 블로그 - I/O Multiplexing 기본 개념부터 심화까지 1부
- 네이버 클라우드 기술 블로그 - I/O Multiplexing 기본 개념부터 심화까지 2부
- kwj1207님 블로그 - I/O Multiplexing과 Asynchronous I/O 그리고 Event Loop
- mark-kim님 블로그 - 사례를 통해 이해하는 네트워크 논블로킹 I/O와 Java NIO
- mark-kim님 블로그 - 사례를 통해 이해하는 Event Loop
- 라인 플러스 기술 블로그 - 비동기 서버에서 이벤트 루프를 블록하면 안되는 이유 1부 - 멀티플렉싱 기반의 다중 접속 서버로 가기까지