...
스레드를 많이 쓰면 쓸수록 성능이 높아지는가
프로세스와 스레드를 처음 학습할때, 스레드는 프로세스 내에 존재하는 실행 단위이며, 이 스레드가 여러개인 덕분에 우리가 음악을 들으며 웹서핑을하거나 파일을 다운 받는 '동시 작업'이 가능하다라고 배웠을 것이다. 그래서인지 이러한 질문에 대해 깊게 고민할 필요 없이, 스레드가 많을 수록 동시 처리 작업 수가 증가하는 것이니, 당연히 멀티 스레드가 무조건 성능이 더 좋다고 생각할 것이다.
하지만 모든 상황에 대해 무조건 멀티 스레드가 싱글 스레드보다 무조건 좋다고 말할 순 없다. 모든 기술들에는 각각의 기회비용이 존재하고, 싱글 스레드와 멀티 스레드 역시 각각의 장점에 맞는 기회비용들이 존재하기 때문이다.
이 부분은 스레드를 겉햝기로만 배운 지원자를 걸러내기 위해 기술 면접에서 가끔 등장하는 고수준의 질문이기도 하다. 만일 기술 면접에서 해당 질문을 받았다고 가정해보자. 여러 대답들이 나올수는 있겠지만, 이 질문은 지원자가 스레드에 어느 정도까지 알고 있는지, 어떤 관점에서 문제를 바라보는지, 알고 있는 지식을 잘 정리해서 성능과 연결지어 판단할 수 있는지 이런 것들을 확인하기 위한 목적이다. 스레드의 내부 구조까지 파악하고 동시성(Concurrency)에 대한 깊은 지식이 있어야만 대답할 수 있는 질문이기 때문이다.
지금부터 싱글 스레드와 멀티 스레드 두 가지 모델을 비교하고 언제 어느때에 무엇이 더 좋은지 왜 멀티 스레드가 항상 성능이 좋지 않은지에 대해 알아보도록하자.
1. 임계 영역에 대한 동기화 비용
멀티 스레드는 자원을 공유하기 때문에 프로세스 생성에 비해 적은 메모리와 자원을 소모하고 컨텍스트 스위칭도 멀티 프로세스에 비해 빠르다는 장점이 있다. 하지만 여러 개의 스레드가 임계 영역(Critical Section)의 공유 자원에 접근할 수 있기 때문에, 데이터의 일관성과 정확성을 유지하기 위해 동기화(Synchronized) 기법을 사용하여야 한다.
임계 영역(Critical Section)
멀티 스레드 프로그래밍에서 임계 영역은 공유 자원을 접근하는 코드 영역을 말한다. 대표적으로 전역 변수나 heap 메모리 영역을 들 수 있겠다.
동기화 기법에는 여러가지가 있는데 대표적인 두가지로 뮤텍스(Mutex) 나 세마포어(Semaphore) 같은 잠금 기법이 있다. 이들은 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 제한해야 하여 데이터에 대한 일관성을 유지한다. 그러나 이러한 동기화 기법은 스레드 간의 경쟁과 대기 상황을 발생시키므로, 오히려 성능에 부정적인 영향을 미칠 수 있다.
예를들어, 뮤텍스나 세마포어와 같은 동기화 기법을 사용하는 경우 스레드가 데이터에 접근하기 전에 락(Lock)을 획득하고, 데이터에 접근한 후에 락을 해제하는데, 이러한 락 획득 및 해제 작업은 추가적인 시간이 소요되며, 나머지 스레드의 실행을 중지하거나, 대기하게 만들어야 하므로 프로그램의 성능이 저하될 수 있다.
또한 CPU 캐시와 메모리 사이의 캐시 데이터 일관성 문제도 발생할 수 있는데, 여러 개의 스레드가 동시에 공유하는 메모리나 데이터에 대해 수정을 가할때 특정 CPU 코어의 캐시에 저장된 데이터와 다른 CPU 코어의 캐시에 저장된 데이터가 일치하지 않는 경우가 발생한다. 이럴 경우, 다른 CPU 코어에서 변경한 데이터가 현재 CPU 코어의 캐시에 반영되지 않은 상태로 사용되어 문제가 발생할 수 있어, CPU 캐시에서 데이터를 불러오는 비용이 발생하므로 성능에 영향을 미치게 된다.
지금까지의 얘기를 정리하자면, 멀티 스레드 프로그램이 많은 양의 공유 데이터를 사용하는 경우 동기화 및 캐시 일관성 작업으로 인해 병목이 일어나 성능이 떨어진다. 그럼 반대로 만일 공유 데이터의 양이 적고, CPU와 메모리 사용량이 적은 경우 오히려 싱글 스레드 프로그램이 멀티 스레드보다 더 좋다고 할 수 있을까? 이는 아주 틀린말은 아니지만, 실제로 대부분의 프로그램에서는 많은 양의 데이터와 복잡한 로직을 처리해야 하고 요즘 CPU는 기본적으로 다중 코어를 탑재하므로, 이러한 병목 현상이 있더라도 왠만한 상황에선 멀티 스레드 프로그램이 싱글 스레드 프로그램보다 빠르다. 그렇지만 멀티 스레드라고 해서 싱글 스레드보다 성능 상승 곡선이 무조건적으로 가파르지 않다.
2. 컨텍스트 스위칭 오버헤드
컨텍스트 스위칭 오버헤드(context switching overhead)는 여러개의 프로세스나 스레드가 있을때, CPU가 현재 프로세스나 스레드의 상태를 저장하고 다른 프로세스나 스레드로 전환될 때 발생하는 비용을 의미한다. 이 스위칭 하는 과정에서 CPU 시간과 자원을 소모하므로 성능에 영향을 미치는 것이다.
우리가 멀티 프로세스 대신 멀티 스레드로 프로그램 모델을 구성하는 이유는 프로세스의 컨텍스트 스위칭 오버헤드 보다 스레드의 컨텍스트 스위칭 오버헤드가 훨씬 작아 병목이 적기 때문이다. 하지만 어디까지나 프로세스에 비해 상대적으로 작다는 것이지 오버헤드 자체 비용은 결코 무시할수는 없다.
즉, 싱글 스레드 모델에서는 스레드가 한개 이니 컨텍스트 스위칭 오버헤드가 발생되지 않지만, 멀티 스레드 모델은 스레드가 여러개이니 컨텍스트 스위칭 오버헤드가 발생하게 되고, 스레드가 많으면 많을 수록 스위칭 횟수도 많아지고 덩달아 오버헤드도 많아져 성능이 저하될 수 있다는 의미이다. 특히 싱글 코어와 같은 옛날 CPU와 같이 코어 수는 적은데 스레드 수를 계속 늘리게 되면, 각 코어에서 경합하는 스레드 수가 점점 많아질 거고, 어느 순간에는 오버헤드 때문에 성능 한계에 부딪히게 될 수 있게 된다.
만일 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티 스레드보다 싱글 스레드로 프로그래밍하는 것이 더 효율적일 수 있다. 조금 극단적인 예를 들어보자면, 1부터 1억 까지 덧셈을 하는 작업을 100번 수행해야 하는 프로그램이 있다고 하자. 싱글 스레드 어플리케이션이라면 다음과 같이 for문을 중첩함으로써 구성할 수 있겠다.
long sum = 0;
for(int j=0; j<100; j++) {
for(int i=0; i<10000000; i++) {
sum += i;
}
}
반면 멀티 스레드 어플리케이션 이라면 아래의 코드를 각각의 100개의 스레드에게 분배하고 동시에 실행시키게 될 것이다.
long sum = 0;
/* --------- 스레드 1 --------- */
new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
sum += i;
}
}).start();
/* --------- -------- --------- */
/* --------- 스레드 2 --------- */
new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
sum += i;
}
}).start();
/* --------- -------- --------- */
// ...
/* --------- 스레드 100 --------- */
new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
sum += i;
}
}).start();
/* --------- -------- --------- */
실제론 그렇지는 않겠지만, 1부터 1억 까지 덧셈하는데 10초가 걸린다고 가정해보자. 일반적으로 생각하면 싱글 스레드 모델에선 10초가 걸리는 작업을 100번 직렬로 수행했으니 1000초가 걸리고, 멀티 스레드 모델에선 100개의 스레드가 동시에 실행하므로 10~11초 사이 정도 걸릴것이라고 예상 할 것이다. 하지만 이는 옳지 않다. 왜냐하면 멀티 스레드에서 발생하는 context swtiching overhead 비용 때문에, 100개의 스레드들이 서로 스위칭되면서 발생되는 병목으로 인해 동시 처리의 이점보다 소요 시간이 클 수 있으므로 오히려 싱글 스레드보다 연산 처리가 느려져 버릴수가 있게 된다.
다시 한번 말하지만 이는 조금 극단적인 예시 이긴 하다. 비록 컨텍스트 스위칭 오버헤드와 추가적인 동기화 오버헤드가 발생하지만, 멀티 스레드는 CPU 코어를 효율적으로 활용하기 때문에 아주 옛날의 싱글 코어 프로세서가 아닌 이상 멀티 스레드의 컨텍스트 스위칭 오버헤드쪽이 더 느리다고 단언할 수는 없다. 따라서 이 부분은 단지 멀티 스레딩의 잠재적인 단점을 보여주기 위해 특별히 설계된 예시이며, 실제 시나리오에서는 다양한 상황에 따라 결과가 달라질 수 있으므로 참고에 유의해야 한다. 글쓴이가 말하고 싶은 것은, "스레드가 많을수록 작업을 분리해서 동시에 처리하니까 항상 빠르다"는 고정 관념을 컴퓨터를 배운 독자분들이라면 깰 필요성이 있다는 것을 강조하고 싶은 점이다.
3. 잔여 스레드의 리소스 낭비
많은 양의 작업을 여러개의 스레드로 빠르게 처리한다는 멀티 스레드 취지는 좋지만, 회사의 서비스 이용률이 24시간 항상 바쁜 상태이지는 않을 것이다. 즉, 멀티 스레드 어플리케이션에서 이용률이 한산하여 스레드를 한두개 밖에 이용하지 않을때, 나머지 잔여 스레드들이 CPU, 메모리, 네트워크 등의 자원을 불필요하게 점유해서 성능 저하나 오류의 원인이 될 수 있게 된다. 우선 놀고 있는 스레드가 많을 수록 불필요하게 메모리를 차지하고 있는 셈이기 때문에 당연히 시스템 자원 낭비가 발생된다. 그런데 놀고 있음에도 CPU는 다른 스레드에게 CPU 시간을 양도하도록 설계 되어 있기 때문에 노는 스레드와 다른 스레드 간에 컨텍스트 스위칭을 하여 CPU의 효율성을 떨어뜨린다. 즉, 스레드가 작업을 수행하지 않더라도 존재 자체만으로 여전히 리소스를 소비하고 오버헤드를 생성하기 때문에 잔여 스레드의 문제는 결코 가볍지 않은 것이다.
이러한 문제를 해결하기 위해서는, 노는 스레드의 개수를 최소화하고, 스레드 풀(Thread Pool)과 같은 매커니즘을 사용하여 스레드의 개수를 관리하여 리소스 낭비를 최소화하는 것이 중요하다.
4. 어플리케이션 성격에 따른 제약
"스레드를 많이 쓰면 쓸수록 동시에 더 많은 작업들을 실행할 수 있다"라는 말에는 우리도 모르게 암묵적으로 해당 어플리케이션은 더 작은 작업들로 잘게 쪼개서 동시에 실행이 가능한 성격의 어플리케이션이라는 전제를 깔고 생각한다. 즉, 어플리케이션의 목적과 주제에 따라 아키텍쳐가 달라질 수 있다는 얘기인데, 예를들어 만약 개발하는 어플리케이션의 동작이 순차적으로 실행돼야만 하는 특징을 가지거나 잘게 쪼개서 동시에 실행하기에 매우 어려운 성격의 어플리케이션이라면 오히려 멀티 스레드 구성은 별 이점이 없다는 것이다. 이에 대한 대표적인 예시로는 CPU 바운드 어플리케이션과 I/O 바운드 어플리케이션을 예로 들 수 있다.
CPU 바운드
- CPU 연산 능력에 의존하는 작업을 말한다
- 데이터 마이닝, 영상 처리 작업, 이미지 프로세싱, 암호화폐 마이닝 ..등
I/O 바운드
- I/O 장치의 응답 속도에 의존하는 작업을 말한다
- 파일 입출력, 네트워크 통신, 데이터베이스 접근 ..등
CPU 바운드 어플리케이션
오라클에서 아키텍트로 일하고 있는 Goetz가 2002, 2006년에 발표한 자바 병렬 프로그래밍에 관한 책과 논문에 따르면 CPU 바운드 작업에서 적절한 스레드 수는 코어 수 + 1 이라고 주장했었다. 이는 위에서 살펴보았던 컨텍스트 스위칭 오버헤드 비용 때문이라는 걸 유추할 수 있을 것이다. 따라서 CPU 바운드 어플리케이션은 cpu를 많이 쓰기 때문에 코어수와 비슷한 수준 이상으로 스레드 수를 늘려봤자 별 이점이 없으며, 오히려 각 코에서 쓰레드 수가 많아질수록 컨텍스트 스위칭 때문에 오버헤드만 더 많아져서 성능이 안 좋은 영향을 주게 된다. 그래서 스레드의 개수가 적절하게 설정되어야 최적의 성능을 얻을 수 있는 것이다.
I/O 바운드 어플리케이션
파일 입출력, 네트워크 통신, 데이터베이스 조회와 같은 입출력 작업이 많을 경우 스레드를 늘리는 것이 좋다. 이러한 작업들은 I/O 장치의 응답 속도에 의존하는 것이 큰데, 예를 들어 파일 입출력은 파일을 읽기 위해 디스크에서 데이터를 조회하는데 있어 하드웨어 한계상 입출력 작업에 걸리는 시간이 상대적으로 오래 걸리므로 이때 CPU는 입출력이 완료될때까지 휴무 상태가 된다. 그래서 만일 싱글 스레드일 경우 I/O 작업하는 동안 블로킹 되어 아래와 같이 낭비가 발생하게 된다.
멀티 스레드는 I/O 작업이 처리될 동안 다음에 이행할 작업들을 다른 스레드에게 자원을 할당하여 수행할 수 있도록 할 수 있다. 네트워크 통신도 마찬가지로 네트워크 상황에 따라 전송 시간이 오래 걸릴 수 있으므로 통신 지연동안 스레드를 재활용 시키는 것이다.
따라서 I/O 작업이 많은 상황에서는 CPU 코어 수보다 2배 3배 혹은 그 이상으로 스레드 수를 늘려주는 것이 코어들을 더 효율적으로 쓸 수 있기 때문에 성능 면에서 이점이 얻을 수 있다. 그러나 우리가 앞서 배웠듯이 스레드를 늘리면 늘릴수록, 잔여 스레드의 컨텍스트 오버헤드와 동기화 등의 문제점이 동반 될 수 있기 때문에, I/O 바운드 어플리케이션에 멀티 스레드 모델 대신 비동기 I/O 처리에 특화된 이벤트 기반 프로그래밍 모델을 접목하기도 한다. 가장 대표적으로 Node.js 어플리케이션을 들 수 있다.
Node.js와 같은 싱글 스레드 + 이벤트 기반 프로그래밍 모델에서는 이벤트 루프(Event Loop)를 통해 이벤트를 감지하고, 이벤트 핸들러(Event Handler)를 호출하여 해당 이벤트를 처리하는 식이다. 만약 입출력 작업을 수행해야 하는 경우, 이벤트 핸들러는 비동기 I/O를 사용하여 입출력 작업을 수행하고, 입출력 작업이 완료될 때까지 이벤트 루프를 블로킹하지 않게 된다. 그래서 작업 처리 도중 다른 작업을 처리할 수 있어서, 멀티 스레드 모델에 비해 더 적은 리소스를 사용하고 높은 처리량을 보여줄 수 있다.
물론 비동기 I/O 처리 주체는 Node.js에 내장된 libuv 라이브러리의 스레드 풀에서 가져온 멀티 스레드로 처리하는 것이다. 웹브라우저도 멀티 스레드 프로그램이며 이를 이용해 비동기 작업을 수행하는 것이다.
그러면 멀티 스레드 프로그래밍 모델과 차이점이 없냐고 말할 수 는 있겠지만, Node.js의 메인 스레드는 싱글 스레드이기 때문에 멀티 스레드 모델에서 발생할 수 있는 동기화 문제나 경쟁 상태 등을 걱정할 필요가 없이 CPU 바운드 혹은 I/O 바운드 작업이 발생하면 그때에만 멀티 스레드를 가져와 사용해, 이른바 멀티 스레드 모델 변형 버전인 '싱글 스레드 + 이벤트 기반 프로그래밍 모델 + 비동기 I/O 모델' 이라고 말한다.
GUI 어플리케이션
GUI 어플리케이션은 창(window), 아이콘(icon), 메뉴(menu), 포인터(pointer) 등의 그래픽 사용자 인터페이스(GUI)를 통해 사용자와 상호작용하는 애플리케이션이다. 키보드, 마우스, 터치스크린 등의 입력 장치로부터 발생하는 이벤트를 감지하고, 적절한 메시지나 액션을 수행 한다.
이러한 GUI 어플리케이션에서는 보통 싱글 스레드를 사용하는 것이 안정적이다. 멀티 스레드를 사용할 경우 스레드 간의 동기화 문제로 인해 이벤트 처리 순서가 뒤바뀌거나, 데드락이 발생할 가능성이 있어, UI 갱신에 문제가 생기게 되어 사용자 경험을 해치게 되기 때문이다. 따라서 이벤트 루프(Event Loop)를 이용해 이벤트 처리를 싱글 스레드로 순차적으로 처리하는 이벤트 기반 프로그래밍 모델에 적합하다.
5. 프로그래밍 난이도
싱글 스레드는 단 하나의 스레드가 모든 작업을 처리하기 때문에, 프로그램 구조가 단순하여 개발이 더 쉽고 CPU, 메모리를 적게 사용한다. 반면 멀티 스레드는 스레드 간의 동기화 처리, 잘못된 스레드 관리로 인해 메모리 누수, 데드락 등의 문제에 대해 관리가 필요하기 때문에 개발이 굉장히 복잡해진다.
글쓴이가 말하고 싶은 것은 당연히 대체적으로 멀티 스레드가 싱글 스레드 보다 성능이 더 뛰어나지만, 그렇다고 해서 무턱대고 어플리케이션 개발을 무작정 멀티 스레드로 진행하다 온갖 동시성 문제에 직면하여, 어플리케이션의 안정성과 유지보수성을 저해하는 것은 두마리 토끼를 모두 잡으려다가 한마리도 못 잡는 격이 되어 버릴수 있다는 점이다.
따라서 멀티 스레드 모델은 프로그래머에게 높은 수준의 역량과 경험이 필요하기 때문에 모델 선택에 있어 신중하게 고려하고, 프로그램의 특성과 목적에 따라 적절한 멀티 스레딩 전략을 선택하고, 성능 최적화와 동시성 관련 이슈들에 대한 철저한 이해와 학습이 필요해진다.
# 참고자료
https://youtu.be/jSaBkvtHhrM
https://www.bryanbraun.com/2012/06/25/multitasking-and-context-switching/
https://www.baeldung.com/java-volatile-variables-thread-safety
https://medium.com/@punyatoya213/multithreading-can-be-fun-too-part-2-e27f1841c8ca
https://medium.com/pocs/single-thread-server-vs-multi-thread-server-c1fda66bbcd0
https://realpython.com/python-concurrency/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.