상태 수집기 개선 과정

Gunjoo Ahn·2022년 8월 19일
0

회고

목록 보기
2/8
post-thumbnail

구현된 상태 수집기 아키텍처

팀을 옮기고 새로 맡은 업무가 상태 수집기 유지 보수였다. C++ Application인 상태 수집기가 동작은 하는 데, 불안정하게 동작하는 상황에 투입되어 최대한 안정화시키는 작업을 빠르게 했어야 했다.

상태 수집기 문제 상황

📌heap 영역 침범 문제

상태 수집기가 SIGSEGV가 발생할 수 없는 곳에서 발생하면서 죽는 상황이었다. 바로 class안에 const string이 어느 순간 string이 아니어서 stringsize()를 call할 수 없다고 에러가 나는 것이다. 그래서 "heap을 어디서 침범했다"고 판단했다.

🩹당장 문제가 발생하는 객체 안의 const string을 static 영역으로

당장 해당 문제만 일단 해결하기 위해서 heap안에 있는 const string을 데이터 영역으로 뺐다. 그러니 size()에서 문제는 발생하지 않고, 정상 동작하는 것처럼 보였다. 하지만 실제 원인을 해결한 것은 아니기에 메모리를 잘못 접근하는 부분을 찾을 필요가 있었다.

🩹Valgrind로 메모리 문제 발견

heap이 침범당하는 것을 찾기 위하여 valgrind를 선택하였다. valgrind를 선택한 이유는 사용법이 간단하고, 따로 컴파일 타임에 무언가 해줄 필요가 없어서 편하게 사용할 수 있기 때문이었다. Valgrind를 사용하니 오만가지 memory leak과 invalid write, read가 떠서 당황했는데, memory leak은 일단 차치하고 invalid write, read을 집중해서 분석했다. 그 중에 array index 참조에서 실수가 있는 부분을 찾아 수정하니 const stringstatic을 빼도 정상 동작하는 것을 확인할 수 있었다.

📌pipe write 제한

위 문제를 해결하고나서 상태를 수집하다 보니, pipe write 제한때문에 메시지를 제대로 수집하지 못하는 상황이 발생하였다. 전체 application 코드를 숙지하기 전이라 문제 발생 후 pipe read, write 부분 코드를 분석하게 되었다. 구현된 로직은 여러 thread로 수집한 정보를 메시지에 담아서 하나의 pipewrite을 하면, main thread가 piperead해서 tcp 소켓을 통해 spring application으로 보내주는 구조였다.

분석을 해보니 pipe write의 실패여부를 -1이 아닌 length로 체크하고 있었는데, pipe write 제한이 있어 최대값만큼 write하고 length랑 맞지 않다는 이유로 exception을 발생시키고 있었다. 그래서 일단 write 반환값이 -1일 경우 write 실패했다고 변경하고 어떻게 수정할지 고민하게 되었다.

🩹같은 프로세스이므로 상태 객체 자체를 보낼 것이 아니라 주소를 보내자

처음에 제안한 것은 단순하게 write을 반복문으로 돌면서 나머지 데이터까지 써주도록 수정하자는 것이었다. 그런데 팀장님이 어차피 하나의 프로세스에서 thread를 통하여 수집하는데, 굳이 메시지를 보낼 필요가 있냐 주소값을 보내도록 수정하자고 의견의 제시하셨다. Multi thread에서 multi process로 수정할 일은 없냐고 물으니 그렇게 확장을 하지는 않을 것같다고 하셨기에 pipe에 메시지 자체가 아닌 메시지 주소를 넘기는 것으로 수정하기로 했다.

여기서 바로 생각할 수 있는 문제는 메시지를 heap에 생성하는 thread와 heap에서 메시지를 읽고 삭제하는 thread가 다르다는 점이었다.

🩹Object Pool 도입

메시지를 생성하는 thread와 메시지를 삭제하는 thread가 다르기에 object pool을 구현하여 메시지 메모리 제어를 하도록 하였다. Object pool을 어떻게 구현할지 고민하게 되었는데, 처음에는 linked-list 방식으로 구현하였다가 array 방식으로 바꾸었다.

Object pool 방식을 도입하니 valgrind에서 memory leak이 없는 것을 확인하였는데, 아마 이전까지는 프로세스가 죽으면서 생성된 메시지 객체의 메모리를 전부 회수하지 못했으나 object pool의 소멸자가 메시지 객체의 메모리를 전부 회수해서 memory leak이 잡힌 것으로 보인다.

Array 방식 vs Linked-list 방식

처음에는 linked-list 방식으로 구현하여 메시지 최대 개수만 제어하려고 하였다. Object pool을 처음 구현해보기도 했고, 메모리 관리라기 보다는 메모리 제한에 목적이 있다고 생각했기 때문이다. Array보다 유연하게 object 개수 컨트롤을 할 수 있겠다 생각했기 때문이다. 그리고 큰 고민없이 구현을 하고 테스트를 하면서 고려해야할 사항들이 속속 드러나게 된다.

구현을 하다보니 포인터로 구현한 linked-list에 성가신 부분이 있었는데, double free 체크object pool 소멸자 구현이었다.

Linked-list를 구현한 별개의 컨테이너를 사용한 것이 아니라, 메시지 클래스에 다음 메시지 객체를 가리키는 주소값을 넣는 방식으로 linked-list 자료구조를 사용하였다. Linked-list 로 object pool을 구현했을 때, 메시지 alloc을 하면 linked-list에서 pop 해서 주소값을 넘겨주고 메시지 객체의 다음 메시지 주소를 null로 바꿨다. 메시지가 free됐을 때는 linked-list에 push 하는데, linked-list에 있는 메시지는 다음 메시지 주소값를 가지고 있는 것일 뿐이다.

