23-1학기, 웹서버를 C++로 만들어봤습니다.
더 기억이 안나기 전에 프로젝트를 어떻게 진행하고자 했고, 어떤 문제점이 있었는지 정리하고하자합니다.
비동기-논블로킹으로 동작하는 웹서버이며, 이벤트 알림 메커니즘 중 하나인 kqueue를 사용했습니다.
논블로킹 서버를 만들때, 커널 알림 메커니즘을 사용해 event기반 아키텍처를 구성해야합니다.
커널 알림 메커니즘에는 epoll, select, iocp 그리고 kqueue 등 다양한 인터페이스가 운영체제별로 다르게 지원됩니다.
libuv
NodeJS가 비동기-논블로킹 이벤트루프를 가지고 있는데, NodeJS의 libuv의 이벤트루프는 위의 인터페이스들을 운영체제별로 다르게 사용합니다.
NodeJS에서 async-await으로 호출하는, network관련 작업/dns작업/파일 시스템 작업 등은 이 libuv모듈에서 처리하게됩니다.(file i/o작업은 libuv의 스레드풀에서 실행됩니다.)
저는 웹서버를 만들기 전에 이런 선행지식을 가지고 시작했습니다.
결론적으로, 스레드풀은 개념만 공부하고 프로젝트에 반영하진 않았습니다.
처음에는 스레드풀을 사용하면 막연히 성능이 향상될거라고만 생각했지만, nginx의 이 글을 보고 생각이 바뀌었습니다.
대부분의 read/write작업은 hdd에 직접 작업을 하는 것이 아닌, page cache에서 처리하며, 이는 매우 빠릅니다. RAM이 충분히 큰 상황에서는 스레드풀을 사용하지 않고도 충분히 빠른 방식으로 작동하게 됩니다.
Read작업을 스레드풀로 offload하는 것은 대용량 파일, 즉 스트리밍 서버의 경우에 적합합니다.
스레드풀을 구현하는 것은 상당히 까다로운 작업이기에 프로젝트 일정에 지연이 생길 것이라고 생각해 따로 구현하지 않고 공부하는 것에 만족했습니다.
작업은 크게 3가지로 분할했습니다.
event loop쪽에서 http request/response 모듈을 가지고 인터페이스의 메서드만을 호출해 구현체에 의존하지 않는 lsp 원칙을 적용했습니다.
이에따라, 이벤트루프에서는 request/response 모듈의 실제 구현에 상관없이 이벤트루프를 작동할 수 있었습니다.
SOLID원칙이 단순하게 객체지향 코드를 작성하는게 목적이 아닌, 협업 속에서 분업을 명확하게 할 수 있는 원칙이라는 것을 깨달을 수 있었던 것 같습니다.
nginx의 소스코드를 보면 모든 event가 하나의 rb_tree 구조에 저장되는 것을 볼 수 있습니다.
처음에 nginx 소스코드를 보면서 구현할때 이벤트들을 rb_tree에 담아서 진행하려고 했습니다만, kqueue의 특징 중 하나가 udata 포인터에 이벤트가 발생했을때 사용할 특정 데이터를 받아올 수 있다는 것을 알고 별도의 자료구조에 담는 것이 아닌,
udata에 이벤트 발생했을때 처리할 객체의 주소를 담고, 이벤트가 발생하면 해당 이벤트 객체를 가져와서 처리하려고 했습니다.
이에따라, rb_tree에서 fd값을 key값으로 찾는 시간을 줄일 수 있었습니다.
100mb짜리 요청을 10명의 클라이언트가 1000번 보내는 부하테스트를 진행했습니다.
m1 mac에서는 문제없이 구동되었으나, 특정 하드웨어 스펙에서 CPU사용량이 100%를 넘어 180%까지 가다가 컴퓨터 자체가 뻗어버리는 경우가 발생했습니다.
문제를 추측하기로, event의 최대 개수를 정하지 않고, 1000 * 10개 = 10000개의 이벤트를 모두 커널에 non-blocking 작업을 위임하는것이 CPU에 너무 많은 부하를 준다는 것이었습니다.
최대 커넥션 개수를 정하지 않았기때문에 모든 클라이언트가 서버로부터 응답을 받기를 대기하고 있고, 서버측에서는 모든 클라이언트에게 응답하기 위해 커널 작업을 하기때문이었습니다.
저 당시에는 blocking write를 통해 커널 작업 감지를 지연해 딜레이를 주어 저 부하테스트를 통과했습니다.
지금와서 생각해보면, 최대 클라이언트 개수를 정하고, 그 개수를 넘어가면 queue에 넣고 처리하는 방법이 좋았을 것 같습니다.