<쓰레드의 이해>
[공부했던 것을 되짚어보며]
사실 지금까지 우리는 프로세스에 대한 이야기만 계속 늘어놨습니다.
커널 오브젝트에, 핸들에, 핸들 테이블과 프로세스 간 통신 이야기만 했습니다.
그리고 쓰레드는 뒤에서 이야기한다고 했었죠.
도대체 '쓰레드'라는게 뭐길래?
아마 이걸 공부하시는 분들이라면 '멀티 쓰레드'라는 말은 한 번쯤은 들어보셨을겁니다.
'프로세스보다 더 잘게 쪼갠 것이 쓰레드다' 라고 알고 계신 분들도 있을거고요.
대충은 맞는 말입니다.
프로세스와 관련된 내용을 잘 알고 계신다면 쓰레드는 크게 어려울 것이 없습니다.
[쓰레드(Thread)란 무엇인가]
일단 쓰레드 이야기를 하기 전에 우리가 지금까지 공부했던 내용을 좀 톺아보고 갈까 합니다.
왜 쓰레드가 필요하게 되었는지, 어째서 생겨났는지를 알기 위해서는 우리가 알고 있는 개념을 좀 정리할 필요가 있습니다.
[멀티 프로세스 기반 프로그램]
둘 이상의 서로 다른 프로그램을 실행하기 위해서는 둘 이상의 프로세스가 생성되는 것은 지극히 당연한 일입니다.
이를테면 발표 자료 준비를 위해서 웹 브라우저와 파워포인트 프로그램을 실행시킵니다.
이 둘은 서로 다른 프로그램이니까 당연하게도 두 개의 프로세스가 생성됩니다.
그런데 하나의 프로그램이 두 가지 이상의 일을 동시에 처리하기 위해서도 둘 이상의 프로세스가 필요합니다.
"이건 또 뭔 소리래요?"
갑자기 뭔 뚱딴지 같은 소리인가 싶으실겁니다.
분명히 지금까지 하나의 프로그램은 하나의 프로세스라고 했는데 이제는 하나의 프로그램이 둘 이상의 일을 처리한다?
사실 우리는 이런 경우를 아주 잘 알고 있습니다.
가장 쉬운 예는 '서버-클라이언트 관계' 입니다.
서버는 하나고 클라이언트는 여럿입니다.
클라이언트는 서버에 요청을 하면 서버는 클라이언트의 요청을 받게 됩니다.
서버는 하나지만 요청을 여럿이 들어오니까 처리해야 할 일이 둘 이상인거죠.
서버랑 클라이언트 이야기가 나왔으니까, 우리가 좋아하는 게임 이야기를 빼놓을 수는 없겠죠.
아마 다들 한 번쯤은 해보셨을 '리그 오브 레전드'를 예로 들어볼까요?
여기서만 해도 서버에서 동시에 처리해야 할 일이 두 가지 이상입니다.
내 캐릭터를 조작하는 것이 하나의 실행 '흐름'이 되고, 적군의 정보가 화면에 나오는 것도 하나의 실행 '흐름'이죠.
그리고 누가 죽었는지, 용은 언제 잡혔는지 등등 여러 실행의 '흐름'이 존재합니다.
다른 온라인 게임들도 잘 생각해보면 여러 흐름들이 있다는 것을 알게 될겁니다.
옛날 온라인 게임들도 다 그랬고요.
다시 말하면 오래 전부터 실행의 흐름을 다양하게 만들어 왔고, 지금도 그렇다는 것입니다.
[멀티 프로세스 운영체제 기반 프로그램의 문제점과 새로운 제안]
이제 하나의 프로그램에서 둘 이상의 실행 흐름이 생길 필요를 알게 되었습니다.
그래서 하나의 프로그램으로 둘 이상의 프로세스를 만들어서 이걸 해결한다고 칩시다.
문제는 이 방법이 좋은 방법은 아니라는 것입니다.
프로세스를 추가로 생성하게 되면 우리가 고려해야 될 것이 있습니다.
바로 컨텍스트 스위칭(Context Switching)입니다.
프로세스가 많으면 많을수록 당연히 컨텍스트 스위칭이 빈번하게 일어납니다.
컨텍스트 스위칭이 발생하는 상황이나 시스템의 성능을 생각한다 하더라도 빈번하게 발생하는 것은 마찬가지입니다.
그러다보니 자연스레 성능의 저하가 올 수밖에 없죠.
프로세스를 만드는 것은 좋은 선택지가 아니라는 것을 알았습니다.
그렇다면 이걸 해결할 방법이 뭐가 있을까요?
생각해보면 간단합니다.
"저장하고 복원하는 컨텍스트 정보의 개수를 줄이자!"
원인을 알았으니까 원인을 줄일 수 있다면 가장 좋은 방법입니다.
그러면, 이 원인을 어떻게 줄이면 가장 좋을까요.
컨텍스트 스위칭의 문제는 CPU뿐만이 아니라 메모리까지도 영향이 갑니다.
코드 영역부터 스택 영역까지 전부 교체를 해야하니까 시간도 걸리고, 부담도 갑니다.
그럼 이 교체하는 부분을 대충 50% 정도만 줄여보면 어떨까요?
컨텍스트 스위칭에 걸리는 시간은 50%까지는 아니더라도 줄어들 수도 있을겁니다.
[해결책, 쓰레드(Thread)]
앞서 이야기했던 웹 브라우저나 파워포인트같이 완전 별개의 프로그램을 동시에 실행하는 것은 어쩔 수가 없습니다.
서로 다른 프로그램이니까 독립적인 프로세스 구조를 가질 수가 없잖아요.
메모리도 따로따로 쓸거고, 공유할 수 있는 부분이 아무것도 없습니다.
그런데, 하나의 프로그램에서 둘 이상의 실행흐름을 가진다고 한다면?
모든 것을 독립적인 구조를 가질 필요는 없을겁니다.
그래서 나온 것이 바로 쓰레드(Thread)라는 개념입니다.
여기서 포인트는 프로세스와 쓰레드의 차이점입니다.
- 프로세스: 완전히 '독립'된 프로그램 실행을 위해 사용
- 쓰레드: 하나의 프로그램 내에서 둘 이상의 흐름을 만들어 낼 때 사용한다.
프로세스와 달리 쓰레드 사이에는 공유하는 요소가 있다.
공유하는 요소가 있으므로 프로세스에 비해 쓰레드의 컨텍스트 스위칭이 빠르다.
[메모리 구조 관점에서 본 프로세스와 쓰레드]
이번에는 메모리 구조의 관점에서 프로세스와 쓰레드를 비교해볼까 합니다.
사실 메모리 구조 관점에서 보게 되면 이 둘의 차이를 확실하게 알 수 있습니다.
위 그림은 부모 프로세스에 의해 자식 프로세스가 생성된 경우입니다.
똑같이 두 개의 흐름이 생겼지만 사실 자식 프로세스는 부모 프로세스와 독립적이라는 것입니다.
그에 비해 쓰레드는 다음과 같은 메모리 구조를 가지게 됩니다.
보다시피 스택 영역을 제외하고는 모두 공유하고 있는 구조입니다.
쓰레드를 생성할 때에는 해당 쓰레드만의 스택을 생성할 뿐, 그 이외의 영역은 모두 공유하고 있습니다.
이러한 쓰레드의 메모리 구조를 통해서 쓰레드의 특성을 한 번 정리해보겠습니다.
1) 쓰레드마다 스택을 독립적으로 할당한다.
프로세스와 마찬가지로 쓰레드는 독립적으로 스택을 할당하게 됩니다.
이전에 '컴퓨터 구조에 대한 세 번째 이야기'를 하면서 스택이 어떻게 사용되는지를 설명했습니다.
스택은 함수 호출 시 전달되는 인자, 되돌아갈 주소값, 지역 변수 등을 저장하기 위한 메모리 공간입니다.
즉, '스택 == 함수 호출 시 필요한 메모리 영역'이 됩니다.
그래서 '스택이 독립적'이라는 뜻은 추가적인 실행 흐름을 만들 수 있다는 것입니다.
결론을 내리자면 다음과 같습니다.
"실행 흐름의 추가를 위한 최소 조건은 독립된 스택이 제공되어야 한다"
2) 코드 영역을 공유한다.
프로세스는 100% 독립된 구조이기 때문에 다음과 같은 형태의 실행을 할 수 없습니다.
하지만 쓰레드는 자신을 생성한 프로세스가 가지고 있는 함수를 호출할 수 있습니다.
코드 영역을 공유하고 있기 때문에 가능한 것입니다.
위 그림을 보면 3개의 main 함수가 존재하고 있습니다.
결과적으로는 프로그램은 하나지만, 3개의 흐름(main, Thread A, B)을 가지는 상황입니다.
3) 데이터 영역과 힙을 공유한다.
위의 그림에서 보여주는 것과 마찬가지로 쓰레드는 데이터와 힙 영역을 공유합니다.
즉, IPC를 사용할 필요가 없어졌다는 것입니다.
힙이나 데이터 영역에 메모리 공간을 할당해서 서로 통신할 수가 있기 때문입니다.
그래서 전역변수와 동적 할당된 메모리 공간은 공유가 됩니다.
물론, 공유할 수 있다고 해서 좋기만 한 것은 아닙니다.
프로그래밍을 하면서 신경을 써야하는 부분이 있는데 이후 '쓰레드의 동기화'에서 다루게 됩니다.
[Windows에서의 프로세스와 쓰레드]
쓰레드는 프로세스 내부에서 생성된다고 했습니다.
우리는 지금까지 프로세스에 관련된 것들을 공부했죠.
프로세스에는 스케줄러가 있듯이, 쓰레드도 프로세스에서 파생된 것이라면 쓰레드도 스케줄러가 있을겁니다.
그런데 유감스럽게도 Windows 입장에서는 '프로세스 == 쓰레드를 담는 상자'입니다.
실제로 프로세스는 상태(Running, Ready, Blocked)를 지니지 않습니다.
상태를 지니게 되는 것은 쓰레드입니다.
또한 스케줄러가 실행의 단위로 선택하는 것도 프로세스가 아닌 '쓰레드'입니다.
결론은 "Windows에 있어서 실행의 중심에 있는 것은 프로세스가 아닌 쓰레드다"라는 것이 됩니다.
"아니 그럼 이제껏 프로세스 상태나 스케줄러나 알고리즘 관련해서 배운건 뭐한겁니까?"
사실 프로세스 상태나 스케줄러, 스케줄링 알고리즘 이야기만 주구장창 했었죠.
그런데 쓰레드에서는 그런 이야기를 더 안할겁니다.
완전히 똑같거든요.
단지 대상이 '프로세스 → 쓰레드' 로 바뀐 것이지 상태, 스케줄러, 스케줄링 알고리즘은 전부 동일합니다.
그러니 프로세스와 그에 관련된 것을 공부하신 것은 쓰레드를 공부한 것과 다를 것이 없습니다.
컨텍스트 스위칭도 쓰레드에서 똑같이 일어납니다.
프로세스의 컨텍스트 스위칭보다는 속도가 훨씬 빠르다는 차이가 있습니다.
다만 프로세스 A에서 쓰레드를 실행하던 중 프로세스 B의 쓰레드로 실행을 옮기는 과정은 어떻게 될까요?
이 상황에서 발생하는 컨텍스트 스위칭은 우리가 알고 있는 프로세스의 컨텍스트 스위칭과 동일합니다.
속해있는 프로세스 영역이 서로 다르기 때문입니다.
실제로 Windows에서는 쓰레드가 존재하지 않는 프로세스는 없습니다.
지금껏 main함수만 가지고 작성했던 프로그램들은 어떻게 된거냐고요?
실행하면 프로세스 생성과 동시에 main 함수를 호출할 쓰레드가 만들어진겁니다.
이러한 쓰레드를 main 쓰레드라고 하며, 프로그래머가 직접 생성하는 쓰레드와는 다른 것입니다.
[쓰레드 구현 모델에 따른 구분]
앞서 살펴본 내용은 쓰레드에 대한 일반적인 이론입니다.
그리고 이번에도 이론적인 이야기를 이어가려고 합니다.
쓰레드에 특성에 대한 이야기를 했으니 이번에는 쓰레드의 구현 원리에 대해서 알아보겠습니다.
실제로 쓰레드는 어떻게 만들어지는 것일까요?
[커널 레벨(Kernel Level) 쓰레드와 유저 레벨(User Level) 쓰레드]
쓰레드가 어떻게 만들어지는가?
그 전에 '누구에 의해서 어떻게 만들어지는가?'를 생각해봅시다.
다시 말해서 '쓰레드를 만드는 주체'에 대한 이야기를 해보겠습니다.
1) 커널 레벨(Kernel Level)
쓰레드를 생성하는 주체가 '커널'인 경우입니다.
이런 경우에는 OS에서 제공해주는 시스템 함수 호출을 통해서 쓰레드 생성을 요구해야 합니다.
CreateProcess 함수를 호출해서 프로세스 생성을 하던 것과 마찬가지로 OS에 요청해서 생성하는 방식이죠.
이처럼 프로그래머의 요청에 따라 쓰레드를 생성하고 스케줄링하는 주체가 커널일 경우 '커널 레벨 쓰레드'라고 합니다.
2) 유저 레벨(User Level)
쓰레드의 생성 주체가 커널이라서 커널 레벨이라면, 생성 주체가 '사용자'라면 '유저 레벨 쓰레드'가 됩니다.
멀티 프로세스 OS라고 해서 무조건적으로 쓰레드를 다 지원하는 것은 아닙니다.
커널 차원에서 쓰레드 기능을 지원하지 않을 때 사용할 수 있는 것이 '유저 레벨 쓰레드'입니다.
커널이 쓰레드 모델을 제공하지 않거나 별도의 쓰레드 모델을 사용할 경우에 적용할 수 있습니다.
'유저 레벨 쓰레드'는 커널에 의존적이지 않은 형태로 쓰레드의 기능을 제공하는 라이브러리를 활용하는 방식입니다.
커널에서 제공하는 기능이 아니기 때문에 실행 또한 유저 영역에서 실행됩니다.
+ 추가 정리 (커널 영역과 유저 영역)
커널 레벨과 유저 레벨을 설명하면서 설명이 덜 된 부분이 있습니다.
그림에서도 커널 영역과 유저 영역을 나눠서 표시를 나타내었는데 이 부분에 대해서 설명을 하겠습니다.
우리가 알고 있는 메모리 구조를 생각하면 코드, 데이터, 힙, 스택으로 나뉘어집니다.
지금까지 우리가 프로그램이 동작하기 위해 사용되는 메모리 공간입니다.
그리고 이러한 영역을 '유저(User) 영역'이라고 합니다.
'커널(Kernel) 영역'은 하나의 프로세스에게 할당된 총 메모리 공간 중에서 유저 영역을 제외한 나머지 영역을 말합니다.
OS가 실행되기 위해서는 당연히 OS의 실행을 위한 메모리 공간도 필요하게 됩니다.
마찬가지로 OS 역시 프로그램이므로 메모리에 올라가야 합니다.
일반적인 프로그램과 똑같이 변수도 선언하고, 메모리도 동적으로 할당하기도 합니다.
그래서 정리하면 다음과 같습니다.
일반 프로그램을 실행시키기 위해 필요한 메모리 공간 → 유저 영역
운영체제의 실행을 위한 메모리 공간 → 커널 영역
과거 32bit Windows에서는 2GB는 유저 영역으로, 2GB는 커널 영역으로 활용하였습니다.
그리고 현재 사용하고 있는 64bit Windows에서는 8TB를 유저, 8TB를 커널 영역으로 활용하고 있습니다.
이처럼 분리하는 이유는 관리하는 측면에서 큰 문제가 생길 수 있기 때문입니다.
C를 예로 들면 바로 이해가 될 것 같습니다.
C는 포인터를 통해서 메모리에 직접적인 접근이 용이합니다.
그런데 유저 영역을 벗어나서 커널 영역의 메모리에 접근해 값을 변경한다면?
무슨 일이 일어날지 아무도 모릅니다.
다만 좋은 결과로 이어진다고 볼 수는 없겠죠.
그래서 유저 영역과 커널 영역의 접근을 하기 위해 유저 모드와 커널 모드라는 것을 Windows에서 사용하게 됩니다.
[커널 모드(Kernel Mode)와 유저 모드(User Mode)]
이제 커널 모드와 유저 모드에 대해서 한 번 알아보겠습니다.
그림만 보고도 바로 이해를 하실 수 있을 개념이라 어렵지는 않을 것입니다.
다만 지금까지 '커널 레벨 쓰레드', '유저 레벨 쓰레드', '커널 영역', '유저 영역'과 같이 용어가 많이 나왔습니다.
헷갈릴 수도 있겠지만 잘 정리하시길 바랍니다.
커널 영역은 유저 영역에 비해서 중요한 영역입니다.
앞에서도 말했듯이 커널 영역에서 문제를 일으킬 일은 어지간해서는 없습니다.
다만! C언어의 포인터 사용과 같은 메모리 참조에 의해 커널 영역의 메모리를 잘못 참조할 수도 있습니다.
이에 대해서 안전장치가 필요하기 때문에 나온 것이 커널 모드와 유저 모드입니다.
커널 모드에서는 커널, 유저 영역에 모두 접근이 가능하지만 유저 모드에서는 커널 영역에 접근할 수 없습니다.
프로세스가 유저 모드로 동작하는 중 커널 영역에 접근하려고 하면 오류를 발생시키고 접근하지 못하게 합니다.
그리고 Windows에서는 OS 차원에서 제공하는 시스템 함수들 중 일부는 호출 시 커널 모드로 동작하게 됩니다.
시스템 함수의 상당 수가 커널의 구동을 필요로 하기 때문입니다.
그래서 이런 함수들을 호출할 때마다 모드가 전환됩니다. (커널↔유저)
문제는 모드를 전환하는 것은 시스템에 부담을 주는 행위입니다.
따라서 상황에 따라 적절하게 사용하는 것이 중요합니다.
마지막으로 커널 모드와 유저 모드를 제공하는 주체는 Windows OS로 알고 있으실겁니다.
실제로는 프로세서(Processor), 다시 말해서 CPU가 제공하게 됩니다.
메모리 보호 기능은 CPU에 있기 때문입니다.
[커널 레벨 쓰레드와 유저 레벨 쓰레드의 장점 및 단점]
마지막으로 각각의 장단점에 대해서 정리하고 마무리를 지어볼까 합니다.
1) 커널 레벨 쓰레드의 장점 및 단점
장점: 커널에서 직접 제공해주는 쓰레드이므로 안전성과 다양한 기능성이 제공된다.
단점: 마찬가지로 커널에서 직접 제공해주기 때문에 유저 모드에서 커널 모드로의 전환이 빈번하게 일어난다.
즉, 성능의 저하가 있을 수 있다.
2) 유저 레벨 쓰레드의 장점 및 단점
장점: 커널은 쓰레드의 존재를 모른다.
계속 유저 모드에서만 동작하기 때문에 커널 모드로의 전환이 필요하지 않다.
따라서 상대적으로 성능이 좋다.
단점: 하나의 프로세스 내에 3개의 쓰레드 A, B, C가 있다고 가정해봅시다.
A 쓰레드가 시스템 함수를 호출했는데 커널에 의해서 블로킹이 됩니다.
그러면 B와 C도 실행이 되지 않습니다.
앞서 말했듯이 커널은 쓰레드의 존재를 모르기 때문에 A 쓰레드가 속한 프로세스 전체가 블로킹이 됩니다.
이와 같은 문제를 해결하는 방법들은 다양하지만, 결국 프로그래밍이 어려워지는 결과를 초래합니다.
그래서 커널 레벨 쓰레드에 비해 결과 예측을 하기가 어렵습니다.