포인터만 다루니 double free시에 double free인 것을 체크하지 않으면 linked-list 자체에 문제가 생겼다. Double free를 하게 되면 linked-list 연결이 중간에 끊겨 버리고 할당은 했는데 도달 할 수 없는 메시지 객체 주소가 생기게 된 것이다.
이 문제는 금방 확인할 수 있었는데 double free를 연속으로 하게되면 linked-list에 self-refernce cycle이 생기면서 나머지 객체에 접근할 수 없게 됐기 때문이다. 같은 주소를 연속해서 두번 push하게 되면 다음 메시지 주소가 자기 자신을 가리키게 되는 것을 확인하면서 문제를 인지하게 되었다.

해당 부분을 해결하기 전에 linked-list를 고집하는 것이 맞나 고민하게 되었다. 좀더 고민을 하니 소멸자 구현에도 문제가 있다는 것을 알게 되었는데, alloc을 하면 alloc한 주소값을 object pool이 모른다는 것이다. 그러면 object pool 객체가 소멸하든 object pool size를 줄이든, alloc돼있는 상태의 주소값은 확인할 수도, 제어를 할 수도 없다. 이 것도 당연하게 보완해서 구현을 해야하는 데, 어차피 이렇게 전부 들고 있을 거라면 차라리 array 구현을 하는 게 낫다고 판단을 하였다. 주어진 일주일안에 Object pool 설계, 구현 및 테스트를 완료했어야 했는데, 다행히 금방 판단을 하여, array로 바꾸어 구현하고 테스트하는 것까지 기한내에 할 수 있었다.

기존 linked-list 코드는 전부 삭제하고 vector로 구현을 하려다가 팀장님께서 object 개수 고정시키라고 해서 아예 array로 박아서 object pool을 구현하였다. 주소값을 모두 가지고 제어하는 array방식의 object pool은 간단히 double free문제 해결 구현과 object pool 소멸자 구현을 할 수 있었다.

📌주기 control

🩹기존에는 각 thread들이 sleep_for로 각자 주기에 맞추어 동작

각 수집 thread들이 각자 thread에서 sleep을 통하여 주기를 맞추고 있었다. 그러니 오랜 시간이 지나거나 thread 실행 시점에 타이밍이 안맞으면 thread들끼리의 수집 주기가 달라질 수 있는 여지가 있었다. 실시간으로 화면에 수집 데이터를 보여주는 상황에서 수집 데이터 시점이 중구난방인 것은 처리가 곤란할 수 있다.

수집 주기를 일치 시키기 위하여 각 스레드들의 주기를 일치시키기 위한 주기마다 thread를 깨우는 새로운 thread를 추가하였다. 그리고 thread가 깨어나기 위해서는 모든 상태 객체들이 mutexconditional variable을 가지고 있어야했다. 구현된 코드에서 모든 thread들이 실행하는 수집 메소드의 클래스들이 하나의 부모를 가지고 있어 이 구조를 이용하였다. 부모의 변수로 mutexcontitional variable두고 notify 메소드를 부모에서 구현한 것이다.

🩹Builder pattern

추가로 이때 부모 클래스에 builder pattern의 fluent interface 방식을 적용하여 자식들이 상속받아 builder pattern으로 객체 생성을 할 수 있도록 리팩토링하였다. 리팩토링한 이유는 지금까지 생성자를 사용하였는데, 유지보수를 하는동안 하나씩 종류별로 생성자가 늘고있었기 때문이다.

📌Fork 후 waitpid에 std::cout이 영향을 미친다?

문제까지는 아니고 waitpid에 옵션을 주어 15초간 0이 반환되면 자식 프로세스가 정상 동작을 하고 있다 판단하는 로직이 있었다. std::cout의 위치를 바꾸니 waitpid 반환값이 0 (자식 프로세스가 동작중임)이나 -1(waitpid 에러)이 아닌 것같은 동작을 하는 것을 확인하였다. 바로 waitpid 반복문 조건을 바로 빠져나왔기 때문이다. 그러면 자식 프로세스가 종료되었나하면 그것은 아니다.

설마 std::coutstd::endl, 즉 flush가 관련된 문제인가? 하고 std::endl을 넣다 뺐다 시도를 해보니 waitpid가 정상 동작 할 때도 있고, 아닐 때도 있는 것을 확인할 수 있었다.

하지만 제대로 확인이 안되는 것이 gdb를 이용하여 보면 무조건 정상 동작을 하는 것이다. 그리고 정상 동작하지 않는 상황에서 확인을 위하여 반복문 종료 이후 std::cout을 로깅을 위해 이 한줄만 추가해도 정상 동작을 해버려 왜 비정상 동작을 하는지 디버깅이 안되는 상황이다...

추가) waitpid status 값도 반복문 안에서 값이 0인지 확인을 하는데, 해당 값이 0이 아닌 경우가 있는 것을 확인 하였다. 그래서 추적해보니 status 값을 stack영역에서 초기화해주지 않고 있었다. waitpid가 내부에서 status에 값을 할당하는 코드가 있을 텐데 이 것이 문제인가..? 라고 생각하면서 일단 초기화해줬더니 문제가 없어졌다.

status 값이 로직상 볼 필요 없는 값이라 삭제하였지만 살짝 찜찜한 결론이었다.

profile
Backend Developer

0개의 댓글