* 본 글은 학부생의 입장에서 공부 내용을 정리하기 위해 작성되었습니다. 틀린 내용이 있으면 피드백 부탁드리며, 무분별한 비방 작성시 차단 될 수 있음을 알려드립니다.
글을 읽기 전 알아야 하는 내용
- 컴퓨터 구조
- 프로세스의 개념
: 현재 프로세스에 관련한 글은 작성하지 않아서 프로세스 관련 정보는 인터넷 찾아보시면 됩니다.
안녕하세요 파일입니다. 오늘은 시스템 소프트웨어에서 가장 중요한 부분 중 하나인 쓰레드(Thread) 의 개념에 대해 알아보겠습니다.
읽기 전 주의
- 최대한 쉽게 설명하도록 글을 작성했으나 전공자의 눈높이에서 작성했으므로 컴퓨팅 관련 지식이 없으시면 읽기 조금 불편할 수 있습니다.
프로세스를 이용한 웹 서버 설계
현재 자신이 어떤 홈페이지의 관리자인데, 이 홈페이지에 제 친구들이 여러명 접속해서, 제 컴퓨터에 있는 사진을 보내달라고 요청했다고 가정해봅시다.
지금 저는 사진을 일일히 제 컴퓨터에서 찾는게 귀찮아서 친구들이 요청한 "너 컴퓨터에 있는 연예인 사진 보내줘~" 에 따라 자동으로 제 컴퓨터에서 해당 하는 연예인 사진을 찾고 그 사진을 친구들에게 응답 하는 소프트웨어를 개발하려고 합니다.
이렇게 인터넷 통신에서 상대방의 요청에 따라 그에 알맞게 필요한 데이터를 응답하는 소프트웨어를 "웹 서버" 라고 표현합니다. 지금 저희는 웹 서버를 만들려고 하는거에요.
하지만 바로 코딩하기 전에 웹 서버의 구조를 먼저 설계하고, 나중에 프로그래밍 하려고 합니다.
그래서 구조를 위와 같이 하려고 합니다.
상대방의 요구에 따라 프로세스를 각각 생성하여 그 프로세스가 디스크에서 해당 하는 파일을 찾도록 설계한 것입니다.
그림으로 쉽게 도식화 하면 다음과 같습니다.
제 친구들 3명이 들어와서 제 웹 서버에 지금 트와이스, 모모랜드, 뉴진스 사진을 요청했습니다. (제가 아는 여자 연예인이 몇 명 안되서요 ㅎㅎ;)
웹 서버는 외부에서 들어온 요청을 위 이미지에 매겨둔 대로 1. -> 2. -> 3. -> 4. 순서대로 처리합니다.
처리 과정은 아래와 같습니다.
1. 먼저 웹 서버의 요청 3개를 어딘가에 등록합니다.
2. 들어온 요청 3개에 따라, 디스크 입출력 (Disk I/O) 이 발생합니다. 현재 디스크를 읽어서 이미지를 찾아내야 할 상황인데 CPU의 처리속도가 디스크를 읽는 속도보다 훨씬 빨라서 속도 저하가 발생할 수 있습니다.
그래서 최대한 빠르게 처리하기 위해 프로세스를 3개 생성해서, 각 프로세스가 트와이스, 모모랜드, 뉴진스 사진 파일을 찾도록 지시합니다.
3. 프로세스가 3개 만들어졌으며, 각 프로세스는 디스크에서 요청에 맞는 이미지 파일을 찾아냅니다.
4. 최종적으로 3개의 이미지를 찾아 냈으며 이 이미지를 각각 상대방 3명에게 적당히 응답(전송) 합니다.
여기서 핵심은 상대방이 요청한 파일에 따라 각각 프로세스를 생성해 파일을 찾는다는 점입니다. 상대방이 여러명이므로 이러한 다중 작업(영어로는 Multi Tasiking) 처리시엔 일반적으로 각 I/O 에 따라 프로세스를 배치하는게 기본적인 멀티 태스킹 설계 원칙입니다.
그러나, 이런 설계가 과연 효율적일까요?
알다 싶이 프로세스는 아주 무겁습니다.
요새야 컴퓨터 HW 성능이 워낙 올라가서 게임도 돌리면서, 유투브도 켜놓고 한 컴퓨터에서 여러가지를 쉽게 할 수가 있어서 느끼기 어렵지만 옛날 구닥다리 컴퓨터를 써보신 분들은 아실껍니다.. 게임 하나만 켜놔도 렉이 엄청 걸려서 인터넷에서 음악 틀어놓는건 상상도 못하고 심지어 메모장 같은걸 켜도 렉이 걸리는 사실을요...
옛날에 실행중인 프로그램(프로세스) 을 싸그리 끄는 유틸리티 프로그램이 괜히 인기가 많았을까요.. (사실 지금도 인기 꽤 많음)
저런식으로 웹 서버를 설계하면 사용자가 한 100명만 들어와도 프로세스를 100개나 생성해야 되서 엄청난 렉이 동반되고 성능 저하가 발생할 가능성이 큽니다.
프로세스는 메모리도 많이 차지하고, 프로세스 자체만으로 봤을땐 번갈아서 실행하는 Context Switching 비용도 상당히 비쌉니다. 여기서 비용이 비싸다는건 CPU가 실행시키는 부하(Overhead) 가 크다는 의미입니다.
어쨌던 간에! 여러 개의 작업을 동시에 수행할 때 그에 맞춰서 프로세스를 여러개 만드는건 비용도 너무 많이 들고 생성시간도 느립니다.
그래서 나온 개념이 바로 쓰레드입니다. 이제 여러 요청에 따라 프로세스를 여러개 만드는게 아니라 쓰레드를 여러개 만들어서 처리하게 될겁니다! (멀티 쓰레딩)
쓰레드는 쉽게 말해서 프로세스 안에서 실행되는 "실행 흐름" 을 이야기 합니다.
하나의 프로세스는 여러개의 쓰레드를 가질 수 있습니다. 또한 쓰레드는 CPU의 기본 실행 단위 입니다.
쓰레드가 가지는 쓰레드 ID, Register, Stack을 합쳐서 쓰레드 문맥(Thread Context) 이라고도 부릅니다.
대부분의 현대 OS는 기본적으로 쓰레드를 지원하며, 쓰레드가 CPU 실행의 기본 단위가 됩니다.
(현재 대다수 OS의 스케줄러는 쓰레드를 최소 단위로 하여 작동함)
여기까지 와서는 잘 와닫지 않으시죠 ㅎ; 그림과 부가 설명을 통해 쓰레드에 대해 더더욱 자세히 알아보도록 하겠습니다.
우리가 C언어나 프로그램 코드를 활용해서 프로세스를 하나 생성했다고 생각해봅시다.
우리가 프로세스를 하나 생성하면 자동으로, 내부적으로 프로세스 안에 '쓰레드' 하나가 만들어지게 됩니다.
이 쓰레드는 자신의 고유한 번호 (id) 를 가지며, CPU의 레지스터 묶음, 자신의 전용 Stack 공간을 가지게 됩니다. 이게 앞에서 설명드렸던 쓰레드 문맥입니다.
쓰레드는 "실행 흐름" 이라고 설명드렸으므로 한 프로세스 안에서 실행 흐름(쓰레드) 은 1개가 될 수도 , 2개가 될 수도, N개가 될 수도 있습니다.
시스템 소프트웨어를 공부하다보면 프로세스를 먼저 배우고, 이후 쓰레드를 배우는데 프로세스를 배울때 쯤에는 CPU의 최소로 실행 하는 단위가 보통 프로세스 라고 배우지만 이것은 사실 아니며 CPU의 실행 단위는 무조건 쓰레드입니다!!!
왜 처음 배울때 프로세스를 CPU의 최소 실행 단위라고 배우나요?
- 저도 처음에 배울때 의문이 많았습니다. 처음에 프로세스를 먼저 배우고 프로세스가 CPU의 실행 단위라고 배웠는데 왜 나중에 쓰레드란걸 다시 배워서 이게 진짜 CPU의 실행 단위다~ 라고 이야기 해주는걸까요?
나무위키를 읽어보니 옛날엔 프로그램을 실행하는 흐름이 오직 프로세스 뿐이였으나, 소프트웨어가 진보하면서 하나의 프로그램에서 복잡한 동시 작업을 요구함에 따라 여러개의 프로세스를 만들어서 처리하는건 속도도 느리고 부하도 많아서 어려웠다고 합니다. 그래서 프로세스보다 더 작은 실행 단위 개념을 만들었고 , 이것이 바로 쓰레드입니다. 그러니깐 역사적으론 프로세스 밖에 없었는데 나중에 쓰레드라는 개념을 만든것이죠 -.-;
먼저 나온게 프로세스니깐 프로세스를 먼저 배우고 나중에 쓰레드를 배운것 이였습니다.
아마 예전에 실행 흐름이 프로세스 하나였단건 지금으로 따지면 프로세스가 곧 통째로 메인쓰레드 자체였다고 봐야 될 거 같습니다.
C언어를 기반으로 보자면 위 처럼 됩니다.
우리가 Hello World 를 작성하는 코드를 1번 처럼 작성하고 컴파일 후 실행하면 프로세스로서 실행되며 main() 함수가 실행되서 화면에 "Hello World" 라는 문자열이 출력되게 됩니다.
그러나 여기서 중요한 점은, 프로세스만 배우고 쓰레드에 대해 몰랐을 때는 프로세스가 main() 을 실행한다고 했겠지만, 실질적으로 CPU의 실행 단위는 쓰레드기 때문에 프로세스 안에 있는 쓰레드가 실행하는게 맞습니다.
정확히 설명하자면 C언어 코드를 작성하고 컴파일 해서 나온 프로그램을 실행한 순간 프로세스가 만들어지고, 그 안에 쓰레드가 하나 자동으로 만들어 지게 됩니다. 그리고 그 쓰레드 안에서 main 함수 코드가 실행되게 됩니다.
재미있죠?
참고로 C언어에서 main() 함수가 프로그램의 시작점(Entry Point)이라고 배웠듯이 프로세스 실행 시 처음 만들어져서, main() 함수 코드 부분을 실행시키는 저 쓰레드를 메인 쓰레드(Main Thread) 라고 합니다.
조금 더 쉽게 설명하면 프로세스가 실행될 수 있었던건 안의 핵, 중심인 쓰레드가 있기 때문이였단거죠!!
정리
- 쓰레드를 배우기 전까진 프로세스가 CPU가 실행 하는 단위라고 생각했을 수 있지만 실제로 CPU의 최소 작업 단위는 쓰레드이다.
- 프로그램을 실행시키면 코드를 실제로 실행시키는건 엄밀히 이야기 했을때 프로세스가 아니라 그 안에 있는 쓰레드가 실행한다. 즉 쓰레드는 프로세스 안에서 실행을 책임지고 있는데, 그래서 쓰레드를 프로세스의 "실행 흐름" 이라고 한다.
- 대부분의 현대 OS는 기본적으로 쓰레드를 지원하고 있고, CPU 실행의 기본 단위로 하고 있다.
아직은 어렵다!
여기 까지 읽으면서 어느정도 쓰레드에 대해 감을 잡으셨겠지만 그래도 쓰레드란 개념이 쉽진 않습니다. 처음 배우면 잘 와닿지도 않고, 특히 쓰레드를 활용해서 코딩할 땐 더더욱 어렵습니다. 쓰레드를 여러개 활용해서 코딩하는데 여기서 버그가 아주 많이 발생하게 되고, 이에 따른 버그를 잡기가 정말 쉽지가 않습니다 ㅠ;
아직 글은 끝나지 않았습니다. 더더욱 읽고 쓰레드에 대해 깊이 이해해봅시다!
함수 호출 vs 멀티 쓰레딩 비교
이번엔 함수 호출(Procedure Calls) 와 멀티 쓰레딩(Multithreading) 을 비교해보겠습니다.
멀티 쓰레딩은 앞에서 간략하게 소개해드렸지만 한 프로세스 안에서 쓰레드(실행 흐름) 여러개를 만들어서 코딩하는걸 의미합니다.
멀티 쓰레딩은 알겠는데 웬 함수 호출하고 비교를 하냐? 라고 하실 수 있겠지만 함수 호출과 쓰레드 생성은 사실 닮은 점이 많습니다. 그것에 대해 알아보자구요.
일단 실행 시킬 예제 코드입니다. C언어 코드인데 별건 없고 전역 변수가 정의 되어 있긴한데 사용하고 있지 않고, 함수(Func0, Func1) 가 인자도 받긴 하는데 코드 안에서 또 사용하고 있지 않습니다(...)
결론적으로 Func0 과 Func1을 어떻게 호출하던 간에 그냥 Hello Func0, Hello Func1 이 호출되는 기능이 다입니다.
그래서 코드에 대해선 자세히 읽어보실 필요 없습니다. 그냥 printf() 실행하는게 다인 함수라서요..
return 도 안쓰고.. PPT 만든 사람이 귀찮았나 봅니다
하지만 중요한건 함수를 호출함에 따라 메모리(RAM) 가 어떻게 관리되냐 입니다. 이건 방금 보여드린 코드를 실행시켰을 때 돌아갈 프로세스의 메모리 구조도인데요. 프로세스 메모리 구조에 관해선 링크를 해두었으니깐 참고하세요!
함수 호출
어쨌던 간에 main() 함수 안에서 Func0(0); 과 Func1(0); 을 호출하는 흐름을 순서대로 살펴봅시다.
C언어 프로그램은 main() 함수에서 실행하고 기본적으로 위에서 아래로 실행하므로 Func0(0); 을 먼저 실행하게 됩니다.
Func0 함수를 호출(실행)하게 되는 것이죠. 함수를 호출한 순간에 Func0에 대한 메모리 공간에 스택 영역에 딱! 잡히게 됩니다.
Func0에 대해 스택 영역에 잡은 메모리 공간을 그림에선 Stack of Func 0 이라고 표현하고 있습니다. 함수가 스택 영역에 잡은 메모리 공간을 다른 말로 "스택 프레임" 이라고 합니다. 현재 Func 0 에 대한 스택 프레임이 생성된 상태죠.
이렇게 스택 프레임을 잡는 이유는 Func0 함수를 호출했을 때 기억해야 할 것이 많기 때문입니다.
우선 Func0에 인자로 전달한 0을 기억해야 하고, 또 Func0이 다 실행되고 return 되어서 다시 돌아갔을때 다음 위치인 Func1을 실행해야 하는데 Func1의 실행 위치도 기억해야 되고, 또 Func 0 안에서 선언한 지역변수도 전부 Func 0의 스택 프레임(Stack of Func 0) 안에 기록하게 됩니다.
그냥 쉽게 생각해서 어떤 함수를 호출하던 간에 그에 맞게 스택 메모리 공간이 할당되고, 그 스택 메모리 공간이 스택 프레임이라고 한단것만 알아두세요. 함수를 호출하면 스택 프레임이 생성됩니다.
일단은.. 막 Func0 를 호출한 시점부터 생각해봅니다.
앞에서 배운대로 일단 Func0에 대한 스택 프레임이 생성되고 이곳에 함수와 관련한 여러 데이터를 저장, 이후 Func0의 본문에 있는 코드 printf("Hello, Func0\n") 을 실행합니다.
그리고 return 되서 빠져 나옵니다. 위에 설명에도 달아뒀지만 C언어에서 반환 값이 없는 void타입이면 끝에 return은 생략해도 됩니다. 없어도 있다고 생각하세요.
return 되서 빠져나왔으므로 함수는 종료된 것이고, 함수가 종료됨에 따라 아까 잡아뒀던 Func 0 의 스택 프레임도 삭제합니다. Func0의 실행이 종료됐으니깐 이제 Func0 에 대한 메모리 공간은 더 이상 필요가 없어 자동으로 삭제됩니다.
Func0(0) 가 다 종료되고, Func1(0) 호출 하기 이전 시점엔 스택 메모리 상태가 다음과 같이 됩니다.
Func0에 대한 스택 프레임 공간이 삭제되어서 스택 메모리가 깨끗한 상태입니다.
이제 그 다음으로 Func1(0) 을 호출합니다.
Func1을 호출해도 앞과 동일한 상황이 발생합니다. 똑같이 Func1에 대한 스택 프레임이 생성되고, Func1의 본문 코드 실행 그리고 return 되어 종료됩니다.
Func1의 호출이 종료되었으므로 Func 1 의 스택 프레임 역시 자동으로 삭제되며 함수 호출은 끝나게 됩니다.
여기서 인상깊게 봤어야 하는건 바로 실행 순서입니다. 일반적으로 C언어에서 함수 호출을 진행하면 Func 0을 실행하고, Func0가 실행할때까지 기다렸다가 Func1을 실행합니다. 또 다시 Func1을 실행할때까지 기다렸다가 마지막으로 main 함수가 종료됩니다.
위 처럼 함수 호출을 하면 Func0가 실행하는 중간엔 Func1 이 중간에 끼어들어서 동시에 작업하지 못합니다.
Func1이 실행중에 Func0가 실행되는 경우도 일어날 수가 없습니다.
앞에서 배운 그림대로 나타내면 다음과 같은 일이 이루어지고 있는겁니다. 왼쪽 코드를 컴파일 해서 실행시키는 순간 프로세스가 만들어지고, 시스템에 의해 프로세스 안에 메인 쓰레드가 자동으로 만들어집니다. (제일 처음에 만들어진 쓰레드)
메인 쓰레드는 main() 함수 안의 코드를 순차적으로 실행합니다. 메인 쓰레드라는 1개의 단일 쓰레드가 함수 코드를 순서대로 실행시킵니다. 이렇게 1개의 프로세스에서 1개의 쓰레드로 프로그램 전체를 동작시키는걸 "싱글 쓰레드 프로그래밍" 이 라고 표현합니다.
사실 우리가 C언어에서 코드를 작성하고 실행시키면 프로세스가 생성되고, 그 안의 메인 쓰레드 1개가 모든 일을 처리하는 형태로 작동하고 있었습니다. 일반적으로 어떤 프로그래밍 언어를 사용하던 별도의 코드를 작성해주지 않으면 이런식으로 전부 싱글 스레드로 작동하여 프로그램이 동작하게 됩니다.
그런데 이런 새로운 요구사항이 나타났다고 생각해봅시다.
현재 Func0 과 Func1은 화면에 무언가를 print 하는 매우 간단한 함수지만, 저 코드가 사실은 은행 전산 업무랑 관련된 코드라서 Func0 은 고객의 이름을, Func1은 고객의 통장 잔고를 가져오는 함수라고 생각해봅시다. (그렇진 않지만 열심히 상상하는 겁니다.)
그러면 상식적으로 생각했을때 고객의 이름하고 고객의 통장 잔고는 한꺼번에 병렬적으로 가져오는게 성능상 훨씬낫습니다. 그런데 위 처럼 메인 쓰레드 1개가 모든 코드 (main 코드) 를 실행하는 구조, 즉 싱글 쓰레드로 프로그래밍 하면 일반적으로 병렬 처리를 절대 할 수가 없습니다. 앞의 함수가 다 종료되기 까지 기다려야 다음 함수가 실행되니깐요.
만약에 Func0 - 고객의 이름을 가져오는 코드가 5초, Func1 - 고객의 통장 잔고를 가져오는 함수가 5초라고 생각해보면 두개를 한꺼번에 실행시켜서 5초만에 끝낼걸, Func0 실행하고 , 결과가 나올때까지 5초 기다리고 Func1 실행하고.. 실행시간 10초가 되어서 실제로 속도상으론 2배가 낭비되는 셈이죠.
싱글 쓰레드 프로그래밍에선 함수를 한꺼번에 실행하는 등 병렬 처리를 할 순 없지만 멀티 쓰레딩을 이용하여 한 프로세스 안에 여러개의 쓰레드(실행 흐름)를 만들고 각각 쓰레드에 일을 지시하면 병렬 처리를 간편하게 할 수 있습니다.
TIP
물론 현대 프로그래밍이 발전 함에 따라서 "동기 / 비동기"라는 개념이 등장하여 꼭 멀티 쓰레드가 아니여도 비동기 코드를 작성하면 싱글 쓰레드 안에서 병렬 처리(다중 작업)를 할 수는 있습니다. 그래서 쓰레드 안에서 코드를 동기 / 비동기로 실행함에 따라 동기 싱글쓰레드, 비동기 싱글쓰레드, 동기 멀티쓰레드, 비동기 멀티 쓰레드 등으로 나누곤 합니다. 그러나 이런 비동기 작업은 최신 프로그래밍 언어 (Python, JS, C# 등등) 에서 부분적으로 지원하는 기능이고 일반적으로 코드를 작성하면 전부 동기적, 위에서 본 대로 순차적으로 함수를 실행합니다. 일단 본 글에서 동기 - 비동기에 관한 내용은 따로 다루지 않습니다. C언어에서 코드를 작성해도 거의 모두가 동기적 (순차적) 으로 작동하기 때문에 본 글에서 동작하는 방식은 전부 동기 쓰레딩 방식입니다.
관련글 : https://hamait.tistory.com/694
2. 멀티 쓰레딩
앞에선 메인 쓰레드 1개가 생성되어 단일 쓰레드가 함수를 순차적으로 수행함에 따라 동시 작업이 불가했습니다.
이제 멀티 쓰레딩 방식을 사용하여 쓰레드를 여러개 생성해 이 답답함을 해결해봅시다!
멀티 쓰레딩 = 하나의 프로세스에서 둘 이상의 쓰레드가 동시에 작업을 수행하는 것
아까와 다르게 이번엔 main() 함수 안에서 CreateThread() 라는 함수를 통해 쓰레드를 생성하고 있습니다. 현재 OS(시스템)가 쓰레드에 대한 실행이건, 프로세스에 대한 생성이건 컴퓨터에 관한 모든걸 전부 관리하고 있기 때문에, 쓰레드를 만들고 싶으면 쓰레드를 만들어 달라고 시스템에 요구하거나, 쓰레드 생성에 관한 시스템 코드를 실행해야 합니다.
리눅스의 경우 시스템 콜, 윈도우의 경우 윈도우 API 라는 함수 모음집을 사용해서 쓰레드를 간편하게 생성할 수 있습니다. 윈도우에서 쓰레드를 손쉽게 생성할 수 있는 함수가 바로 CreateThread() 함수입니다.
그런데 중요한 점은 쓰레드 생성시 그 쓰레드가 실행시킬 함수를 인자로 제공한다는 점입니다.
CreateThread() 함수는 쓰레드 생성시 그 쓰레드가 실행시킬 함수를 인자로 주고 (정확히는 함수의 주소를 주어 함수 포인터가 받아낼 수 있게 합니다.), 쓰레드 ID를 반환(return) 합니다.
위 코드를 봤을때 의미적으로 hThread0 라는 놈은 생성시에 Func0 라는 함수를 실행시키게 되고,
hThread1 이라는 놈은 생성시에 Func1 이라는 함수를 실행시키게 됩니다.
그러니깐 쓰레드의 몸통은 함수가 된다는 겁니다. 쓰레드가 함수를 실행하고, 그 함수가 return 되어 종료되면 쓰레드 역시 자동으로 종료되게 됩니다. 쓰레드의 할일이 제공된 함수를 실행시키는 역할인데 제공된 함수를 다 실행하면 쓰레드 역시 자동 종료되야 맞겠죠!
만약에 쓰레드가 종료되지 않게 계속 돌리고 싶다면 쓰레드가 실행시키는 함수에 while(1) 과 같이 반복문을 돌려서 코드 실행을 계속 유지시켜주면 됩니다. 이렇게 하면 생성된 쓰레드는 계속 코드를 실행시키게 됩니다.
어쨌던 간에 핵심은 쓰레드 생성시 인자로 들어온 함수를 실행한다! 쓰레드의 몸통[실체]은 함수 몸통이다! 라는 점입니다.
이러한 특징때문에 쓰레드는 함수의 특징을 그대로 물려 받게 됩니다.
사실 코드만 봤을땐 함수 호출이랑 비슷해보입니다. 어쨌던 간에 쓰레드가 각각 맞는 함수를 호출하는거니깐 결국 함수 호출이랑 동일한거 아닌가? 라고 생각할 수 있겠지만 의미적으로나 기능적으로나 전혀 동일하지 않습니다. 이렇게 쓰레드를 생성해서 함수를 각각 돌리게 되면, 두개의 쓰레드가 동시에 실행됨에 따라, 그 안에 든 두개의 함수 역시 동시에 실행됩니다!!
물론 정확히 이야기 하면 동시에 실행되는건 아니고 CPU가 아주 빠르게 번갈아 가면서 2개의 쓰레드를 실행시키게 되는 것 입니다. 쓰레드를 번갈아 실행시키는 Context Switching 이 이루어지고 있는 것이죠. 앞에서 이야기 했듯이 CPU의 실행 단위가 쓰레드이기 때문에 그렇습니다.
하지만 이 번갈아 가면서 실행시키는 속도가 사람이 인지하기도 어려울 정도로 아주 빠르기 때문에 사람은 동시에 처리한다고 느낄겁니다.
사실 이 코드만 봐서는 왜 번갈아가면서 실행되는지 조금 납득이 가지 않으실건데요. 대충 이해해서 이렇게 이해하면 됩니다.
일단은 코드를 어떻게 작성했던 간에 C언어는 위에서 아래로 코드를 해석합니다.
일단 첫번째 CreateThread() 를 호출해서 Func0를 실행시키는 쓰레드를 하나 만든다고 운영체제(OS) 에게 알려주고, 그 쓰레드를 관리하기 위한 id를 hThread0에 받습니다. id는 참고로 정수입니다.
이게 Func0(0) 와 같은 함수 호출이였으면 이제 Func0(0) 가 실행되고 출력되기까지 기다렸다가 밑에 코드를 실행했을 테지만 지금은 상황이 다릅니다.
지금 저 위에 코드는 어 운영체제야~ 일단 쓰레드좀 하나 만들어줘 이 쓰레드는 Func0을 실행하는 쓰레드야. 실행은 너가 알아서 잘 할 수 있지? OS에 스케줄러 같은거 있잖아. 그걸로 알아서 잘 실행시켜봐~
와 같은 형태로 지시한 코드입니다.
위의 코드는 단순히 쓰레드를 생성하고 그 쓰레드가 어떤 함수를 실행시킬지 알려준 코드에 불과하기 때문에 쓰레드가 언제 실행될 지 지시한 코드가 아닙니다.
일반적으로 OS에는 스케줄러라는 것이 있는데 이것의 기능이 바로 프로세스 안에 있는 아주 많은 쓰레드를 어느 순서로 실행시킬 지 정하는 것이기 때문에, 실제로 프로세스 안에서 쓰레드를 여러개 만들면 그 중에서 어떤 것이 먼저 실행될 지 제어하는건 불가능 합니다.
지금은 단순히 OS에게 쓰레드를 만들어 Func0를 실행하라고 요구한 코드입니다. 언제 실행될지는 모릅니다. 근데 확실한건 이 쓰레드가 생성 되었기 때문에 언젠가 OS 스케줄러에 의해 실행되어서, Func0이 실행될 건 확실합니다.
한마디로 쓰레드를 생성해서 실행 "예약" 해둔 개념에 가깝죠. 절대로 함수 호출처럼 이 쓰레드가 반드시 지금 시점에서 실행되어야 하고, 결과를 기다렸다가 다음 쓰레드를 생성한다! 라는 개념은 아닙니다.
그냥 쓰레드 실행은 OS 스케줄러한테 맡길태니깐 일단 쓰레드를 생성해서 Func0 실행을 예약 해줘! 라는 개념에 가깝다고 이해하시면 됩니다.
그래서 이렇게 2번째 CreateThread() 호출에서도, 위에서 Func0 을 실행시키는 쓰레드를 생성만 했을 뿐 아직 실행이 됐는지 안됐는지는 모릅니다. 쓰레드를 만든다고 그 순간 바로 실행되는것도 아니고, 결과를 기다리는 것도 아닙니다. 함수 호출이였으면 확실하게 Func 0 가 종료된 후 Func1 이 실행된다고 말할 수 있지만 여기선 아닙니다. 위에서 설명드린대로요.
일단은 위에서 Func0에 대한 쓰레드가 1개 생성됐고 그 쓰레드가 아직 실행되지 않은 상태에서 Func1을 생성하는 2번째 쓰레드가 생성했다고 가정해봅니다.
현재 프로세스 안에서 메인 쓰레드, Func0을 실행시키는 쓰레드0, Func1을 실행시키는 쓰레드 1이 생성됐으며 OS의 스케줄러가 PC에서 돌아가는 프로세스 안에 있는 쓰레드를 계속 번갈아가면서 실행하다가, 저희의 쓰레드를 딱 실행할 시점이 오면! Func0를 실행하고, Func1을 실행하게 됩니다. 아주빠르게요. 사실 순서는 정확히 모릅니다.
위에서 말씀드렸듯이 현재 컴퓨터 안에서 생성된 여러 쓰레드를 어떤 순서로 실행시킬지 정하는건 OS의 스케줄러가 하는 일이기 때문에, 실행 순서는 보장되지 않습니다.. 그러나 확실한건 Func0 랑 Func1 을 번갈아가면서 실행시키기 때문에 순서는 보장되지 않아도 두 함수를 동시에 실행시키는 느낌이 들것이고 병렬처리가 가능해집니다.
글로만 설명하니깐 굉장히 울렁거리는 기분인데 그림을 그려서 알아보겠습니다.
왼쪽 코드를 컴파일 해서 실행시키는 순간, 코드에 맞게 프로세스가 생성되고 시스템에 의해 그 프로세스 안에 첫 번째 쓰레드인 메인 쓰레드가 생성됩니다. 그리고 그 메인 쓰레드는 main() 함수 안의 코드를 실행합니다.
메인 쓰레드 안에서 쓰레드 생성 코드를 실행시켜서 Func0을 실행시킬 쓰레드 하나를 생성합니다. 이 쓰레드는 메인 쓰레드에 의해 태어난 자식이므로, 자식 쓰레드 0번이라고 칭하겠습니다. 여기서 메인 쓰레드는 자식 쓰레드의 부모 쓰레드가 됩니다. 이 쓰레드의 몸통은 Func0 함수의 코드입니다.
그러나! 아직 생성만 됐을뿐 쓰레드는 OS 스케줄러에 의해 실행되기 전까진 생성됐다고 곧 바로 실행되지 않는다고 설명을 드렸습니다.
아직 Child Thread 0 번이 실행되지 않았다고 가정 후 Main Thread 안에서 두번째 쓰레드 생성 명령어 실행되고 2번째 자식인 Child Thread 1번이 다시 생성됩니다. 이녀석은 Func1을 실행시키는 쓰레드가 되지만 역시 생성 이후 바로 실행되지 않습니다.
이렇게 하여 메인 쓰레드는 쓰레드 2개를 생성하는 코드를 전부 실행하고 일을 끝냈습니다.
CPU가 일을 열심히하다가 OS 스케줄러에 의해 어느순간 저희가 만들어 놓은 Child Thread 2개를 실행할 시점이 왔습니다! 스케줄러는 Child Thread 2개를 번갈아가면서 아주 빠르게 실행합니다. (Context Switching 발생) Child Thread 0가 먼저 실행될 수도 있고 Child Thread 1 이 먼저 실행될 수도 있습니다.
어쨌던 Func0() 랑 Func1() 을 번갈아서 실행하는게 사람에겐 아주 찰나의 시간이기에 우리는 두 함수가 동시에 실행된다고 느낍니다. 출력 예상 결과는 Hello Thread0, Hello Thread 1 또는 Hello Thread 1, Hello Thread0 입니다.
콘솔 창에서 출력을 봤을땐 두 결과가 동시에 출력되는것으로 보입니다!!
이렇게 하여 우리는 병렬 처리를 해낼 수 있게 되었습니다. 메인 쓰레드야 무조건 시스템이 자동으로 생성해주니 논외로 치고 쓰레드 2개를 새로 만들어서 멀티 쓰레딩을 통해 동시 작업을 할 수 있게 된거죠!! 아~주 정확히는 번갈아가면서 실행하는거라 CPU가 동시에 실행하는건 있을 수 없는 일이지만 어쨌던 간에 사람이 느끼기엔 그렇기 때문에 이런 작업을 병렬 처리나, 동시 작업 등으로 표현합니다.
그리고 위에서 설명을 빼먹었는데 쓰레드를 생성하면 함수와 동일하게 자신의 스택 메모리 공간을 할당 받습니다.
근데 함수랑 다른건 함수는 순차 실행되서 위 쓰레드 스택 공간 처럼 동시에 존재할 수가 없었죠.
쓰레드는 실행되기 전까진 이렇게 자신의 스택 메모리 공간 2개가 동시에 존재할 수도 있습니다.
함수 호출 vs 멀티 쓰레딩 사진으로 비교
1. 함수 호출의 경우 하나의 싱글 쓰레드(메인 쓰레드) 가 모든 코드 처리
2. 멀티 쓰레드 코드의 경우 실행 시킬 함수에 맞춰 각각 쓰레드를 생성해 병렬 처리
함수 호출, 멀티 쓰레딩 공통점
- 함수 호출이나 쓰레드는 전역 변수에는 자유롭게 접근할 수 있지만 함수 서로의 지역 변수엔 접근이 불가능하다.
: 알다 싶이 함수 내부의 지역 변수는 중괄호 내부에서만 정의 되어, 그 함수 본인만 본인의 지역변수에 접근할 수 있습니다. 쓰레드의 몸통[실체] 역시 함수이기 때문에 함수의 특징을 그대로 물려받습니다.
- 전역 변수나 힙 영역 (전역 포인터 변수로 가리키고 있는 경우) 은 모든 함수와 쓰레드에서 접근 가능함.
함수 호출, 멀티 쓰레딩 차이점
- 쓰레드 별로 스택 공간이 잡힌다. : 함수도 마찬가지긴 한데 아마 이건 동시에 존재할 수 있다는 점에서 이야기 한 것인듯??
- 동시에(병렬적)으로 실행된다. : 쓰레드 설명 / 함수는 아님
Remind
그래서 위에서 배웠던 내용을 다시 복습해보자면.. 1번과 같이 코드 작성 후 컴파일 해서 실행시키게 되면, 2. main 함수 부분 코드 실행을 위해 프로세스가 자동으로 생성됩니다.
3. 그리고 main() 함수를 실행하는 쓰레드가 내부에 자동으로 생성됩니다. 우리가 따로 생성하지 않아도 시스템에서 자동으로 1개를 만들어 줍니다. 프로세스 실행시 처음 만들어지는 이 쓰레드를 '메인 쓰레드' 라고 합니다.
'메인 쓰레드' 는 '메인 함수'를 실행하게 됩니다. 이후 main() 실행이 끝나면 메인 쓰레드가 종료되고 전체 프로세스가 종료됩니다.
물론 이 경우엔 따로 쓰레드를 만들지 않고 메인 쓰레드 1개가 모든걸 처리하는 형태가 되는것이고..
아까 예제에서 본 것 처럼 쓰레드를 여러개 만들어서 프로그램을 구성할 수 있습니다. 실행 시 프로세스나 그 안의 메인 쓰레드는 일단 자동 생성되는거고, 메인 쓰레드 안에서 저희가 작성한 코드가 실행될 건데 이 코드에 의해 여러개의 자식 쓰레드(Child Thread) 를 생성할 수 있습니다.
그리고 설명드리진 않았지만 제일 처음에 코드 실행을 위해 자동 생성되는 메인 쓰레드의 경우 메인 쓰레드가 종료되면 그 안에 쓰레드를 몇 개 만들었건 간에 프로세스 전체가 바로 종료되버립니다. 메인 쓰레드가 프로그램의 메인 코드를 실행하는 쓰레드인데 이게 Child Thread 보다 먼저 종료되버린다면 프로세스가 통째로 종료되기 때문에 그 안의 쓰레드는 모두 삭제되고 Child Thread 의 코드가 실행되지 않을 수 있습니다.
다만 모든 프로그래밍 언어에서 메인 쓰레드가 종료된다고 해서 전체 프로세스가 종료되는건 아닌 거 같습니다. 파이썬 같은 언어에서 쓰레드 테스트를 해보니 메인 쓰레드에서 자식 쓰레드를 생성하고 메인 쓰레드가 자식 쓰레드보다 먼저 종료되도 여전히 자식 쓰레드의 코드가 실행되고 있었습니다. 그럼에도 일단은 C언어에서는 메인 쓰레드가 종료되면 전체 프로세스가 종료됨을 유의하시길 바랍니다.
Remind 2
쓰레드를 처음 소개할 때 쓰레드를 프로세스 안의 '실행 흐름' 이라고 소개해드렸습니다. 사실 쓰레드는 영어로 Thread 인데, 재미있게도 Thread는 '실' 이라는 뜻입니다. 바늘 하고 실 할때 그 실이요. 앞에서 쓰레드를 그릴때 제가 프로세스 안에 네모나 원으로 표현해서 그 안에서 프로그램 코드가 실행된다고 설명을 했었는데 사실 위 사진처럼 쓰레드를 저런 꼬불꼬불한 실로 그리기도 합니다.
저 꼬불꼬불한 실이 아래로 내려가면서 움직이면서 코드가 실행되는걸 상상해보세요.
실처럼 생긴것, 저 Thread가 바로 프로세스 안의 '실행 흐름' 이니깐요!
저 실모양 자체가 실행 흐름입니다.
1. 일단 위 사진에서는 그게 중요한게 아니라 쓰레드는 자신만의 스택, 레지스터 공간을 가진다고 하였습니다. 이걸 몇번이나 설명한지 모르겠지만 왼쪽은 싱글 쓰레드 프로세스, 쓰레드를 하나만 갖는 프로세스 입니다.
보다 싶이 registers 와 stack 이 저 메인 쓰레드가 전용으로 갖는 스택과 레지스터 공간입니다.
그리고 위에 코드, 데이터(전역 변수), 파일 등은 쓰레드가 공유합니다.
2. 오른쪽은 하나의 메인쓰레드 에서 자식 쓰레드 2개를 생성해서 총 쓰레드 3개가 프로그램 안에서 돌아가고 있는 모습입니다. 보다 싶이 쓰레드 각각이 레지스터와 스택 공간을 갖습니다. 쓰레드는 스택 공간만 있으면 생성가능하고 이 공간은 수백 KB 밖에 안됩니다. 프로세스를 여러개 생성하는거보다 쓰레드를 여러개 만드는게 훨씬 더 경제적인 이유입니다.
쓰레드 사용 장점 (프로세스와 비교해서)
- 컨텍스트 스위칭의 부하(Overhead)가 적습니다
- 생성 시간이 짧습니다.
- 동기화 부하가 적습니다.
- 메모리 (자원소비) 가 적습니다.
다만 이건 어디까지나 프로세스와 비교한 쓰레드의 특징일뿐 쓰레드 자체도 아주 값싼 자원은 아닙니다. 쓰레드 역시 마구 남용해서 만들면 컴퓨터에 큰 부하를 줄 수 있습니다.
프로세스와 쓰레드의 관계
여기까지 다 읽으셨으면 실제로 프로그램을 실행시키면 프로세스가 생성되는건 맞지만, 실제 코드는 그 안에서 또 다시 생성되는 쓰레드가 실행한다! 라고 이해하셨을 겁니다.
그럼 프로세스는 그냥 빈껍데기인걸까요? 그건 아닙니다
프로세스는 1개 또는 그 이상의 쓰레드와, 쓰레드가 돌아갈 수 있도록 해주는 자원(환경) 으로 구성되게 됩니다.
CPU 의 기본 실행단위는 쓰레드가 맞지만 프로세스는 쓰레드를 위한 자원 환경을 제공해주는 중요한 녀석입니다.
프로세스를 집으로 비유하자면, 쓰레드는 집 안에서 실제로 살고 활동하는 사람입니다.
실제 활동 주체는 사람일지 몰라도 사람은 집 없이 밖에서 나돌아다니면서 살 수 없습니다!!
'CS > System' 카테고리의 다른 글
[시스템 소프트웨어] 스택 프레임(Stack Frame) 이란 무엇인가? : 어셈블리 명령어로 알아보자 (0) | 2023.01.22 |
---|---|
[시스템 소프트웨어] 어셈블리어 개요 / 어셈블리어(Assembly)란? (0) | 2022.12.26 |
[Unix] 시스템 소프트웨어 개요 / 시스템 프로그래밍이란 무엇인가? (OS, System Call) (0) | 2022.11.16 |